双盲复算前端(三):检查器字段、节点卡片摘要,以及检查器与节点卡片的钩子

- 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 里已经不成立的「下面两种还是占位」注释
This commit is contained in:
ThearchyHelios
2026-10-05 14:23:17 +08:00
parent cac34c90dd
commit 7a9dc93dde
8 changed files with 887 additions and 11 deletions
+377
View File
@@ -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<string, any> => !!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<string> {
const parents = new Map<string, string[]>()
for (const e of edges) parents.set(e.target, [...(parents.get(e.target) ?? []), e.source])
const seen = new Set<string>()
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<string, any>, 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<VerifySettings | null> | null = null
let verifyValue: VerifySettings | null | undefined
function useVerifyDefaults(): VerifySettings | null {
const [value, setValue] = useState<VerifySettings | null>(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 (
<div className="mt-1 text-2xs leading-snug" style={{ color: 'var(--st-waiting)' }} {...{ [attr]: '' }}>{children}</div>
)
}
// ---------------------------------------------------------------- 各字段
/** 要复算的口径卡:下拉选画布上的口径卡,上游的一组、其余的一组;留空取上游的口径卡 */
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) => (
<option key={n.id} value={n.id}>{T.cardOption(String(n.data.label || n.id), n.data.config?.caliber)}</option>
)
return (
<>
<select id={id} className="field" value={current} aria-invalid={invalid || undefined} aria-describedby={describedBy}
data-dual-field="card" onChange={(e) => onChange(e.target.value || undefined)}>
<option value="">{T.cardDefault(upstream.length === 1 ? cardName(upstream[0]) : null, upstream.length)}</option>
{upstream.length > 0 && <optgroup label={T.cardUpstream}>{upstream.map(option)}</optgroup>}
{other.length > 0 && <optgroup label={T.cardOther}>{other.map(option)}</optgroup>}
{/* 写着的卡已经不在画布上:照实列出来(不让它看着像「默认」),下面再说一句 */}
{current && !known && <option value={current}>{T.cardGone(current)}</option>}
</select>
{current && known && !upstream.some((n) => n.id === current) && <Warn attr="data-dual-card-warn">{T.cardNotUpstream}</Warn>}
{current && !known && <Warn attr="data-dual-card-warn">{T.cardMissing(current)}</Warn>}
</>
)
}
/**
* 复算的指标:所选口径卡的指标逐个勾。没写(全部勾着)是缺省——复算输入来自查询的全部指标;全部勾回来时写回
* 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 (
<div id={id} className="text-2xs leading-snug text-faint" aria-describedby={describedBy} data-dual-metrics="none">
{target.upstream.length > 1 && !target.card ? T.summary.many(target.upstream.length) : T.metricsNoCard}
</div>
)
}
if (metrics === null || !metrics.length) {
return (
<div id={id} aria-describedby={describedBy} data-dual-metrics={metrics === null ? 'pinned' : 'empty'}>
<div className="text-2xs leading-snug text-faint">{metrics === null ? T.metricsPinned : T.metricsNone}</div>
{picked.length > 0 && (
<button type="button" className="btn btn-xs mt-1" onClick={() => onChange(undefined)}>{T.metricsClear}</button>
)}
</div>
)
}
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<string>) => {
const ordered = metrics.map((m) => m.id).filter((m) => next.has(m))
onChange(ordered.length === metrics.length ? undefined : ordered)
}
return (
<div id={id} role="group" aria-label={T.field.metrics} aria-describedby={describedBy} data-dual-metrics="list">
<div className="space-y-0.5 rounded border px-2 py-1.5">
{metrics.map((m) => {
const on = checked.has(m.id)
const last = on && checked.size === 1
return (
<label key={m.id} className={clsx('flex items-center gap-2 py-0.5 text-xs', last ? 'cursor-not-allowed' : 'cursor-pointer')}
title={last ? T.metricsLast : undefined} data-dual-metric-option={m.id}>
<input type="checkbox" className="accent-[var(--accent)]" checked={on} disabled={last}
onChange={(e) => {
const next = new Set(checked)
if (e.target.checked) next.add(m.id)
else next.delete(m.id)
write(next)
}} />
<span className="min-w-0 flex-1 truncate">{T.metricOption(m.name, m.id)}</span>
</label>
)
})}
</div>
<div className="mt-1 text-2xs leading-snug text-faint" data-dual-metrics-state={all ? 'default' : 'picked'}>
{all ? T.metricsDefault : T.metricsPicked(checked.size, metrics.length)}
</div>
{stale.length > 0 && (
<Warn attr="data-dual-metrics-stale">
{T.metricsStale(stale)}
<button type="button" className="btn btn-xs ml-1 align-baseline" onClick={() => onChange(valid.length ? valid : undefined)}>
{T.metricsClear}
</button>
</Warn>
)}
</div>
)
}
/**
* 口径说明:要复算的每个指标一段,给盲算模型看的业务定义(它看不到原流程的 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<string, string> = isRecord(value)
? Object.fromEntries(Object.entries(value).filter(([, v]) => typeof v === 'string')) as Record<string, string> : {}
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 (
<div id={id} className="text-2xs leading-snug text-faint" aria-describedby={describedBy} data-dual-definitions="none">
{metrics === null ? T.definitionsPinned : T.definitionsNone}
</div>
)
}
return (
<div id={id} role="group" aria-label={T.field.definitions} aria-describedby={describedBy} className="space-y-1.5"
data-dual-definitions="list">
{rows.map((m) => (
<div key={m.id} data-dual-definition={m.id}>
<label className="mb-0.5 block truncate text-2xs text-dim" htmlFor={`${id}-${m.id}`}>{T.metricOption(m.name, m.id)}</label>
<textarea id={`${id}-${m.id}`} className="field text-xs" rows={2} value={defs[m.id] ?? ''}
placeholder={T.definitionPlaceholder(m.name || m.id)} onChange={(e) => set(m.id, e.target.value)} />
</div>
))}
{gone.map((k) => (
<Warn key={k} attr="data-dual-definition-gone">
<span className="flex items-start gap-1">
<span className="min-w-0 flex-1">{T.definitionGone(k)}</span>
<button type="button" className="shrink-0 rounded p-0.5 hover:bg-hover" aria-label={`${T.definitionRemove}:${k}`}
title={T.definitionRemove} onClick={() => set(k, null)}>
<X size={11} aria-hidden />
</button>
</span>
</Warn>
))}
</div>
)
}
/**
* 盲算模型 · 接入。照报告节点「结论句裁判」的写法:留空跟随设置;只填了模型时接入按模型名找,留空那一项改叫
* 「按模型名找接入」(verify_model_spec 哪一级写了就整组用哪一级,不跨级拼)
*/
function ProviderField({ id, value, config, invalid, describedBy, onChange }: FieldRenderProps) {
const providers = useCatalog((s) => s.providers)
const provider = typeof value === 'string' ? value : ''
const known = providers.some((p) => p.name === provider)
const byModel = !provider && typeof config.model === 'string' && !!config.model.trim()
return (
<select id={id} className="field" value={provider} aria-invalid={invalid || undefined} aria-describedby={describedBy}
data-dual-field="provider" onChange={(e) => onChange(e.target.value || undefined)}>
<option value="">{byModel ? T.providerFromModel : T.providerFollow}</option>
{provider && !known && providers.length > 0 && <option value={provider}>{T.providerGone(provider)}</option>}
{providers.map((p) => <option key={p.id} value={p.name}>{p.name}{p.enabled ? '' : T.providerOff}</option>)}
</select>
)
}
/** 盲算模型 · 模型:可以手填,下拉给所选接入(没选接入时给全部启用接入)的模型;只填了模型时写明实际用哪个接入 */
function ModelField({ id, value, config, invalid, describedBy, onChange }: FieldRenderProps) {
const providers = useCatalog((s) => s.providers)
const provider = typeof config.provider === 'string' ? config.provider : ''
const pv = provider ? providers.find((p) => p.name === provider) : undefined
const model = typeof value === 'string' ? value : ''
const byModel = !provider && !!model.trim()
const owner = byModel ? providerOfModel(providers, model.trim()) : undefined
const enabled = providers.filter((p) => p.enabled)
const fallback = enabled.find((p) => p.kind !== 'mock') ?? enabled[0]
const hint = byModel && providers.length > 0
? T.byModel(model.trim(), owner && { name: owner.provider.name, enabled: owner.enabled }, fallback?.name) : ''
const models = pv ? (pv.models ?? []).map((m) => m.id) : [...new Set(modelOptions(providers).map((o) => o.value))]
return (
<>
<input id={id} className="field mono text-xs" list={`${id}-models`} value={model} spellCheck={false}
placeholder={provider ? T.modelOfProvider : T.modelFollow} aria-invalid={invalid || undefined}
aria-describedby={describedBy} data-dual-field="model"
onChange={(e) => onChange(e.target.value.trim() ? e.target.value : undefined)} />
<datalist id={`${id}-models`}>{models.map((m) => <option key={m} value={m} />)}</datalist>
{hint && (
<div className="mt-1 text-2xs leading-snug" data-dual-by-model={owner ? (owner.enabled ? 'found' : 'disabled') : 'default'}
style={owner?.enabled ? undefined : { color: 'var(--st-waiting)' }}>
<span className={owner?.enabled ? 'text-faint' : undefined}>{hint}</span>
</div>
)}
</>
)
}
/** 允许的相对误差:留空跟随设置,占位里写出设置里眼下是多少;负数标出来(服务端也不收) */
function ToleranceField({ id, value, invalid, describedBy, onChange }: FieldRenderProps) {
const settings = useVerifyDefaults()
const bad = typeof value === 'number' && (!Number.isFinite(value) || value < 0)
return (
<>
<input id={id} className="field tnum" type="number" min={0} step="any" value={value ?? ''}
placeholder={T.tolerancePlaceholder(settings?.dual_check?.tolerance)}
aria-invalid={invalid || bad || undefined} aria-describedby={[describedBy, bad ? `${id}-bad` : ''].filter(Boolean).join(' ') || undefined}
style={bad ? { borderColor: 'var(--err)' } : undefined} data-dual-field="tolerance"
onChange={(e) => onChange(e.target.value === '' ? undefined : Number(e.target.value))} />
{bad && <div id={`${id}-bad`} className="mt-1 text-2xs leading-snug" style={{ color: 'var(--err)' }}>{T.toleranceInvalid}</div>}
</>
)
}
const ENABLED = ['follow', 'on', 'off'] as const
type Enabled = typeof ENABLED[number]
/** 启用:跟随设置 / 开启 / 关闭三态(null 或不写、true、false)。跟随设置时写回 undefined,不在配置里留 null */
function EnabledField({ id, value, describedBy, onChange }: FieldRenderProps) {
const settings = useVerifyDefaults()
const current: Enabled = value === true ? 'on' : value === false ? 'off' : 'follow'
const pick = (v: Enabled) => onChange(v === 'on' ? true : v === 'off' ? false : undefined)
const radio = useRadioGroup(ENABLED, current, pick)
return (
<div id={id} role="radiogroup" aria-label={T.field.enabled} aria-describedby={describedBy}
className="flex flex-wrap gap-0.5 rounded border p-0.5" data-dual-field="enabled">
{ENABLED.map((v) => (
<button key={v} type="button" {...radio(v)} data-value={v}
// 按字数分宽度、不折行:「跟随设置(当前开启)」比「开启」长得多,平分会把它挤成两行;太窄时整段换到下一行
className={clsx('flex-auto whitespace-nowrap rounded px-2 py-1 text-2xs transition-colors',
current === v ? 'font-medium text-fg' : 'text-dim hover:bg-hover')}
style={current === v ? { background: 'var(--accent-soft)', boxShadow: 'inset 0 0 0 1px var(--accent)' } : undefined}
onClick={() => pick(v)}>
{v === 'follow' ? T.enabledFollow(settings?.dual_check?.enabled) : T.enabled[v]}
</button>
))}
</div>
)
}
/** nodeDefs 里 dual_check 各字段的 render */
export const dualCheckFields = {
card: (p: FieldRenderProps) => <CardField {...p} />,
metrics: (p: FieldRenderProps) => <MetricsField {...p} />,
definitions: (p: FieldRenderProps) => <DefinitionsField {...p} />,
provider: (p: FieldRenderProps) => <ProviderField {...p} />,
model: (p: FieldRenderProps) => <ModelField {...p} />,
tolerance: (p: FieldRenderProps) => <ToleranceField {...p} />,
enabled: (p: FieldRenderProps) => <EnabledField {...p} />,
}
+3
View File
@@ -618,6 +618,9 @@ function FieldInput({ field, id, nodeId, syntax, value, config, invalid, describ
const defaults = useRunDefaults(field.key === 'scope' || field.type === 'collection')
const aria = { 'aria-invalid': invalid || undefined, 'aria-describedby': describedBy }
if (field.render) {
return <>{field.render({ id, nodeId, value, config, invalid, describedBy, disabled, onChange })}</>
}
switch (field.type) {
case 'text':
if (syntax !== 'plain') {
+4 -1
View File
@@ -766,10 +766,13 @@ function NodeCardImpl({ id, data, selected, isConnectable }: NodeProps<FlowNode>
const reportStamp = useReportStamp(id, type === 'report')
// 只有排队中的卡片要知道自己有几路上游,别的卡片不必为改图重跑这一趟
const fanIn = useStudio((s) => (state === 'queued' ? fanInOf(s.nodes, s.edges)[id] ?? 0 : 0))
// 节点定义给了摘要钩子(摘要要提到别的节点:双盲复算复算的是哪张口径卡)就用它,没给照旧走 summarize。
// 选择器只返回字符串:别的节点改了、这一句没变,卡片就不重渲染
const hookedSummary = useStudio((s) => def?.summary?.({ id, config: data.config, nodes: s.nodes, edges: s.edges }) ?? '')
const Icon = def?.icon ?? Sparkles
const handles = sourceHandles(type, data.config)
const summary = summarize(type, data.config)
const summary = hookedSummary || summarize(type, data.config)
const meta = statusMeta(state)
// 循环容器在两轮之间也是 running,但在跑的是循环体:不转圈、不挂光弧。
// 回放时取游标那一刻的(航迹上的 looping 是最后的值)
+68 -3
View File
@@ -1,3 +1,4 @@
import type { ReactNode } from 'react'
import {
Bot, Braces, Brain, CheckCircle2, Code2, Combine, Database, FileInput, FileOutput, FileText,
Gauge, GitBranch, GitCompareArrows, Hand, Repeat, Search, ShieldAlert, Shuffle, Users, Wrench,
@@ -6,6 +7,7 @@ import {
APPROVAL_POLICY_LABEL, CHALLENGE_TEXT, DUAL_CHECK_TEXT, MERGE_TEXT, NODE_TYPE_LABEL, UPGRADE_POLICY_LABEL,
} from '../lib/terms'
import type { NodeType } from '../types'
import { dualCheckFields, dualCheckSummary } from './DualCheckFields'
export type FieldType =
| 'text' | 'textarea' | 'prompt' | 'code' | 'number' | 'select' | 'switch'
@@ -51,6 +53,23 @@ export interface FieldDef {
*/
unknownLabel?: (value: string) => string
advanced?: boolean
/**
* 现有字段类型画不了的配置(选画布上某一类节点、三态开关、接入加模型)交给它画。
* 给了 render 就不再按 type 渲染;type 仍用于默认值和校验
*/
render?: (p: FieldRenderProps) => ReactNode
}
/** FieldDef.render 拿到的:当前值、写回、所在节点和配置,以及无障碍属性 */
export interface FieldRenderProps {
id: string
nodeId: string
value: any
config: Record<string, any>
invalid: boolean
describedBy?: string
disabled?: boolean
onChange: (v: any) => void
}
/**
@@ -91,6 +110,19 @@ export interface NodeDef {
sources: HandleDef[] | null
fields: FieldDef[]
defaults: Record<string, any>
/**
* 卡片正文那一行摘要要提到画布上别的节点时给它(双盲复算复算的是哪张口径卡、几个指标),没给的照旧走
* NodeCard 的 summarize。NodeCard 在 studio store 的选择器里调它、每条事件都会跑:要快、没有副作用、只返回字符串
*/
summary?: (ctx: NodeSummaryContext) => string
}
/** NodeDef.summary 拿到的:这个节点的 id 和配置,以及画布上的全部节点和连线 */
export interface NodeSummaryContext {
id: string
config: Record<string, any>
nodes: readonly { id: string; data: { nodeType: NodeType; label?: string; config?: Record<string, any> } }[]
edges: readonly { source: string; target: string }[]
}
const MODEL_FIELDS: FieldDef[] = [
@@ -658,14 +690,47 @@ export const NODE_DEFS: Record<NodeType, NodeDef> = {
metrics: [{ id: 'total', name: '', unit: '', expression: '' }],
},
},
// 下面两种还是占位:服务端的执行器原样跳过(engine/nodes/dual_check.py、challenge.py),没有可配的字段。
// 说明里写明尚未生效,检查器顶上就是这一句;各自的工作包补上字段时把这句去掉
dual_check: {
type: 'dual_check', label: NODE_TYPE_LABEL.dual_check, category: '把关', icon: GitCompareArrows,
description: DUAL_CHECK_TEXT.description,
hasTarget: true, sources: [{ id: 'out', label: '' }],
fields: [],
// 配置的键和缺省见 types.ts 的 DualCheckConfig(期 3 约定)。口径卡、指标、口径说明要看画布上别的节点,
// 盲算模型是接入加模型,启用是三态,都交给 DualCheckFields 画;type 只管默认值和校验时当什么看
fields: [
{ key: 'card', label: DUAL_CHECK_TEXT.field.card, type: 'select', help: DUAL_CHECK_TEXT.help.card, render: dualCheckFields.card },
{
key: 'metrics', label: DUAL_CHECK_TEXT.field.metrics, type: 'json', help: DUAL_CHECK_TEXT.help.metrics,
render: dualCheckFields.metrics,
},
{
key: 'definitions', label: DUAL_CHECK_TEXT.field.definitions, type: 'json', help: DUAL_CHECK_TEXT.help.definitions,
render: dualCheckFields.definitions,
},
{
key: 'enabled', label: DUAL_CHECK_TEXT.field.enabled, type: 'select', help: DUAL_CHECK_TEXT.help.enabled,
render: dualCheckFields.enabled,
},
{
// 没写和写 degrade 是一回事:选项从 degrade 排起,没写时下拉框落在它上面,正好是缺省
key: 'on_reject', label: DUAL_CHECK_TEXT.field.onReject, type: 'select', help: DUAL_CHECK_TEXT.help.onReject,
options: [
{ value: 'degrade', label: DUAL_CHECK_TEXT.onReject.degrade },
{ value: 'fail', label: DUAL_CHECK_TEXT.onReject.fail },
],
unknownLabel: DUAL_CHECK_TEXT.onRejectUnknown,
},
{ key: 'provider', label: DUAL_CHECK_TEXT.field.provider, type: 'select', advanced: true, render: dualCheckFields.provider },
{
key: 'model', label: DUAL_CHECK_TEXT.field.model, type: 'text', advanced: true, help: DUAL_CHECK_TEXT.help.model,
render: dualCheckFields.model,
},
{
key: 'tolerance', label: DUAL_CHECK_TEXT.field.tolerance, type: 'number', min: 0, advanced: true,
help: DUAL_CHECK_TEXT.help.tolerance, render: dualCheckFields.tolerance,
},
],
defaults: {},
summary: dualCheckSummary,
},
challenge: {
type: 'challenge', label: NODE_TYPE_LABEL.challenge, category: '把关', icon: ShieldAlert,
+223 -3
View File
@@ -24,7 +24,9 @@
"original_rendered": "45,678.5元",
"blind_rendered": "51,388.3元",
"delta_pct": 12.5,
"diff": ["原流程按支付时间 paid_at 筛选时间范围,盲算按下单时间 created_at 筛选"],
"diff": [
"原流程按支付时间 paid_at 筛选时间范围,盲算按下单时间 created_at 筛选"
],
"fallback": false
},
{
@@ -79,7 +81,10 @@
"diff": {
"equivalent": false,
"items": [
{ "aspect": "time", "text": "原流程按支付时间 paid_at 筛选时间范围,盲算按下单时间 created_at 筛选" }
{
"aspect": "time",
"text": "原流程按支付时间 paid_at 筛选时间范围,盲算按下单时间 created_at 筛选"
}
],
"fallback": false,
"text_diff": [
@@ -140,7 +145,10 @@
"diff": {
"equivalent": false,
"items": [
{ "aspect": "other", "text": "两条 SQL 的写法不同,未能归纳成具体差异,请对照原文" }
{
"aspect": "other",
"text": "两条 SQL 的写法不同,未能归纳成具体差异,请对照原文"
}
],
"fallback": true,
"text_diff": [
@@ -150,5 +158,217 @@
}
}
]
},
"graph": {
"nodes": [
{
"id": "start",
"type": "input",
"position": {
"x": 0,
"y": 120
},
"data": {
"label": "输入",
"config": {
"fields": [
{
"name": "week",
"required": true
}
]
}
}
},
{
"id": "fetch",
"type": "agent",
"position": {
"x": 280,
"y": 120
},
"data": {
"label": "取数",
"config": {
"prompt": "查询本周各门店的订单",
"tools": [
"db_query__shop"
],
"assign_to": "kpi"
}
}
},
{
"id": "card",
"type": "metrics",
"position": {
"x": 560,
"y": 40
},
"data": {
"label": "周报口径卡",
"config": {
"caliber": "周报口径",
"caliber_version": "v1",
"metrics": [
{
"id": "gmv",
"name": "销售额",
"unit": "元",
"expression": "vars.kpi.gmv"
},
{
"id": "orders",
"name": "订单数",
"unit": "单",
"expression": "vars.kpi.orders"
},
{
"id": "aov",
"name": "客单价",
"unit": "元",
"expression": "vars.kpi.gmv / vars.kpi.orders"
}
]
}
}
},
{
"id": "card2",
"type": "metrics",
"position": {
"x": 560,
"y": 260
},
"data": {
"label": "月报口径卡",
"config": {
"caliber": "月报口径",
"caliber_version": "v2",
"metrics": [
{
"id": "m_gmv",
"name": "月销售额",
"unit": "元",
"expression": "vars.kpi.gmv"
}
]
}
}
},
{
"id": "dc",
"type": "dual_check",
"position": {
"x": 840,
"y": 40
},
"data": {
"label": "复核周报口径",
"config": {}
}
},
{
"id": "done",
"type": "output",
"position": {
"x": 1120,
"y": 40
},
"data": {
"label": "成果",
"config": {
"fields": [
{
"name": "结果",
"value": "{{ last_message }}"
}
]
}
}
}
],
"edges": [
{
"id": "e1",
"source": "start",
"target": "fetch"
},
{
"id": "e2",
"source": "fetch",
"target": "card"
},
{
"id": "e3",
"source": "fetch",
"target": "card2"
},
{
"id": "e4",
"source": "card",
"target": "dc"
},
{
"id": "e5",
"source": "dc",
"target": "done"
}
]
},
"providers": [
{
"id": "prov-a",
"name": "云端接入",
"kind": "anthropic",
"base_url": null,
"default_model": "model-a",
"models": [
{
"id": "model-a"
},
{
"id": "model-b"
}
],
"enabled": true,
"extra": {}
},
{
"id": "prov-b",
"name": "备用接入",
"kind": "openai",
"base_url": null,
"default_model": "model-c",
"models": [
{
"id": "model-c"
}
],
"enabled": false,
"extra": {}
}
],
"verify": {
"provider": null,
"model": null,
"dual_check": {
"enabled": true,
"tolerance": 1e-06,
"max_queries": 6,
"timeout_s": 120
},
"challenge": {
"enabled": true,
"max_claims": 5,
"max_queries": 4,
"timeout_s": 90
},
"inquiry": {
"enabled": true,
"max_queries": 6,
"timeout_s": 120
},
"daily_max_usd": 2.0
}
}
+41 -2
View File
@@ -1,6 +1,12 @@
import { useState } from 'react'
import type { JSX } from 'react'
import { ReactFlow, ReactFlowProvider } from '@xyflow/react'
import { ApprovalCard } from '../../run/RunPanel'
import type { Approval } from '../../types'
import { Inspector } from '../../canvas/Inspector'
import { NodeCard } from '../../canvas/NodeCard'
import { toFlow, useStudio } from '../../store/studio'
import { useCatalog } from '../../store/catalog'
import type { Approval, GraphSpec, Provider } from '../../types'
import data from './dual_check.json'
/**
@@ -8,8 +14,10 @@ import data from './dual_check.json'
* - ?syn=dual-check-approval:正式运行里两路对不上的审批卡,宽栏(两条 SQL 左右并排)
* - ?syn=dual-check-approval-rail:同一张卡摆在 360px 的画布右栏里(两条 SQL 上下排)
* - ?syn=dual-check-approval-exploratory:探索运行,后果里不提出具
* - ?syn=dual-check-inspector:一张小画布(真的节点卡片)加 360px 的检查器,选中双盲复算节点;检查器改的是
* studio store 里的节点配置,卡片上的摘要跟着变
*
* 完整对照的工件(GET /api/artifacts/{id})和审批回复接口由检查脚本用 page.route 伪造,直接打开时卡上照实说
* 完整对照的工件(GET /api/artifacts/{id})、审批回复、设置、校验都由检查脚本用 page.route 伪造,直接打开时卡上照实说
* 无法加载完整对照。提交后 onResolved 只把运行 id 记到 window.__dualResolved,不去接事件流
*/
const approval = (patch: Partial<Approval> = {}): Approval => ({
@@ -26,6 +34,36 @@ function Card({ value }: { value: Approval }) {
)
}
const nodeTypes = { card: NodeCard }
/**
* 检查器用例:第一次渲染前把夹具里的图放进 studio store、选中双盲复算节点,模型接入直接写进目录 store(不走接口)。
* 用例都在一个预览页里预先加载,store 只能在这里(用到时)改,不能在模块顶层改
*/
function InspectorCase() {
useState(() => {
const { nodes, edges } = toFlow(data.graph as unknown as GraphSpec)
useStudio.setState({ nodes, edges, selectedId: 'dc' })
useCatalog.setState({ providers: data.providers as unknown as Provider[] })
return true
})
const nodes = useStudio((s) => s.nodes)
const edges = useStudio((s) => s.edges)
return (
<div className="flex h-full min-h-0 max-w-full" data-fixture="dual-check-inspector">
<div className="min-w-0 flex-1 max-sm:hidden" data-dual-canvas="">
<ReactFlowProvider>
<ReactFlow nodes={nodes} edges={edges} nodeTypes={nodeTypes} fitView nodesDraggable={false} nodesConnectable={false}
proOptions={{ hideAttribution: true }} />
</ReactFlowProvider>
</div>
<div className="h-full w-[360px] max-w-full shrink-0 overflow-hidden border-l bg-panel" data-dual-inspector="">
<Inspector />
</div>
</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>
@@ -40,4 +78,5 @@ export const cases: Record<string, () => JSX.Element> = {
<Card value={approval({ run_class: 'exploratory' })} />
</div>
),
'dual-check-inspector': () => <InspectorCase />,
}
+8 -2
View File
@@ -3385,10 +3385,14 @@ export const DUAL_CHECK_TEXT = {
metricsNone: '这张口径卡还没有定义指标',
metricsPinned: '这张口径卡引用了其他工作流发布的口径,指标在运行时按引用的版本确定:将复算其中输入来自查询的全部指标',
metricsStale: (ids: string[]) => `已选的指标不在这张口径卡中:${ids.join('、')}`,
metricsClear: '清除已选',
metricsClear: '清除不在卡中的指标',
metricsLast: '至少保留一个指标;要复算全部指标时全部勾选即可',
metricOption: (name: string | null | undefined, id: string) => (name && name !== id ? `${name}(${id})` : id),
definitionPlaceholder: (name: string) => `「${name}」的口径说明`,
definitionsNone: '选择要复算的指标后,可以为每个指标写一段口径说明',
definitionsNone: '选择要复算的口径卡后,可以为每个指标写一段口径说明',
definitionsPinned: '这张口径卡引用了其他工作流发布的口径,指标在运行时才确定,无法在这里逐个填写口径说明',
definitionGone: (id: string) => `指标「${id}」已不在复算范围内,这段说明不会交给盲算模型`,
definitionRemove: '删除这段说明',
providerFollow: '跟随设置',
providerFromModel: '按模型名找接入',
providerGone: (name: string) => `${name}(不存在)`,
@@ -3408,6 +3412,7 @@ export const DUAL_CHECK_TEXT = {
tolerancePlaceholder: (v: number | null | undefined) => (v == null ? '跟随设置' : `跟随设置(${v})`),
toleranceInvalid: '请填写不小于 0 的数',
onReject: { degrade: '记为出具缺口,出具降档(默认)', fail: '判为失败,运行终止' } as Record<string, string>,
onRejectUnknown: (v: string) => `${v}:无法识别的值,请从上方选项中选择`,
enabled: { follow: '跟随设置', on: '开启', off: '关闭' },
/** 「跟随设置」那一项附上设置里眼下是开是关(设置还没取回来就不附) */
enabledFollow: (on: boolean | null | undefined) => (on == null ? '跟随设置' : `跟随设置(当前${on ? '开启' : '关闭'})`),
@@ -3417,6 +3422,7 @@ export const DUAL_CHECK_TEXT = {
card: (name: string, picked: number | null, total: number | null) => `复算「${name}」 · ${
picked != null ? `${picked} 个指标` : total != null ? `全部 ${total} 个指标` : '全部指标'}`,
noCard: '复算上游的口径卡 · 尚未连接口径卡',
many: (n: number) => `复算上游的口径卡 · 上游有 ${n} 张,请指定`,
off: '已关闭',
},
+163
View File
@@ -76,6 +76,7 @@ const text = async (loc) => (await loc.first().innerText().catch(() => '')).repl
/** 两个颜色的对比度(WCAG):颜色是 getComputedStyle 给的 rgb / rgba / color(srgb …),半透明的叠在底色上 */
function contrast(fg, bg, base) {
const parse = (c) => {
if (typeof c !== 'string') return null
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.]+))?\)/)
@@ -360,6 +361,168 @@ await section('审批卡:390px 不横向溢出、窄栏里上下排、深浅
await shot(rail.page, 'approval-rail-dark')
})
// ---------------------------------------------------------------- 检查器与节点卡片
/**
* 检查器用例:设置(相对误差、启用的「跟随设置」要写出眼下的值)按夹具答,改配置触发的校验、变量分析回空,
* 节点配置直接从 studio store 读(window.__studio,开发服务器才挂)
*/
async function inspector(opts = {}) {
const out = await preview('dual-check-inspector', {
...opts,
extra: (req, url) => {
if (req.method() === 'GET' && url.pathname === '/api/settings') return { json: { run: {}, verify: fx.verify } }
if (req.method() === 'POST' && url.pathname === '/api/workflows/validate') return { json: { issues: [] } }
if (req.method() === 'POST' && url.pathname === '/api/workflows/variables') return { json: { variables: [], issues: [] } }
return null
},
})
await out.page.waitForSelector('[data-field="card"] select')
return out
}
const config = (page) => page.evaluate(() => window.__studio.getState().nodes.find((n) => n.id === 'dc').data.config)
const summaryOf = (page) => text(page.locator('.react-flow__node[data-id="dc"] .nc-summary'))
/** 画布上的节点是 React Flow 在下一拍才按新配置重画的:等摘要变成想要的那句(最多 3 秒),返回最后看到的 */
async function summaryBecomes(page, want) {
const ok = await page.waitForFunction((w) => {
const el = document.querySelector('.react-flow__node[data-id="dc"] .nc-summary')
return !!el && (typeof w === 'string' ? el.textContent.trim() === w : el.textContent.trim().endsWith(w.suffix))
}, want, { timeout: 3000 }).then(() => true, () => false)
return { ok, seen: await summaryOf(page) }
}
await section('检查器:每一项都写进节点配置,节点卡片的摘要跟着变', async () => {
const { page, errors, sent } = await inspector()
const panel = page.locator('[data-dual-inspector]')
check('说明里不再写「功能开发中」', !(await text(panel)).includes('功能开发中') && (await text(panel)).includes('另起一路独立计算'))
check('节点卡片的摘要:复算哪张口径卡、全部几个指标', await summaryOf(page) === '复算「周报口径卡」 · 全部 3 个指标', await summaryOf(page))
// 口径卡:下拉选画布上的口径卡,上游的一组、其余的一组;留空取上游的那张
const card = page.locator('[data-field="card"] select')
const groups = await card.evaluate((el) => [...el.querySelectorAll('optgroup')].map((g) => `${g.label}:${[...g.querySelectorAll('option')].map((o) => o.value).join('+')}`))
check('口径卡:上游的一组、其余的一组', groups.join('|') === '上游的口径卡:card|其他口径卡(需连接到本节点上游):card2', groups.join('|'))
check('……留空那一项写明默认取上游的哪一张', (await card.locator('option').first().innerText()) === '默认:上游的口径卡「周报口径卡」')
check('……标签指向这个下拉', await page.locator('[data-field="card"] label').getAttribute('for') === await card.getAttribute('id'))
await card.selectOption('card2')
check('选另一张:写进 card', (await config(page)).card === 'card2', JSON.stringify(await config(page)))
check('……不在上游的照实提醒', (await text(page.locator('[data-dual-card-warn]'))).includes('不在本节点上游'))
const swapped = await summaryBecomes(page, '复算「月报口径卡」 · 全部 1 个指标')
check('……摘要换成这张卡', swapped.ok, swapped.seen)
await card.selectOption('')
check('选回默认:card 去掉,不留空串', !('card' in (await config(page))) || (await config(page)).card === undefined, JSON.stringify(await config(page)))
// 指标:逐个勾;全部勾着是缺省,不写;最后一个不让取消
const opts = page.locator('[data-dual-metric-option]')
check('指标:列出这张卡的三个指标,默认全勾、说明是缺省', await opts.count() === 3
&& await page.locator('[data-dual-metric-option] input:checked').count() === 3
&& (await text(page.locator('[data-dual-metrics-state]'))) === '未指定:复算全部输入来自查询的指标')
check('……一组有读屏名', await page.locator('[data-dual-metrics="list"]').getAttribute('aria-label') === '复算的指标')
await page.locator('[data-dual-metric-option="aov"] input').uncheck()
check('取消一个:写进 metrics(按卡里的顺序)', JSON.stringify((await config(page)).metrics) === '["gmv","orders"]', JSON.stringify((await config(page)).metrics))
const two = await summaryBecomes(page, '复算「周报口径卡」 · 2 个指标')
check('……摘要写几个指标', two.ok, two.seen)
check('……说明写已选几个', (await text(page.locator('[data-dual-metrics-state]'))) === '已选 2 / 3 个指标')
await page.locator('[data-dual-metric-option="orders"] input').uncheck()
check('只剩一个:不让取消(一个都不勾和缺省分不清)', await page.locator('[data-dual-metric-option="gmv"] input').isDisabled()
&& JSON.stringify((await config(page)).metrics) === '["gmv"]')
// 口径说明:只列要复算的指标;写了的存成 {指标 id: 说明}
const defs = page.locator('[data-dual-definition]')
check('口径说明:只列要复算的指标', (await defs.evaluateAll((els) => els.map((e) => e.dataset.dualDefinition))).join(',') === 'gmv')
const words = '销售额按支付时间统计,不含退款'
await page.locator('[data-dual-definition="gmv"] textarea').fill(words)
check('写一段:存成 {指标 id: 说明}', JSON.stringify((await config(page)).definitions) === JSON.stringify({ gmv: words }),
JSON.stringify((await config(page)).definitions))
check('……每段有自己的标签(指标名)', (await text(page.locator('[data-dual-definition="gmv"] label'))) === '销售额(gmv)')
for (const id of ['orders', 'aov']) await page.locator(`[data-dual-metric-option="${id}"] input`).check()
check('全部勾回来:metrics 去掉(缺省)', (await config(page)).metrics === undefined, JSON.stringify((await config(page)).metrics))
await page.locator('[data-dual-definition="orders"] textarea').fill('订单数按支付成功的订单计')
await page.locator('[data-dual-metric-option="orders"] input').uncheck()
check('写了说明的指标被取消:照实提醒、能删掉', (await text(page.locator('[data-dual-definition-gone]'))).includes('「orders」'))
await page.locator('[data-dual-definition-gone] button').click()
check('……删掉后只剩还在复算的那段', JSON.stringify((await config(page)).definitions) === JSON.stringify({ gmv: words }))
await page.locator('[data-dual-definition="gmv"] textarea').fill('')
check('清空最后一段:definitions 去掉', (await config(page)).definitions === undefined)
await page.locator('[data-dual-metric-option="orders"] input').check()
// 启用:跟随设置 / 开启 / 关闭
const enabled = page.locator('[data-field="enabled"] [role="radiogroup"]')
check('启用:三态,跟随设置写出设置里眼下是开着的', (await enabled.locator('[role="radio"]').allInnerTexts()).join('|') === '跟随设置(当前开启)|开启|关闭'
&& await enabled.locator('[role="radio"][aria-checked="true"]').getAttribute('data-value') === 'follow')
await enabled.locator('[data-value="off"]').click()
const off = await summaryBecomes(page, { suffix: ' · 已关闭' })
check('关闭:enabled 为 false,摘要标「已关闭」', (await config(page)).enabled === false && off.ok, off.seen)
await enabled.locator('[data-value="off"]').focus()
await page.keyboard.press('ArrowLeft')
check('键盘方向键换选项:开启是 true', (await config(page)).enabled === true
&& await page.evaluate(() => document.activeElement?.getAttribute('data-value')) === 'on')
await enabled.locator('[data-value="follow"]').click()
check('跟随设置:enabled 去掉,不留 null', (await config(page)).enabled === undefined, JSON.stringify(await config(page)))
// 不采用原流程时
const reject = page.locator('[data-field="on_reject"] select')
check('不采用原流程时:默认落在「记为出具缺口」上', (await reject.locator('option:checked').innerText()) === '记为出具缺口,出具降档(默认)')
await reject.selectOption('fail')
check('……选判为失败:on_reject 为 fail', (await config(page)).on_reject === 'fail')
// 高级:盲算模型、相对误差
await page.locator('button', { hasText: '高级选项' }).click()
const provider = page.locator('[data-field="provider"] select')
check('盲算模型 · 接入:留空跟随设置,停用的接入照实标出', (await provider.locator('option').allInnerTexts()).join('|') === '跟随设置|云端接入|备用接入(已停用)',
(await provider.locator('option').allInnerTexts()).join('|'))
await provider.selectOption('云端接入')
const model = page.locator('[data-field="model"] input')
check('选了接入:provider 写进去,模型的占位换成接入的默认模型', (await config(page)).provider === '云端接入'
&& await model.getAttribute('placeholder') === '留空则使用该接入的默认模型')
await model.fill('model-b')
check('模型:写进 model', (await config(page)).model === 'model-b')
await provider.selectOption('')
check('只填了模型:接入留空那一项改叫「按模型名找接入」,写明按模型名用哪个接入', (await provider.locator('option').first().innerText()) === '按模型名找接入'
&& (await text(page.locator('[data-dual-by-model]'))).includes('将按模型名使用接入「云端接入」') && (await config(page)).provider === undefined)
const tol = page.locator('[data-field="tolerance"] input')
check('允许的相对误差:占位写出设置里的值', await tol.getAttribute('placeholder') === '跟随设置(0.000001)', await tol.getAttribute('placeholder'))
await tol.fill('0.001')
check('……填了写成数', (await config(page)).tolerance === 0.001)
await tol.fill('-1')
check('……负数标出来', (await text(page.locator('[data-field="tolerance"]'))).includes('请填写不小于 0 的数') && await tol.getAttribute('aria-invalid') === 'true')
await tol.fill('')
check('……清空回到跟随设置', (await config(page)).tolerance === undefined)
const KEYS = ['card', 'metrics', 'definitions', 'provider', 'model', 'tolerance', 'on_reject', 'enabled']
const keys = Object.entries(await config(page)).filter(([, v]) => v !== undefined).map(([k]) => k)
check('配置里只有约定的键,留空的项不留空值', keys.every((k) => KEYS.includes(k)) && keys.sort().join(',') === 'model,on_reject', keys.join(','))
check('没有写库的请求', !sent.some((s) => s.blocked), JSON.stringify(sent.filter((s) => s.blocked)))
check('页面没有报错', errors.length === 0, errors.join(';'))
await shot(page, 'inspector-dark')
})
await section('检查器:360px 栏、390px 窄屏不横向溢出,深浅色可读', async () => {
for (const [width, theme] of [[1280, 'dark'], [390, 'light']]) {
const { page, errors } = await inspector({ width, height: 900, theme })
await page.locator('button', { hasText: '高级选项' }).click()
await page.locator('[data-field="model"] input').fill('model-zzz')
const w = await page.evaluate(() => {
const panel = document.querySelector('[data-dual-inspector]')
const body = panel.querySelector('fieldset')
return { page: document.documentElement.scrollWidth - document.documentElement.clientWidth,
panel: panel.scrollWidth - panel.clientWidth, body: body ? body.scrollWidth - body.clientWidth : 0 }
})
check(`${width}px(${theme}):检查器不横向溢出`, w.page <= 0 && w.panel <= 0 && w.body <= 0, JSON.stringify(w))
const c = await page.evaluate(() => {
const base = getComputedStyle(document.querySelector('[data-dual-inspector]')).backgroundColor
const el = document.querySelector('[data-dual-by-model]')
const radio = document.querySelector('[data-field="enabled"] [role="radio"][aria-checked="true"]')
return { base, hint: el && getComputedStyle(el).color, radio: radio && getComputedStyle(radio).color,
radioBg: radio && getComputedStyle(radio).backgroundColor }
})
const hint = contrast(c.hint, c.base, c.base)
check(`${theme}:只填了模型的提醒对比度 ≥ 4.5`, hint >= 4.5, `${hint.toFixed(2)}(${c.hint} on ${c.base})`)
const radio = contrast(c.radio, c.radioBg, c.base)
check(`${theme}:启用里选中那一项对比度 ≥ 4.5`, radio >= 4.5, `${radio.toFixed(2)}(${c.radio} on ${c.radioBg})`)
await shot(page, `inspector-${width}-${theme}`)
check(`${width}px(${theme}):页面没有报错`, errors.length === 0, errors.join(';'))
}
})
await browser.close()
console.log(failed ? `\n✗ 双盲复算与反方审稿:${failed} 项未通过` : '\n✓ 双盲复算与反方审稿:全部通过')
process.exit(failed ? 1 : 0)