diff --git a/frontend/src/api/whatif.ts b/frontend/src/api/whatif.ts index f7ecb7a5..a076a6ec 100644 --- a/frontend/src/api/whatif.ts +++ b/frontend/src/api/whatif.ts @@ -4,8 +4,8 @@ * 请求函数(get / post / del……)和 ApiError 从 ./http 导入,不要从 ./client 导入:client.ts 要导入 * 这个文件把它挂到 api 上,反过来导入就成了循环(见 http.ts 开头的说明)。 */ -import type { WhatIfRequest, WhatIfResult } from '../types' -import { request } from './http' +import type { WhatIfRequest, WhatIfResult, WhatIfRowsPage } from '../types' +import { get, request } from './http' export const whatif = { /** @@ -16,4 +16,9 @@ export const whatif = { request(`/runs/${encodeURIComponent(runId)}/evidence/whatif`, { method: 'POST', body: JSON.stringify(body), signal: opts?.signal, }), + /** 一页查询快照的行(从第 offset 行起最多 limit 行,行号从 0 数):推演区里给整份结果、数组字段勾行用 */ + rows: (runId: string, artifact: string, page: { offset: number; limit: number }, opts?: { signal?: AbortSignal }) => + get(`/runs/${encodeURIComponent(runId)}/evidence/whatif/rows?${new URLSearchParams({ + artifact, offset: String(page.offset), limit: String(page.limit), + })}`, { signal: opts?.signal }), } diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 68e05972..9845df76 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -2771,6 +2771,7 @@ export const WHATIF_TEXT = { unsealed: '运行未封存,数据可能还会变', sealBroken: '封存核对未通过,推演所依据的数据可能已被改动', loading: '推演中…', + failed: '推演失败', result: '推演结果', unchanged: '无变化', delta: (text: string) => `变化 ${text}`, @@ -2780,12 +2781,22 @@ export const WHATIF_TEXT = { inputLabel: (path: string) => `${path} 的推演值`, notNumber: '请输入数值', masked: '已遮罩', + withheld: '查询快照无法核对,不显示原值', excludedRows: (rows: number[]) => `已排除第 ${rows.map((r) => r + 1).join('、')} 行`, pickRows: (alias: string) => `在查询 ${alias} 中勾选要排除的行`, - rowsHint: '勾选的行在推演中视为不存在', + pickHere: (n: number) => `勾选要排除的行(共 ${n} 行)`, + pickHereClose: '收起要排除的行', + rowsHint: '勾选的行在推演中视为不存在,这个指标没有用到的行不能勾选', + rowUnused: '这个指标没有用到这一行,排除它不影响结果', + rowsPending: '推演结果返回后才能勾选', + rowsLoading: '正在读取查询结果…', + /** 三个数已按千分位写好(formatNumber) */ + rowsRange: (from: string, to: string, total: string) => `第 ${from}–${to} 行,共 ${total} 行`, + rowsMore: (n: number) => `再显示 ${n} 行`, excludeHead: '排除', excludeRow: (row: number) => `排除第 ${row + 1} 行`, excludedCount: (n: number) => `已排除 ${n} 行`, + backToResult: '回到推演结果', segments: '报告中会变的地方', segmentsNone: (n: number) => `报告中引用这个指标的 ${n} 处不受影响`, segmentsUnknown: '这个指标无法推演,报告中引用它的地方会如何变化无法确定', @@ -2793,6 +2804,7 @@ export const WHATIF_TEXT = { inReport: (node: string) => `另一份报告(${node})`, unsupported: '不支持推演的输入', wholeMetric: '整个指标', + nothingToAdjust: '这个指标没有可以修改的输入,也没有可以排除的查询行', affected: '可能受影响的下游口径卡', affectedHint: '这些口径卡引用了本卡的指标,推演不会重算它们', affectedCard: (caliber: string, version: string) => `口径卡「${caliber || '未命名'}」${version ? ` ${version}` : ''}`, diff --git a/frontend/src/run/EvidencePanel.tsx b/frontend/src/run/EvidencePanel.tsx index 0f98a344..608a8380 100644 --- a/frontend/src/run/EvidencePanel.tsx +++ b/frontend/src/run/EvidencePanel.tsx @@ -30,7 +30,10 @@ import { useEvidenceHost } from './evidenceHost' import { EvidenceProvenance } from './EvidenceProvenance' import { CopyChip } from './Markdown' import { SqlCheckList } from './SqlChecks' -import { WhatIf, WhatIfRows, WhatIfToggle, useWhatIfDraft } from './WhatIf' +import { + WhatIf, WhatIfRows, WhatIfSummary, WhatIfToggle, focusResult, inSpans, pickMetric, usableRows, useWhatIfDraft, + useWhatIfResult, wholeReason, +} from './WhatIf' /** * 证据面板:点开报告里的一个片段,看它从哪来。 @@ -65,7 +68,8 @@ import { WhatIf, WhatIfRows, WhatIfToggle, useWhatIfDraft } from './WhatIf' * 引用、且片段接口的查询步骤带 provenance: true 时请求推断来源接口;推断来源的缓存只在面板开着期间有效。 * * 数字推演(WhatIf.tsx):指标步骤上的「推演」展开推演区,改输入的值、在查询步骤里勾掉几行,看这个数和报告会变成 - * 多少。推演不是出具,不写运行记录、不改报告。勾选框只在推演时出现在查询步骤的结果窗口里。 + * 多少。推演不是出具,不写运行记录、不改报告。勾选框只在推演时出现在查询步骤的结果窗口里,只有这个指标用到的行能勾, + * 表格底下带着推演结果。 * * 四种摆法:side 从右侧弹出(宽屏),drawer 从底部抽出(窄屏),inline 在 360px 的 * 画布右栏里直接栏内展开,dock 放进页面给的一块常驻位置(记录页的「证据」页签)。 @@ -311,8 +315,17 @@ function SegmentBody({ panelId, doc, artifact, runId, runClass, seg, unit, block const whatifReady = !!runId && !docIssue && !!metricStep && !!whatifCard && !!whatifMetric const whatifOpen = whatif.open && whatifReady const whatifId = `${panelId}-whatif` - // 能勾行的查询步骤:指标的输入(单元格取数、Agent 字段)落在它上面。合并查询的输入步骤不给——指标引用的是合并结果 - const pickable = new Set(inputs.map((inp) => inp.artifact).filter((a): a is string => !!a)) + // 推演的请求和答复也放在这一层:查询步骤里勾行的表格底下要带着推演结果,和推演区是同一份 + const whatifRequest = useMemo(() => (whatifOpen && whatifCard && whatifMetric + ? { ...(report ? { report } : {}), card: whatifCard, metric: whatifMetric, overrides: whatif.overrides } : null), + [whatifOpen, report, whatifCard, whatifMetric, whatif.overrides]) + const whatifView = useWhatIfResult(runId, whatifRequest) + // 每份快照里这个指标用得到的行:别的行勾了不影响结果,表格里不让勾 + const whatifUsable = useMemo(() => usableRows(pickMetric(whatifView.data, whatifMetric)), [whatifView.data, whatifMetric]) + // 能勾行的查询步骤:指标的输入(单元格取数、Agent 字段)落在它上面。合并查询的输入步骤不给——指标引用的是合并结果。 + // 整个指标推演不了(口径卡没记输入)时一行都不给勾 + const pickable = new Set(wholeReason(whatifView.data, whatifMetric) ? [] + : inputs.map((inp) => inp.artifact).filter((a): a is string => !!a)) const queryLink = (artifactOf?: string) => { const i = artifactOf ? queries.findIndex((q) => q.artifact === artifactOf && !q.merged_into) : -1 return i >= 0 ? { alias: queries[i].alias ?? '', go: () => jumpTo(i) } : undefined @@ -393,8 +406,8 @@ function SegmentBody({ panelId, doc, artifact, runId, runClass, seg, unit, block linkOf={linkOf} workflowName={workflowName(pinnedWorkflow(metricStep))} whatif={whatifReady ? { open: whatifOpen, onToggle: whatif.toggle, controls: whatifId } : undefined}> - {whatifOpen && runId && whatifCard && whatifMetric && ( - )} @@ -425,8 +438,15 @@ function SegmentBody({ panelId, doc, artifact, runId, runClass, seg, unit, block {queries.map((q, i) => ( whatif.toggleRow(q.artifact!, row) } + pick={whatifOpen && whatifMetric && q.artifact && !q.merged_into && pickable.has(q.artifact) + ? { + excluded: whatif.excluded(q.artifact), + onToggle: (row) => whatif.toggleRow(q.artifact!, row), + usable: whatifUsable ? (row) => inSpans(whatifUsable.get(q.artifact!), row) : undefined, + // 勾了行,新值就在表格底下:推演区在上面,往往已经不在视野里 + summary: focusResult(whatifId)} />, + busy: whatifView.status === 'loading', + } : undefined} /> ))} @@ -1361,8 +1381,13 @@ function SourcesPart({ inputs, linkOf }: { inputs: EvidenceInput[]; linkOf: (inp */ function QueryPart({ id, step, seal, masked, pick }: { id: string; step: EvidenceStep; seal?: EvidenceSeal; masked: string[] - /** 推演时:结果窗口每行前面加勾选框,勾掉的行在推演里视为不存在 */ - pick?: { excluded: number[]; onToggle: (row: number) => void } + /** + * 推演时:结果窗口每行前面加勾选框,勾掉的行在推演里视为不存在。usable 说哪几行这个指标用得到(别的行不让勾), + * summary 是画在表格底下的推演结果 + */ + pick?: { + excluded: number[]; onToggle: (row: number) => void; usable?: (row: number) => boolean; summary?: ReactNode; busy?: boolean + } }) { const host = useEvidenceHost() const win = useMemo(() => queryWindow(step), [step]) @@ -1454,7 +1479,8 @@ function QueryPart({ id, step, seal, masked, pick }: { {win.columns.length > 0 && win.rows.length > 0 && (
0 && 'mt-1.5')}> {pick && win.index - ?