mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:21:58 +00:00
双盲复算前端(三):检查器字段、节点卡片摘要,以及检查器与节点卡片的钩子
- 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:
@@ -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} />,
|
||||
}
|
||||
@@ -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') {
|
||||
|
||||
@@ -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 是最后的值)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 />,
|
||||
}
|
||||
|
||||
@@ -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: '已关闭',
|
||||
},
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user