证据面板加数字推演:指标步骤上「推演」,改输入、勾掉查询结果里的行,看这个数和报告怎么变

- run/WhatIf.tsx:推演区。顶上醒目横幅「推演,非出具:不会写进运行记录,也不会改动报告」,运行未封存另行提示;
  旧值 → 新值和变化量(比率、百分数写「个百分点」)、推演代入式、输入(数值可改:停顿 450 毫秒或回车、失焦才提交,
  写的不是数提示并保留上次的结果,Esc 退回)、不支持推演的输入和原因、报告中会变的地方(可定位)、可能受影响的
  下游口径卡、恢复原值;加载、失败(可重试)、空态都有。新改动来了旧请求直接丢掉,答复按请求体缓存
- EvidencePanel.tsx:MetricPart 挂「推演」开关和推演区;QueryPart 只在推演时把结果窗口换成带勾选框的表(WhatIfRows),
  勾掉的行划掉、遮罩列照旧写「已遮罩」。草稿按(运行、报告、口径卡、指标)认,同一个指标的几处引用之间来回切不清空
- 接口 api.whatif.run、推演相关类型(types.ts 末尾独立一段)、WHATIF_TEXT 文案
- 预览用例 ?syn=whatif / whatif-rail 和 check-whatif.mjs(54 项,用 page.route 假接口):请求体、防抖、回车、排除行、
  恢复原值、收起、不支持的输入、遮罩列、加载 / 失败 / 未封存、390px 与 360px 右栏不横向溢出、深浅色横幅对比度
This commit is contained in:
ThearchyHelios
2026-10-05 04:04:30 +08:00
parent 9852e4d33a
commit cf038c95f5
8 changed files with 1133 additions and 20 deletions
+14 -2
View File
@@ -1,7 +1,19 @@
/**
* 数字推演的接口(/api/runs/{run_id}/…)。现在是空的,数字推演的工作包往里加,挂在 api.whatif 上。
* 数字推演的接口(/api/runs/{run_id}/evidence/whatif),挂在 api.whatif 上。推演只读:不写运行记录、不改报告。
*
* 请求函数(get / post / del……)和 ApiError 从 ./http 导入,不要从 ./client 导入:client.ts 要导入
* 这个文件把它挂到 api 上,反过来导入就成了循环(见 http.ts 开头的说明)。
*/
export const whatif = {}
import type { WhatIfRequest, WhatIfResult } from '../types'
import { request } from './http'
export const whatif = {
/**
* 在一次运行的口径卡上推演。signal 用来丢掉过时的请求:输入还在改、又发了新的一次时,上一次的答复不再要。
* 取消照旧抛 AbortError(request 不把它当成连不上服务端)
*/
run: (runId: string, body: WhatIfRequest, opts?: { signal?: AbortSignal }) =>
request<WhatIfResult>(`/runs/${encodeURIComponent(runId)}/evidence/whatif`, {
method: 'POST', body: JSON.stringify(body), signal: opts?.signal,
}),
}
+33
View File
@@ -0,0 +1,33 @@
import type { JSX } from 'react'
import { EvidenceDoc } from '../../run/EvidenceDoc'
import evidenceQuery from '../../run/__tests__/evidence-query.json'
import type { EvidenceDocData } from '../../types'
/**
* 数字推演的预览用例,scripts/check-whatif.mjs 按它们检查:
* - ?syn=whatif:一份报告(evidence-query.json 里后端 compose_doc 真跑出来的文档,只用通用名),点数字打开证据面板,
* 指标步骤上的「推演」展开推演区。宽屏从右侧弹出,窄屏(390px)从底部抽出
* - ?syn=whatif-rail:同一份报告摆在 360px 的画布右栏里,面板栏内展开
*
* 片段接口、推演接口都由检查脚本用 page.route 伪造(按运行 id 认出这份夹具),直接打开时面板照实说取不到证据链。
* 给了 runClass,文档不再按运行 id 去问运行类别。
*/
const DOC = evidenceQuery.doc as unknown as EvidenceDocData
function Report({ dense = false }: { dense?: boolean }) {
return (
<EvidenceDoc doc={DOC} artifact={evidenceQuery.doc_artifact} runId={evidenceQuery.run_id} label="周报" tally
dense={dense} runClass="formal" />
)
}
export const cases: Record<string, () => JSX.Element> = {
whatif: () => (
<div className="mx-auto max-w-3xl p-4 text-sm" data-fixture="whatif"><Report /></div>
),
'whatif-rail': () => (
<div className="h-full w-[360px] max-w-full overflow-y-auto border-r bg-panel p-2.5" data-fixture="whatif-rail">
<Report dense />
</div>
),
}
+39 -1
View File
@@ -2758,9 +2758,47 @@ export const INQUIRY_TEXT = {
title: '审批质询',
}
/** 数字推演:在一次运行的基础上改动输入,推演指标的变化 */
/**
* 数字推演:在一次运行的基础上改动输入,推演指标的变化(证据面板里口径卡上的数,run/WhatIf.tsx)。
* 推演永远不是出具:横幅要说清不会写进运行记录、不会改动报告。行号对人一律从 1 数(和查询结果表格的 # 列一致)
*/
export const WHATIF_TEXT = {
title: '数字推演',
open: '推演',
openHint: '改动输入或排除查询结果中的行,查看这个数和报告会如何变化',
close: '收起推演',
banner: '推演,非出具:不会写进运行记录,也不会改动报告',
unsealed: '运行未封存,数据可能还会变',
sealBroken: '封存核对未通过,推演所依据的数据可能已被改动',
loading: '推演中…',
result: '推演结果',
unchanged: '无变化',
delta: (text: string) => `变化 ${text}`,
substituted: '推演代入式',
inputs: '输入',
original: (value: string) => `原值 ${value}`,
inputLabel: (path: string) => `${path} 的推演值`,
notNumber: '请输入数值',
masked: '已遮罩',
excludedRows: (rows: number[]) => `已排除第 ${rows.map((r) => r + 1).join('、')} 行`,
pickRows: (alias: string) => `在查询 ${alias} 中勾选要排除的行`,
rowsHint: '勾选的行在推演中视为不存在',
excludeHead: '排除',
excludeRow: (row: number) => `排除第 ${row + 1} 行`,
excludedCount: (n: number) => `已排除 ${n} 行`,
segments: '报告中会变的地方',
segmentsNone: (n: number) => `报告中引用这个指标的 ${n} 处不受影响`,
segmentsUnknown: '这个指标无法推演,报告中引用它的地方会如何变化无法确定',
locate: '定位',
inReport: (node: string) => `另一份报告(${node})`,
unsupported: '不支持推演的输入',
wholeMetric: '整个指标',
affected: '可能受影响的下游口径卡',
affectedHint: '这些口径卡引用了本卡的指标,推演不会重算它们',
affectedCard: (caliber: string, version: string) => `口径卡「${caliber || '未命名'}」${version ? ` ${version}` : ''}`,
indirect: '间接引用',
reset: '恢复原值',
empty: '这个指标的计算式没有引用任何输入,无可推演',
}
/** 双盲复算节点:节点库说明、检查器和运行结果 */
+55 -10
View File
@@ -30,6 +30,7 @@ import { useEvidenceHost } from './evidenceHost'
import { EvidenceProvenance } from './EvidenceProvenance'
import { CopyChip } from './Markdown'
import { SqlCheckList } from './SqlChecks'
import { WhatIf, WhatIfRows, WhatIfToggle, useWhatIfDraft } from './WhatIf'
/**
* 证据面板:点开报告里的一个片段,看它从哪来。
@@ -63,6 +64,9 @@ import { SqlCheckList } from './SqlChecks'
* Excel 导入期 4:上传表格的单元格多三节——数据版本、推断的来源、相关核对(EvidenceProvenance)。只在片段是单元格
* 引用、且片段接口的查询步骤带 provenance: true 时请求推断来源接口;推断来源的缓存只在面板开着期间有效。
*
* 数字推演(WhatIf.tsx):指标步骤上的「推演」展开推演区,改输入的值、在查询步骤里勾掉几行,看这个数和报告会变成
* 多少。推演不是出具,不写运行记录、不改报告。勾选框只在推演时出现在查询步骤的结果窗口里。
*
* 四种摆法:side 从右侧弹出(宽屏),drawer 从底部抽出(窄屏),inline 在 360px 的
* 画布右栏里直接栏内展开,dock 放进页面给的一块常驻位置(记录页的「证据」页签)。
* 都不是模态的:开着面板照样能在正文里走。
@@ -161,7 +165,7 @@ export function EvidencePanel({ id, mode, doc, artifact, runId, runClass, view,
? <ViolationList doc={doc} onLocate={onLocate} />
: found
? <SegmentBody panelId={id} doc={doc} artifact={artifact} runId={runId} runClass={runClass} seg={found.seg}
unit={found.unit} block={found.block} onViolations={onViolations} />
unit={found.unit} block={found.block} onViolations={onViolations} onLocate={onLocate} />
: claimAt
? <ClaimBody doc={doc} artifact={artifact} runId={runId} runClass={runClass} unit={claimAt.unit}
block={claimAt.block} />
@@ -205,11 +209,13 @@ function flashOnce(el: HTMLElement) {
setTimeout(() => { if (el.dataset.flash === 'focus') delete el.dataset.flash }, 1600)
}
function SegmentBody({ panelId, doc, artifact, runId, runClass, seg, unit, block, onViolations }: {
function SegmentBody({ panelId, doc, artifact, runId, runClass, seg, unit, block, onViolations, onLocate }: {
panelId: string
doc: EvidenceDocData; artifact?: string; runId?: string; runClass?: string; seg: EvidenceSegment; unit: EvidenceUnit
block: EvidenceBlock
onViolations: () => void
/** 跳到正文里的某个片段(推演区「报告中会变的地方」的定位) */
onLocate: (segId: string) => void
}) {
const report = doc.node_id || undefined
const key = runId ? segmentKey(runId, seg.id, report) : ''
@@ -295,6 +301,22 @@ function SegmentBody({ panelId, doc, artifact, runId, runClass, seg, unit, block
return i >= 0 ? { index: i, alias: queries[i].alias ?? '', go: () => jumpTo(i) } : undefined
}
const violations = (doc.violations ?? []).filter((v) => v.segment === seg.id)
// 数字推演:按(运行、报告、口径卡、指标)认一份草稿。同一个指标的几处引用之间来回切,推演不收起(定位到另一处
// 引用也是这样);换了指标、换成别的片段才收起清空。卡和指标先按文档目录认:切片段时证据链还没回来,也认得出是同一个
const whatifCard = entry?.kind === 'metric' ? entry.node_id : metricStep?.node_id
const whatifMetric = entry?.kind === 'metric' ? entry.locator?.metric : metricStep?.metric
const whatif = useWhatIfDraft(runId && isMetric && whatifCard && whatifMetric
? [runId, report ?? '', whatifCard, whatifMetric].join('\u0000') : '')
// 证据链回来了、正文就是封存的那一份才给推演:链是别的报告的,推演出来的数也对不上正文
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 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
}
const seal: EvidenceSeal | undefined = docIssue ? undefined : detail?.seal ?? graphData?.seal
const reason = reasonOf(seg) || detail?.note || ''
const verdict = sealVerdict(seal, {
@@ -369,7 +391,13 @@ function SegmentBody({ panelId, doc, artifact, runId, runClass, seg, unit, block
failed={chainFailed}
resolved={state === 'deterministic'}
linkOf={linkOf}
workflowName={workflowName(pinnedWorkflow(metricStep))} />
workflowName={workflowName(pinnedWorkflow(metricStep))}
whatif={whatifReady ? { open: whatifOpen, onToggle: whatif.toggle, controls: whatifId } : undefined}>
{whatifOpen && runId && whatifCard && whatifMetric && (
<WhatIf id={whatifId} runId={runId} report={report} card={whatifCard} metric={whatifMetric} draft={whatif}
queryOf={queryLink} onLocate={onLocate} />
)}
</MetricPart>
)}
{isMetric && inputs.some(notableInput) && (
@@ -396,7 +424,10 @@ function SegmentBody({ panelId, doc, artifact, runId, runClass, seg, unit, block
)}
{queries.map((q, i) => (
<QueryPart key={`${q.artifact ?? ''}-${i}`} id={queryId(i)} step={q} seal={seal} masked={masked} />
<QueryPart key={`${q.artifact ?? ''}-${i}`} id={queryId(i)} step={q} seal={seal} masked={masked}
pick={whatifOpen && q.artifact && !q.merged_into && pickable.has(q.artifact)
? { excluded: whatif.excluded(q.artifact), onToggle: (row) => whatif.toggleRow(q.artifact!, row) }
: undefined} />
))}
{provenanceOn && runId && <EvidenceProvenance runId={runId} segId={seg.id} report={report} />}
@@ -1073,8 +1104,8 @@ function Sentence({ unit, active }: { unit: EvidenceUnit; active: string }) {
)
}
/** 指标步骤:名称、值、口径与版本(来源、升版处置)、原式、代入式(输入标签)、复算 */
function MetricPart({ step, entry, inputs, runId, seal, pending, failed, resolved, linkOf, workflowName }: {
/** 指标步骤:名称、值、口径与版本(来源、升版处置)、原式、代入式(输入标签)、复算;可以展开数字推演 */
function MetricPart({ step, entry, inputs, runId, seal, pending, failed, resolved, linkOf, workflowName, whatif, children }: {
step?: EvidenceStep
entry?: Record<string, any>
inputs: EvidenceInput[]
@@ -1090,6 +1121,10 @@ function MetricPart({ step, entry, inputs, runId, seal, pending, failed, resolve
linkOf: (input: EvidenceInput) => InputLink
/** 钉住的上游工作流在目录里的名字(接口没给名字时用) */
workflowName?: string
/** 「推演」开关:证据链回来了、正文是封存的那一份才有 */
whatif?: { open: boolean; onToggle: () => void; controls: string }
/** 展开的推演区 */
children?: ReactNode
}) {
const host = useEvidenceHost()
const name = step?.name ?? entry?.name ?? step?.metric ?? entry?.locator?.metric ?? NONE
@@ -1131,6 +1166,7 @@ function MetricPart({ step, entry, inputs, runId, seal, pending, failed, resolve
{EVIDENCE_TEXT.sqlCheckFailed}
</span>
)}
{whatif && <WhatIfToggle open={whatif.open} onToggle={whatif.onToggle} controls={whatif.controls} />}
</div>
{(caliber || version || artifact || from) && (
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 text-2xs text-dim">
@@ -1194,6 +1230,7 @@ function MetricPart({ step, entry, inputs, runId, seal, pending, failed, resolve
{pending ? EVIDENCE_TEXT.chainPending : failed || (runId ? EVIDENCE_TEXT.chainMissing : EVIDENCE_TEXT.noRun)}
</p>
)}
{children}
</Part>
)
}
@@ -1322,7 +1359,11 @@ function SourcesPart({ inputs, linkOf }: { inputs: EvidenceInput[]; linkOf: (inp
* 复验哈希),复用工件查看。被遮罩的列在表里写「已遮罩」。查询快照哈希对不上时用失败色写出来:
* 这时候这些行不能当证据,比「有出处」重要得多
*/
function QueryPart({ id, step, seal, masked }: { id: string; step: EvidenceStep; seal?: EvidenceSeal; masked: string[] }) {
function QueryPart({ id, step, seal, masked, pick }: {
id: string; step: EvidenceStep; seal?: EvidenceSeal; masked: string[]
/** 推演时:结果窗口每行前面加勾选框,勾掉的行在推演里视为不存在 */
pick?: { excluded: number[]; onToggle: (row: number) => void }
}) {
const host = useEvidenceHost()
const win = useMemo(() => queryWindow(step), [step])
const [open, setOpen] = useState(false)
@@ -1412,9 +1453,13 @@ function QueryPart({ id, step, seal, masked }: { id: string; step: EvidenceStep;
)}
{win.columns.length > 0 && win.rows.length > 0 && (
<div ref={scroller} className={clsx(bad.length > 0 && 'mt-1.5')}>
<ResultTable table={table} full title={EVIDENCE_TEXT.query(alias)}
highlight={win.marks.length || win.cells?.length ? { rows: win.marks, cols: win.cols, cells: win.cells } : undefined}
masked={hidden} />
{pick && win.index
? <WhatIfRows win={win} hidden={hidden} excluded={pick.excluded} onToggle={pick.onToggle} />
: (
<ResultTable table={table} full title={EVIDENCE_TEXT.query(alias)}
highlight={win.marks.length || win.cells?.length ? { rows: win.marks, cols: win.cols, cells: win.cells } : undefined}
masked={hidden} />
)}
</div>
)}
{!win.rows.length && (
+457
View File
@@ -0,0 +1,457 @@
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react'
import clsx from 'clsx'
import { CornerDownRight, FlaskConical, LocateFixed, RotateCcw } from 'lucide-react'
import { api } from '../api/client'
import { ErrorNotice, Spinner, isComposing } from '../components/ui'
import { evidenceValue as valueText, type queryWindow } from '../lib/evidence'
import { NONE } from '../lib/format'
import { WHATIF_TEXT } from '../lib/terms'
import type { WhatIfInput, WhatIfMetric, WhatIfOverrides, WhatIfRequest, WhatIfResult } from '../types'
/**
* 数字推演:证据面板里点开口径卡上的一个数,改某个输入的值、或者在查询结果里勾掉几行,立即看到这个指标和报告里
* 引用它的地方会变成多少(POST …/evidence/whatif,后端 engine/recompute.py 按口径卡同一套规则重算)。
*
* 推演永远不是出具:不写进运行记录、不改报告。顶上的横幅一直在,运行没封存时另说「数据可能还会变」。
*
* 三样东西分开放:
* - useWhatIfDraft:改动的草稿(输入框里的原文、提交了的新值、勾掉的行)。放在证据面板的片段那一层(SegmentBody),
* 推演区和查询步骤的勾选框共用一份;同一个指标的几处引用之间来回切不清空,换了指标才清空
* - WhatIf:推演区。输入框停顿一会儿(或回车、失焦)才提交,勾行立刻提交;请求在路上时上一次的结果变淡留着,
* 新的改动来了旧请求直接丢掉。答复按请求体缓存,「恢复原值」回到原来那份不再请求
* - WhatIfRows:推演时查询步骤里的结果窗口换成它,每行前面多一个勾选框
*
* 不能改的输入(文本、列表、遮罩列、决定取哪一格的下标)照实显示,原因列在「不支持推演的输入」里。
*/
/** 输入框停顿多久提交一次:边打字边请求,结果跟着抖 */
const SETTLE_MS = 450
interface DraftState {
scope: string
open: boolean
/** 输入框里的原文。没碰过的输入不在里面,框里显示原值 */
text: Record<string, string>
/** 已提交的新值:请求按它发 */
inputs: Record<string, number>
/** 勾掉的行:查询快照的工件 id → 快照里的行号(从 0 数) */
exclude: Record<string, number[]>
}
const freshDraft = (scope: string, open = false): DraftState => ({ scope, open, text: {}, inputs: {}, exclude: {} })
export interface WhatIfDraft {
open: boolean
toggle: () => void
text: Record<string, string>
/** 输入框改了:停顿一会儿才提交。old 是这个输入的原值,填回原值不算改动 */
type: (path: string, value: string, old: unknown) => void
/** 回车、失焦:立刻提交 */
commit: () => void
/** Esc:输入框退回已提交的值(没提交过就是原值) */
revert: (path: string) => void
toggleRow: (artifact: string, row: number) => void
excluded: (artifact: string | undefined) => number[]
overrides: WhatIfOverrides
/** 有改动(含还没提交的输入框):「恢复原值」可点 */
dirty: boolean
reset: () => void
}
/** 输入框里的文本 → 数。空的是 undefined(回到原值),不是数的是 null。千分位逗号、空格不算错 */
export function parseNumber(raw: string): number | null | undefined {
const text = raw.replace(/[,,\s]/g, '')
if (!text) return undefined
if (!/^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/.test(text)) return null
const n = Number(text)
return Number.isFinite(n) ? n : null
}
/** 原值写进输入框的样子:数照原样(不加千分位,改起来不用先删逗号),缺的是空 */
const plain = (value: unknown): string => (typeof value === 'number' && Number.isFinite(value) ? String(value) : '')
/**
* 提交:输入框里写的是数、而且和原值不同的才算改动。清空了回到原值;写的不是数的保留上一次提交的值——
* 输错一个字,结果不该悄悄跳回原值
*/
function settled(state: DraftState, olds: Record<string, unknown>): Record<string, number> {
const out: Record<string, number> = {}
for (const [path, raw] of Object.entries(state.text)) {
const n = parseNumber(raw)
if (n === null) {
if (path in state.inputs) out[path] = state.inputs[path]
} else if (n !== undefined && olds[path] !== n) {
out[path] = n
}
}
return out
}
export function useWhatIfDraft(scope: string): WhatIfDraft {
const [stored, setState] = useState<DraftState>(() => freshDraft(scope))
// 换了一个指标(另一张卡、另一个数,或者不是指标了):推演收起、改动清空
const state = stored.scope === scope ? stored : freshDraft(scope)
if (stored.scope !== scope) setState(state)
const timer = useRef<ReturnType<typeof setTimeout>>(undefined)
const olds = useRef<Record<string, unknown>>({})
useEffect(() => () => clearTimeout(timer.current), [])
const commit = useCallback(() => {
clearTimeout(timer.current)
setState((s) => ({ ...s, inputs: settled(s, olds.current) }))
}, [])
const type = useCallback((path: string, value: string, old: unknown) => {
olds.current[path] = old
setState((s) => ({ ...s, text: { ...s.text, [path]: value } }))
clearTimeout(timer.current)
timer.current = setTimeout(commit, SETTLE_MS)
}, [commit])
const revert = useCallback((path: string) => {
setState((s) => {
const text = { ...s.text }
if (path in s.inputs) text[path] = String(s.inputs[path])
else delete text[path]
return { ...s, text }
})
}, [])
const toggleRow = useCallback((artifact: string, row: number) => {
setState((s) => {
const rows = s.exclude[artifact] ?? []
const next = rows.includes(row) ? rows.filter((r) => r !== row) : [...rows, row].sort((a, b) => a - b)
const exclude = { ...s.exclude }
if (next.length) exclude[artifact] = next
else delete exclude[artifact]
return { ...s, exclude }
})
}, [])
const reset = useCallback(() => {
clearTimeout(timer.current)
setState((s) => freshDraft(s.scope, s.open))
}, [])
// 收起就清空:再展开从原值开始,勾选框也跟着收起,不留一份看不见的改动
const toggle = useCallback(() => {
clearTimeout(timer.current)
setState((s) => (s.open ? freshDraft(s.scope) : { ...s, open: true }))
}, [])
const overrides = useMemo(() => ({ inputs: state.inputs, exclude_rows: state.exclude }), [state.inputs, state.exclude])
const excluded = useCallback((artifact: string | undefined) => (artifact ? state.exclude[artifact] ?? [] : []),
[state.exclude])
return {
open: state.open, toggle, text: state.text, type, commit, revert, toggleRow, excluded, overrides, reset,
dirty: Object.keys(state.text).length > 0 || Object.keys(state.inputs).length > 0 || Object.keys(state.exclude).length > 0,
}
}
/** 指标步骤上的「推演」开关 */
export function WhatIfToggle({ open, onToggle, controls }: { open: boolean; onToggle: () => void; controls: string }) {
return (
<button type="button" className={clsx('btn btn-xs ml-auto shrink-0', open && 'border-[var(--st-waiting)]')}
aria-expanded={open} aria-controls={controls} title={open ? WHATIF_TEXT.close : WHATIF_TEXT.openHint}
onClick={onToggle} data-whatif-toggle={open ? 'open' : 'closed'}>
<FlaskConical size={11} aria-hidden /> {WHATIF_TEXT.open}
</button>
)
}
type Slot = { key: string; status: 'loading' | 'ok' | 'error'; data?: WhatIfResult; error?: unknown; last?: WhatIfResult }
/** 推演区:横幅、结果、输入、不支持的输入、报告里会变的地方、下游口径卡、恢复原值 */
export function WhatIf({ id, runId, report, card, metric, draft, queryOf, onLocate }: {
id: string
runId: string
/** 正文这份报告的撰写节点 id:按它选口径卡算过的那一份,报告里会变的地方也只列它的(都能定位) */
report?: string
/** 口径卡节点 id */
card: string
metric: string
draft: WhatIfDraft
/** 输入落在哪个查询步骤上:有的话给「在查询 Qn 中勾选要排除的行」,点一下跳过去 */
queryOf: (artifact?: string) => { alias: string; go: () => void } | undefined
/** 定位到正文里的一个片段 */
onLocate?: (segId: string) => void
}) {
const body: WhatIfRequest = useMemo(() => ({ ...(report ? { report } : {}), card, metric, overrides: draft.overrides }),
[report, card, metric, draft.overrides])
const key = JSON.stringify(body)
const [slot, setSlot] = useState<Slot>({ key: '', status: 'loading' })
const [nonce, setNonce] = useState(0)
const cache = useRef(new Map<string, WhatIfResult>())
useEffect(() => {
const hit = cache.current.get(key)
if (hit) {
setSlot({ key, status: 'ok', data: hit, last: hit })
return
}
const ctrl = new AbortController()
setSlot((s) => ({ key, status: 'loading', last: s.last }))
api.whatif.run(runId, JSON.parse(key) as WhatIfRequest, { signal: ctrl.signal }).then((data) => {
cache.current.set(key, data)
setSlot({ key, status: 'ok', data, last: data })
}, (error: unknown) => {
if ((error as { name?: string } | null)?.name === 'AbortError') return
setSlot((s) => ({ key, status: 'error', error, last: s.last }))
})
return () => ctrl.abort()
}, [runId, key, nonce])
// 改动之后、请求还没发出去的那一下也算在路上:别拿上一次的答复当这次的
const status = slot.key === key ? slot.status : 'loading'
const data = status === 'ok' ? slot.data : slot.last
const loading = status === 'loading'
const m: WhatIfMetric | undefined = data?.metrics.find((x) => x.id === metric) ?? data?.metrics[0]
const unsupported = (data?.unsupported ?? []).filter((u) => u.metric === metric)
const unknown = !!m && m.rendered_new == null
const changedSegs = (data?.segments ?? []).filter((s) => s.changed)
return (
<section id={id} className="mt-2 space-y-2.5 rounded border p-2" data-whatif="" aria-label={WHATIF_TEXT.title}
style={{ borderColor: 'color-mix(in srgb, var(--st-waiting) 45%, var(--border))' }}>
{/* 醒目靠底色、左边的色条和图标;字用正文色:提醒色的字压在同色的浅底上,浅色主题下对比度不到 4.5 */}
<p role="note" className="flex items-start gap-1.5 rounded-r px-2 py-1.5 font-medium leading-snug" data-whatif-banner=""
style={{ color: 'var(--text)', background: 'var(--st-waiting-soft)', borderLeft: '3px solid var(--st-waiting)' }}>
<FlaskConical size={13} className="mt-px shrink-0" style={{ color: 'var(--st-waiting)' }} aria-hidden />
<span>{WHATIF_TEXT.banner}</span>
</p>
{data && !data.sealed && (
<p className="text-2xs" style={{ color: 'var(--st-waiting)' }} data-whatif-unsealed={data.seal.sealed ? 'broken' : 'open'}>
{data.seal.sealed && data.seal.ok === false ? WHATIF_TEXT.sealBroken : WHATIF_TEXT.unsealed}
</p>
)}
{status === 'error' && <ErrorNotice error={slot.error} onRetry={() => setNonce((n) => n + 1)} />}
{!data && loading && (
<p className="flex items-center gap-1.5 text-dim" data-whatif-loading=""><Spinner size={12} /> {WHATIF_TEXT.loading}</p>
)}
{m && status !== 'error' && (
<div aria-live="polite" aria-busy={loading || undefined} className={clsx('transition-opacity', loading && 'opacity-60')}
data-whatif-result={loading ? 'loading' : m.status}>
<div className="mb-0.5 text-2xs font-medium text-faint">{WHATIF_TEXT.result}</div>
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span className="mono tnum text-dim" data-whatif-old="">{m.rendered_old}</span>
<span className="text-faint" aria-hidden>→</span>
<span className="mono tnum text-sm font-semibold" data-whatif-new=""
style={m.changed ? { color: 'var(--accent)' } : undefined}>{m.rendered_new ?? NONE}</span>
{m.rendered_delta
? <span className="tnum text-2xs" data-whatif-delta="">{WHATIF_TEXT.delta(m.rendered_delta)}</span>
: m.changed === false && <span className="text-2xs text-faint" data-whatif-delta="">{WHATIF_TEXT.unchanged}</span>}
{loading && <span className="text-faint"><Spinner size={11} /></span>}
</div>
{m.reason && <p className="mt-0.5" style={{ color: 'var(--st-waiting)' }} data-whatif-reason="">{m.reason}</p>}
{m.status === 'ok' && m.changed && m.substituted && (
<div className="mt-1">
<div className="text-2xs text-faint">{WHATIF_TEXT.substituted}</div>
<code className="mono block whitespace-pre-wrap break-all rounded bg-bg px-1.5 py-1 text-2xs" data-whatif-substituted="">
{m.substituted}
</code>
</div>
)}
</div>
)}
{m && (
<div data-whatif-inputs="">
<div className="mb-1 text-2xs font-medium text-faint">{WHATIF_TEXT.inputs}</div>
{m.inputs.length ? (
<ul className="space-y-1.5">
{m.inputs.map((inp, i) => (
<InputRow key={inp.path} id={`${id}-in${i}`} input={inp} draft={draft} link={queryOf(inp.artifact)} />
))}
</ul>
) : <p className="text-dim" data-whatif-empty="">{WHATIF_TEXT.empty}</p>}
</div>
)}
{unsupported.length > 0 && (
<div data-whatif-unsupported="">
<div className="mb-0.5 text-2xs font-medium text-faint">{WHATIF_TEXT.unsupported}</div>
<ul className="space-y-0.5">
{unsupported.map((u) => (
<li key={u.input ?? ''} className="[overflow-wrap:anywhere]" data-whatif-unsupported-input={u.input ?? ''}>
<span className="mono">{u.input ?? WHATIF_TEXT.wholeMetric}</span>
<span className="text-dim">:{u.reason}</span>
</li>
))}
</ul>
</div>
)}
{m && !!data?.segments.length && (
<div data-whatif-segments="">
<div className="mb-0.5 text-2xs font-medium text-faint">{WHATIF_TEXT.segments}</div>
{unknown ? <p className="text-dim">{WHATIF_TEXT.segmentsUnknown}</p>
: changedSegs.length ? (
<ul className="space-y-0.5">
{changedSegs.map((s) => (
<li key={`${s.report}-${s.sid}`} className="flex flex-wrap items-center gap-x-1.5" data-whatif-segment={s.sid}>
<span className="mono tnum text-dim">{s.rendered_old}</span>
<span className="text-faint" aria-hidden>→</span>
<span className="mono tnum font-semibold">{s.rendered_new}</span>
{onLocate && (!report || s.report === report)
? (
<button type="button" className="btn btn-xs btn-ghost" onClick={() => onLocate(s.sid)}
data-whatif-locate={s.sid}>
<LocateFixed size={10} aria-hidden /> {WHATIF_TEXT.locate}
</button>
)
: <span className="text-2xs text-faint">{WHATIF_TEXT.inReport(s.report)}</span>}
</li>
))}
</ul>
) : <p className="text-dim" data-whatif-segments-none="">{WHATIF_TEXT.segmentsNone(data.segments.length)}</p>}
</div>
)}
{!!data?.affected_cards.length && (
<div data-whatif-affected="">
<div className="text-2xs font-medium text-faint">{WHATIF_TEXT.affected}</div>
<p className="mb-0.5 text-2xs text-faint">{WHATIF_TEXT.affectedHint}</p>
<ul className="space-y-0.5">
{data.affected_cards.map((c) => (
<li key={c.node_id} data-whatif-affected-card={c.node_id}>
{WHATIF_TEXT.affectedCard(c.caliber, c.version)}
{!c.direct && <span className="text-faint"> · {WHATIF_TEXT.indirect}</span>}
</li>
))}
</ul>
</div>
)}
<div className="flex items-center gap-2 border-t pt-2">
<button type="button" className="btn btn-xs" onClick={draft.reset} disabled={!draft.dirty} data-whatif-reset="">
<RotateCcw size={11} aria-hidden /> {WHATIF_TEXT.reset}
</button>
{loading && data && <span className="flex items-center gap-1 text-2xs text-faint"><Spinner size={11} /> {WHATIF_TEXT.loading}</span>}
</div>
</section>
)
}
/** 一个输入:能改的给输入框(原值写在旁边),别的照实显示;排除了几行、去哪一步勾行,写在下面 */
function InputRow({ id, input, draft, link }: {
id: string; input: WhatIfInput; draft: WhatIfDraft; link?: { alias: string; go: () => void }
}) {
const raw = draft.text[input.path]
const committed = draft.overrides.inputs[input.path]
const baseline = committed !== undefined ? String(committed) : plain(input.old)
const invalid = raw !== undefined && parseNumber(raw) === null
const onKeyDown = (e: ReactKeyboardEvent<HTMLInputElement>) => {
if (isComposing(e)) return
if (e.key === 'Enter') {
e.preventDefault()
draft.commit()
} else if (e.key === 'Escape' && raw !== undefined && raw !== baseline) {
// 改了一半的值先退回去:这一下 Esc 不关面板
e.preventDefault()
e.stopPropagation()
draft.revert(input.path)
}
}
const fromQuery = !!link && !input.masked && (input.kind === 'container' || !!input.locator)
return (
<li className="rounded border px-2 py-1.5" data-whatif-input={input.path} data-whatif-kind={input.kind}
style={input.changed ? { borderColor: 'color-mix(in srgb, var(--accent) 55%, var(--border))' } : undefined}>
<div className="mono truncate text-2xs text-dim" title={input.path}>{input.path}</div>
{input.masked ? (
<p className="mt-0.5 italic text-faint" data-whatif-masked="">{WHATIF_TEXT.masked}</p>
) : input.editable ? (
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1">
<input id={id} type="text" inputMode="decimal" autoComplete="off" spellCheck={false}
className={clsx('field mono tnum w-36 min-w-0 py-1', invalid && 'border-[var(--err)]')}
value={raw ?? plain(input.old)} placeholder={input.old == null ? NONE : undefined}
aria-label={WHATIF_TEXT.inputLabel(input.path)} aria-invalid={invalid || undefined}
aria-describedby={invalid ? `${id}-err` : undefined}
onChange={(e) => draft.type(input.path, e.target.value, input.old)}
onKeyDown={onKeyDown} onBlur={draft.commit} data-whatif-field="" />
<span className="tnum text-2xs text-faint">{WHATIF_TEXT.original(valueText(input.old))}</span>
{invalid && <span id={`${id}-err`} className="text-2xs" style={{ color: 'var(--err)' }}>{WHATIF_TEXT.notNumber}</span>}
</div>
) : (
<p className="mono tnum mt-0.5 [overflow-wrap:anywhere]" data-whatif-value="">
{valueText(input.old)}
{input.changed && <> <span className="text-faint" aria-hidden>→</span> {valueText(input.new)}</>}
</p>
)}
{!!input.excluded_rows?.length && (
<p className="mt-0.5 text-2xs" style={{ color: 'var(--st-waiting)' }} data-whatif-excluded="">
{WHATIF_TEXT.excludedRows(input.excluded_rows)}
</p>
)}
{fromQuery && (
<button type="button" className="btn btn-xs btn-ghost -ml-1 mt-0.5" onClick={link!.go} data-whatif-pick="">
<CornerDownRight size={10} aria-hidden /> {WHATIF_TEXT.pickRows(link!.alias)}
</button>
)}
</li>
)
}
/**
* 推演时查询步骤里的结果窗口:每行前面一个勾选框,勾掉的行在推演里视为不存在(划掉、变淡)。只有知道每一行
* 在快照里是第几行时才画(index);被遮罩的列照样写「已遮罩」,高亮的格和平时一样描边
*/
export function WhatIfRows({ win, hidden, excluded, onToggle }: {
win: ReturnType<typeof queryWindow>; hidden: string[]; excluded: number[]; onToggle: (row: number) => void
}) {
if (!win.index) return null
const index = win.index
const masked = new Set(hidden)
const cells = win.cells ? new Set(win.cells.map(([r, c]) => `${r}\u0000${c}`)) : null
const marked = new Set([...win.marks, ...(win.cells ?? []).map(([r]) => r)])
const cols = new Set(win.cols)
return (
<div className="overflow-hidden rounded border" data-whatif-rows="">
<div className="overflow-x-auto">
<table className="w-full text-2xs">
<thead>
<tr className="border-b bg-elev">
{/* 勾选框和行号两列只占自己的宽度:表格撑满窄栏时多出来的宽度留给数据列 */}
<th className="w-px whitespace-nowrap px-1.5 py-1 text-left font-medium text-dim">{WHATIF_TEXT.excludeHead}</th>
<th className="mono w-px px-1.5 py-1 text-right font-medium text-dim">#</th>
{win.columns.map((c) => <th key={c} className="mono px-1.5 py-1 text-left font-medium text-dim">{c}</th>)}
</tr>
</thead>
<tbody>
{win.rows.map((row, i) => {
const n = index[i]
const off = excluded.includes(n)
const hit = marked.has(i)
return (
<tr key={n} className="border-b last:border-0" data-whatif-row={n} data-excluded={off ? '' : undefined}
style={hit && !off ? { background: 'var(--st-done-soft)' } : undefined}>
<td className="w-px px-1.5 py-1 text-center">
<input type="checkbox" className="align-middle" checked={off} onChange={() => onToggle(n)}
aria-label={WHATIF_TEXT.excludeRow(n)} />
</td>
<td className="mono w-px px-1.5 py-1 text-right text-faint">{n + 1}</td>
{row.map((cell, j) => {
const col = win.columns[j]
const mark = hit && !off && (cells ? cells.has(`${i}\u0000${col}`) : cols.has(col))
const mask = masked.has(col)
return (
<td key={j} className={clsx('mono max-w-[160px] truncate px-1.5 py-1',
off ? 'text-faint line-through' : mark ? 'font-semibold' : 'text-dim')}
data-highlight={mark ? 'cell' : undefined} data-masked={mask ? '' : undefined}
style={mark ? { outline: '1px solid var(--st-done)', outlineOffset: -1, color: 'var(--text)' } : undefined}
title={mask ? WHATIF_TEXT.masked : cell == null ? '' : String(cell)}>
{mask ? <span className="font-sans italic text-faint">{WHATIF_TEXT.masked}</span>
: cell == null ? <span className="opacity-40">null</span>
: typeof cell === 'object' ? JSON.stringify(cell) : String(cell)}
</td>
)
})}
</tr>
)
})}
</tbody>
</table>
</div>
<div className="flex flex-wrap items-center gap-x-2 border-t px-1.5 py-1 text-2xs text-faint">
<span>{WHATIF_TEXT.rowsHint}</span>
{excluded.length > 0 && (
<span style={{ color: 'var(--st-waiting)' }} data-whatif-excluded-count="">{WHATIF_TEXT.excludedCount(excluded.length)}</span>
)}
</div>
</div>
)
}
+113
View File
@@ -3020,3 +3020,116 @@ export interface SqlCheckItem {
relation_id?: string
sql_excerpt?: string
}
// ===========================================================================
// 数字推演(后端 app/api/whatif.py、app/engine/recompute.py):在一次运行的口径卡上改输入、排除查询快照里的行,
// 看指标和报告里引用它的地方会变成多少。推演不是出具:不写运行记录、不改报告
// ===========================================================================
/** 推演的改动:输入路径 → 新值;查询快照的工件 id → 要排除的行号(快照里的行号,从 0 数) */
export interface WhatIfOverrides {
inputs: Record<string, number>
exclude_rows: Record<string, number[]>
}
/** POST /runs/{id}/evidence/whatif 的请求体 */
export interface WhatIfRequest {
/** 报告撰写节点 id:只列这份报告里引用的地方 */
report?: string
/** 口径卡节点 id */
card: string
/** 只推演这一个指标 */
metric?: string
overrides: WhatIfOverrides
}
/** 一个输入在推演里的样子 */
export interface WhatIfInput {
path: string
via?: string | null
node_id?: string | null
/** 落在哪份查询快照上(cell() 取的格、agent 字段、整份查询结果):排除这份快照里的行会影响它 */
artifact?: string
locator?: EvidenceLocator
kind: 'number' | 'text' | 'bool' | 'missing' | 'container' | (string & {})
/** 数值(或原本缺失)的标量、不是遮罩列、不决定取哪一格:可以改 */
editable: boolean
/** 原值、推演用的值。容器写大概(「[12 项]」);遮罩列的一律是 null */
old?: unknown
new?: unknown
changed?: boolean
/** 起了作用的被排除行(快照里的行号,从 0 数) */
excluded_rows?: number[]
masked?: boolean
}
/**
* unchanged:没碰到改动,原值原样交回;ok:重算了;excluded:排除行后缺了输入,算不出来;error:新值让计算出错(除以零……);
* unsupported:有输入的完整值找不回来,推演不了;masked:用到了遮罩列,不推演
*/
export type WhatIfStatus = 'unchanged' | 'ok' | 'excluded' | 'error' | 'unsupported' | 'masked'
export interface WhatIfMetric {
id: string
name: string
unit: string
decimals?: number | null
format?: string
old: unknown
new: unknown
delta: number | null
/** 按口径卡的格式渲染(和报告里的字同一套规则) */
rendered_old: string
/** unsupported、masked 时为 null:不知道会变成什么 */
rendered_new: string | null
/** 带正负号;比率、百分数写「个百分点」。没变化、不是数时为 null */
rendered_delta: string | null
changed: boolean | null
status: WhatIfStatus | (string & {})
reason: string | null
/** 推演用的代入式(重算了才有;没碰到改动的是卡上原来的代入式) */
substituted: string | null
inputs: WhatIfInput[]
}
/** 报告里引用这个指标的一处,推演之后会写成什么 */
export interface WhatIfSegment {
report: string
unit: string
sid: string
metric: string
rendered_old: string
rendered_new: string | null
changed: boolean | null
}
export interface WhatIfUnsupported {
metric: string
/** null:整个指标都推演不了(口径卡没记输入) */
input: string | null
reason: string
}
/** 引用了这张口径卡的下游口径卡:只标可能受影响,不重算 */
export interface WhatIfAffectedCard {
node_id: string
artifact: string
caliber: string
version: string
metrics: string[]
/** 直接引用(false 是经别的卡间接引用) */
direct: boolean
}
export interface WhatIfResult {
run_id: string
card: { node_id: string; artifact: string; caliber: string; version: string }
report: string | null
/** 运行已封存且封存核对一致:推演依据的数据不会再变 */
sealed: boolean
seal: { sealed: boolean; ok: boolean | null }
metrics: WhatIfMetric[]
segments: WhatIfSegment[]
unsupported: WhatIfUnsupported[]
affected_cards: WhatIfAffectedCard[]
}
+1 -1
View File
@@ -96,7 +96,7 @@ const ESTIMATE = {
stream: 173, chat: 160, studio: 149, manage: 145, runs: 112, publish: 92, shell: 87,
evidence: 91, versions: 60, catalog: 45, 'ui-kit': 70, ui: 61, guards: 8, 'canvas-layout': 2, copy: 2, tokens: 1, trace: 1, decode: 1,
// 占位,换成真检查时按实测改
schedules: 1, compare: 1, verify: 1, inquiry: 1, bundle: 1, whatif: 1,
schedules: 1, compare: 1, verify: 1, inquiry: 1, bundle: 1, whatif: 25,
}
// 最慢的两项按段拆成几份。用的是它们自己的段过滤:最后一份用 *_SKIP 兜住「其余全部」,以后新加的段
// 不用改这里也跑得到(拆出去的那几份要是一段都没对上,那一份会报「一项都没跑」)。
+421 -6
View File
@@ -1,8 +1,423 @@
// 数字推演的检查。现在只是占位:功能还在开发,这里只打一行通过,让 check-all 的清单先排上它。
// 数字推演的工作包把它换成真检查:沿用别的检查的输出格式——每项缩进两格、以 ✓ / ✗ 开头,最后顶格一行
// 总结,没过的退出码非 0(check-all 按缩进的 ✓ / ✗ 计数)。
// 数字推演(证据面板里口径卡上的数,run/WhatIf.tsx)的回归检查。
//
// 守的是这些「看着没坏、其实用不了」的地方:横幅没了或者不醒目(推演被当成出具)、边打字边发请求、请求体里的
// 输入路径和行号不对(行号对人从 1 数,对接口从 0 数)、勾选框在不推演时也冒出来、恢复原值之后还留着一行没勾回去、
// 不支持的输入一声不吭、窄屏(390px)被推演区撑出横向滚动、浅色主题下横幅看不清。
//
// 跑在预览页上:/preview.html?syn=whatif(宽屏右侧弹出、窄屏底部抽出)和 ?syn=whatif-rail(360px 画布右栏)。
// 文档是 frontend/src/run/__tests__/evidence-query.json 里后端 compose_doc 真跑出来的(只用通用名);片段接口
// 按它的 segments 作答,推演接口按后端 engine/recompute.py 的规则在这里算(只覆盖用到的三个指标),非 GET 一律拦掉。
//
// node scripts/check-whatif.mjs
console.log('=== 数字推演 ===')
console.log(' ✓ 占位:功能开发中')
console.log('\n✓ 数字推演:占位通过')
// 对别的实例跑时带上地址:AGENTLAB_WEB=http://localhost:<前端端口> node scripts/check-whatif.mjs
// WHATIF_SHOTS=<目录> 时亮暗两套各截几张,供人眼复核。
import { mkdirSync, readFileSync } from 'node:fs'
import { chromium } from '../frontend/node_modules/playwright-core/index.mjs'
const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273'
const CHROME = process.env.CHROME_PATH ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
const SHOTS = process.env.WHATIF_SHOTS ?? ''
const root = new URL('..', import.meta.url).pathname
const fx = JSON.parse(readFileSync(`${root}frontend/src/run/__tests__/evidence-query.json`, 'utf8'))
const RUN = fx.run_id
const BANNER = '推演,非出具:不会写进运行记录,也不会改动报告'
let failed = 0
const check = (name, cond, detail = '') => {
console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`)
if (!cond) failed++
}
async function section(name, fn) {
console.log(`\n=== ${name} ===`)
try {
await fn()
} catch (e) {
check(`${name} 中途出错`, false, String(e?.message ?? e).split('\n')[0])
}
}
// ---------------------------------------------------------------- 夹具
/** 片段接口的答复:夹具里的原样,s22 改成老运行的容器输入(推演不了的那种) */
function segmentReply(sid) {
const body = structuredClone(fx.segments[sid])
if (sid === 's22') {
body.chain[0] = { ...body.chain[0], expression: 'sum(vars.adjust.items)', substituted: 'sum(vars.adjust.items)' }
body.chain[1] = { step: 'input', path: 'vars.adjust.items', value: '[4 项]', node_id: 'adjust', via: 'code',
field: 'items', role: 'compute', status: 'ok' }
}
return body
}
const chainOf = (sid) => fx.segments[sid].chain
const inputsOf = (sid) => chainOf(sid).filter((s) => s.step === 'input')
const Q1 = chainOf('s12')[1].artifact
const Q4 = chainOf('s12')[2].artifact
const Q3 = inputsOf('s70')[0].artifact
const thousands1 = (v) => v.toLocaleString('en-US', { minimumFractionDigits: 1, maximumFractionDigits: 1 })
/** 推演接口在这里按后端的规则算:给了新值的用新值;cell / agent 字段那一格所在的行被排除了,指标排除后无法计算 */
const METRICS = {
wow: {
sid: 's12', name: '环比增幅', unit: '%', calc: ([g, p]) => Math.round((g - p) / p * 1000) / 10,
render: (v) => `${v.toFixed(1)}%`, delta: (d) => `${d > 0 ? '+' : '-'}${Math.abs(d).toFixed(1)} 个百分点`,
},
spread: {
sid: 's70', name: '首末单差额', unit: '元', calc: ([t, l]) => Math.round((t - l) * 10) / 10,
render: (v) => `${thousands1(v)}元`, delta: (d) => `${d > 0 ? '+' : '-'}${thousands1(Math.abs(d))}元`,
},
}
function whatifReply(body) {
const overrides = body?.overrides ?? {}
const inputs = overrides.inputs ?? {}
const exclude = overrides.exclude_rows ?? {}
const base = { run_id: RUN, card: { node_id: 'caliber', artifact: chainOf('s12')[0].artifact, caliber: '周报口径', version: 'v3' },
report: body?.report ?? null, sealed: true, seal: { sealed: true, ok: true }, affected_cards: [] }
if (body?.metric === 'adj') {
return { ...base, metrics: [{ id: 'adj', name: '调整后销售额', unit: '元', old: 44000, new: 44000, delta: 0,
rendered_old: '44,000.0元', rendered_new: '44,000.0元', rendered_delta: null, changed: false, status: 'unchanged',
reason: null, substituted: 'sum(vars.adjust.items)',
inputs: [{ path: 'vars.adjust.items', via: 'code', node_id: 'adjust', kind: 'container', editable: false,
old: '[4 项]', new: '[4 项]', changed: false }] }],
segments: [{ report: 'write', unit: 'u4', sid: 's22', metric: 'adj', rendered_old: '44,000.0元', rendered_new: '44,000.0元', changed: false }],
unsupported: [{ metric: 'adj', input: 'vars.adjust.items', reason: '无法推演:当时没有保存这个输入的完整值' }] }
}
const def = METRICS[body?.metric]
const step = chainOf(def.sid)[0]
const views = inputsOf(def.sid).map((inp) => {
const row = inp.locator?.row
const view = { path: inp.path, via: inp.via, node_id: inp.node_id, artifact: inp.artifact, locator: inp.locator,
kind: 'number', editable: true, old: inp.value }
if (inp.path in inputs) return { ...view, new: inputs[inp.path], changed: inputs[inp.path] !== inp.value, touched: true }
if ((exclude[inp.artifact] ?? []).includes(row)) {
return { ...view, new: null, changed: true, excluded_rows: [row], touched: true, gone: true }
}
return { ...view, new: inp.value, changed: false }
})
const touched = views.some((v) => v.touched)
const gone = views.find((v) => v.gone)
let status = 'unchanged'; let value = step.value; let reason = null
if (touched && gone) {
status = 'excluded'; value = null; reason = `排除后无法计算:${gone.path} 所在的第 ${gone.locator.row + 1} 行已排除`
} else if (touched) {
status = 'ok'; value = def.calc(views.map((v) => v.new))
}
const delta = value == null ? null : Math.round((value - step.value) * 10) / 10
const rendered = value == null ? '—' : def.render(value)
const metric = { id: step.metric, name: def.name, unit: def.unit, old: step.value, new: value, delta,
rendered_old: step.rendered, rendered_new: rendered, rendered_delta: delta ? def.delta(delta) : null,
changed: value !== step.value, status, reason, substituted: status === 'ok' ? `(推演代入式 ${views.map((v) => v.new).join('、')})` : step.substituted,
inputs: views.map(({ touched: _t, gone: _g, ...v }) => v) }
return { ...base, metrics: [metric], unsupported: [],
segments: [{ report: 'write', unit: fx.segments[def.sid].unit.id, sid: def.sid, metric: step.metric,
rendered_old: step.rendered, rendered_new: rendered, changed: rendered !== step.rendered }] }
}
// ---------------------------------------------------------------- 页面
const browser = await chromium.launch({ executablePath: CHROME })
/**
* 开一页:片段接口按夹具答,推演接口按 whatifReply 答(reply 给了就由它改:延迟、出错、未封存),记下每次推演的
* 请求体和时刻。别的非 GET 一律拦掉,别的 GET 放行
*/
async function open(syn, { width = 1280, height = 900, theme = 'dark', reply = null } = {}) {
const ctx = await browser.newContext({ viewport: { width, height }, colorScheme: theme, timezoneId: 'Asia/Shanghai' })
ctx.setDefaultTimeout(6000)
const page = await ctx.newPage()
const sent = []
const errors = []
page.on('pageerror', (e) => errors.push(e.message))
page.on('dialog', (d) => { errors.push(`原生对话框:${d.message()}`); void d.dismiss() })
await page.route((u) => new URL(u).pathname.startsWith('/api/'), async (route) => {
const req = route.request()
const url = new URL(req.url())
if (req.method() === 'POST' && url.pathname === `/api/runs/${RUN}/evidence/whatif`) {
const body = req.postDataJSON()
sent.push({ body, at: Date.now() })
const out = reply ? await reply(body, sent.length) : { json: whatifReply(body) }
return route.fulfill({ status: out.status ?? 200, json: out.json })
}
if (req.method() !== 'GET') return route.abort()
const seg = url.pathname.match(new RegExp(`/api/runs/${RUN}/evidence/segments/([^/]+)$`))
if (seg) return route.fulfill({ json: segmentReply(decodeURIComponent(seg[1])) })
return route.continue()
})
await page.goto(`${WEB}/preview.html?syn=${syn}&theme=${theme}`, { waitUntil: 'networkidle' })
await page.waitForSelector('[data-evidence-doc]')
return { ctx, page, sent, errors }
}
/** 点开一个片段,等指标步骤和「推演」开关出来 */
async function openMetric(page, sid) {
await page.click(`[data-seg="${sid}"]`)
await page.waitForSelector('[data-ev-metric] [data-whatif-toggle]')
}
/** 展开推演,等第一份答复画出来 */
async function openWhatIf(page) {
await page.click('[data-whatif-toggle]')
await page.waitForSelector('[data-whatif-result="unchanged"], [data-whatif-result="ok"]')
}
const settle = (page, ms = 700) => page.waitForTimeout(ms)
const text = (page, sel) => page.locator(sel).first().innerText()
const shot = async (page, name) => {
if (!SHOTS) return
mkdirSync(SHOTS, { recursive: true })
await page.screenshot({ path: `${SHOTS}/${name}.png`, fullPage: false })
}
await section('打开推演', async () => {
const { ctx, page, sent, errors } = await open('whatif')
await openMetric(page, 's12')
const toggle = page.locator('[data-whatif-toggle]')
check('指标步骤上有「推演」,默认收起', (await toggle.getAttribute('aria-expanded')) === 'false')
check('不推演时查询步骤里没有勾选框', await page.locator('[data-whatif-rows], [data-whatif-row] input').count() === 0)
await openWhatIf(page)
check('展开后开关标为展开、指向推演区', (await toggle.getAttribute('aria-expanded')) === 'true'
&& (await page.locator(`#${await toggle.getAttribute('aria-controls')}[data-whatif]`).count()) === 1)
check('顶上醒目写着「推演,非出具」', (await text(page, '[data-whatif-banner]')).trim() === BANNER)
check('第一次请求不带改动', JSON.stringify(sent[0]?.body) === JSON.stringify(
{ report: 'write', card: 'caliber', metric: 'wow', overrides: { inputs: {}, exclude_rows: {} } }), JSON.stringify(sent[0]?.body))
check('原值 → 原值,标「无变化」', (await text(page, '[data-whatif-new]')) === '8.7%'
&& (await text(page, '[data-whatif-delta]')) === '无变化')
const fields = await page.locator('[data-whatif-field]').count()
check('两个数值输入都能改,框里是原值', fields === 2
&& await page.locator('[data-whatif-input="vars.kpi.gmv"] [data-whatif-field]').inputValue() === '45678.5')
check('取自查询的输入给「去勾选要排除的行」', await page.locator('[data-whatif-pick]').count() === 2)
check('输入落在的两个查询步骤里出现勾选框', await page.locator('[data-whatif-rows]').count() === 2
&& await page.locator('[data-whatif-row] input[type="checkbox"]').count() === 2)
check('报告里引用它的一处不受影响', (await text(page, '[data-whatif-segments-none]')).includes('1 处不受影响'))
check('「恢复原值」在没有改动时不可点', await page.locator('[data-whatif-reset]').isDisabled())
await shot(page, 'open-dark')
check('页面没有报错', errors.length === 0, errors.join(';'))
await ctx.close()
})
await section('改一个输入', async () => {
const { ctx, page, sent } = await open('whatif')
await openMetric(page, 's12')
await openWhatIf(page)
const field = page.locator('[data-whatif-input="vars.kpi.gmv"] [data-whatif-field]')
await field.fill('')
const before = sent.length
await field.pressSequentially('50000', { delay: 40 })
await page.waitForTimeout(150)
check('边打字不发请求', sent.length === before)
await page.waitForSelector('[data-whatif-result="ok"]')
check('停顿之后只发一次', sent.length === before + 1, `${sent.length - before} 次`)
check('请求体带着改动的输入', JSON.stringify(sent.at(-1).body.overrides) === JSON.stringify({ inputs: { 'vars.kpi.gmv': 50000 }, exclude_rows: {} }),
JSON.stringify(sent.at(-1).body.overrides))
check('显示新值和变化量', (await text(page, '[data-whatif-new]')) === '19.0%'
&& (await text(page, '[data-whatif-delta]')) === '变化 +10.3 个百分点', await text(page, '[data-whatif-result]'))
check('报告里会变的那一处:8.7% → 19.0%,可定位', (await text(page, '[data-whatif-segment="s12"]')).includes('19.0%')
&& await page.locator('[data-whatif-locate="s12"]').count() === 1)
check('横幅一直在', (await text(page, '[data-whatif-banner]')).trim() === BANNER)
// 回车立刻提交,不等停顿
await field.fill('60000')
const t0 = Date.now()
await field.press('Enter')
await page.waitForFunction(() => document.querySelector('[data-whatif-new]')?.textContent === '42.8%')
check('回车立刻提交', sent.at(-1).body.overrides.inputs['vars.kpi.gmv'] === 60000 && sent.at(-1).at - t0 < 300,
`${sent.at(-1).at - t0}ms`)
// 写的不是数:提示,保留上一次的结果
await field.fill('六万')
await settle(page)
check('不是数:提示「请输入数值」、不发请求', (await page.locator('[data-whatif-input="vars.kpi.gmv"]').innerText()).includes('请输入数值')
&& sent.at(-1).body.overrides.inputs['vars.kpi.gmv'] === 60000)
// Esc 退回已提交的值,面板不关
await field.press('Escape')
check('Esc 退回已提交的值,面板还开着', await field.inputValue() === '60000' && await page.locator('[data-evidence-panel]').count() === 1)
// 定位:焦点回到正文里那一处,推演不收起
await page.click('[data-whatif-locate="s12"]')
await page.waitForTimeout(200)
check('定位到正文里那一处,推演照旧展开', await page.evaluate(() => document.activeElement?.getAttribute('data-seg')) === 's12'
&& await page.locator('[data-whatif]').count() === 1 && (await text(page, '[data-whatif-new]')) === '42.8%')
await ctx.close()
})
await section('勾掉一行', async () => {
const { ctx, page, sent } = await open('whatif')
await openMetric(page, 's12')
await openWhatIf(page)
const box = page.locator(`[data-ev-query="${Q4}"] [data-whatif-row="0"] input[type="checkbox"]`)
check('勾选框的读屏名从 1 数', (await box.getAttribute('aria-label')) === '排除第 1 行')
await box.check()
await page.waitForSelector('[data-whatif-result="excluded"]')
check('请求体带 exclude_rows(快照工件 id → 从 0 数的行号)',
JSON.stringify(sent.at(-1).body.overrides) === JSON.stringify({ inputs: {}, exclude_rows: { [Q4]: [0] } }), JSON.stringify(sent.at(-1).body.overrides))
check('这一行划掉', await page.locator(`[data-ev-query="${Q4}"] [data-whatif-row="0"][data-excluded]`).count() === 1)
check('排除后无法计算:写明原因、新值是「—」', (await text(page, '[data-whatif-reason]')).startsWith('排除后无法计算')
&& (await text(page, '[data-whatif-new]')) === '—')
check('输入下写明排除了哪一行', (await text(page, `[data-whatif-input="cell(nodes.pull, 0, 'gmv')"] [data-whatif-excluded]`)) === '已排除第 1 行')
check('结果窗口下写明已排除几行', (await text(page, `[data-ev-query="${Q4}"] [data-whatif-excluded-count]`)) === '已排除 1 行')
await shot(page, 'excluded-dark')
// 恢复原值:输入框、勾选框、结果都回去
await page.locator('[data-whatif-input="vars.kpi.gmv"] [data-whatif-field]').fill('50000')
await page.locator('[data-whatif-input="vars.kpi.gmv"] [data-whatif-field]').press('Enter')
await settle(page, 300)
await page.click('[data-whatif-reset]')
await page.waitForSelector('[data-whatif-result="unchanged"]')
check('恢复原值:结果回到原值', (await text(page, '[data-whatif-new]')) === '8.7%')
check('恢复原值:输入框回到原值、勾选框都放开',
await page.locator('[data-whatif-input="vars.kpi.gmv"] [data-whatif-field]').inputValue() === '45678.5'
&& !(await box.isChecked()) && await page.locator('[data-whatif-row][data-excluded]').count() === 0)
check('恢复原值后按钮不可点', await page.locator('[data-whatif-reset]').isDisabled())
// 收起:勾选框跟着收起,结果窗口换回原来的表格
await page.click('[data-whatif-toggle]')
check('收起后推演区和勾选框都没了,查询结果照常显示', await page.locator('[data-whatif], [data-whatif-rows]').count() === 0
&& await page.locator(`[data-ev-query="${Q4}"] table`).count() === 1)
await ctx.close()
})
await section('多行快照与遮罩', async () => {
const { ctx, page, sent } = await open('whatif')
await openMetric(page, 's70')
await openWhatIf(page)
const rows = page.locator(`[data-ev-query="${Q3}"] [data-whatif-row]`)
check('窗口里每行一个勾选框', await rows.count() === chainOf('s70').find((s) => s.step === 'query').rows.length)
check('遮罩列照旧写「已遮罩」', await page.locator(`[data-ev-query="${Q3}"] [data-whatif-rows] td[data-masked]`).count() > 0
&& (await page.locator(`[data-ev-query="${Q3}"] [data-whatif-rows] td[data-masked]`).first().innerText()).trim() === '已遮罩')
await page.locator(`[data-ev-query="${Q3}"] [data-whatif-row="5"] input`).check()
await page.waitForSelector('[data-whatif-result="excluded"]')
check('勾掉第 6 行:请求里是行号 5', JSON.stringify(sent.at(-1).body.overrides.exclude_rows) === JSON.stringify({ [Q3]: [5] }))
await ctx.close()
})
await section('不支持的输入', async () => {
const { ctx, page } = await open('whatif')
await openMetric(page, 's22')
await openWhatIf(page)
check('不支持推演的输入列出原因', (await text(page, '[data-whatif-unsupported-input="vars.adjust.items"]'))
.includes('无法推演:当时没有保存这个输入的完整值'))
check('容器输入不给输入框', await page.locator('[data-whatif-field]').count() === 0
&& (await text(page, '[data-whatif-input="vars.adjust.items"] [data-whatif-value]')).includes('[4 项]'))
await ctx.close()
})
await section('加载、失败、未封存', async () => {
let release
const gate = new Promise((r) => { release = r })
const slow = await open('whatif', { reply: async (body, n) => {
if (n === 1) await gate
return { json: whatifReply(body) }
} })
await openMetric(slow.page, 's12')
await slow.page.click('[data-whatif-toggle]')
await slow.page.waitForSelector('[data-whatif-loading]')
check('答复回来之前显示「推演中」,横幅已经在', (await text(slow.page, '[data-whatif-loading]')).includes('推演中')
&& (await text(slow.page, '[data-whatif-banner]')).trim() === BANNER)
release()
await slow.page.waitForSelector('[data-whatif-result="unchanged"]')
check('答复回来后加载提示消失', await slow.page.locator('[data-whatif-loading]').count() === 0)
await slow.ctx.close()
const flaky = await open('whatif', { reply: async (body, n) => (n === 1
? { status: 500, json: { detail: '推演服务暂时不可用' } } : { json: whatifReply(body) }) })
await openMetric(flaky.page, 's12')
await flaky.page.click('[data-whatif-toggle]')
await flaky.page.waitForSelector('[data-whatif] [role="alert"]')
check('失败时给出原因和「重试」', (await text(flaky.page, '[data-whatif] [role="alert"]')).includes('重试'))
await flaky.page.locator('[data-whatif] [role="alert"]').getByRole('button', { name: '重试' }).click()
await flaky.page.waitForSelector('[data-whatif-result="unchanged"]')
check('重试之后正常显示', await flaky.page.locator('[data-whatif] [role="alert"]').count() === 0)
await flaky.ctx.close()
const open_ = await open('whatif', { reply: async (body) => ({ json: { ...whatifReply(body), sealed: false, seal: { sealed: false, ok: null } } }) })
await openMetric(open_.page, 's12')
await openWhatIf(open_.page)
check('运行未封存时提示数据可能还会变', (await text(open_.page, '[data-whatif-unsealed]')) === '运行未封存,数据可能还会变')
await open_.ctx.close()
})
/** 页面和面板正文都没有横向滚动(表格自己的横向滚动条不算:那是表格容器里的) */
async function overflow(page) {
return page.evaluate(() => {
const doc = document.documentElement
const body = document.querySelector('[data-ev-body]')
const box = document.querySelector('[data-whatif]')
return { page: doc.scrollWidth - doc.clientWidth, body: body ? body.scrollWidth - body.clientWidth : 0,
whatif: box ? box.scrollWidth - box.clientWidth : 0 }
})
}
await section('窄屏', async () => {
for (const theme of ['dark', 'light']) {
const { ctx, page, errors } = await open('whatif', { width: 390, height: 844, theme })
await openMetric(page, 's12')
check(`390px(${theme}):面板从底部抽出`, await page.locator('[data-evidence-panel="drawer"]').count() === 1)
await openWhatIf(page)
await page.locator('[data-whatif-input="vars.kpi.gmv"] [data-whatif-field]').fill('50000')
await page.locator('[data-whatif-input="vars.kpi.gmv"] [data-whatif-field]').press('Enter')
await page.waitForSelector('[data-whatif-result="ok"]')
await page.locator(`[data-ev-query="${Q4}"] [data-whatif-row="0"] input`).check()
await page.waitForSelector('[data-whatif-result="excluded"]')
const o = await overflow(page)
check(`390px(${theme}):推演展开、勾了行也没有横向溢出`, o.page <= 0 && o.body <= 1 && o.whatif <= 1, JSON.stringify(o))
await shot(page, `narrow-${theme}`)
check(`390px(${theme}):页面没有报错`, errors.length === 0, errors.join(';'))
await ctx.close()
}
const rail = await open('whatif-rail', { width: 1100, height: 900 })
await openMetric(rail.page, 's70')
check('360px 画布右栏:面板栏内展开', await rail.page.locator('[data-evidence-panel="inline"]').count() === 1)
await openWhatIf(rail.page)
const o = await rail.page.evaluate(() => {
const box = document.querySelector('[data-fixture="whatif-rail"]')
const panel = document.querySelector('[data-whatif]')
return { rail: box.scrollWidth - box.clientWidth, whatif: panel.scrollWidth - panel.clientWidth }
})
check('360px 画布右栏:推演区不撑宽右栏', o.rail <= 0 && o.whatif <= 1, JSON.stringify(o))
await shot(rail.page, 'rail-dark')
await rail.ctx.close()
})
/** 两个颜色的对比度(WCAG):颜色是 getComputedStyle 给的 rgb / rgba / color(srgb …),半透明的叠在底色上 */
function contrast(fg, bg, base) {
const parse = (c) => {
const m = c.match(/color\(srgb ([\d.]+) ([\d.]+) ([\d.]+)(?: \/ ([\d.]+))?\)/)
if (m) return [m[1] * 255, m[2] * 255, m[3] * 255, m[4] == null ? 1 : Number(m[4])]
const n = c.match(/rgba?\(([\d.]+),\s*([\d.]+),\s*([\d.]+)(?:,\s*([\d.]+))?\)/)
return n ? [Number(n[1]), Number(n[2]), Number(n[3]), n[4] == null ? 1 : Number(n[4])] : null
}
const over = (top, under) => top.slice(0, 3).map((v, i) => v * top[3] + under[i] * (1 - top[3]))
const lum = ([r, g, b]) => {
const f = (v) => { const s = v / 255; return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4 }
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b)
}
const b0 = parse(base); const b1 = parse(bg); const f0 = parse(fg)
if (!b0 || !b1 || !f0) return 0
const under = over(b1, b0.slice(0, 3))
const a = lum(over(f0, under)); const b = lum(under)
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05)
}
await section('深浅色可读', async () => {
for (const theme of ['dark', 'light']) {
const { ctx, page } = await open('whatif', { theme })
await openMetric(page, 's12')
await openWhatIf(page)
const c = await page.evaluate(() => {
const banner = document.querySelector('[data-whatif-banner]')
const panel = document.querySelector('[data-evidence-panel]')
const s = getComputedStyle(banner)
return { fg: s.color, bg: s.backgroundColor, base: getComputedStyle(panel).backgroundColor,
weight: Number(s.fontWeight) }
})
const ratio = contrast(c.fg, c.bg, c.base)
check(`${theme}:横幅文字对比度 ≥ 4.5`, ratio >= 4.5, `${ratio.toFixed(2)}(${c.fg} on ${c.bg})`)
check(`${theme}:横幅加粗`, c.weight >= 500, String(c.weight))
await shot(page, `open-${theme}`)
await ctx.close()
}
})
await browser.close()
console.log(failed ? `\n✗ 数字推演:${failed} 项未通过` : '\n✓ 数字推演:全部通过')
process.exit(failed ? 1 : 0)