mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 13:32:00 +00:00
数字推演复核修正(第 1 轮,界面):整份结果在推演区里勾行,只让用到的行可勾,勾完结果就在手边,加载中和失败后不摆上一次的答复
- 整份结果、数组字段作输入时,输入下面给「勾选要排除的行(共 N 行)」,按页取整份快照来勾:证据链里没有它的查询步骤、 或者查询步骤只给前后 2 行时也勾得全 - 查询步骤里的勾行表格只让这个指标用到的行可勾(row_span),别的行禁用并说明没用到,勾了也不发请求 - 勾行表格底下带推演结果(旧值 → 新值或原因)和「回到推演结果」;勾完、答复回来后各把它带进视野一次(答复回来时上面 的内容会变高,把表格往下挤) - 推演的请求和答复提到片段那一层,推演区和查询步骤共用一份;答复回来之前随改动变的部分变淡、标为忙,请求失败时结果、 排除标记、报告中会变的地方、下游口径卡都不显示,不拿上一次的答复充数 - 整个指标推演不了(口径卡没记输入)时只说原因,不再同时写「无可推演」和「无变化」,也不画勾选框 - 快照读不出来的输入写「查询快照无法核对,不显示原值」;没有能改的输入、也没有能勾的行时说一句为什么 - check-whatif 补对应检查:整份结果勾行、上下文行不能勾、勾完结果整个在视野里、封存核对未通过、加载中和失败后、整个 指标推演不了、窄屏里的勾行表格;ESTIMATE 按实测改成 28 秒
This commit is contained in:
@@ -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 }),
|
||||
}
|
||||
|
||||
@@ -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}` : ''}`,
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user