diff --git a/frontend/src/dev/fixtures/dual_check.json b/frontend/src/dev/fixtures/dual_check.json new file mode 100644 index 00000000..d20f0086 --- /dev/null +++ b/frontend/src/dev/fixtures/dual_check.json @@ -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)" + ] + } + } + ] + } +} diff --git a/frontend/src/dev/fixtures/dual_check.tsx b/frontend/src/dev/fixtures/dual_check.tsx new file mode 100644 index 00000000..940398ff --- /dev/null +++ b/frontend/src/dev/fixtures/dual_check.tsx @@ -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 => ({ + ...(data.approval as unknown as Approval), + created_at: new Date(Date.now() - 180_000).toISOString().replace('Z', ''), + ...patch, +}) + +function Card({ value }: { value: Approval }) { + return ( +
+ { (window as any).__dualResolved = id }} /> +
+ ) +} + +export const cases: Record JSX.Element> = { + 'dual-check-approval': () => ( +
+ ), + 'dual-check-approval-rail': () => ( +
+ +
+ ), + 'dual-check-approval-exploratory': () => ( +
+ +
+ ), +} diff --git a/frontend/src/run/DualCheckApproval.tsx b/frontend/src/run/DualCheckApproval.tsx index 3632e494..de12f7ba 100644 --- a/frontend/src/run/DualCheckApproval.tsx +++ b/frontend/src/run/DualCheckApproval.tsx @@ -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 +} + +/** 一个不一致的指标在卡上要画的东西:先由 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>({}) + 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 ( +
+ {views.length > 0 &&

{T.intro(views.length)}

} + {loading && ( +
+ {T.loading} +
+ )} + {lost && views.length > 0 && ( +
{T.loadFailed}
+ )} + {data && (str(data.model) || data.same_model) && ( +
+ {str(data.model) && {T.model(data.model!)}} + {data.same_model && ( + + {T.sameModel} + + )} +
+ )} + + {!views.length ? ( +

{T.empty}

+ ) : ( + <> + {views.map((v) => ( + setChoices((cur) => ({ ...cur, [v.id]: c }))} /> + ))} + + {/* 选了会怎样,常显在提交按钮上面:审核人得在提交之前知道「采用盲算」并不替换报告里的数 */} +
+ {T.consequence(approval.run_class)} + {T.consequenceFail} +
+
+ +