双盲复算前端(二):两路对不上时的审批卡,三选一裁决

- run/DualCheckApproval.tsx:期 0 的空插槽换成真组件。先按 payload.summary 画两个值、相差比例、摘要里的差异,
  完整对照(card_artifact)到了再补盲算模型、全部具体差异和「对照两条 SQL」;只有兜底说明时写明未能归纳成具体差异
  并默认展开对照;对照区先列每个输入两边各取到多少,只对照不同的输入,两条 SQL 按卡片宽度左右并排或上下排,
  text_diff 里有的行在原文中高亮,规范化后对不上原文的不乱标、另列规范化后不同的行;只差一个方面时说明选择会记入
  数据目录;每个指标三选一(原生单选,键盘可操作),缺一个都不能提交,回复走审批接口,value 为 {choices, note};
  后果按运行类别说,写明「采用盲算」不替换报告里的数,探索运行不提出具;完整对照取不到时按摘要照样能裁决
- RunPanel:payload.kind 为 dual_check 时不画通用的输入框、备注和「提交 / 驳回并终止」,署名那行保留,
  onResolved 交给 DualCheckApproval;其他审批卡不变
- 预览用例 dev/fixtures/dual_check.tsx(夹具 dual_check.json 与检查脚本共用,数据虚构)
- check-verify:期 0 的占位换成真检查——核查设置的叫法和服务端逐项比对,审批卡的摘要与补全、具体差异、只有兜底、
  并排与高亮、不选全不能提交、请求体、取不到完整对照、探索运行、390px 与右栏不溢出、深浅色对比度
This commit is contained in:
ThearchyHelios
2026-10-05 14:07:57 +08:00
parent 4d29b67cef
commit cac34c90dd
5 changed files with 1047 additions and 44 deletions
+154
View File
@@ -0,0 +1,154 @@
{
"_note": "双盲复算的预览用例和 scripts/check-verify.mjs 共用的夹具。表名、数字都是虚构的通用示例,不来自任何真实数据。",
"approval": {
"id": "ap-dc-0001",
"run_id": "run-dc-0001",
"node_id": "dc",
"mode": "input",
"title": "双盲复算:2 个指标不一致",
"status": "pending",
"response": {},
"workflow_name": "门店周报",
"node_label": "复核周报口径",
"run_class": "formal",
"payload": {
"kind": "dual_check",
"node_id": "dc",
"title": "双盲复算:2 个指标不一致",
"card_artifact": "d0ab1e5c0ffee000d0ab1e5c0ffee000d0ab1e5c0ffee000d0ab1e5c0ffee000",
"summary": [
{
"metric_id": "gmv",
"name": "销售额",
"unit": "元",
"original_rendered": "45,678.5元",
"blind_rendered": "51,388.3元",
"delta_pct": 12.5,
"diff": ["原流程按支付时间 paid_at 筛选时间范围,盲算按下单时间 created_at 筛选"],
"fallback": false
},
{
"metric_id": "refund_rate",
"name": "退款率",
"unit": "",
"original_rendered": "2.35%",
"blind_rendered": "3.10%",
"delta_pct": 31.91,
"diff": [],
"fallback": true
}
]
}
},
"card": {
"model": "blind-model-x",
"same_model": false,
"metrics": [
{
"metric_id": "gmv",
"name": "销售额",
"unit": "元",
"decimals": 1,
"original": {
"value": 45678.5,
"rendered": "45,678.5元",
"inputs": [
{
"path": "vars.kpi.gmv",
"value": 45678.5,
"sql": "SELECT SUM(amount) AS gmv\nFROM orders\nWHERE paid_at >= '2026-09-01'\n AND paid_at < '2026-09-08'",
"source": "shop",
"alias": "Q1"
}
]
},
"blind": {
"value": 51388.3,
"rendered": "51,388.3元",
"inputs": [
{
"path": "vars.kpi.gmv",
"value": 51388.3,
"sql": "SELECT SUM(amount) AS gmv\nFROM orders\nWHERE created_at >= '2026-09-01'\n AND created_at < '2026-09-08'",
"source": "shop",
"alias": "B1",
"rationale": "销售额按下单时间统计本周全部订单金额之和"
}
]
},
"diff": {
"equivalent": false,
"items": [
{ "aspect": "time", "text": "原流程按支付时间 paid_at 筛选时间范围,盲算按下单时间 created_at 筛选" }
],
"fallback": false,
"text_diff": [
"- WHERE paid_at >= '2026-09-01'",
"- AND paid_at < '2026-09-08'",
"+ WHERE created_at >= '2026-09-01'",
"+ AND created_at < '2026-09-08'"
]
}
},
{
"metric_id": "refund_rate",
"name": "退款率",
"unit": "",
"decimals": 4,
"original": {
"value": 0.0235,
"rendered": "2.35%",
"inputs": [
{
"path": "vars.kpi.refunds",
"value": 47,
"sql": "SELECT COUNT(*) AS refunds FROM refunds r JOIN orders o ON o.id = r.order_id WHERE o.status <> 'test'",
"source": "shop",
"alias": "Q2"
},
{
"path": "vars.kpi.orders",
"value": 2000,
"sql": "SELECT COUNT(*) AS orders FROM orders WHERE status <> 'test'",
"source": "shop",
"alias": "Q3"
}
]
},
"blind": {
"value": 0.031,
"rendered": "3.10%",
"inputs": [
{
"path": "vars.kpi.refunds",
"value": 62,
"sql": "SELECT COUNT(DISTINCT order_id) AS refunds FROM refunds WHERE order_id IN (SELECT id FROM orders)",
"source": "shop",
"alias": "B2",
"rationale": "退款笔数按有退款记录的订单去重计数"
},
{
"path": "vars.kpi.orders",
"value": 2000,
"sql": "SELECT COUNT(*) AS orders FROM orders WHERE status <> 'test'",
"source": "shop",
"alias": "B3",
"rationale": "订单总数,不含测试订单"
}
]
},
"diff": {
"equivalent": false,
"items": [
{ "aspect": "other", "text": "两条 SQL 的写法不同,未能归纳成具体差异,请对照原文" }
],
"fallback": true,
"text_diff": [
"- SELECT COUNT(*) AS refunds FROM refunds AS r JOIN orders AS o ON o.id = r.order_id WHERE o.status <> 'test'",
"+ SELECT COUNT(DISTINCT order_id) AS refunds FROM refunds WHERE order_id IN (SELECT id FROM orders)"
]
}
}
]
}
}
+43
View File
@@ -0,0 +1,43 @@
import type { JSX } from 'react'
import { ApprovalCard } from '../../run/RunPanel'
import type { Approval } from '../../types'
import data from './dual_check.json'
/**
* 双盲复算的预览用例,scripts/check-verify.mjs 按它们检查(夹具在同目录的 dual_check.json,检查脚本读的是同一份):
* - ?syn=dual-check-approval:正式运行里两路对不上的审批卡,宽栏(两条 SQL 左右并排)
* - ?syn=dual-check-approval-rail:同一张卡摆在 360px 的画布右栏里(两条 SQL 上下排)
* - ?syn=dual-check-approval-exploratory:探索运行,后果里不提出具
*
* 完整对照的工件(GET /api/artifacts/{id})和审批回复接口由检查脚本用 page.route 伪造,直接打开时卡上照实说
* 无法加载完整对照。提交后 onResolved 只把运行 id 记到 window.__dualResolved,不去接事件流
*/
const approval = (patch: Partial<Approval> = {}): Approval => ({
...(data.approval as unknown as Approval),
created_at: new Date(Date.now() - 180_000).toISOString().replace('Z', ''),
...patch,
})
function Card({ value }: { value: Approval }) {
return (
<div className="overflow-hidden rounded-lg border" style={{ borderColor: 'var(--warn)' }}>
<ApprovalCard approval={value} onResolved={(id) => { (window as any).__dualResolved = id }} />
</div>
)
}
export const cases: Record<string, () => JSX.Element> = {
'dual-check-approval': () => (
<div className="mx-auto max-w-3xl p-4 text-sm" data-fixture="dual-check-approval"><Card value={approval()} /></div>
),
'dual-check-approval-rail': () => (
<div className="h-full w-[360px] max-w-full overflow-y-auto border-r bg-panel" data-fixture="dual-check-approval-rail">
<Card value={approval()} />
</div>
),
'dual-check-approval-exploratory': () => (
<div className="mx-auto max-w-3xl p-4 text-sm" data-fixture="dual-check-approval-exploratory">
<Card value={approval({ run_class: 'exploratory' })} />
</div>
),
}
+449 -5
View File
@@ -1,15 +1,459 @@
import type { Approval } from '../types'
import { useEffect, useId, useMemo, useState } from 'react'
import type { CSSProperties } from 'react'
import { AlertTriangle, Check, ChevronRight } from 'lucide-react'
import clsx from 'clsx'
import { api } from '../api/client'
import { useCatalog } from '../store/catalog'
import { useStudio } from '../store/studio'
import { Spinner, useToast } from '../components/ui'
import { NONE } from '../lib/format'
import { DUAL_CHECK_TEXT } from '../lib/terms'
import type {
Approval, DualCheckBlindInput, DualCheckCard, DualCheckChoice, DualCheckInput, DualCheckMetric, DualCheckReply,
DualCheckSummaryItem,
} from '../types'
const T = DUAL_CHECK_TEXT.approval
const CHOICES: DualCheckChoice[] = ['a', 'b', 'neither']
export interface DualCheckApprovalProps {
/** payload.kind 为 'dual_check' 的审批:双盲复算两路对不上、交给人裁决 */
approval: Approval
/**
* 提交之后谁来重新接上运行的事件流,同 ApprovalCard 的 onResolved:对话页、运行页各自接,没给就走画布的 attachRun。
* 两处都接会开两条订阅,各收一份事件
*/
onResolved?: (runId: string) => void | Promise<void>
}
/** 一个不一致的指标在卡上要画的东西:先由 payload.summary 填,完整对照(card_artifact)到了再补全 */
interface MetricView {
id: string
name: string
original: string | null
blind: string | null
/** 已乘过 100;null 是原流程值为 0(约定),undefined 是没给 */
deltaPct: number | null | undefined
/** 具体差异,一条一句(兜底说明不算) */
items: string[]
/** 只有兜底说明:两条 SQL 的写法不同,未能归纳成具体差异 */
fallbackOnly: boolean
/** 具体差异之外还有未能归纳的部分(服务端那句「另有未能归纳的差异」) */
rest: string | null
/** 两条 SQL 等价,差在取用的结果上 */
equivalent: boolean
/** 只差一个方面:审核人的选择会记入数据目录 */
single: boolean
full: DualCheckMetric | null
}
const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined)
const num = (v: unknown): number | undefined => (typeof v === 'number' && Number.isFinite(v) ? v : undefined)
/** payload.summary 里认得出的项(老服务端、写坏了的项跳过,不让整张卡崩掉) */
function summaryOf(raw: unknown): DualCheckSummaryItem[] {
return Array.isArray(raw)
? raw.filter((s): s is DualCheckSummaryItem => !!s && typeof s === 'object' && typeof s.metric_id === 'string' && !!s.metric_id)
: []
}
/** 工件内容像不像一份对照:至少得有 metrics 数组,里面的项带 metric_id */
function cardOf(content: unknown): DualCheckCard | null {
if (!content || typeof content !== 'object' || !Array.isArray((content as DualCheckCard).metrics)) return null
const card = content as DualCheckCard
return { ...card, metrics: card.metrics.filter((m) => !!m && typeof m === 'object' && typeof m.metric_id === 'string') }
}
function viewsOf(summary: DualCheckSummaryItem[], card: DualCheckCard | null): MetricView[] {
const full = new Map((card?.metrics ?? []).map((m) => [m.metric_id, m]))
// 先按摘要的顺序,工件里另有的(约定里不该有,有就照实列出来)跟在后面
const ids = [...new Set([...summary.map((s) => s.metric_id), ...full.keys()])]
return ids.map((id) => {
const s = summary.find((x) => x.metric_id === id)
const f = full.get(id) ?? null
const base = {
id,
name: str(s?.name) ?? str(f?.name) ?? id,
original: str(s?.original_rendered) ?? str(f?.original?.rendered) ?? null,
blind: str(s?.blind_rendered) ?? str(f?.blind?.rendered) ?? null,
deltaPct: s && 'delta_pct' in s ? num(s.delta_pct) ?? null : f?.delta_pct === null ? null : num(f?.delta_pct),
}
if (f) {
const items = (Array.isArray(f.diff?.items) ? f.diff.items : []).filter((i) => !!i && typeof i.text === 'string' && !!i.text)
// aspect 为 other 的是兜底说明,不算具体差异;只有它的时候卡上写那句固定的说法、默认展开对照
const concrete = items.filter((i) => i.aspect !== 'other')
const other = items.filter((i) => i.aspect === 'other')
const equivalent = f.diff?.equivalent === true
const aspects = new Set(concrete.map((i) => i.aspect))
return {
...base,
items: concrete.map((i) => i.text),
fallbackOnly: !equivalent && !concrete.length,
rest: concrete.length && other.length ? other.map((i) => i.text).join(';') : null,
equivalent,
// 「只差一个方面」以服务端为准(它决定写不写数据目录),这里只在没用兜底、具体差异都落在同一方面时提示
single: !equivalent && f.diff?.fallback !== true && aspects.size === 1 && !aspects.has('parse'),
full: f,
}
}
return {
...base,
items: Array.isArray(s?.diff) ? s.diff.filter((t): t is string => typeof t === 'string' && !!t) : [],
fallbackOnly: s?.fallback === true,
rest: null,
equivalent: false,
single: false,
full: null,
}
})
}
/** 相差比例:服务端给的已乘过 100、保留两位(12.5 是相差 12.5%),带正负号;小到两位小数写不出来的写「<0.01%」 */
function deltaText(pct: number | null | undefined): string | null {
if (pct === undefined) return null
if (pct === null) return T.deltaZero
const abs = Math.abs(pct)
const sign = pct > 0 ? '+' : pct < 0 ? '-' : ''
return T.delta(abs < 0.01 ? `${sign}<0.01%` : `${sign}${abs.toLocaleString('en-US', { maximumFractionDigits: 2 })}%`)
}
/**
* 审批卡里双盲复算那一块(RunPanel 的 ApprovalCard 在 payload.kind === 'dual_check' 时渲染它)。
* 审批卡里双盲复算那一块(RunPanel 的 ApprovalCard 在 payload.kind === 'dual_check' 时渲染它,通用的输入框、备注和
* 「提交 / 驳回并终止」不画——mode 是 input,通用那套会把一个字符串当 value 发出去)。
*
* 现在是占位,什么都不画;双盲复算的工作包在这里补上两路结果的对照,payload 的形状由那边定。
* 先按 payload.summary 画:两个值、相差多少、摘要里的差异,选择也能先做;完整对照(card_artifact)到了再补上盲算模型、
* 全部具体差异和「对照两条 SQL」。取不到完整对照时照实说,按摘要照样能裁决。每个指标三选一、缺一个都不能提交,
* 回复走审批接口:value 为 {choices, note}
*/
export function DualCheckApproval(_props: DualCheckApprovalProps) {
return null
export function DualCheckApproval({ approval, onResolved }: DualCheckApprovalProps) {
const toast = useToast()
const refreshApprovals = useCatalog((s) => s.refreshApprovals)
const attachRun = useStudio((s) => s.attachRun)
const uid = useId()
const payload = approval.payload ?? {}
const summary = useMemo(() => summaryOf(payload.summary), [payload.summary])
const cardId = str(payload.card_artifact) ?? ''
const [card, setCard] = useState<{ id: string; data?: DualCheckCard | null } | null>(null)
useEffect(() => {
if (!cardId) return
let alive = true
setCard({ id: cardId })
api.artifact(cardId).then(
(res) => { if (alive) setCard({ id: cardId, data: cardOf(res?.content) }) },
// 取不到(工件被清理、网络断了)和取回来认不出一样处理:按摘要裁决
() => { if (alive) setCard({ id: cardId, data: null }) },
)
return () => { alive = false }
}, [cardId])
const slot = card?.id === cardId ? card : null
const data = slot?.data ?? null
const loading = !!cardId && slot?.data === undefined
const lost = !cardId || slot?.data === null
const views = useMemo(() => viewsOf(summary, data), [summary, data])
const [choices, setChoices] = useState<Record<string, DualCheckChoice>>({})
const [note, setNote] = useState('')
const [busy, setBusy] = useState(false)
const pending = views.filter((v) => !choices[v.id]).length
const submit = async () => {
if (busy || pending || !views.length) return
setBusy(true)
try {
const value: DualCheckReply = {
choices: Object.fromEntries(views.map((v) => [v.id, choices[v.id]])),
note: note.trim(),
}
// 照 ApprovalCard 里 mode 为 input 时的写法:备注外层也放一份,节点拿到的是 {approved, note, value}
await api.approvals.decide(approval.id, { approved: true, note: value.note, value })
await refreshApprovals()
if (onResolved) await onResolved(approval.run_id)
else await attachRun(approval.run_id)
toast(T.done, 'ok')
} catch (e) {
toast.error(e)
} finally {
setBusy(false)
}
}
return (
<div className="@container mb-2 space-y-2" data-dual-check="" data-state={loading ? 'loading' : lost ? 'summary' : 'ready'}>
{views.length > 0 && <p className="text-xs leading-relaxed" data-dual-intro="">{T.intro(views.length)}</p>}
{loading && (
<div className="flex items-center gap-1.5 text-2xs text-faint" role="status" data-dual-loading="">
<Spinner size={10} /> {T.loading}
</div>
)}
{lost && views.length > 0 && (
<div className="text-2xs leading-relaxed" style={{ color: 'var(--st-waiting)' }} data-dual-load-failed="">{T.loadFailed}</div>
)}
{data && (str(data.model) || data.same_model) && (
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-2xs">
{str(data.model) && <span className="mono text-dim [overflow-wrap:anywhere]" data-dual-model="">{T.model(data.model!)}</span>}
{data.same_model && (
<span className="flex items-center gap-1" style={{ color: 'var(--st-waiting)' }} data-dual-same-model="">
<AlertTriangle size={11} aria-hidden className="shrink-0" /> {T.sameModel}
</span>
)}
</div>
)}
{!views.length ? (
<p className="text-2xs leading-relaxed text-dim" data-dual-empty="">{T.empty}</p>
) : (
<>
{views.map((v) => (
<MetricBlock key={v.id} view={v} name={`${uid}-${v.id}`} choice={choices[v.id]}
onChoose={(c) => setChoices((cur) => ({ ...cur, [v.id]: c }))} />
))}
{/* 选了会怎样,常显在提交按钮上面:审核人得在提交之前知道「采用盲算」并不替换报告里的数 */}
<div className="text-2xs leading-relaxed text-dim" data-dual-consequence="">
{T.consequence(approval.run_class)}
<span className="mt-0.5 block">{T.consequenceFail}</span>
</div>
<div>
<label className="label" htmlFor={`${uid}-note`}>{T.note}</label>
<textarea id={`${uid}-note`} className="field" rows={2} value={note} placeholder={T.notePlaceholder}
data-dual-note="" onChange={(e) => setNote(e.target.value)} />
</div>
{pending > 0 && (
<div id={`${uid}-pending`} className="text-2xs" style={{ color: 'var(--st-waiting)' }} data-dual-pending="">
{T.pending(pending)}
</div>
)}
<button type="button" className="btn btn-primary w-full justify-center" disabled={busy || pending > 0}
aria-describedby={pending > 0 ? `${uid}-pending` : undefined} data-dual-submit="" onClick={() => void submit()}>
{busy ? <Spinner size={11} /> : <Check size={12} aria-hidden />} {T.submit}
</button>
</>
)}
</div>
)
}
/** 一个不一致的指标:名字、两个值、相差多少、差异说明、对照两条 SQL、三选一 */
function MetricBlock({ view, name, choice, onChoose }: {
view: MetricView; name: string; choice?: DualCheckChoice; onChoose: (c: DualCheckChoice) => void
}) {
const uid = useId()
// 只有兜底说明时默认展开:一条具体说法都没有,审核人只能对照原文判断
const [open, setOpen] = useState<boolean | null>(null)
const expanded = !!view.full && (open ?? view.fallbackOnly)
const delta = deltaText(view.deltaPct)
return (
<div className="rounded border bg-bg p-2" role="group" aria-labelledby={`${uid}-name`} data-dual-metric={view.id}>
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span id={`${uid}-name`} className="min-w-0 text-xs font-medium [overflow-wrap:anywhere]" data-dual-name="">{view.name}</span>
{delta && (
<span className="tnum ml-auto shrink-0 text-2xs font-medium" style={{ color: 'var(--st-waiting)' }} title={T.deltaHint}
data-dual-delta="">{delta}</span>
)}
</div>
<dl className="mt-1 grid grid-cols-2 gap-2" data-dual-values="">
<div className="min-w-0">
<dt className="text-2xs text-faint">{DUAL_CHECK_TEXT.original}</dt>
<dd className="tnum m-0 text-sm font-medium [overflow-wrap:anywhere]" data-dual-value="original">{view.original ?? NONE}</dd>
</div>
<div className="min-w-0">
<dt className="text-2xs text-faint">{DUAL_CHECK_TEXT.blind}</dt>
<dd className="tnum m-0 text-sm font-medium [overflow-wrap:anywhere]" data-dual-value="blind">{view.blind ?? NONE}</dd>
</div>
</dl>
<div className="mt-1.5" data-dual-diff="">
<div className="text-2xs text-faint">{T.diffHead}</div>
{view.equivalent ? (
<p className="text-2xs leading-relaxed text-dim" data-dual-equivalent="">{T.equivalent}</p>
) : view.fallbackOnly ? (
<p className="text-2xs leading-relaxed" style={{ color: 'var(--st-waiting)' }} data-dual-fallback="">{T.fallbackOnly}</p>
) : view.items.length > 0 && (
<ul className="list-disc space-y-0.5 pl-4 text-2xs leading-relaxed [overflow-wrap:anywhere]">
{view.items.map((t, i) => <li key={i} data-dual-diff-item="">{t}</li>)}
</ul>
)}
{view.rest && <p className="mt-0.5 text-2xs leading-relaxed text-faint" data-dual-diff-rest="">{view.rest}</p>}
</div>
{view.full && (
<button type="button" className="btn btn-xs mt-1.5" aria-expanded={expanded} aria-controls={expanded ? `${uid}-cmp` : undefined}
data-dual-compare-toggle="" onClick={() => setOpen(!expanded)}>
<ChevronRight size={11} aria-hidden style={{ transform: expanded ? 'rotate(90deg)' : 'none', transition: 'transform .15s' }} />
{expanded ? T.compareClose : T.compare}
</button>
)}
{expanded && view.full && <Compare id={`${uid}-cmp`} metric={view.full} />}
{view.single && <p className="mt-1.5 text-2xs leading-relaxed text-dim" data-dual-catalog="">{T.catalog}</p>}
{/* 原生单选:Tab 进组、方向键换选项,读屏念得出是哪个指标的哪一项 */}
<fieldset className="mt-2 min-w-0 border-0 p-0" data-dual-choice={view.id}>
<legend className="sr-only">{T.choiceGroup(view.name)}</legend>
<div className="flex flex-wrap gap-1.5">
{CHOICES.map((c) => (
<label key={c}
className={clsx('flex cursor-pointer items-center gap-1.5 rounded border px-2 py-1 text-2xs transition-colors hover:bg-hover',
choice === c ? 'font-medium' : 'text-dim')}
style={choice === c ? { borderColor: 'var(--accent)', background: 'var(--accent-soft)', color: 'var(--text)' } : undefined}>
<input type="radio" className="accent-[var(--accent)]" name={name} value={c} checked={choice === c}
onChange={() => onChoose(c)} />
{T.choices[c]}
</label>
))}
</div>
</fieldset>
</div>
)
}
/** 一个输入在两边的取值和 SQL */
interface Pair {
path: string
original?: DualCheckInput
blind?: DualCheckBlindInput
source: string | null
/** SQL 或取到的值不同 */
differs: boolean
}
/** 比 SQL 时不看空白和大小写:原样写成一行和换了行的是同一条 */
const normSql = (sql: unknown): string => (typeof sql === 'string' ? sql.replace(/\s+/g, ' ').trim().toLowerCase() : '')
/** 比一行时再去掉行尾的逗号、分号:规范化的写法逗号在行尾,原文不一定 */
const normLine = (line: string): string => line.replace(/\s+/g, ' ').trim().replace(/[,;]+$/, '').trim().toLowerCase()
const sameValue = (a: unknown, b: unknown): boolean => {
if (typeof a === 'number' && typeof b === 'number') return a === b
try { return JSON.stringify(a) === JSON.stringify(b) } catch { return false }
}
function pairsOf(metric: DualCheckMetric): Pair[] {
const byPath = new Map<string, { original?: DualCheckInput; blind?: DualCheckBlindInput }>()
const valid = (i: unknown): i is DualCheckInput => !!i && typeof i === 'object' && typeof (i as DualCheckInput).path === 'string'
for (const i of (Array.isArray(metric.original?.inputs) ? metric.original.inputs : []).filter(valid)) {
byPath.set(i.path, { ...byPath.get(i.path), original: i })
}
for (const i of (Array.isArray(metric.blind?.inputs) ? metric.blind.inputs : []).filter(valid)) {
byPath.set(i.path, { ...byPath.get(i.path), blind: i })
}
return [...byPath].map(([path, p]) => ({
path, ...p,
source: str(p.original?.source) ?? str(p.blind?.source) ?? null,
differs: normSql(p.original?.sql) !== normSql(p.blind?.sql) || !sameValue(p.original?.value, p.blind?.value),
}))
}
/** text_diff(「- 」原流程、「+ 」盲算)→ 两边各自要高亮的行(按 normLine 比) */
function marksOf(textDiff: unknown): { original: Set<string>; blind: Set<string> } {
const original = new Set<string>()
const blind = new Set<string>()
for (const line of Array.isArray(textDiff) ? textDiff : []) {
if (typeof line !== 'string') continue
if (line.startsWith('- ')) original.add(normLine(line.slice(2)))
else if (line.startsWith('+ ')) blind.add(normLine(line.slice(2)))
}
original.delete('')
blind.delete('')
return { original, blind }
}
function valueText(v: unknown): string {
if (v == null) return NONE
if (typeof v === 'number') return Number.isFinite(v) ? v.toLocaleString('en-US', { maximumFractionDigits: 6 }) : String(v)
if (typeof v === 'string' || typeof v === 'boolean') return String(v)
try {
const s = JSON.stringify(v)
return s.length > 80 ? `${s.slice(0, 79)}…` : s
} catch {
return String(v)
}
}
/** 高亮的行:两边都用同一种提醒底色——谁对谁错由审核人判断,不用红绿暗示哪边是「删掉的」 */
const MARK: CSSProperties = {
background: 'color-mix(in srgb, var(--warn) 16%, transparent)', color: 'var(--text)', boxShadow: 'inset 2px 0 0 var(--warn)',
}
/**
* 对照两条 SQL:先列每个输入两边各取到多少,再把不同的输入的两条 SQL 左右并排(卡片窄于 32rem 时上下排,按卡片
* 自己的宽度算,画布右栏里也是上下排),规范化后不同的行在原文里高亮;规范化的写法和原文对不上(模型常把 SQL 写成
* 一行)时原文不乱标,规范化后不同的行另列一份,每行标是哪一边
*/
function Compare({ id, metric }: { id: string; metric: DualCheckMetric }) {
const pairs = useMemo(() => pairsOf(metric), [metric])
const marks = useMemo(() => marksOf(metric.diff?.text_diff), [metric])
const lines = useMemo(() => (Array.isArray(metric.diff?.text_diff) ? metric.diff.text_diff : [])
.filter((l): l is string => typeof l === 'string' && (l.startsWith('- ') || l.startsWith('+ ')))
.map((l) => ({ side: l.startsWith('- ') ? 'original' as const : 'blind' as const, text: l.slice(2) })), [metric])
const differing = pairs.filter((p) => p.differs)
return (
<div id={id} className="mt-1.5 min-w-0 space-y-2" data-dual-compare="">
{pairs.length > 0 && (
<div>
<div className="mb-0.5 text-2xs text-faint">{T.inputs}</div>
<ul className="space-y-0.5">
{pairs.map((p) => (
<li key={p.path} className="flex min-w-0 flex-wrap items-baseline gap-x-2 text-2xs" data-dual-input={p.path}
data-differs={p.differs ? '' : undefined}>
<code className="mono min-w-0 [overflow-wrap:anywhere]">{p.path}</code>
<span className="tnum text-dim">{DUAL_CHECK_TEXT.original} {valueText(p.original?.value)}</span>
<span className={clsx('tnum', !p.differs && 'text-dim')} style={p.differs ? { color: 'var(--st-waiting)' } : undefined}>
{DUAL_CHECK_TEXT.blind} {valueText(p.blind?.value)}
</span>
</li>
))}
</ul>
</div>
)}
{(differing.length ? differing : pairs).map((p) => (
<div key={p.path} className="min-w-0" data-dual-pair={p.path}>
<div className="mb-0.5 text-2xs text-faint [overflow-wrap:anywhere]">
<code className="mono">{p.path}</code>{p.source ? ` · ${T.source(p.source)}` : ''}
</div>
<div className="grid grid-cols-1 gap-2 @lg:grid-cols-2">
<SqlPane side="original" label={T.sqlOriginal} sql={p.original?.sql} marks={marks.original} />
<SqlPane side="blind" label={T.sqlBlind} sql={p.blind?.sql} marks={marks.blind} />
</div>
{str(p.blind?.rationale) && (
<p className="mt-1 text-2xs leading-relaxed text-dim [overflow-wrap:anywhere]" data-dual-rationale="">
<span className="text-faint">{T.rationale}:</span>{p.blind!.rationale}
</p>
)}
</div>
))}
{lines.length > 0 && (
<div data-dual-text-diff="">
<div className="mb-0.5 text-2xs text-faint">{T.textDiff}</div>
<div className="mono max-h-48 overflow-y-auto rounded border bg-panel px-2 py-1.5 text-2xs leading-relaxed focus-visible:outline-2 focus-visible:outline-[color:var(--accent)]"
tabIndex={0} role="group" aria-label={T.textDiff}>
{lines.map((l, i) => (
<div key={i} className="flex min-w-0 gap-2" data-diff-side={l.side}>
{/* 标签列等宽:两边的行首对齐,一眼看出是同一处的两种写法 */}
<span className="w-[3em] shrink-0 font-sans text-faint">{l.side === 'original' ? DUAL_CHECK_TEXT.original : DUAL_CHECK_TEXT.blind}</span>
<span className="min-w-0 whitespace-pre-wrap text-dim [overflow-wrap:anywhere]">{l.text}</span>
</div>
))}
</div>
</div>
)}
</div>
)
}
/** 一条 SQL:逐行画,text_diff 里有的行高亮。折行显示、不横向撑开,太长时框里纵向滚动,框能用键盘聚焦 */
function SqlPane({ side, label, sql, marks }: { side: 'original' | 'blind'; label: string; sql?: string | null; marks: Set<string> }) {
const lines = typeof sql === 'string' && sql.trim() ? sql.replace(/\s+$/, '').split('\n') : null
return (
<figure className="m-0 min-w-0">
<figcaption className="mb-0.5 text-2xs text-faint">{label}</figcaption>
<pre className={clsx('max-h-56 overflow-y-auto whitespace-pre-wrap rounded border bg-panel px-1 py-1.5 text-2xs leading-relaxed [overflow-wrap:anywhere] focus-visible:outline-2 focus-visible:outline-[color:var(--accent)]',
lines ? 'mono text-dim' : 'font-sans text-faint')}
tabIndex={0} aria-label={label} data-dual-sql={side}>
{lines
? lines.map((line, i) => (marks.has(normLine(line))
? <mark key={i} className="block rounded-sm px-1" style={MARK} data-diff-line={side === 'original' ? 'del' : 'add'}>{line || ' '}</mark>
: <span key={i} className="block px-1">{line || ' '}</span>))
: <span className="px-1">{T.sqlMissing}</span>}
</pre>
</figure>
)
}
+38 -33
View File
@@ -104,6 +104,9 @@ export function ApprovalCard({ approval, onResolved, showWorkflow = true }: {
const where = approval.node_label || approval.node_id
// 人工审批节点挂起的审批(工具、代码的审批另有 kind):卡上标明运行类别、批了会怎样,审的是报告时附上核对概况
const humanNode = payload.kind === 'human_node'
// 双盲复算两路对不上的审批:mode 是 input,但三选一和提交由 DualCheckApproval 自己画——通用的输入框、备注和
// 「提交 / 驳回并终止」会和它重复,通用的提交还会把一个字符串当 value 发出去
const dualCheck = payload.kind === 'dual_check'
const reports: ApprovalReport[] = Array.isArray(payload.reports)
? payload.reports.filter((r: unknown): r is ApprovalReport =>
!!r && typeof r === 'object' && typeof (r as ApprovalReport).node_id === 'string')
@@ -147,7 +150,7 @@ export function ApprovalCard({ approval, onResolved, showWorkflow = true }: {
)}
{/* 双盲复算对不上、交给人裁决的审批,卡上画两路结果的对照 */}
{payload.kind === 'dual_check' && <DualCheckApproval approval={approval} />}
{dualCheck && <DualCheckApproval approval={approval} onResolved={onResolved} />}
<ApprovalReports reports={reports} runId={approval.run_id} runClass={approval.run_class} />
<ChallengeSummary reports={reports} runId={approval.run_id} />
@@ -169,7 +172,7 @@ export function ApprovalCard({ approval, onResolved, showWorkflow = true }: {
</pre>
)}
{!approveMode && (
{!approveMode && !dualCheck && (
<textarea
className="field mb-2"
rows={4}
@@ -180,40 +183,42 @@ export function ApprovalCard({ approval, onResolved, showWorkflow = true }: {
/>
)}
<input
className="field mb-2"
aria-label="备注"
placeholder="备注(将传递给后续节点)"
value={note}
onChange={(e) => setNote(e.target.value)}
/>
{!dualCheck && (<>
<input
className="field mb-2"
aria-label="备注"
placeholder="备注(将传递给后续节点)"
value={note}
onChange={(e) => setNote(e.target.value)}
/>
<div className="flex gap-2">
<button className="btn btn-primary flex-1 justify-center" disabled={busy} onClick={() => decide(true)}>
<Check size={12} aria-hidden /> {approveMode ? '批准' : '提交'}
</button>
{trustKey && (
<button className="btn flex-1 justify-center" disabled={busy} data-approval-always
title={TOOL_TRUST_TEXT.alwaysHint(trustKey)} aria-describedby={`approval-always-${approval.id}`}
onClick={() => decide(true, true)}>
<CheckCheck size={12} aria-hidden /> {TOOL_TRUST_TEXT.alwaysButton}
<div className="flex gap-2">
<button className="btn btn-primary flex-1 justify-center" disabled={busy} onClick={() => decide(true)}>
<Check size={12} aria-hidden /> {approveMode ? '批准' : '提交'}
</button>
{trustKey && (
<button className="btn flex-1 justify-center" disabled={busy} data-approval-always
title={TOOL_TRUST_TEXT.alwaysHint(trustKey)} aria-describedby={`approval-always-${approval.id}`}
onClick={() => decide(true, true)}>
<CheckCheck size={12} aria-hidden /> {TOOL_TRUST_TEXT.alwaysButton}
</button>
)}
<button className="btn btn-danger flex-1 justify-center" disabled={busy} onClick={() => decide(false)}>
<Ban size={12} aria-hidden /> {approveMode ? '驳回' : '驳回并终止'}
</button>
)}
<button className="btn btn-danger flex-1 justify-center" disabled={busy} onClick={() => decide(false)}>
<Ban size={12} aria-hidden /> {approveMode ? '驳回' : '驳回并终止'}
</button>
</div>
{/* 批了会怎样,常显而不是藏在 hover 里:审批人得在按下去之前知道 */}
<div className="mt-1.5 text-2xs leading-relaxed text-dim">
{approveMode
? '批准:继续运行;驳回:转入「驳回」分支'
: '提交:以你填写的内容继续运行;驳回:终止本次运行'}
</div>
{trustKey && (
<div id={`approval-always-${approval.id}`} className="mt-0.5 text-2xs leading-relaxed text-dim">
{TOOL_TRUST_TEXT.alwaysButton}:{TOOL_TRUST_TEXT.alwaysHint(trustKey)}
</div>
)}
{/* 批了会怎样,常显而不是藏在 hover 里:审批人得在按下去之前知道 */}
<div className="mt-1.5 text-2xs leading-relaxed text-dim">
{approveMode
? '批准:继续运行;驳回:转入「驳回」分支'
: '提交:以你填写的内容继续运行;驳回:终止本次运行'}
</div>
{trustKey && (
<div id={`approval-always-${approval.id}`} className="mt-0.5 text-2xs leading-relaxed text-dim">
{TOOL_TRUST_TEXT.alwaysButton}:{TOOL_TRUST_TEXT.alwaysHint(trustKey)}
</div>
)}
</>)}
<div className="mt-1 flex items-center gap-1 text-2xs leading-relaxed"
style={actor ? { color: 'var(--text-dim)' } : { color: 'var(--st-waiting)' }}>
<PenLine size={10} aria-hidden className="shrink-0" />
+363 -6
View File
@@ -1,8 +1,365 @@
// 双盲复算与反方审稿的检查。现在只是占位:功能还在开发,这里只打一行通过,让 check-all 的清单先排上它。
// 双盲复算与反方审稿的工作包把它换成真检查:沿用别的检查的输出格式——每项缩进两格、以 ✓ / ✗ 开头,最后顶格一行
// 总结,没过的退出码非 0(check-all 按缩进的 ✓ / ✗ 计数)。
// 双盲复算与反方审稿的回归检查(期 3)。
//
// 双盲复算这一半守的是这些「看着没坏、其实用不了」的地方:
// - 审批卡:两路对不上时,卡上要先按摘要画出来(完整对照还在路上时不能一片空白),再按工件补全;具体差异逐条列,
// 只有兜底说明时写明「未能归纳成具体差异」并默认展开对照;对照两条 SQL 时左右并排(窄栏上下排),规范化后不同的
// 行高亮;三个选择缺一个都不能提交,提交的回复是 {choices, note};卡上不能再冒出通用的输入框和「提交 / 驳回并终止」
// (mode 是 input,通用那套会把一个字符串当 value 发出去);后果按运行类别说,探索运行不提出具;
// - 检查器:口径卡、指标、口径说明、盲算模型、相对误差、不采用原流程时、启用,每一项都真的写进节点配置;
// 节点卡片的摘要写出复算哪张口径卡、几个指标;
// - 设置页「核查」一节:读回各项,改了之后整组写 verify(不限存 null),叫法和服务端 VERIFY_SETTING_LABEL 一字不差;
// - 390px 窄屏不横向溢出,深浅色都读得清,键盘能操作。
//
// 页面都跑在预览页(/preview.html?syn=…,用例在 frontend/src/dev/fixtures/dual_check.tsx,夹具 dual_check.json 两边
// 共用)和设置页上,接口一律 page.route 伪造:完整对照的工件、审批回复、设置的读写都在浏览器里答,别的非 GET 拦掉,
// 不碰真实的写接口。文案对照那一节不开浏览器:读 backend/app/api/settings.py,terms.ts 借 vite 转译后直接 import。
//
// node scripts/check-verify.mjs
console.log('=== 双盲复算与反方审稿 ===')
console.log(' ✓ 占位:功能开发中')
console.log('\n✓ 双盲复算与反方审稿:占位通过')
// 对别的实例跑时带上地址:AGENTLAB_WEB=http://localhost:<前端端口> node scripts/check-verify.mjs
// VERIFY_SHOTS=<目录> 时亮暗两套各截几张,供人眼复核。
import { mkdirSync, readFileSync } from 'node:fs'
import { chromium } from '../frontend/node_modules/playwright-core/index.mjs'
const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273'
const CHROME = process.env.CHROME_PATH ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
const SHOTS = process.env.VERIFY_SHOTS ?? ''
const root = new URL('..', import.meta.url).pathname
const fx = JSON.parse(readFileSync(`${root}frontend/src/dev/fixtures/dual_check.json`, 'utf8'))
const AP = fx.approval
const CARD_ID = AP.payload.card_artifact
let failed = 0
const check = (name, cond, detail = '') => {
const d = String(detail ?? '').replace(/\s+/g, ' ').slice(0, 160)
console.log(` ${cond ? '✓' : '✗'} ${name}${d ? ` — ${d}` : ''}`)
if (!cond) failed++
}
/** 一节一节地跑:某一节里等待超时、元素找不到,只记成这一节失败,收掉它开的页面,接着跑下一节 */
const opened = new Set()
async function section(name, fn) {
console.log(`\n=== ${name} ===`)
try {
await fn()
} catch (e) {
check(`${name} 中途出错`, false, String(e?.message ?? e).split('\n')[0])
} finally {
for (const c of opened) await c.close().catch(() => {})
opened.clear()
}
}
// terms.ts 借 vite 转译成 data: URL 再 import(同 check-decode):依赖也各自转好替换进去
const loaded = new Map()
async function load(path) {
if (loaded.has(path)) return loaded.get(path)
const res = await fetch(`${WEB}${path}?t=${Date.now()}`)
if (!res.ok) throw new Error(`${path} → HTTP ${res.status}`)
let code = await res.text()
const deps = new Set([...code.matchAll(/from\s+["'](\/src\/[^"'?]+)(?:\?[^"']*)?["']/g)].map((m) => m[1]))
for (const dep of deps) {
const url = await load(dep)
const pattern = new RegExp(`(["'])${dep.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\?[^"']*)?\\1`, 'g')
code = code.replace(pattern, JSON.stringify(url))
}
const url = 'data:text/javascript;base64,' + Buffer.from(code).toString('base64')
loaded.set(path, url)
return url
}
const shot = async (page, name) => {
if (!SHOTS) return
mkdirSync(SHOTS, { recursive: true })
await page.screenshot({ path: `${SHOTS}/${name}.png`, fullPage: true })
}
const text = async (loc) => (await loc.first().innerText().catch(() => '')).replace(/\s+/g, ' ').trim()
/** 两个颜色的对比度(WCAG):颜色是 getComputedStyle 给的 rgb / rgba / color(srgb …),半透明的叠在底色上 */
function contrast(fg, bg, base) {
const parse = (c) => {
const m = c.match(/color\(srgb ([\d.]+) ([\d.]+) ([\d.]+)(?: \/ ([\d.]+))?\)/)
if (m) return [m[1] * 255, m[2] * 255, m[3] * 255, m[4] == null ? 1 : Number(m[4])]
const n = c.match(/rgba?\(([\d.]+),\s*([\d.]+),\s*([\d.]+)(?:,\s*([\d.]+))?\)/)
return n ? [Number(n[1]), Number(n[2]), Number(n[3]), n[4] == null ? 1 : Number(n[4])] : null
}
const over = (top, under) => top.slice(0, 3).map((v, i) => v * top[3] + under[i] * (1 - top[3]))
const lum = ([r, g, b]) => {
const f = (v) => { const s = v / 255; return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4 }
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b)
}
const b0 = parse(base); const b1 = parse(bg); const f0 = parse(fg)
if (!b0 || !b1 || !f0) return 0
const under = over(b1, b0.slice(0, 3))
const a = lum(over(f0, under)); const b = lum(under)
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05)
}
// ---------------------------------------------------------------- 文案对照(不开浏览器)
await section('核查设置的叫法和服务端一字不差', async () => {
// 设置页保存时 422 的报错写的是 VERIFY_SETTING_LABEL 里的叫法:界面上的字段名得是同一套,人才找得到是哪一项
const py = readFileSync(`${root}backend/app/api/settings.py`, 'utf8')
const block = py.match(/^VERIFY_SETTING_LABEL = \{([\s\S]*?)^\}/m)?.[1] ?? ''
const labels = Object.fromEntries([...block.matchAll(/"([^"]+)":\s*"([^"]+)"/g)].map((m) => [m[1], m[2]]))
check('从 settings.py 读出了 VERIFY_SETTING_LABEL', Object.keys(labels).length >= 10, `${Object.keys(labels).length} 项`)
const { VERIFY_SETTING_TEXT: T } = await import(await load('/src/lib/terms.ts'))
const keys = Object.keys(T).filter((k) => k !== 'section')
const missing = Object.keys(labels).filter((k) => !(k in T))
const extra = keys.filter((k) => !(k in labels))
check('VERIFY_SETTING_TEXT 顶层的键和服务端完全相同(section 除外)', !missing.length && !extra.length,
`缺 ${missing.join('、') || '无'};多 ${extra.join('、') || '无'}`)
const wrong = Object.entries(labels).filter(([k, v]) => T[k] !== v)
check('……每一项的叫法一字不差', !wrong.length, wrong.map(([k, v]) => `${k}:${T[k]} ≠ ${v}`).join(';'))
check('……这一节其余的文案放在 section 里', !!T.section?.title && typeof T.section.spend === 'function')
})
// ---------------------------------------------------------------- 页面
const browser = await chromium.launch({ executablePath: CHROME })
/**
* 开一个预览用例。完整对照的工件按 artifact 答(默认夹具里的那份;给函数可以延迟、出错),审批回复记下请求体、
* 回一个伪造的运行;审批列表回空;别的非 GET 一律拦掉,别的 GET 放行
*/
async function preview(syn, { width = 1280, height = 900, theme = 'dark', artifact = null, extra = null } = {}) {
const ctx = await browser.newContext({ viewport: { width, height }, colorScheme: theme, timezoneId: 'Asia/Shanghai' })
opened.add(ctx)
ctx.setDefaultTimeout(6000)
const page = await ctx.newPage()
const sent = []
const errors = []
page.on('pageerror', (e) => errors.push(e.message))
page.on('dialog', (d) => { errors.push(`原生对话框:${d.message()}`); void d.dismiss() })
await page.route((u) => new URL(u).pathname.startsWith('/api/'), async (route) => {
const req = route.request()
const url = new URL(req.url())
if (extra) {
const out = await extra(req, url)
if (out) return route.fulfill({ status: out.status ?? 200, json: out.json })
}
if (req.method() === 'POST' && url.pathname === `/api/approvals/${AP.id}/decide`) {
sent.push({ path: url.pathname, body: req.postDataJSON() })
return route.fulfill({ json: { id: AP.run_id, status: 'running', run_class: AP.run_class, input: {}, output: {}, usage: {} } })
}
if (req.method() !== 'GET') {
sent.push({ path: url.pathname, blocked: true, method: req.method() })
return route.abort()
}
if (url.pathname === `/api/artifacts/${CARD_ID}`) {
const out = artifact ? await artifact() : { json: { id: CARD_ID, content: fx.card } }
return route.fulfill({ status: out.status ?? 200, json: out.json })
}
if (url.pathname === '/api/approvals') return route.fulfill({ json: [] })
return route.continue()
})
await page.goto(`${WEB}/preview.html?syn=${syn}&theme=${theme}&actor=张工`, { waitUntil: 'domcontentloaded' })
await page.waitForSelector(`[data-fixture="${syn}"]`)
return { ctx, page, sent, errors }
}
const metric = (page, id) => page.locator(`[data-dual-metric="${id}"]`)
await section('审批卡:先按摘要画,再取完整对照', async () => {
// 完整对照晚一点到:这段时间里卡上照样有两个值、相差多少、摘要里的差异,选择也能先做
let release
const gate = new Promise((r) => { release = r })
const { page, sent, errors } = await preview('dual-check-approval', {
artifact: async () => { await gate; return { json: { id: CARD_ID, content: fx.card } } },
})
await page.waitForSelector('[data-dual-check]')
const card = page.locator('[data-approval]')
check('卡头是审批标题', (await text(card)).includes('双盲复算:2 个指标不一致'))
check('开头一句说几个指标不一致、要逐个选', (await text(page.locator('[data-dual-intro]'))) === '盲算与原流程有 2 个指标不一致,请逐个选择采用哪一边的结果')
check('每个不一致的指标一块,按摘要的顺序', (await page.locator('[data-dual-metric]').evaluateAll((els) => els.map((e) => e.dataset.dualMetric))).join(',')
=== 'gmv,refund_rate')
const gmv = metric(page, 'gmv')
check('指标名、原流程值、盲算值', (await text(gmv.locator('[data-dual-name]'))) === '销售额'
&& (await text(gmv.locator('[data-dual-value="original"]'))) === '45,678.5元' && (await text(gmv.locator('[data-dual-value="blind"]'))) === '51,388.3元')
check('……两个值各标是哪一边', (await text(gmv.locator('[data-dual-values]'))).includes('原流程') && (await text(gmv.locator('[data-dual-values]'))).includes('盲算'))
check('相差百分比带正负号', (await text(gmv.locator('[data-dual-delta]'))) === '相差 +12.5%' && (await text(metric(page, 'refund_rate').locator('[data-dual-delta]'))) === '相差 +31.91%',
`${await text(gmv.locator('[data-dual-delta]'))} / ${await text(metric(page, 'refund_rate').locator('[data-dual-delta]'))}`)
check('完整对照还没到:先列摘要里的具体差异', (await text(gmv.locator('[data-dual-diff-item]'))) === AP.payload.summary[0].diff[0])
check('……只有兜底的那个指标写明未能归纳', (await text(metric(page, 'refund_rate').locator('[data-dual-fallback]'))) === '两条 SQL 的写法不同,未能归纳成具体差异')
check('……说明正在加载完整对照', (await page.locator('[data-dual-loading]').count()) > 0)
check('卡上没有通用的输入框和备注', await card.locator('textarea[aria-label="你的输入"], input[aria-label="备注"]').count() === 0)
check('……也没有通用的「提交」「驳回并终止」', await card.getByRole('button', { name: '提交', exact: true }).count() === 0
&& await card.getByRole('button', { name: /驳回/ }).count() === 0)
check('……通用那句「提交:以你填写的内容继续运行」也不在', !(await text(card)).includes('以你填写的内容继续运行'))
check('署名那一行还在', (await text(card)).includes('将以「张工」签批'))
release()
await page.waitForSelector('[data-dual-check][data-state="ready"]')
check('完整对照到了:写出盲算模型', (await text(page.locator('[data-dual-model]'))) === '盲算模型:blind-model-x')
check('……具体差异按工件逐条列', (await gmv.locator('[data-dual-diff-item]').count()) === 1
&& (await text(gmv.locator('[data-dual-diff-item]'))) === fx.card.metrics[0].diff.items[0].text)
check('……具体差异的那个指标默认收着对照,给「对照两条 SQL」', (await gmv.locator('[data-dual-compare-toggle]').getAttribute('aria-expanded')) === 'false'
&& (await text(gmv.locator('[data-dual-compare-toggle]'))) === '对照两条 SQL' && await gmv.locator('[data-dual-compare]').count() === 0)
const rr = metric(page, 'refund_rate')
check('……只有兜底的那个指标默认展开对照', (await rr.locator('[data-dual-compare-toggle]').getAttribute('aria-expanded')) === 'true'
&& await rr.locator('[data-dual-compare]').count() === 1)
check('……兜底的原话不再列成一条「具体差异」', await rr.locator('[data-dual-diff-item]').count() === 0)
check('只差一个方面的指标:说明选择会记入数据目录', (await text(gmv.locator('[data-dual-catalog]'))).includes('记入数据目录')
&& await rr.locator('[data-dual-catalog]').count() === 0)
const after = await text(page.locator('[data-dual-consequence]'))
check('正式运行:后果写明报告里的数仍取自原流程、采用盲算或都不对会让出具降档', after.includes('报告中的数仍取自原流程') && after.includes('出具降档'), after)
check('没有发出任何写请求', sent.length === 0, JSON.stringify(sent))
check('页面没有报错', errors.length === 0, errors.join(';'))
await shot(page, 'approval-dark')
})
await section('审批卡:并排对照与高亮', async () => {
const { page, errors } = await preview('dual-check-approval')
await page.waitForSelector('[data-dual-check][data-state="ready"]')
const gmv = metric(page, 'gmv')
const toggle = gmv.locator('[data-dual-compare-toggle]')
// 键盘:Tab 到按钮上回车展开
await toggle.focus()
await page.keyboard.press('Enter')
check('键盘回车展开对照,按钮标为展开、指向对照区', (await toggle.getAttribute('aria-expanded')) === 'true'
&& await page.locator(`#${await toggle.getAttribute('aria-controls')}[data-dual-compare]`).count() === 1)
check('……按钮改说「收起对照」', (await text(toggle)) === '收起对照')
const orig = gmv.locator('[data-dual-sql="original"]')
const blind = gmv.locator('[data-dual-sql="blind"]')
check('两条 SQL 都在,各标是哪一边', (await text(orig)).includes('paid_at') && (await text(blind)).includes('created_at')
&& (await text(gmv.locator('[data-dual-pair]'))).includes('原流程的 SQL') && (await text(gmv.locator('[data-dual-pair]'))).includes('盲算的 SQL'))
const marks = await gmv.evaluate((el) => ({
del: [...el.querySelectorAll('[data-dual-sql="original"] [data-diff-line="del"]')].map((m) => m.textContent.trim()),
add: [...el.querySelectorAll('[data-dual-sql="blind"] [data-diff-line="add"]')].map((m) => m.textContent.trim()),
}))
check('text_diff 的行在原流程那边高亮', marks.del.join('|') === "WHERE paid_at >= '2026-09-01'|AND paid_at < '2026-09-08'", marks.del.join('|'))
check('……在盲算那边也高亮', marks.add.join('|') === "WHERE created_at >= '2026-09-01'|AND created_at < '2026-09-08'", marks.add.join('|'))
check('……相同的行不高亮', !(await orig.locator('[data-diff-line]').allInnerTexts()).some((t) => t.includes('FROM orders')))
const o = await orig.boundingBox()
const b = await blind.boundingBox()
check('宽栏里左右并排:同一行、盲算在右边', !!o && !!b && Math.abs(o.y - b.y) < 2 && b.x >= o.x + o.width - 1, JSON.stringify({ o, b }))
check('盲算的说明', (await text(gmv.locator('[data-dual-rationale]'))).includes('销售额按下单时间统计本周全部订单金额之和'))
check('输入取值:路径、两边的值', (await text(gmv.locator('[data-dual-input="vars.kpi.gmv"]'))).includes('45,678.5')
&& (await text(gmv.locator('[data-dual-input="vars.kpi.gmv"]'))).includes('51,388.3'))
const lines = await gmv.locator('[data-dual-text-diff] [data-diff-side]').evaluateAll((els) => els.map((e) => e.dataset.diffSide))
check('规范化后不同的行另列一份,每行标是哪一边', lines.join(',') === 'original,original,blind,blind', lines.join(','))
const rr = metric(page, 'refund_rate')
check('两个输入、只有一个不同:只对照不同的那一个', (await rr.locator('[data-dual-pair]').evaluateAll((els) => els.map((e) => e.dataset.dualPair))).join(',')
=== 'vars.kpi.refunds')
check('……输入取值两项都列,不同的那项标出来', await rr.locator('[data-dual-input]').count() === 2
&& await rr.locator('[data-dual-input="vars.kpi.refunds"][data-differs]').count() === 1
&& await rr.locator('[data-dual-input="vars.kpi.orders"][data-differs]').count() === 0)
// 原流程那条规范化后多了 AS,和原文对不上:不乱标;盲算那条规范化后和原文一字不差:照样高亮
check('……一行写完的 SQL:规范化后对不上原文的不乱标,对得上的照样高亮', await rr.locator('[data-dual-sql="original"] [data-diff-line]').count() === 0
&& await rr.locator('[data-dual-sql="blind"] [data-diff-line="add"]').count() === 1)
check('……规范化后不同的行照样另列', await rr.locator('[data-dual-text-diff] [data-diff-side]').count() === 2)
await page.keyboard.press('Enter')
check('再按一次回车收起', (await toggle.getAttribute('aria-expanded')) === 'false' && await gmv.locator('[data-dual-compare]').count() === 0)
check('页面没有报错', errors.length === 0, errors.join(';'))
})
await section('审批卡:不选全不能提交,提交的回复是 {choices, note}', async () => {
const { page, sent, errors } = await preview('dual-check-approval')
await page.waitForSelector('[data-dual-check][data-state="ready"]')
const submit = page.locator('[data-dual-submit]')
check('三个选项必选:一个都没选时不能提交', await submit.isDisabled() && (await text(page.locator('[data-dual-pending]'))) === '还有 2 个指标未选择')
const gmv = metric(page, 'gmv')
const opts = await gmv.locator('[data-dual-choice] label').allInnerTexts()
check('三个选项的说法', opts.map((t) => t.trim()).join('|') === '采用原流程的结果|采用盲算的结果|两边都不对', opts.join('|'))
check('……每组有读屏名:哪个指标采用哪一边', (await text(gmv.locator('[data-dual-choice] legend'))) === '「销售额」采用哪一边的结果')
await gmv.locator('label', { hasText: '采用原流程的结果' }).click()
check('选了一个:还差一个,仍不能提交', await submit.isDisabled() && (await text(page.locator('[data-dual-pending]'))) === '还有 1 个指标未选择')
// 键盘:方向键在一组里换选项
const rr = metric(page, 'refund_rate')
await rr.locator('input[value="a"]').focus()
await page.keyboard.press('ArrowRight')
await page.keyboard.press('ArrowRight')
check('键盘方向键在一组里换选项', await rr.locator('input[value="neither"]').isChecked() && !(await rr.locator('input[value="b"]').isChecked()))
check('全选了:可以提交、不再提示未选', !(await submit.isDisabled()) && await page.locator('[data-dual-pending]').count() === 0)
const note = '销售额以支付时间为准;退款率两边都没有剔除部分退款'
await page.locator('[data-dual-note]').fill(note)
await submit.click()
await page.waitForFunction(() => window.__dualResolved != null)
const posts = sent.filter((s) => s.path === `/api/approvals/${AP.id}/decide`)
check('走审批回复接口,只发一次', posts.length === 1, String(posts.length))
const body = posts[0]?.body ?? {}
check('请求体:value 是 {choices, note}', JSON.stringify(body.value) === JSON.stringify({ choices: { gmv: 'a', refund_rate: 'neither' }, note }),
JSON.stringify(body.value))
check('……照 input 模式的写法:approved 为真、备注同时放在外层', body.approved === true && body.note === note, JSON.stringify(body))
check('提交后交给调用方重新接上运行', await page.evaluate(() => window.__dualResolved) === AP.run_id)
check('……提示已提交、运行继续', (await text(page.locator('body'))).includes('已提交裁决,运行继续'))
check('没有别的写请求', !sent.some((s) => s.blocked), JSON.stringify(sent.filter((s) => s.blocked)))
check('页面没有报错', errors.length === 0, errors.join(';'))
})
await section('审批卡:完整对照取不到时,按摘要照样能裁决', async () => {
const { page, sent } = await preview('dual-check-approval', {
artifact: async () => ({ status: 404, json: { detail: '工件不存在' } }),
})
await page.waitForSelector('[data-dual-load-failed]')
check('写明无法加载完整对照、以下仅为摘要', (await text(page.locator('[data-dual-load-failed]'))) === '无法加载完整对照,以下仅为摘要')
check('……摘要照样在:值、相差、差异', (await text(metric(page, 'gmv').locator('[data-dual-value="blind"]'))) === '51,388.3元'
&& await metric(page, 'gmv').locator('[data-dual-diff-item]').count() === 1)
check('……没有可对照的 SQL,不给「对照两条 SQL」', await page.locator('[data-dual-compare-toggle]').count() === 0)
for (const id of ['gmv', 'refund_rate']) await metric(page, id).locator('label', { hasText: '采用盲算的结果' }).click()
await page.locator('[data-dual-submit]').click()
await page.waitForFunction(() => window.__dualResolved != null)
const body = sent.find((s) => s.path === `/api/approvals/${AP.id}/decide`)?.body
check('……照样能提交', JSON.stringify(body?.value?.choices) === JSON.stringify({ gmv: 'b', refund_rate: 'b' }), JSON.stringify(body))
})
await section('审批卡:探索运行不提出具', async () => {
const { page } = await preview('dual-check-approval-exploratory')
await page.waitForSelector('[data-dual-check][data-state="ready"]')
const said = await text(page.locator('[data-dual-consequence]'))
check('后果只说记为缺口,不写任何出具相关的话', said.includes('记为缺口') && !said.includes('出具'), said)
})
await section('审批卡:390px 不横向溢出、窄栏里上下排、深浅色可读', async () => {
for (const theme of ['dark', 'light']) {
const { page, errors } = await preview('dual-check-approval', { width: 390, height: 844, theme })
await page.waitForSelector('[data-dual-check][data-state="ready"]')
await metric(page, 'gmv').locator('[data-dual-compare-toggle]').click()
const w = await page.evaluate(() => ({
page: document.documentElement.scrollWidth - document.documentElement.clientWidth,
card: (() => { const c = document.querySelector('[data-dual-check]'); return c.scrollWidth - c.clientWidth })(),
}))
check(`390px(${theme}):两边都展开后不横向溢出`, w.page <= 0 && w.card <= 1, JSON.stringify(w))
const o = await metric(page, 'gmv').locator('[data-dual-sql="original"]').boundingBox()
const b = await metric(page, 'gmv').locator('[data-dual-sql="blind"]').boundingBox()
check(`390px(${theme}):两条 SQL 上下排`, !!o && !!b && b.y >= o.y + o.height - 1, JSON.stringify({ o, b }))
const c = await page.evaluate(() => {
const card = document.querySelector('[data-approval]')
const base = getComputedStyle(document.body).backgroundColor
const pick = (sel) => {
const el = document.querySelector(sel)
if (!el) return null
// 往上找第一层不透明(或半透明)的底色
let bg = 'rgba(0, 0, 0, 0)'
for (let n = el; n && n !== document.documentElement; n = n.parentElement) {
const v = getComputedStyle(n).backgroundColor
if (v !== 'rgba(0, 0, 0, 0)' && v !== 'transparent') { bg = v; break }
}
return { fg: getComputedStyle(el).color, bg }
}
return { base, card: getComputedStyle(card).backgroundColor, delta: pick('[data-dual-delta]'),
consequence: pick('[data-dual-consequence]'), mark: pick('[data-diff-line="del"]'), fallback: pick('[data-dual-fallback]') }
})
for (const key of ['delta', 'consequence', 'mark', 'fallback']) {
const v = c[key]
const ratio = v ? contrast(v.fg, v.bg, c.base) : 0
check(`${theme}:${({ delta: '相差比例', consequence: '后果说明', mark: '高亮的行', fallback: '兜底说明' })[key]}对比度 ≥ 4.5`, ratio >= 4.5,
`${ratio.toFixed(2)}(${v?.fg} on ${v?.bg})`)
}
await shot(page, `approval-narrow-${theme}`)
check(`390px(${theme}):页面没有报错`, errors.length === 0, errors.join(';'))
}
const rail = await preview('dual-check-approval-rail', { width: 1280, height: 900 })
await rail.page.waitForSelector('[data-dual-check][data-state="ready"]')
await metric(rail.page, 'gmv').locator('[data-dual-compare-toggle]').click()
const r = await rail.page.evaluate(() => {
const box = document.querySelector('[data-fixture="dual-check-approval-rail"]')
return box.scrollWidth - box.clientWidth
})
check('360px 画布右栏:展开对照也不撑宽右栏', r <= 0, `${r}px`)
const o = await metric(rail.page, 'gmv').locator('[data-dual-sql="original"]').boundingBox()
const b = await metric(rail.page, 'gmv').locator('[data-dual-sql="blind"]').boundingBox()
check('360px 画布右栏:宽屏下也按卡片的宽度上下排', !!o && !!b && b.y >= o.y + o.height - 1, JSON.stringify({ o, b }))
await shot(rail.page, 'approval-rail-dark')
})
await browser.close()
console.log(failed ? `\n✗ 双盲复算与反方审稿:${failed} 项未通过` : '\n✓ 双盲复算与反方审稿:全部通过')
process.exit(failed ? 1 : 0)