数字推演复核修正(第 1 轮,界面):整份结果在推演区里勾行,只让用到的行可勾,勾完结果就在手边,加载中和失败后不摆上一次的答复

- 整份结果、数组字段作输入时,输入下面给「勾选要排除的行(共 N 行)」,按页取整份快照来勾:证据链里没有它的查询步骤、
  或者查询步骤只给前后 2 行时也勾得全
- 查询步骤里的勾行表格只让这个指标用到的行可勾(row_span),别的行禁用并说明没用到,勾了也不发请求
- 勾行表格底下带推演结果(旧值 → 新值或原因)和「回到推演结果」;勾完、答复回来后各把它带进视野一次(答复回来时上面
  的内容会变高,把表格往下挤)
- 推演的请求和答复提到片段那一层,推演区和查询步骤共用一份;答复回来之前随改动变的部分变淡、标为忙,请求失败时结果、
  排除标记、报告中会变的地方、下游口径卡都不显示,不拿上一次的答复充数
- 整个指标推演不了(口径卡没记输入)时只说原因,不再同时写「无可推演」和「无变化」,也不画勾选框
- 快照读不出来的输入写「查询快照无法核对,不显示原值」;没有能改的输入、也没有能勾的行时说一句为什么
- check-whatif 补对应检查:整份结果勾行、上下文行不能勾、勾完结果整个在视野里、封存核对未通过、加载中和失败后、整个
  指标推演不了、窄屏里的勾行表格;ESTIMATE 按实测改成 28 秒
This commit is contained in:
ThearchyHelios
2026-10-05 05:43:17 +08:00
parent 09237a4c99
commit 633af950db
7 changed files with 635 additions and 125 deletions
+7 -2
View File
@@ -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<WhatIfResult>(`/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<WhatIfRowsPage>(`/runs/${encodeURIComponent(runId)}/evidence/whatif/rows?${new URLSearchParams({
artifact, offset: String(page.offset), limit: String(page.limit),
})}`, { signal: opts?.signal }),
}
+13 -1
View File
@@ -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}` : ''}`,
+37 -11
View File
@@ -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 && (
<WhatIf id={whatifId} runId={runId} report={report} card={whatifCard} metric={whatifMetric} draft={whatif}
{whatifOpen && runId && whatifMetric && (
<WhatIf id={whatifId} runId={runId} report={report} metric={whatifMetric} draft={whatif} view={whatifView}
queryOf={queryLink} onLocate={onLocate} />
)}
</MetricPart>
@@ -425,8 +438,15 @@ 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}
pick={whatifOpen && q.artifact && !q.merged_into && pickable.has(q.artifact)
? { excluded: whatif.excluded(q.artifact), onToggle: (row) => 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: <WhatIfSummary view={whatifView} metric={whatifMetric} onBack={() => 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 && (
<div ref={scroller} className={clsx(bad.length > 0 && 'mt-1.5')}>
{pick && win.index
? <WhatIfRows win={win} hidden={hidden} excluded={pick.excluded} onToggle={pick.onToggle} />
? <WhatIfRows win={win} hidden={hidden} excluded={pick.excluded} onToggle={pick.onToggle} usable={pick.usable}
summary={pick.summary} busy={pick.busy} />
: (
<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}
+345 -102
View File
@@ -1,12 +1,12 @@
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from 'react'
import clsx from 'clsx'
import { CornerDownRight, FlaskConical, LocateFixed, RotateCcw } from 'lucide-react'
import { CornerDownRight, FlaskConical, ListChecks, 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 { evidenceValue as valueText, queryWindow } from '../lib/evidence'
import { NONE, formatNumber } from '../lib/format'
import { WHATIF_TEXT } from '../lib/terms'
import type { WhatIfInput, WhatIfMetric, WhatIfOverrides, WhatIfRequest, WhatIfResult } from '../types'
import type { EvidenceStep, WhatIfInput, WhatIfMetric, WhatIfOverrides, WhatIfRequest, WhatIfResult, WhatIfRowsPage } from '../types'
/**
* 数字推演:证据面板里点开口径卡上的一个数,改某个输入的值、或者在查询结果里勾掉几行,立即看到这个指标和报告里
@@ -14,18 +14,23 @@ import type { WhatIfInput, WhatIfMetric, WhatIfOverrides, WhatIfRequest, WhatIfR
*
* 推演永远不是出具:不写进运行记录、不改报告。顶上的横幅一直在,运行没封存时另说「数据可能还会变」。
*
* 三样东西分开放:
* - useWhatIfDraft:改动的草稿(输入框里的原文、提交了的新值、勾掉的行)。放在证据面板的片段那一层(SegmentBody),
* 推演区和查询步骤的勾选框共用一份;同一个指标的几处引用之间来回切不清空,换了指标才清空
* - WhatIf:推演区。输入框停顿一会儿(或回车、失焦)才提交,勾行立刻提交;请求在路上时上一次的结果变淡留着,
* 新的改动来了旧请求直接丢掉。答复按请求体缓存,「恢复原值」回到原来那份不再请求
* - WhatIfRows:推演时查询步骤里的结果窗口换成它,每行前面多一个勾选框
* 几样东西分开放:
* - useWhatIfDraft:改动的草稿(输入框里的原文、提交了的新值、勾掉的行)
* - useWhatIfResult:推演的请求和答复。答复按请求体缓存,「恢复原值」回到原来那份不再请求;新的改动来了旧请求直接丢掉
* 这两样都放在证据面板的片段那一层(SegmentBody):推演区和查询步骤里勾行的表格共用一份草稿、一份答复(勾了行,
* 表格底下就写着新值)。同一个指标的几处引用之间来回切不清空,换了指标才清空
* - WhatIf:推演区。输入框停顿一会儿(或回车、失焦)才提交,勾行立刻提交。请求在路上时,凡是随改动变的地方(结果、
* 报告里会变的地方、下游口径卡、输入上的新值和排除标记)都变淡;请求失败时这些一概不显示,不拿上一次的答复充数
* - WhatIfRows:勾行的表格。推演时查询步骤里的结果窗口换成它;整份结果、数组字段作输入时,推演区里另给一份按页取
* 整份快照的(SnapshotRows)。只有这个指标的输入实际用到的行可勾(row_span),别的行勾了也不影响结果
*
* 不能改的输入(文本、列表、遮罩列、决定取哪一格的下标)照实显示,原因列在「不支持推演的输入」里。
* 不能改的输入(文本、对不上快照的列表、遮罩列、决定取哪一格的下标)照实显示,原因列在「不支持推演的输入」里。
*/
/** 输入框停顿多久提交一次:边打字边请求,结果跟着抖 */
const SETTLE_MS = 450
/** 推演区里勾行时一次取几行 */
const ROWS_PAGE = 50
interface DraftState {
scope: string
@@ -142,6 +147,83 @@ export function useWhatIfDraft(scope: string): WhatIfDraft {
}
}
/** 推演的答复:loading 是这次改动的答复还没回来、error 是这次的请求失败了,这两种时候 data 是上一次的 */
export interface WhatIfView {
status: 'loading' | 'ok' | 'error'
data?: WhatIfResult
error?: unknown
retry: () => void
}
type Slot = { key: string; scope: string; status: 'loading' | 'ok' | 'error'; data?: WhatIfResult; error?: unknown; last?: WhatIfResult }
/**
* 发推演请求、收答复。request 为 null(推演收着)时不发。改动之后、请求还没发出去的那一下也算在路上:别拿上一次的
* 答复当这次的。上一次的答复只在同一个指标(运行、报告、卡、指标都一样)之间沿用,换了指标不拿别的指标的数充数
*/
export function useWhatIfResult(runId: string | undefined, request: WhatIfRequest | null): WhatIfView {
const key = runId && request ? JSON.stringify([runId, request]) : ''
const scope = runId && request ? JSON.stringify([runId, request.report ?? '', request.card, request.metric ?? '']) : ''
const [slot, setSlot] = useState<Slot>({ key: '', scope: '', status: 'loading' })
const [nonce, setNonce] = useState(0)
const cache = useRef(new Map<string, WhatIfResult>())
useEffect(() => {
if (!key || !runId) return
const hit = cache.current.get(key)
if (hit) {
setSlot({ key, scope, status: 'ok', data: hit, last: hit })
return
}
const ctrl = new AbortController()
setSlot((s) => ({ key, scope, status: 'loading', last: s.scope === scope ? s.last : undefined }))
const [, body] = JSON.parse(key) as [string, WhatIfRequest]
api.whatif.run(runId, body, { signal: ctrl.signal }).then((data) => {
cache.current.set(key, data)
setSlot({ key, scope, status: 'ok', data, last: data })
}, (error: unknown) => {
if ((error as { name?: string } | null)?.name === 'AbortError') return
setSlot((s) => ({ key, scope, status: 'error', error, last: s.scope === scope ? s.last : undefined }))
})
return () => ctrl.abort()
}, [runId, key, scope, nonce])
const retry = useCallback(() => setNonce((n) => n + 1), [])
const status = slot.key === key ? slot.status : 'loading'
const last = slot.scope === scope ? slot.last : undefined
return { status, data: status === 'ok' ? slot.data : last, error: status === 'error' ? slot.error : undefined, retry }
}
/** 答复里要推演的那个指标 */
export function pickMetric(data: WhatIfResult | undefined, metric: string | undefined): WhatIfMetric | undefined {
return data?.metrics.find((x) => String(x.id) === metric) ?? data?.metrics[0]
}
/** 整个指标推演不了(口径卡没记输入):unsupported 里 input 为空的那一条 */
export const wholeReason = (data: WhatIfResult | undefined, metric: string | undefined) =>
data?.unsupported.find((u) => String(u.metric) === metric && u.input == null)?.reason
/** 每份快照里这个指标用得到的行段(含两端):各输入的 row_span。答复还没回来时是 undefined */
export function usableRows(m: WhatIfMetric | undefined): Map<string, [number, number][]> | undefined {
if (!m) return undefined
const out = new Map<string, [number, number][]>()
for (const inp of m.inputs) {
if (inp.artifact && inp.row_span) out.set(inp.artifact, [...(out.get(inp.artifact) ?? []), inp.row_span])
}
return out
}
export const inSpans = (spans: [number, number][] | undefined, row: number) =>
!!spans?.some(([a, b]) => row >= a && row <= b)
/** 「回到推演结果」:焦点送回推演结果(推演不了时送回推演区),滚进视野、描一下边 */
export function focusResult(id: string) {
const el = document.getElementById(`${id}-result`) ?? document.getElementById(id)
if (!el) return
el.focus({ preventScroll: true })
el.scrollIntoView({ block: 'nearest' })
el.dataset.flash = 'focus'
setTimeout(() => { if (el.dataset.flash === 'focus') delete el.dataset.flash }, 1600)
}
/** 指标步骤上的「推演」开关 */
export function WhatIfToggle({ open, onToggle, controls }: { open: boolean; onToggle: () => void; controls: string }) {
return (
@@ -153,59 +235,88 @@ export function WhatIfToggle({ open, onToggle, controls }: { open: boolean; onTo
)
}
type Slot = { key: string; status: 'loading' | 'ok' | 'error'; data?: WhatIfResult; error?: unknown; last?: WhatIfResult }
/**
* 推演结果的旧值 → 新值(或原因)、变化量。推演区(big:新值大一号)和勾行的表格底下都用它;tag 是各部分的
* data-* 前缀,两处分开,检查脚本不会把表格底下那份当成推演区的
*/
function Outcome({ m, loading, big = false, tag }: { m: WhatIfMetric; loading: boolean; big?: boolean; tag: string }) {
const at = (part: string) => ({ [`data-${tag}-${part}`]: '' })
return (
<span className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span className="mono tnum text-dim" {...at('old')}>{m.rendered_old}</span>
<span className="text-faint" aria-hidden>→</span>
<span className={clsx('mono tnum font-semibold', big && 'text-sm')} {...at('new')}
style={m.changed ? { color: 'var(--accent)' } : undefined}>{m.rendered_new ?? NONE}</span>
{m.rendered_delta
? <span className="tnum text-2xs" {...at('delta')}>{WHATIF_TEXT.delta(m.rendered_delta)}</span>
: m.changed === false && <span className="text-2xs text-faint" {...at('delta')}>{WHATIF_TEXT.unchanged}</span>}
{loading && <span className="text-faint"><Spinner size={11} /></span>}
</span>
)
}
/**
* 勾行的表格底下带的推演结果:勾了一行,新值(或推演不了的原因)就在手边,不用回头找。「回到推演结果」把焦点送回
* 推演区。读屏器由推演区的结果播报,这里不再重复
*/
export function WhatIfSummary({ view, metric, onBack }: { view: WhatIfView; metric: string; onBack?: () => void }) {
const m = pickMetric(view.data, metric)
const loading = view.status === 'loading'
const failed = view.status === 'error'
const whole = wholeReason(view.data, metric)
const reason = failed ? undefined : whole ?? m?.reason
return (
<div className="border-t px-1.5 py-1 text-2xs" data-whatif-summary={failed ? 'error' : loading ? 'loading' : m?.status ?? ''}>
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
<span className="font-medium text-faint">{WHATIF_TEXT.result}</span>
{failed
? <span style={{ color: 'var(--err)' }}>{WHATIF_TEXT.failed}</span>
: m && !whole
? <span className={clsx('text-xs transition-opacity', loading && 'opacity-60')}><Outcome m={m} loading={loading} tag="whatif-summary" /></span>
: !m && <Spinner size={11} />}
{onBack && (
<button type="button" className="btn btn-xs btn-ghost ml-auto" onClick={onBack} data-whatif-back="">
{WHATIF_TEXT.backToResult}
</button>
)}
</div>
{reason && (
<p className={clsx('mt-0.5 [overflow-wrap:anywhere] transition-opacity', loading && 'opacity-60')}
style={{ color: 'var(--st-waiting)' }}>{reason}</p>
)}
</div>
)
}
/** 推演区:横幅、结果、输入、不支持的输入、报告里会变的地方、下游口径卡、恢复原值 */
export function WhatIf({ id, runId, report, card, metric, draft, queryOf, onLocate }: {
export function WhatIf({ id, runId, report, metric, draft, view, queryOf, onLocate }: {
id: string
runId: string
/** 正文这份报告的撰写节点 id:按它选口径卡算过的那一份,报告里会变的地方也只列它的(都能定位) */
/** 正文这份报告的撰写节点 id:报告里会变的地方只列它的(都能定位) */
report?: string
/** 口径卡节点 id */
card: string
metric: string
draft: WhatIfDraft
view: WhatIfView
/** 输入落在哪个查询步骤上:有的话给「在查询 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 { status, data } = view
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 failed = status === 'error'
const m = pickMetric(data, metric)
const unsupported = (data?.unsupported ?? []).filter((u) => String(u.metric) === metric)
// 整个指标推演不了(口径卡没记输入):只说原因,不画输入、不写「无变化」
const whole = wholeReason(data, metric)
const unknown = !!m && m.rendered_new == null
const changedSegs = (data?.segments ?? []).filter((s) => s.changed)
const adjustable = !!m?.inputs.some((i) => i.editable || i.row_span)
const summary = <WhatIfSummary view={view} metric={metric} onBack={() => focusResult(id)} />
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))' }}>
<section id={id} tabIndex={-1} className="mt-2 space-y-2.5 rounded border p-2 focus:outline-none" 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)' }}>
@@ -218,51 +329,58 @@ export function WhatIf({ id, runId, report, card, metric, draft, queryOf, onLoca
</p>
)}
{status === 'error' && <ErrorNotice error={slot.error} onRetry={() => setNonce((n) => n + 1)} />}
{failed && <ErrorNotice error={view.error} onRetry={view.retry} />}
{!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}>
{m && !failed && (
// 「回到推演结果」把焦点送到这里:描边由 focusResult 设的 data-flash 画(静态描边,减少动效时照样看得见)
<div id={`${id}-result`} tabIndex={-1} aria-live="polite" aria-busy={loading || undefined}
className={clsx('rounded-sm transition-opacity focus:outline-none data-[flash]:outline data-[flash]:outline-2 data-[flash]:outline-offset-2 data-[flash=focus]:outline-[color:var(--accent)]',
loading && 'opacity-60')}
data-whatif-result={loading ? 'loading' : whole ? 'whole' : 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>
{whole ? (
<>
<span className="mono tnum text-dim" data-whatif-old="">{m.rendered_old}</span>
<p className="mt-0.5" style={{ color: 'var(--st-waiting)' }} data-whatif-reason="">{whole}</p>
</>
) : (
<>
<Outcome m={m} loading={loading} big tag="whatif" />
{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 && (
{m && !whole && (
<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>
<>
<ul className="space-y-1.5">
{m.inputs.map((inp, i) => (
<InputRow key={inp.path} id={`${id}-in${i}`} runId={runId} input={inp} draft={draft}
link={queryOf(inp.artifact)} status={status} summary={summary} />
))}
</ul>
{!adjustable && <p className="mt-1 text-2xs text-dim" data-whatif-nothing="">{WHATIF_TEXT.nothingToAdjust}</p>}
</>
) : <p className="text-dim" data-whatif-empty="">{WHATIF_TEXT.empty}</p>}
</div>
)}
{unsupported.length > 0 && (
{!whole && 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">
@@ -276,8 +394,9 @@ export function WhatIf({ id, runId, report, card, metric, draft, queryOf, onLoca
</div>
)}
{m && !!data?.segments.length && (
<div data-whatif-segments="">
{m && !whole && !failed && !!data?.segments.length && (
<div aria-busy={loading || undefined} className={clsx('transition-opacity', loading && 'opacity-60')}
data-whatif-segments={loading ? 'loading' : ''}>
<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 ? (
@@ -302,8 +421,9 @@ export function WhatIf({ id, runId, report, card, metric, draft, queryOf, onLoca
</div>
)}
{!!data?.affected_cards.length && (
<div data-whatif-affected="">
{!whole && !failed && !!data?.affected_cards.length && (
<div aria-busy={loading || undefined} className={clsx('transition-opacity', loading && 'opacity-60')}
data-whatif-affected={loading ? 'loading' : ''}>
<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">
@@ -327,10 +447,17 @@ export function WhatIf({ id, runId, report, card, metric, draft, queryOf, onLoca
)
}
/** 一个输入:能改的给输入框(原值写在旁边),别的照实显示;排除了几行、去哪一步勾行,写在下面 */
function InputRow({ id, input, draft, link }: {
id: string; input: WhatIfInput; draft: WhatIfDraft; link?: { alias: string; go: () => void }
/**
* 一个输入:能改的给输入框(原值写在旁边),别的照实显示;排除了几行、去哪勾行,写在下面。
* 随改动变的新值、排除标记来自答复:这次改动的答复还没回来时(loading)是上一次的,变淡;请求失败了(error)不显示
*/
function InputRow({ id, runId, input, draft, link, status, summary }: {
id: string; runId: string; input: WhatIfInput; draft: WhatIfDraft; link?: { alias: string; go: () => void }
status: WhatIfView['status']; summary: ReactNode
}) {
const shown = status !== 'error'
const dim = status === 'loading' ? 'opacity-60' : undefined
const [picking, setPicking] = useState(false)
const raw = draft.text[input.path]
const committed = draft.overrides.inputs[input.path]
const baseline = committed !== undefined ? String(committed) : plain(input.old)
@@ -347,13 +474,20 @@ function InputRow({ id, input, draft, link }: {
draft.revert(input.path)
}
}
const fromQuery = !!link && !input.masked && (input.kind === 'container' || !!input.locator)
const span = input.masked ? undefined : input.row_span
// 只用到快照里一行的(单元格取数、Agent 的单格字段)去查询步骤里勾:那儿有前后几行可对照;整份结果、数组字段
// 用到一大段行,查询步骤只给前后几行,在这里按页勾全;单行却没有查询步骤可去的,也在这里勾
const single = !!span && span[0] === span[1]
const pickInQuery = !!link && single && input.kind !== 'container'
const pickHere = !!span && !pickInQuery
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}>
style={input.changed && shown ? { 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.withheld ? (
<p className="mt-0.5 italic text-faint" data-whatif-withheld="">{WHATIF_TEXT.withheld}</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}
@@ -369,30 +503,125 @@ function InputRow({ id, input, draft, link }: {
) : (
<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)}</>}
{input.changed && shown && (
<span className={dim}> <span className="text-faint" aria-hidden>→</span> {valueText(input.new)}</span>
)}
</p>
)}
{!!input.excluded_rows?.length && (
<p className="mt-0.5 text-2xs" style={{ color: 'var(--st-waiting)' }} data-whatif-excluded="">
{!!input.excluded_rows?.length && shown && (
<p className={clsx('mt-0.5 text-2xs', dim)} style={{ color: 'var(--st-waiting)' }} data-whatif-excluded="">
{WHATIF_TEXT.excludedRows(input.excluded_rows)}
</p>
)}
{fromQuery && (
{pickInQuery && (
<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>
)}
{pickHere && input.artifact && span && (
<>
<button type="button" className="btn btn-xs btn-ghost -ml-1 mt-0.5" aria-expanded={picking}
onClick={() => setPicking((v) => !v)} data-whatif-pick-here={picking ? 'open' : 'closed'}>
<ListChecks size={10} aria-hidden /> {picking ? WHATIF_TEXT.pickHereClose : WHATIF_TEXT.pickHere(span[1] - span[0] + 1)}
</button>
{picking && (
<SnapshotRows key={`${input.artifact}:${span[0]}:${span[1]}`} runId={runId} artifact={input.artifact} span={span}
excluded={draft.excluded(input.artifact)} onToggle={(row) => draft.toggleRow(input.artifact!, row)}
summary={summary} busy={status === 'loading'} />
)}
</>
)}
</li>
)
}
type Loaded = { status: 'loading' | 'ok' | 'error'; page?: WhatIfRowsPage; rows: unknown[][]; index: number[]; error?: unknown }
/**
* 推演时查询步骤里的结果窗口:每行前面一个勾选框,勾掉的行在推演里视为不存在(划掉、变淡)。只有知道每一行
* 在快照里是第几行时才画(index);被遮罩的列照样写「已遮罩」,高亮的格和平时一样描边
* 推演区里的勾行表格:按页取这份快照里输入用到的那一段行(整份结果是全部,数组字段是它那一段),遮罩列写「已遮罩」。
* 查询步骤只给被引用的行前后几行,整份结果作输入时大多数行勾不到,所以这里另取
*/
export function WhatIfRows({ win, hidden, excluded, onToggle }: {
win: ReturnType<typeof queryWindow>; hidden: string[]; excluded: number[]; onToggle: (row: number) => void
function SnapshotRows({ runId, artifact, span, excluded, onToggle, summary, busy }: {
runId: string; artifact: string; span: [number, number]; excluded: number[]; onToggle: (row: number) => void
summary: ReactNode; busy: boolean
}) {
const [first, last] = span
const [loaded, setLoaded] = useState<Loaded>({ status: 'loading', rows: [], index: [] })
const [offset, setOffset] = useState(first)
const [nonce, setNonce] = useState(0)
useEffect(() => {
const ctrl = new AbortController()
setLoaded((s) => ({ ...s, status: 'loading' }))
api.whatif.rows(runId, artifact, { offset, limit: Math.min(ROWS_PAGE, last - offset + 1) }, { signal: ctrl.signal })
.then((page) => setLoaded((s) => {
// 只留输入用到的那一段(数组字段的行段后面可能还有别的行),已经有的不重复接
const have = new Set(s.index)
const keep = page.row_index.map((r, i) => [r, i] as const).filter(([r]) => r >= first && r <= last && !have.has(r))
return { status: 'ok', page, rows: [...s.rows, ...keep.map(([, i]) => page.rows[i])], index: [...s.index, ...keep.map(([r]) => r)] }
}), (error: unknown) => {
if ((error as { name?: string } | null)?.name === 'AbortError') return
setLoaded((s) => ({ ...s, status: 'error', error }))
})
return () => ctrl.abort()
}, [runId, artifact, offset, first, last, nonce])
const page = loaded.page
const win = useMemo(() => queryWindow({
rows: loaded.rows, columns: page?.columns ?? [], row_index: loaded.index, row_offset: loaded.index[0] ?? first,
total_rows: page?.total_rows ?? null,
} as Pick<EvidenceStep, 'rows' | 'columns' | 'row_offset' | 'row_index' | 'total_rows' | 'highlight'>), [loaded.rows, loaded.index, page, first])
const next = (loaded.index.at(-1) ?? first - 1) + 1
const remaining = Math.max(0, last - next + 1)
return (
<div className="mt-1 space-y-1" data-whatif-snapshot={artifact}>
{loaded.status === 'error' && <ErrorNotice error={loaded.error} onRetry={() => setNonce((n) => n + 1)} />}
{page?.note && <p className="text-2xs text-dim" data-whatif-snapshot-note="">{page.note}</p>}
{!page && loaded.status === 'loading' && (
<p className="flex items-center gap-1.5 text-2xs text-dim"><Spinner size={11} /> {WHATIF_TEXT.rowsLoading}</p>
)}
{win.rows.length > 0 && (
<WhatIfRows win={win} hidden={page?.masked ?? []} excluded={excluded} onToggle={onToggle}
usable={(row) => row >= first && row <= last} summary={summary} busy={busy} />
)}
{win.rows.length > 0 && (
<div className="flex flex-wrap items-center gap-x-2 text-2xs text-faint">
<span data-whatif-snapshot-range="">
{WHATIF_TEXT.rowsRange(formatNumber(first + 1), formatNumber(next), formatNumber(page?.total_rows ?? last + 1))}
</span>
{remaining > 0 && (
<button type="button" className="btn btn-xs btn-ghost" disabled={loaded.status === 'loading'}
onClick={() => setOffset(next)} data-whatif-snapshot-more="">
{loaded.status === 'loading' ? <Spinner size={10} /> : null} {WHATIF_TEXT.rowsMore(Math.min(ROWS_PAGE, remaining))}
</button>
)}
</div>
)}
</div>
)
}
/**
* 勾行的表格:每行前面一个勾选框,勾掉的行在推演里视为不存在(划掉、变淡)。只有知道每一行在快照里是第几行时才画
* (index);被遮罩的列照样写「已遮罩」,高亮的格和平时一样描边。usable 说哪几行这个指标用得到:别的行勾了也不影响
* 结果,勾选框禁用并说明(已经勾上的照样能取消);答复还没回来、不知道时一律先不让勾。
*
* summary(推演结果)连同说明画在表格底下:推演区在上面,勾完行它往往不在视野里。勾了一行就把底下这条带进视野
* (已经看得见就不动),答复回来时(busy 转为假)再带一次:上面的内容(原因、下游口径卡)这时会变高,把表格往下挤。
* 不做成贴底的浮条:表格靠近视野底边时浮条会盖住刚勾的那一行
*/
export function WhatIfRows({ win, hidden, excluded, onToggle, usable, summary, busy = false }: {
win: ReturnType<typeof queryWindow>; hidden: string[]; excluded: number[]; onToggle: (row: number) => void
usable?: (row: number) => boolean
summary?: ReactNode
/** 推演请求还在路上 */
busy?: boolean
}) {
const foot = useRef<HTMLDivElement>(null)
const reveal = useRef(false)
useEffect(() => {
if (!reveal.current || busy) return
reveal.current = false
foot.current?.scrollIntoView({ block: 'nearest' })
})
if (!win.index) return null
const index = win.index
const masked = new Set(hidden)
@@ -416,14 +645,25 @@ export function WhatIfRows({ win, hidden, excluded, onToggle }: {
const n = index[i]
const off = excluded.includes(n)
const hit = marked.has(i)
const open = off || (usable ? usable(n) : false)
return (
<tr key={n} className="border-b last:border-0" data-whatif-row={n} data-excluded={off ? '' : undefined}
data-whatif-row-unused={open ? 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)}
<td className="w-px px-1.5 py-1 text-center"
title={open ? undefined : usable ? WHATIF_TEXT.rowUnused : WHATIF_TEXT.rowsPending}>
<input type="checkbox" className="align-middle disabled:cursor-not-allowed disabled:opacity-40"
checked={off} disabled={!open}
onChange={() => {
onToggle(n)
// 勾完把底下的推演结果带进视野(已经看得见就不动):从输入那儿跳过来时表格常在视野边上。
// 答复回来后再带一次(上面的 effect)
reveal.current = true
requestAnimationFrame(() => foot.current?.scrollIntoView({ block: 'nearest' }))
}}
aria-label={WHATIF_TEXT.excludeRow(n)} />
</td>
<td className="mono w-px px-1.5 py-1 text-right text-faint">{n + 1}</td>
<td className={clsx('mono w-px px-1.5 py-1 text-right', open ? 'text-faint' : 'text-faint opacity-60')}>{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))
@@ -446,11 +686,14 @@ export function WhatIfRows({ win, hidden, excluded, onToggle }: {
</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 ref={foot} data-whatif-rows-foot="">
<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>
{summary}
</div>
</div>
)
+29
View File
@@ -3060,7 +3060,14 @@ export interface WhatIfInput {
changed?: boolean
/** 起了作用的被排除行(快照里的行号,从 0 数) */
excluded_rows?: number[]
/**
* 这个输入实际用到的快照行(含两端,从 0 数):单格一行、数组字段一段、整份查询结果全部。排除别的行影响不到它,
* 界面只让这几行可勾。遮罩的、快照读不出来的没有
*/
row_span?: [number, number]
masked?: boolean
/** 落在的查询快照读不出来(文件不在、与哈希不一致):遮没遮核对不了,值不给 */
withheld?: boolean
}
/**
@@ -3121,6 +3128,28 @@ export interface WhatIfAffectedCard {
direct: boolean
}
/**
* GET /runs/{id}/evidence/whatif/rows:一页查询快照的行(推演时勾行用),形状和证据面板的查询步骤一致,
* 可以直接交给 queryWindow。遮罩列写「已遮罩」;快照读不出来时没有行,note 说明原因
*/
export interface WhatIfRowsPage {
run_id: string
artifact: string
node_id?: string | null
tool?: string | null
source?: string | null
columns: string[]
rows: unknown[][]
row_offset: number
row_index: number[]
total_rows: number | null
truncated: boolean | null
masked: string[]
hash_ok: boolean | null
sealed: boolean
note?: string
}
export interface WhatIfResult {
run_id: string
card: { node_id: string; artifact: string; caliber: string; version: string }
+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: 25,
schedules: 1, compare: 1, verify: 1, inquiry: 1, bundle: 1, whatif: 28,
}
// 最慢的两项按段拆成几份。用的是它们自己的段过滤:最后一份用 *_SKIP 兜住「其余全部」,以后新加的段
// 不用改这里也跑得到(拆出去的那几份要是一段都没对上,那一份会报「一项都没跑」)。
+203 -8
View File
@@ -2,11 +2,14 @@
//
// 守的是这些「看着没坏、其实用不了」的地方:横幅没了或者不醒目(推演被当成出具)、边打字边发请求、请求体里的
// 输入路径和行号不对(行号对人从 1 数,对接口从 0 数)、勾选框在不推演时也冒出来、恢复原值之后还留着一行没勾回去、
// 不支持的输入一声不吭、窄屏(390px)被推演区撑出横向滚动、浅色主题下横幅看不清。
// 不支持的输入一声不吭、窄屏(390px)被推演区撑出横向滚动、浅色主题下横幅看不清。还有复核第 1 轮查出来的:整份
// 结果作输入时无行可勾、窗口里勾了不起作用的行也给勾、勾完行结果不在视野里、加载中和失败后还摆着上一次的答复、
// 封存核对没过不说、整个指标推演不了时又写「无可推演」又写「无变化」。
//
// 跑在预览页上:/preview.html?syn=whatif(宽屏右侧弹出、窄屏底部抽出)和 ?syn=whatif-rail(360px 画布右栏)。
// 文档是 frontend/src/run/__tests__/evidence-query.json 里后端 compose_doc 真跑出来的(只用通用名);片段接口
// 按它的 segments 作答,推演接口按后端 engine/recompute.py 的规则在这里算(只覆盖用到的三个指标),非 GET 一律拦掉。
// 按它的 segments 作答(s18 改成整份结果作输入),推演接口按后端 engine/recompute.py 的规则在这里算(只覆盖用到的
// 几个指标),勾行接口按夹具里 Q3 的快照作答,别的非 GET 一律拦掉。
//
// node scripts/check-whatif.mjs
// 对别的实例跑时带上地址:AGENTLAB_WEB=http://localhost:<前端端口> node scripts/check-whatif.mjs
@@ -38,7 +41,10 @@ async function section(name, fn) {
// ---------------------------------------------------------------- 夹具
/** 片段接口的答复:夹具里的原样,s22 改成老运行的容器输入(推演不了的那种) */
/**
* 片段接口的答复:夹具里的原样。s22 改成老运行的容器输入(推演不了的那种);s18 改成整份查询结果作输入
* (len(vars.orders.rows)):证据链里没有它的查询步骤,和后端真跑出来的一样
*/
function segmentReply(sid) {
const body = structuredClone(fx.segments[sid])
if (sid === 's22') {
@@ -46,6 +52,13 @@ function segmentReply(sid) {
body.chain[1] = { step: 'input', path: 'vars.adjust.items', value: '[4 项]', node_id: 'adjust', via: 'code',
field: 'items', role: 'compute', status: 'ok' }
}
if (sid === 's18') {
body.chain = [
{ ...body.chain[0], name: '订单笔数', expression: 'len(vars.orders.rows)', substituted: 'len(vars.orders.rows)',
value: 12, rendered: '12单', unit: '单', decimals: null, status: 'ok', recompute_ok: true },
{ step: 'input', path: 'vars.orders.rows', value: '[12 项]', node_id: 'parse', via: 'transform', field: 'rows', status: 'ok' },
]
}
return body
}
@@ -54,7 +67,10 @@ 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 SNAP = fx.snapshots.Q3.content
const thousands1 = (v) => v.toLocaleString('en-US', { minimumFractionDigits: 1, maximumFractionDigits: 1 })
const NO_LINEAGE = '这个指标生成于系统升级前,口径卡没有记录输入,不能推演'
const AFFECTED = [{ node_id: 'yearly', artifact: 'b1'.repeat(32), caliber: '年报口径', version: 'v1', metrics: ['y'], direct: false }]
/** 推演接口在这里按后端的规则算:给了新值的用新值;cell / agent 字段那一格所在的行被排除了,指标排除后无法计算 */
const METRICS = {
@@ -74,6 +90,32 @@ function whatifReply(body) {
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: [] }
const changed = Object.keys(inputs).length > 0 || Object.keys(exclude).length > 0
if (body?.metric === 'new_users') {
// 整份查询结果作输入:行数就是指标,勾掉几行少几单
const gone = exclude[Q3] ?? []
const value = SNAP.rows.length - gone.length
return { ...base, affected_cards: gone.length ? AFFECTED : [],
metrics: [{ id: 'new_users', name: '订单笔数', unit: '单', old: 12, new: value, delta: value - 12,
rendered_old: '12单', rendered_new: `${value}单`, rendered_delta: gone.length ? `-${gone.length}单` : null,
changed: gone.length > 0, status: gone.length ? 'ok' : 'unchanged', reason: null,
substituted: 'len(vars.orders.rows)',
inputs: [{ path: 'vars.orders.rows', via: 'transform', node_id: 'parse', artifact: Q3, row_span: [0, 11],
kind: 'container', editable: false, old: '[12 项]', new: `[${value} 项]`, changed: gone.length > 0,
...(gone.length ? { excluded_rows: gone } : {}) }] }],
segments: [{ report: 'write', unit: fx.segments.s18.unit.id, sid: 's18', metric: 'new_users', rendered_old: '12单',
rendered_new: `${value}单`, changed: gone.length > 0 }],
unsupported: [] }
}
if (body?.metric === 'refund_rate') {
// 升级前的口径卡:没记输入,整个指标推演不了
return { ...base, metrics: [{ id: 'refund_rate', name: '退款率', unit: '', old: 0.0235, new: 0.0235, delta: 0,
rendered_old: '2.35%', rendered_new: '2.35%', rendered_delta: null, changed: false, status: 'unchanged', reason: null,
substituted: null, inputs: [] }],
segments: [{ report: 'write', unit: fx.segments.s16.unit.id, sid: 's16', metric: 'refund_rate', rendered_old: '2.35%',
rendered_new: '2.35%', changed: false }],
unsupported: [{ metric: 'refund_rate', input: null, reason: NO_LINEAGE }] }
}
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',
@@ -93,8 +135,9 @@ function whatifReply(body) {
const step = chainOf(def.sid)[0]
const views = inputsOf(def.sid).map((inp) => {
const row = inp.locator?.row
// row_span:这个输入实际用到的快照行,单格就是它那一行(后端 recompute._row_span)
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 }
row_span: [row, row], 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 }
@@ -115,11 +158,23 @@ function whatifReply(body) {
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: [],
return { ...base, metrics: [metric], unsupported: [], affected_cards: changed ? AFFECTED : [],
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 }] }
}
/** 勾行接口:按夹具里 Q3 的快照给一页(phone 列在夹具里已经写成「已遮罩」) */
function rowsReply(url) {
const artifact = url.searchParams.get('artifact')
const offset = Number(url.searchParams.get('offset') ?? 0)
const limit = Number(url.searchParams.get('limit') ?? 50)
if (artifact !== Q3) return { status: 422, json: { detail: '这份查询快照不属于这次运行,或不在封存范围内,不能用于推演', code: 'whatif_bad_input' } }
const picked = SNAP.rows.slice(offset, offset + limit)
return { json: { run_id: RUN, artifact, node_id: 'fetch', tool: 'db_query__shop', source: 'shop', columns: SNAP.columns,
rows: picked, row_offset: offset, row_index: picked.map((_, i) => offset + i), total_rows: SNAP.rows.length,
truncated: false, masked: ['phone'], hash_ok: true, sealed: true } }
}
// ---------------------------------------------------------------- 页面
const browser = await chromium.launch({ executablePath: CHROME })
@@ -133,6 +188,7 @@ async function open(syn, { width = 1280, height = 900, theme = 'dark', reply = n
ctx.setDefaultTimeout(6000)
const page = await ctx.newPage()
const sent = []
const pages = []
const errors = []
page.on('pageerror', (e) => errors.push(e.message))
page.on('dialog', (d) => { errors.push(`原生对话框:${d.message()}`); void d.dismiss() })
@@ -146,13 +202,18 @@ async function open(syn, { width = 1280, height = 900, theme = 'dark', reply = n
return route.fulfill({ status: out.status ?? 200, json: out.json })
}
if (req.method() !== 'GET') return route.abort()
if (url.pathname === `/api/runs/${RUN}/evidence/whatif/rows`) {
pages.push(Object.fromEntries(url.searchParams))
const out = rowsReply(url)
return route.fulfill({ status: out.status ?? 200, json: out.json })
}
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 }
return { ctx, page, sent, pages, errors }
}
/** 点开一个片段,等指标步骤和「推演」开关出来 */
@@ -169,6 +230,14 @@ async function openWhatIf(page) {
const settle = (page, ms = 700) => page.waitForTimeout(ms)
const text = (page, sel) => page.locator(sel).first().innerText()
/** 这个元素整个在面板正文(窄屏抽屉、宽屏侧栏都是 [data-ev-body] 在滚)和窗口的可见范围里 */
const inView = (page, sel) => page.evaluate((s) => {
const el = document.querySelector(s)
if (!el) return false
const body = document.querySelector('[data-ev-body]')?.getBoundingClientRect() ?? { top: 0, bottom: innerHeight }
const r = el.getBoundingClientRect()
return r.top >= Math.max(0, body.top) - 1 && r.bottom <= Math.min(innerHeight, body.bottom) + 1
}, sel)
const shot = async (page, name) => {
if (!SHOTS) return
mkdirSync(SHOTS, { recursive: true })
@@ -193,8 +262,9 @@ await section('打开推演', async () => {
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 page.locator('[data-whatif-rows]').count() === 2
&& await page.locator('[data-whatif-row] input[type="checkbox"]').count() === 2
&& await page.locator('[data-whatif-row] input[type="checkbox"]:disabled').count() === 0)
check('报告里引用它的一处不受影响', (await text(page, '[data-whatif-segments-none]')).includes('1 处不受影响'))
check('「恢复原值」在没有改动时不可点', await page.locator('[data-whatif-reset]').isDisabled())
await shot(page, 'open-dark')
@@ -296,6 +366,74 @@ await section('多行快照与遮罩', async () => {
await ctx.close()
})
await section('只让这个指标用到的行可勾', async () => {
const { ctx, page, sent } = await open('whatif')
await openMetric(page, 's70')
await openWhatIf(page)
const row = (n) => page.locator(`[data-ev-query="${Q3}"] [data-whatif-row="${n}"]`)
check('用到的第 6、12 行可勾', !(await row(5).locator('input').isDisabled()) && !(await row(11).locator('input').isDisabled()))
const unused = await page.locator(`[data-ev-query="${Q3}"] [data-whatif-row][data-whatif-row-unused]`).count()
check('前后几行上下文不能勾,悬停写明没用到', unused === 6 && await row(4).locator('input').isDisabled()
&& (await row(4).locator('td').first().getAttribute('title')) === '这个指标没有用到这一行,排除它不影响结果', `${unused} 行`)
const before = sent.length
await row(4).locator('input').click({ force: true })
await settle(page, 300)
check('点了不起作用的行也不发请求', sent.length === before && !(await row(4).locator('input').isChecked()))
check('表格下面说明只有用到的行能勾', (await text(page, `[data-ev-query="${Q3}"] [data-whatif-rows]`)).includes('没有用到的行不能勾选'))
await ctx.close()
})
await section('整份结果作输入:推演区里勾行', async () => {
const { ctx, page, sent, pages } = await open('whatif')
await openMetric(page, 's18')
await openWhatIf(page)
check('证据链里没有它的查询步骤', await page.locator('[data-ev-query]').count() === 0)
const toggle = page.locator('[data-whatif-input="vars.orders.rows"] [data-whatif-pick-here]')
check('输入下面给「勾选要排除的行(共 12 行)」', (await toggle.innerText()).includes('勾选要排除的行(共 12 行)'))
await toggle.click()
await page.waitForSelector('[data-whatif-snapshot] [data-whatif-row]')
check('按页取这份快照,从第 0 行起', pages[0]?.artifact === Q3 && pages[0]?.offset === '0', JSON.stringify(pages[0]))
const rows = page.locator('[data-whatif-snapshot] [data-whatif-row]')
check('12 行都在、都能勾', await rows.count() === 12
&& await page.locator('[data-whatif-snapshot] [data-whatif-row] input:disabled').count() === 0)
check('遮罩列写「已遮罩」', await page.locator('[data-whatif-snapshot] td[data-masked]').count() === 12)
check('写明是第几行到第几行', (await text(page, '[data-whatif-snapshot-range]')) === '第 1–12 行,共 12 行')
await page.locator('[data-whatif-snapshot] [data-whatif-row="7"] input').check()
await page.waitForSelector('[data-whatif-result="ok"]')
check('请求体带 exclude_rows:第 8 行是行号 7',
JSON.stringify(sent.at(-1).body.overrides) === JSON.stringify({ inputs: {}, exclude_rows: { [Q3]: [7] } }), JSON.stringify(sent.at(-1).body.overrides))
check('新值随之变化:12单 → 11单', (await text(page, '[data-whatif-result] [data-whatif-new]')) === '11单'
&& (await text(page, '[data-whatif-result] [data-whatif-delta]')) === '变化 -1单')
check('勾行的表格底下带着推演结果', (await text(page, '[data-whatif-snapshot] [data-whatif-summary]')).includes('11单'))
check('输入下写明排除了哪一行', (await text(page, '[data-whatif-input="vars.orders.rows"] [data-whatif-excluded]')) === '已排除第 8 行')
check('报告里引用它的那一处跟着变', (await text(page, '[data-whatif-segment="s18"]')).includes('11单'))
await shot(page, 'table-input-dark')
await ctx.close()
})
await section('勾行后结果就在手边', async () => {
const { ctx, page } = await open('whatif')
await openMetric(page, 's12')
await openWhatIf(page)
await page.locator(`[data-whatif-input="cell(nodes.pull, 0, 'gmv')"] [data-whatif-pick]`).click()
await page.locator(`[data-ev-query="${Q4}"] [data-whatif-row="0"] input`).check()
await page.waitForSelector(`[data-ev-query="${Q4}"] [data-whatif-summary="excluded"]`)
const summary = await text(page, `[data-ev-query="${Q4}"] [data-whatif-summary]`)
check('勾了行,表格底下就写着新值和原因', summary.includes('—') && summary.includes('排除后无法计算'), summary)
await page.waitForTimeout(200)
check('答复回来后,表格底下这份结果整个在视野里', await inView(page, `[data-ev-query="${Q4}"] [data-whatif-summary]`),
JSON.stringify(await page.evaluate((s) => {
const r = document.querySelector(s)?.getBoundingClientRect()
const b = document.querySelector('[data-ev-body]')?.getBoundingClientRect()
return { top: r?.top, bottom: r?.bottom, bodyTop: b?.top, bodyBottom: b?.bottom, h: innerHeight }
}, `[data-ev-query="${Q4}"] [data-whatif-summary]`)))
await page.click(`[data-ev-query="${Q4}"] [data-whatif-back]`)
await page.waitForTimeout(250)
check('「回到推演结果」:焦点回到推演结果、滚进视野', await page.evaluate(() => document.activeElement?.hasAttribute('data-whatif-result'))
&& await inView(page, '[data-whatif-result]'))
await ctx.close()
})
await section('不支持的输入', async () => {
const { ctx, page } = await open('whatif')
await openMetric(page, 's22')
@@ -304,6 +442,20 @@ await section('不支持的输入', async () => {
.includes('无法推演:当时没有保存这个输入的完整值'))
check('容器输入不给输入框', await page.locator('[data-whatif-field]').count() === 0
&& (await text(page, '[data-whatif-input="vars.adjust.items"] [data-whatif-value]')).includes('[4 项]'))
check('没有能改的输入、也没有能勾的行:说一句为什么', (await text(page, '[data-whatif-nothing]')).includes('没有可以修改的输入'))
await ctx.close()
})
await section('整个指标推演不了', async () => {
const { ctx, page } = await open('whatif')
await openMetric(page, 's16')
await page.click('[data-whatif-toggle]')
await page.waitForSelector('[data-whatif-result="whole"]')
const result = await text(page, '[data-whatif-result]')
check('只说原因:口径卡没有记录输入', result.includes('口径卡没有记录输入,不能推演'), result)
check('不写「无变化」,也不说「没有引用任何输入」', !result.includes('无变化') && !result.includes('→')
&& await page.locator('[data-whatif-empty], [data-whatif-inputs], [data-whatif-unsupported]').count() === 0)
check('不画勾选框', await page.locator('[data-whatif-rows]').count() === 0)
await ctx.close()
})
@@ -350,6 +502,40 @@ await section('加载、失败、未封存', async () => {
await openWhatIf(open_.page)
check('运行未封存时提示数据可能还会变', (await text(open_.page, '[data-whatif-unsealed]')) === '运行未封存,数据可能还会变')
await open_.ctx.close()
const broken = await open('whatif', { reply: async (body) => ({ json: { ...whatifReply(body), sealed: false, seal: { sealed: true, ok: false } } }) })
await openMetric(broken.page, 's12')
await openWhatIf(broken.page)
check('封存核对未通过时照实说', (await text(broken.page, '[data-whatif-unsealed="broken"]')) === '封存核对未通过,推演所依据的数据可能已被改动')
await broken.ctx.close()
})
await section('加载中、失败后不留上一次的答复', async () => {
let release
const gate = new Promise((r) => { release = r })
const { ctx, page } = await open('whatif', { reply: async (body, n) => {
if (n === 2) await gate
if (n === 3) return { status: 500, json: { detail: '推演服务暂时不可用' } }
return { json: whatifReply(body) }
} })
await openMetric(page, 's12')
await openWhatIf(page)
await page.locator(`[data-ev-query="${Q4}"] [data-whatif-row="0"] input`).check()
await page.waitForSelector('[data-whatif-segments="loading"]')
check('答复回来之前:报告中会变的地方变淡、标为忙,结果也是', (await page.locator('[data-whatif-segments]').getAttribute('aria-busy')) === 'true'
&& await page.locator('[data-whatif-result="loading"]').count() === 1)
release()
await page.waitForSelector('[data-whatif-result="excluded"]')
check('答复回来后按这一次画:排除标记、下游口径卡', await page.locator('[data-whatif-excluded]').count() === 1
&& await page.locator('[data-whatif-affected=""]').count() === 1)
const field = page.locator('[data-whatif-input="vars.kpi.gmv"] [data-whatif-field]')
await field.fill('50000')
await field.press('Enter')
await page.waitForSelector('[data-whatif] [role="alert"]')
check('请求失败:上一次的结果、排除标记、报告中会变的地方、下游口径卡都不再显示',
await page.locator('[data-whatif-result], [data-whatif-excluded], [data-whatif-segments], [data-whatif-affected]').count() === 0)
check('输入框还在,能接着改', await page.locator('[data-whatif-field]').count() === 2)
await ctx.close()
})
/** 页面和面板正文都没有横向滚动(表格自己的横向滚动条不算:那是表格容器里的) */
@@ -377,6 +563,15 @@ await section('窄屏', async () => {
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}`)
// 推演区里另给的勾行表格(四列,带遮罩列)在窄屏里同样不撑宽
// 先关上抽屉再点另一处:抽屉盖着正文下半截
await page.locator('[data-evidence-panel] button[aria-label="关闭证据"]').click()
await openMetric(page, 's18')
await openWhatIf(page)
await page.click('[data-whatif-pick-here]')
await page.waitForSelector('[data-whatif-snapshot] [data-whatif-row]')
const t = await overflow(page)
check(`390px(${theme}):推演区里的勾行表格不横向溢出`, t.page <= 0 && t.body <= 1 && t.whatif <= 1, JSON.stringify(t))
check(`390px(${theme}):页面没有报错`, errors.length === 0, errors.join(';'))
await ctx.close()
}