From 7a9dc93ddeead8371f33277aa86d2ecee8beebfd Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 5 Oct 2026 14:23:17 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8F=8C=E7=9B=B2=E5=A4=8D=E7=AE=97=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=EF=BC=88=E4=B8=89=EF=BC=89=EF=BC=9A=E6=A3=80=E6=9F=A5?= =?UTF-8?q?=E5=99=A8=E5=AD=97=E6=AE=B5=E3=80=81=E8=8A=82=E7=82=B9=E5=8D=A1?= =?UTF-8?q?=E7=89=87=E6=91=98=E8=A6=81=EF=BC=8C=E4=BB=A5=E5=8F=8A=E6=A3=80?= =?UTF-8?q?=E6=9F=A5=E5=99=A8=E4=B8=8E=E8=8A=82=E7=82=B9=E5=8D=A1=E7=89=87?= =?UTF-8?q?=E7=9A=84=E9=92=A9=E5=AD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - nodeDefs:照约定一字不差地加 FieldDef.render 与 FieldRenderProps;NodeDef 加可选的 summary(ctx) 钩子 (ctx:id、config、画布上的 nodes 与 edges,只返回字符串);dual_check 换成真字段:要复算的口径卡、复算的指标、 口径说明、启用、审核人不采用原流程的结果时(基本),盲算模型的接入与模型、允许的相对误差(高级) - Inspector:照约定一字不差地在 FieldInput 的 switch 前交给 field.render - canvas/DualCheckFields.tsx(新):口径卡下拉分上游、其余两组,不在上游或不在画布上照实提醒;指标逐个勾, 全勾回到缺省不写、最后一个不让取消;口径说明逐个指标写,被取消的指标上已写的说明列出来可删;盲算模型照「结论句 裁判」的写法(只填模型时写明按模型名用哪个接入);相对误差的占位写出设置里的值;启用三态,跟随设置不留 null。 每个控件只写自己的键,留空写回 undefined - NodeCard:用只返回字符串的 studio 选择器调摘要钩子,有就用、没有照旧 summarize;摘要写「复算「卡名」 · N 个指标」, 关闭时标出;去掉 dual_check 的占位 case - 预览用例加检查器(小画布用真的节点卡片 + 360px 检查器),check-verify 加检查器写配置、摘要跟着变、窄屏和对比度 - 去掉 nodeDefs 里已经不成立的「下面两种还是占位」注释 --- frontend/src/canvas/DualCheckFields.tsx | 377 ++++++++++++++++++++++ frontend/src/canvas/Inspector.tsx | 3 + frontend/src/canvas/NodeCard.tsx | 5 +- frontend/src/canvas/nodeDefs.ts | 71 +++- frontend/src/dev/fixtures/dual_check.json | 226 ++++++++++++- frontend/src/dev/fixtures/dual_check.tsx | 43 ++- frontend/src/lib/terms.ts | 10 +- scripts/check-verify.mjs | 163 ++++++++++ 8 files changed, 887 insertions(+), 11 deletions(-) create mode 100644 frontend/src/canvas/DualCheckFields.tsx diff --git a/frontend/src/canvas/DualCheckFields.tsx b/frontend/src/canvas/DualCheckFields.tsx new file mode 100644 index 00000000..79d411e1 --- /dev/null +++ b/frontend/src/canvas/DualCheckFields.tsx @@ -0,0 +1,377 @@ +import { useEffect, useMemo, useState } from 'react' +import type { ReactNode } from 'react' +import { X } from 'lucide-react' +import clsx from 'clsx' +import { api } from '../api/client' +import { useStudio } from '../store/studio' +import { modelOptions, providerOfModel, useCatalog } from '../store/catalog' +import { useRadioGroup } from '../components/ui' +import { DUAL_CHECK_TEXT } from '../lib/terms' +import type { VerifySettings } from '../types' +import type { FieldRenderProps, NodeSummaryContext } from './nodeDefs' + +/* + * 双盲复算节点的检查器控件(nodeDefs 里 dual_check 各字段的 render)和节点卡片的摘要。 + * + * 口径卡、要复算的指标、口径说明都得看画布上别的节点:从 studio store 读,不往检查器的 props 里加字段。 + * 每个控件只写自己那个键(FieldRenderProps.onChange);写回 undefined 就是「留空 = 跟随默认」,配置里不留空值。 + * 后端认的键和缺省见 types.ts 的 DualCheckConfig + */ + +const T = DUAL_CHECK_TEXT + +type CanvasNode = NodeSummaryContext['nodes'][number] +type CanvasEdge = NodeSummaryContext['edges'][number] + +const isRecord = (v: unknown): v is Record => !!v && typeof v === 'object' && !Array.isArray(v) +const ids = (v: unknown): string[] => (Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string' && !!x) : []) + +/** 某个节点的全部上游(沿连线往回走),不含它自己 */ +function upstreamOf(id: string, edges: readonly CanvasEdge[]): Set { + const parents = new Map() + for (const e of edges) parents.set(e.target, [...(parents.get(e.target) ?? []), e.source]) + const seen = new Set() + const stack = [...(parents.get(id) ?? [])] + while (stack.length) { + const cur = stack.pop()! + if (seen.has(cur) || cur === id) continue + seen.add(cur) + stack.push(...(parents.get(cur) ?? [])) + } + return seen +} + +/** 画布上的口径卡:本节点上游的在前(复算只能复算它们),其余的另列 */ +function cardsOf(id: string, nodes: readonly CanvasNode[], edges: readonly CanvasEdge[]) { + const up = upstreamOf(id, edges) + const cards = nodes.filter((n) => n.data.nodeType === 'metrics') + return { upstream: cards.filter((n) => up.has(n.id)), other: cards.filter((n) => !up.has(n.id)) } +} + +/** + * 实际复算哪张卡:写了 card 就是它(不在画布上时 node 为 null);没写取上游的口径卡,上游只有一张时就是它, + * 好几张时说不准,node 为 null、由调用方照实说 + */ +function targetOf(config: Record, id: string, nodes: readonly CanvasNode[], edges: readonly CanvasEdge[]) { + const { upstream } = cardsOf(id, nodes, edges) + const card = typeof config.card === 'string' && config.card ? config.card : '' + if (card) return { node: nodes.find((n) => n.id === card && n.data.nodeType === 'metrics') ?? null, card, upstream } + return { node: upstream.length === 1 ? upstream[0] : null, card: '', upstream } +} + +/** 一张口径卡在摘要里的名字:节点名,没起名的用口径名,再没有用 id */ +const cardName = (n: CanvasNode): string => + String(n.data.label || n.data.config?.caliber || n.id) + +/** 口径卡里定义的指标;引用别的工作流发布的口径(caliber_from)时指标运行时才知道,返回 null */ +function metricsOf(n: CanvasNode): { id: string; name: string }[] | null { + const cfg = n.data.config ?? {} + if (isRecord(cfg.caliber_from)) return null + return (Array.isArray(cfg.metrics) ? cfg.metrics : []) + .filter((m: any) => isRecord(m) && typeof m.id === 'string' && m.id) + .map((m: any) => ({ id: String(m.id), name: typeof m.name === 'string' ? m.name : '' })) +} + +/** + * 节点卡片的摘要:复算哪张口径卡、几个指标(NodeDef.summary)。NodeCard 在 studio store 的选择器里调它, + * 每条事件都会跑一遍:只做几次数组查找,返回字符串 + */ +export function dualCheckSummary({ id, config, nodes, edges }: NodeSummaryContext): string { + const S = T.summary + const { node, card, upstream } = targetOf(config, id, nodes, edges) + const picked = ids(config.metrics) + let text: string + if (node) { + const all = metricsOf(node) + text = S.card(cardName(node), picked.length ? picked.length : null, all ? all.length : null) + } else if (card) { + text = S.card(card, picked.length ? picked.length : null, null) + } else { + text = upstream.length > 1 ? S.many(upstream.length) : S.noCard + } + return config.enabled === false ? `${text} · ${S.off}` : text +} + +// ---------------------------------------------------------------- 设置里的默认值 + +/** + * 设置里核查一组的值:相对误差、启用的「跟随设置」要写出眼下是多少。整个会话取一次(同检查器的运行默认值), + * 取不到就不写,不挡编辑 + */ +let verifyPending: Promise | null = null +let verifyValue: VerifySettings | null | undefined +function useVerifyDefaults(): VerifySettings | null { + const [value, setValue] = useState(verifyValue ?? null) + useEffect(() => { + if (verifyValue !== undefined) return + verifyPending ??= api.settings.get().then( + (s) => (verifyValue = isRecord(s?.verify) ? s.verify as VerifySettings : null), + () => { verifyPending = null; return null }, // 这次没取到,下次打开再试 + ) + let alive = true + void verifyPending.then((v) => { if (alive) setValue(v) }) + return () => { alive = false } + }, []) + return verifyValue ?? value +} + +/** 字段下面的一行提醒(所选口径卡不在上游之类),提醒色 */ +function Warn({ children, attr }: { children: ReactNode; attr: string }) { + return ( +
{children}
+ ) +} + +// ---------------------------------------------------------------- 各字段 + +/** 要复算的口径卡:下拉选画布上的口径卡,上游的一组、其余的一组;留空取上游的口径卡 */ +function CardField({ id, nodeId, value, invalid, describedBy, onChange }: FieldRenderProps) { + const nodes = useStudio((s) => s.nodes) + const edges = useStudio((s) => s.edges) + const { upstream, other } = useMemo(() => cardsOf(nodeId, nodes, edges), [nodeId, nodes, edges]) + const current = typeof value === 'string' ? value : '' + const known = [...upstream, ...other].some((n) => n.id === current) + const option = (n: CanvasNode) => ( + + ) + return ( + <> + + {current && known && !upstream.some((n) => n.id === current) && {T.cardNotUpstream}} + {current && !known && {T.cardMissing(current)}} + + ) +} + +/** + * 复算的指标:所选口径卡的指标逐个勾。没写(全部勾着)是缺省——复算输入来自查询的全部指标;全部勾回来时写回 + * undefined,配置里不留一份和缺省等价的清单。最后一个不让取消:一个都不勾和缺省分不清 + */ +function MetricsField({ id, nodeId, value, config, describedBy, onChange }: FieldRenderProps) { + const nodes = useStudio((s) => s.nodes) + const edges = useStudio((s) => s.edges) + const target = useMemo(() => targetOf(config, nodeId, nodes, edges), [config, nodeId, nodes, edges]) + const picked = ids(value) + const metrics = target.node ? metricsOf(target.node) : undefined + if (!target.node || metrics === undefined) { + return ( +
+ {target.upstream.length > 1 && !target.card ? T.summary.many(target.upstream.length) : T.metricsNoCard} +
+ ) + } + if (metrics === null || !metrics.length) { + return ( +
+
{metrics === null ? T.metricsPinned : T.metricsNone}
+ {picked.length > 0 && ( + + )} +
+ ) + } + const known = new Set(metrics.map((m) => m.id)) + const valid = picked.filter((p) => known.has(p)) + const stale = picked.filter((p) => !known.has(p)) + const all = !picked.length + const checked = new Set(all ? metrics.map((m) => m.id) : valid) + const write = (next: Set) => { + const ordered = metrics.map((m) => m.id).filter((m) => next.has(m)) + onChange(ordered.length === metrics.length ? undefined : ordered) + } + return ( +
+
+ {metrics.map((m) => { + const on = checked.has(m.id) + const last = on && checked.size === 1 + return ( + + ) + })} +
+
+ {all ? T.metricsDefault : T.metricsPicked(checked.size, metrics.length)} +
+ {stale.length > 0 && ( + + {T.metricsStale(stale)} + + + )} +
+ ) +} + +/** + * 口径说明:要复算的每个指标一段,给盲算模型看的业务定义(它看不到原流程的 SQL)。存成 {指标 id: 说明}, + * 清空的那一项去掉,全清空写回 undefined。已经写了、指标却不在复算范围里的照样列出来,好让人看见并删掉 + */ +function DefinitionsField({ id, nodeId, value, config, describedBy, onChange }: FieldRenderProps) { + const nodes = useStudio((s) => s.nodes) + const edges = useStudio((s) => s.edges) + const target = useMemo(() => targetOf(config, nodeId, nodes, edges), [config, nodeId, nodes, edges]) + const defs: Record = isRecord(value) + ? Object.fromEntries(Object.entries(value).filter(([, v]) => typeof v === 'string')) as Record : {} + const metrics = target.node ? metricsOf(target.node) : undefined + const picked = ids(config.metrics) + const rows = metrics ? (picked.length ? metrics.filter((m) => picked.includes(m.id)) : metrics) : [] + const gone = Object.keys(defs).filter((k) => !rows.some((r) => r.id === k)) + const set = (metric: string, text: string | null) => { + const next = { ...defs } + if (text != null && text.trim()) next[metric] = text + else delete next[metric] + onChange(Object.keys(next).length ? next : undefined) + } + if (!rows.length && !gone.length) { + return ( +
+ {metrics === null ? T.definitionsPinned : T.definitionsNone} +
+ ) + } + return ( +
+ {rows.map((m) => ( +
+ +