mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 04:11:58 +00:00
运行详情「工件」页签:双盲复算对照、反方审稿记录、质询回答单独归到「核查记录」一组;窄屏上说明换到第二行,第几件和第几次尝试在截断范围外,悬停写全说明
分组:以前这三种都算进「证据快照」,那一组的说明却写着「查询、工具调用、知识检索的原始结果」。model.ts 加 artifactGroup(证据快照、核查记录、节点产出三组,按种类分),筛选多一档「核查记录」,件数、悬停说明跟着改:证据快照写上口径卡、报告文档和表结构,核查记录说清装的是什么、核查时执行的查询快照仍归在证据快照。别的种类的归类不变(isEvidence 改成只认证据快照这一组,认不出的种类照旧算证据快照)。口径卡里容器型变量输入的完整值(metric_input)以前露键名,补上名字「指标输入」和说明。 窄屏:以前一行里挤着种类、说明、校验值、大小和时刻,390px 时说明只剩三个字、序号也被截掉。行按列表这一栏的宽度排(容器查询,35rem 以下),窄时说明换到第二行占满整行,第一行留种类、校验值、大小和时刻(时刻和种类对齐);序号和第几次尝试从被截断的说明里挪出来;按钮的悬停写「打开某种工件:整句说明 #n」。容器设在列表上、不设在整个页签上,查看工件的弹窗(fixed 定位)不落进容器里。新文案放在 terms.ts 的 RUN_ARTIFACTS_TEXT。 新预览用例 ?syn=run-artifacts(虚构的门店周报,22 件,各种工件都有,含封存后追加的长问题质询回答)。check-verify:分组和 isEvidence 的口径、四档筛选的件数和悬停、点各档只剩该组;390px 深浅色下说明都在第二行、被截断的说明看得见至少 200px(以前 0 到 63px)、第几件整个落在行内、时刻在第一行和种类对齐、不横向溢出、对比度。check-inquiry:工件页签的读法改成按 data 属性,质询回答归在核查记录、悬停带整句说明
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
import { useMemo, useState, type JSX } from 'react'
|
||||
import { parseServerTime } from '../../lib/format'
|
||||
import { ArtifactsPane, type ArtifactList } from '../../pages/runs/ArtifactsPane'
|
||||
import type { RunArtifact } from '../../pages/runs/model'
|
||||
import { emptyTrace } from '../../run/trace'
|
||||
import type { GraphSpec, NodeType } from '../../types'
|
||||
|
||||
/**
|
||||
* 运行详情「工件」页签(pages/runs/ArtifactsPane)的预览用例,scripts/check-verify.mjs 按它检查分组和窄屏。
|
||||
*
|
||||
* - ?syn=run-artifacts:虚构的「门店周报」一次正式运行,各种工件都有:取数节点的查询快照、表结构快照和产出,循环里
|
||||
* 逐区汇总的三次产出(第二次是重试),口径卡的指标输入、指标集,写周报的报告文档,双盲复算、反方审稿各自执行的查询
|
||||
* 和对照、审稿记录,封存后追加的两条质询回答(一条问题很长)和质询执行的查询。宽窄、深浅色都用这一个,检查脚本换
|
||||
* 视口宽度和主题。
|
||||
*
|
||||
* 工件列表直接交给页签(不走接口);航迹的起止照这次运行的开始、结束时间给:运行期间产出的能跳到航迹里那一刻,封存后
|
||||
* 追加的只写时间。点过的跳转记在 data-fixture-moment 上。门店、片区、金额、数据源都是虚构的。
|
||||
*/
|
||||
|
||||
const STARTED = '2026-10-05T06:00:00'
|
||||
const FINISHED = '2026-10-05T06:12:00'
|
||||
|
||||
const NODES: [string, NodeType, string][] = [
|
||||
['pull', 'tool', '拉取门店销量'],
|
||||
['summarize', 'llm', '逐区汇总'],
|
||||
['caliber', 'metrics', '周报口径'],
|
||||
['write', 'report', '写周报'],
|
||||
['dual', 'dual_check', '双盲复算'],
|
||||
['review', 'challenge', '反方审稿'],
|
||||
]
|
||||
const GRAPH: GraphSpec = {
|
||||
nodes: NODES.map(([id, type, label], i) => ({ id, type, position: { x: i * 220, y: 0 }, data: { label, config: {} } })),
|
||||
edges: NODES.slice(1).map(([id], i) => ({ source: NODES[i][0], target: id })),
|
||||
}
|
||||
const LABELS = Object.fromEntries(NODES.map(([id, , label]) => [id, label]))
|
||||
|
||||
/** 虚构的内容校验值:64 位十六进制,各不相同 */
|
||||
const sha = (n: number) => (n * 2654435761 >>> 0).toString(16).padStart(8, '0').repeat(8)
|
||||
let seq = 0
|
||||
const art = (kind: string, node: string | null, at: string, size: number, meta: Record<string, any> | null = null): RunArtifact => (
|
||||
{ id: sha(++seq), kind, node_id: node, size, meta, created_at: `2026-10-05T${at}` })
|
||||
|
||||
/** 质询的问题:服务端只记前 40 字(engine/inquiry.py 的 QUESTION_BRIEF),满 40 字的说明会很长 */
|
||||
const LONG_QUESTION = '青禾片区的线上订单是按下单门店还是按收货地址归到片区的?如果按收货地址,跨片区配'
|
||||
|
||||
const RUN_ARTIFACTS: RunArtifact[] = [
|
||||
art('schema_snapshot', 'pull', '06:00:05', 3120, { source: '门店库', tables: 3 }),
|
||||
art('query_snapshot', 'pull', '06:00:10', 18_400, { source: '门店库', tables: ['orders', 'stores'] }),
|
||||
art('query_snapshot', 'pull', '06:00:20', 2_060, { source: '门店库', tables: ['refunds'] }),
|
||||
art('node_output', 'pull', '06:00:30', 1_210, { type: 'tool', attempt: 1 }),
|
||||
art('node_output', 'summarize', '06:01:00', 860, { type: 'llm', attempt: 1 }),
|
||||
art('node_output', 'summarize', '06:02:00', 910, { type: 'llm', attempt: 2 }),
|
||||
art('node_output', 'summarize', '06:03:00', 880, { type: 'llm', attempt: 1 }),
|
||||
art('metric_input', 'caliber', '06:04:00', 5_400, { path: 'vars.region_rows' }),
|
||||
art('metric_set', 'caliber', '06:04:30', 2_380, { caliber: '周报口径', version: 'v3' }),
|
||||
art('node_output', 'caliber', '06:04:40', 1_020, { type: 'metrics', attempt: 1 }),
|
||||
art('report_doc', 'write', '06:05:00', 16_820),
|
||||
art('node_output', 'write', '06:05:10', 4_310, { type: 'report', attempt: 1 }),
|
||||
art('query_snapshot', 'dual', '06:06:00', 1_480, { source: '门店库', tables: ['orders'] }),
|
||||
art('dual_check', 'dual', '06:06:30', 6_920, { metrics: 3 }),
|
||||
art('node_output', 'dual', '06:07:00', 740, { type: 'dual_check', attempt: 1 }),
|
||||
art('query_snapshot', 'review', '06:08:00', 1_150, { source: '门店库', tables: ['refunds'] }),
|
||||
art('query_snapshot', 'review', '06:09:00', 1_330, { source: '门店库', tables: ['orders'] }),
|
||||
art('challenge', 'review', '06:10:00', 8_640, { report: 'write' }),
|
||||
art('node_output', 'review', '06:10:30', 690, { type: 'challenge', attempt: 1 }),
|
||||
// 封存后追加的:审批质询(不挂在节点上)
|
||||
art('query_snapshot', null, '07:00:00', 1_260, { source: '门店库', tables: ['orders'] }),
|
||||
art('inquiry_answer', null, '07:01:00', 2_510, { report: 'write', unit: 'u3', question: LONG_QUESTION }),
|
||||
art('inquiry_answer', null, '07:20:00', 1_940, { report: 'write', unit: 'u5', question: '退货率怎么算的?' }),
|
||||
]
|
||||
|
||||
function RunArtifacts(): JSX.Element {
|
||||
const list: ArtifactList = useMemo(() => ({ items: RUN_ARTIFACTS, state: 'ok', error: null, reload: () => undefined }), [])
|
||||
const trace = useMemo(() => ({
|
||||
...emptyTrace(), phase: 'succeeded' as const,
|
||||
startedAt: parseServerTime(STARTED)?.getTime(), endedAt: parseServerTime(FINISHED)?.getTime(),
|
||||
}), [])
|
||||
const [moment, setMoment] = useState<number | null>(null)
|
||||
return (
|
||||
<div className="h-screen min-w-0" data-fixture="run-artifacts" data-fixture-moment={moment ?? ''}>
|
||||
<ArtifactsPane list={list} graph={GRAPH} trace={trace} labelOf={(id) => (id ? LABELS[id] : undefined)}
|
||||
onMoment={(at) => setMoment(at)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const cases: Record<string, () => JSX.Element> = {
|
||||
'run-artifacts': () => <RunArtifacts />,
|
||||
}
|
||||
@@ -3397,6 +3397,34 @@ export const RUN_COMPARE_TEXT = {
|
||||
notes: '说明',
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行详情「工件」页签(pages/runs/ArtifactsPane.tsx):这次运行按内容校验值存下的每一件东西,按节点分组。筛选按
|
||||
* pages/runs/model.ts 的 artifactGroup 分三组:证据快照是结论里数字的出处;核查记录是双盲复算、反方审稿、审批质询对
|
||||
* 结论的核查与问答,单独一组,不和数字的出处混在一起;节点产出是过程里的中间值。各种工件的名字和说明在 model.ts,
|
||||
* 后来新加的放在这里(质询回答的在 INQUIRY_TEXT)
|
||||
*/
|
||||
export const RUN_ARTIFACTS_TEXT = {
|
||||
filterGroup: '按类型筛选工件',
|
||||
filter: { all: '全部', evidence: '证据快照', verify: '核查记录', output: '节点产出' } as Record<string, string>,
|
||||
/** 筛选按钮的悬停:这一组装的是什么。核查时执行的查询按种类仍是查询快照,归在证据快照里,要说一声 */
|
||||
filterHint: {
|
||||
evidence: '查询、工具调用、知识检索的原始结果,以及口径卡、报告文档和查询时的表结构:结论里的数字从这里来',
|
||||
verify: '双盲复算的对照、反方审稿的记录、审批质询的回答:对结论的核查与问答。核查时执行的查询,快照归在证据快照里',
|
||||
output: '每个节点每执行一次的产出:循环里每一轮都有一件',
|
||||
} as Record<string, string>,
|
||||
/** 同一个节点同一类的第几件:循环里几十件节点产出要分得开 */
|
||||
nth: (n: number) => `#${n}`,
|
||||
attempt: (n: number) => `第 ${n} 次尝试`,
|
||||
/** 打开一件的悬停写全说明:窄屏、长问题的说明在行上会截断 */
|
||||
openTitle: (kind: string, about: string) => `打开${kind}:${about}(读取时按内容校验值重新校验)`,
|
||||
/**
|
||||
* 口径卡里容器型变量(vars.*)输入的完整值(engine/nodes/metrics.py 的 metric_input,工件行的 meta 里有 {path}):
|
||||
* 推演时拿它还原输入。和口径卡一样归在证据快照
|
||||
*/
|
||||
kindMetricInput: '指标输入',
|
||||
aboutMetricInput: (path?: string | null) => (path ? `口径卡输入「${path}」的完整值` : '口径卡输入的完整值'),
|
||||
}
|
||||
|
||||
/**
|
||||
* 可验证报告:把一次已封存运行的报告导出成一个 HTML 文件,收件人不连服务也能在浏览器里核对。
|
||||
* 入口在记录页的封存凭证条上;页面本身的文案在服务端 bundle/verifier.js 里(它是离开系统独立打开的文件)
|
||||
|
||||
@@ -4,12 +4,12 @@ import clsx from 'clsx'
|
||||
import { api } from '../../api/client'
|
||||
import { EmptyState, ErrorState, Skeleton, StatusBadge } from '../../components/ui'
|
||||
import { formatBytes, formatClock, formatDateTime, formatNumber, formatTime, parseServerTime } from '../../lib/format'
|
||||
import { nodeTypeLabel } from '../../lib/terms'
|
||||
import { RUN_ARTIFACTS_TEXT as T, nodeTypeLabel } from '../../lib/terms'
|
||||
import { ArtifactViewer } from '../../run/AssistantStream'
|
||||
import { topology } from '../../run/derive'
|
||||
import type { Trace } from '../../run/trace'
|
||||
import type { GraphSpec } from '../../types'
|
||||
import { artifactDescription, artifactKindLabel, isEvidence, type RunArtifact } from './model'
|
||||
import { artifactDescription, artifactGroup, artifactKindLabel, type ArtifactGroup, type RunArtifact } from './model'
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 数据
|
||||
@@ -65,22 +65,22 @@ export function useRunArtifacts(runId: string, refreshKey: string): ArtifactList
|
||||
// 页签
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
type KindFilter = 'all' | 'evidence' | 'output'
|
||||
const FILTER_LABEL: Record<KindFilter, string> = { all: '全部', evidence: '证据快照', output: '节点产出' }
|
||||
const FILTER_HINT: Record<KindFilter, string> = {
|
||||
all: '',
|
||||
evidence: '查询、工具调用、知识检索的原始结果:结论里的数字从这里来',
|
||||
output: '每个节点每执行一次的产出:循环里每一轮都有一件',
|
||||
}
|
||||
/** 筛选:全部,或者 model.ts 的三组之一(证据快照、核查记录、节点产出) */
|
||||
type KindFilter = 'all' | ArtifactGroup
|
||||
const FILTERS: readonly KindFilter[] = ['all', 'evidence', 'verify', 'output']
|
||||
/** 一次循环能有上千件:先列这么多,其余点了再展开 */
|
||||
const FIRST_ROWS = 300
|
||||
|
||||
/**
|
||||
* 工件:这次运行按内容哈希存下来的每一件东西——查询和工具调用的原始结果、每个
|
||||
* 节点每一次的产出。审阅出具物时从这里下钻到证据;打开时按哈希复验。
|
||||
* 工件:这次运行按内容哈希存下来的每一件东西——查询和工具调用的原始结果、核查留下的
|
||||
* 记录、每个节点每一次的产出。审阅出具物时从这里下钻到证据;打开时按哈希复验。
|
||||
*
|
||||
* 按节点分组、按执行顺序排,和航迹的泳道同一个顺序。能对上时刻的,一键跳到
|
||||
* 航迹里它产出的那一刻。
|
||||
*
|
||||
* 每一行按列表这一栏自己的宽度排(容器查询,不看视口:详情区在宽屏上也可能很窄)。窄时
|
||||
* 说明换到第二行占满整行,第一行留种类、校验值、大小和时刻;以前挤在一行里,390px
|
||||
* 宽时说明只剩三个字。序号和第几次尝试放在截断的范围外,按钮的悬停写全说明。
|
||||
*/
|
||||
export function ArtifactsPane({ list, graph, trace, labelOf, onMoment }: {
|
||||
list: ArtifactList
|
||||
@@ -96,17 +96,17 @@ export function ArtifactsPane({ list, graph, trace, labelOf, onMoment }: {
|
||||
const items = list.items
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const c: Record<KindFilter, number> = { all: 0, evidence: 0, output: 0 }
|
||||
const c: Record<KindFilter, number> = { all: 0, evidence: 0, verify: 0, output: 0 }
|
||||
for (const a of items ?? []) {
|
||||
c.all += 1
|
||||
c[isEvidence(a.kind) ? 'evidence' : 'output'] += 1
|
||||
c[artifactGroup(a.kind)] += 1
|
||||
}
|
||||
return c
|
||||
}, [items])
|
||||
const bytes = useMemo(() => (items ?? []).reduce((n, a) => n + (a.size ?? 0), 0), [items])
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const shown = (items ?? []).filter((a) => filter === 'all' || (filter === 'evidence') === isEvidence(a.kind))
|
||||
const shown = (items ?? []).filter((a) => filter === 'all' || artifactGroup(a.kind) === filter)
|
||||
const order = graph ? topology(graph).order : []
|
||||
const rank = new Map(order.map((id, i) => [id, i]))
|
||||
const byNode = new Map<string, RunArtifact[]>()
|
||||
@@ -144,14 +144,14 @@ export function ArtifactsPane({ list, graph, trace, labelOf, onMoment }: {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col" data-run-artifacts="">
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-x-3 gap-y-1 border-b px-4 py-1.5 text-2xs text-faint">
|
||||
<div className="flex gap-0.5" role="group" aria-label="按类型筛选工件">
|
||||
{(Object.keys(FILTER_LABEL) as KindFilter[]).map((k) => (
|
||||
<div className="flex flex-wrap gap-0.5" role="group" aria-label={T.filterGroup}>
|
||||
{FILTERS.map((k) => (
|
||||
<button
|
||||
key={k}
|
||||
type="button"
|
||||
aria-pressed={filter === k}
|
||||
disabled={k !== 'all' && !counts[k]}
|
||||
title={FILTER_HINT[k] || undefined}
|
||||
title={T.filterHint[k]}
|
||||
onClick={() => setFilter(k)}
|
||||
data-artifact-kind={k}
|
||||
className={clsx(
|
||||
@@ -159,7 +159,7 @@ export function ArtifactsPane({ list, graph, trace, labelOf, onMoment }: {
|
||||
filter === k ? 'border-[var(--border-strong)] bg-hover text-fg' : 'border-transparent hover:bg-hover hover:text-dim',
|
||||
)}
|
||||
>
|
||||
{FILTER_LABEL[k]} <span className="tnum">{counts[k]}</span>
|
||||
{T.filter[k]} <span className="tnum">{counts[k]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -169,7 +169,9 @@ export function ArtifactsPane({ list, graph, trace, labelOf, onMoment }: {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{/* 行按这一栏的宽度排(容器查询)。容器设在列表上、不设在整个页签上:查看工件的弹窗挂在页签里、是 fixed 定位,
|
||||
早期实现容器查询的浏览器给容器加布局隔离,会把它当成 fixed 元素的定位参照,弹窗就只盖住这一栏 */}
|
||||
<div className="@container min-h-0 flex-1 overflow-y-auto">
|
||||
{groups.map(([nodeId, rows]) => {
|
||||
if (budget <= 0) return null
|
||||
const take = rows.slice(0, budget)
|
||||
@@ -219,7 +221,8 @@ function Group({ nodeId, rows, total, graph, trace, labelOf, t0, t1, onOpen, onM
|
||||
style={{ background: node ? `var(--nt-${node.type}, var(--text-faint))` : 'var(--text-faint)' }} />
|
||||
<span className="min-w-0 truncate font-medium text-fg">{label}</span>
|
||||
{node && <span className="shrink-0 text-faint">{nodeTypeLabel(node.type)}</span>}
|
||||
{nodeId && label !== nodeId && <span className="mono shrink-0 text-faint">{nodeId}</span>}
|
||||
{/* 窄时让给名字:节点 id 是给对照画布用的,名字和类型已经认得出是哪个节点 */}
|
||||
{nodeId && label !== nodeId && <span className="mono shrink-0 text-faint @max-[35rem]:hidden">{nodeId}</span>}
|
||||
{state && <StatusBadge status={state} size={11} animate={false} />}
|
||||
<span className="flex-1" />
|
||||
<span className="tnum shrink-0 text-faint">{total} 件</span>
|
||||
@@ -228,32 +231,37 @@ function Group({ nodeId, rows, total, graph, trace, labelOf, t0, t1, onOpen, onM
|
||||
const n = (ordinal.get(a.kind) ?? 0) + 1
|
||||
ordinal.set(a.kind, n)
|
||||
const kind = artifactKindLabel(a.kind)
|
||||
const group = artifactGroup(a.kind)
|
||||
const about = artifactDescription(a.kind, a.meta)
|
||||
const attempt = Number(a.meta?.attempt)
|
||||
const created = parseServerTime(a.created_at ?? null)?.getTime()
|
||||
const at = t0 != null && created != null && created >= t0 && (t1 == null || created <= t1) ? created : null
|
||||
const numbered = (perKind.get(a.kind) ?? 0) > 1
|
||||
const title = `${label} · ${kind}${numbered ? ` #${n}` : ''}`
|
||||
// 第几件、第几次尝试:说明再长、截得再短也要看得见,放在截断的范围外
|
||||
const extra = [numbered ? T.nth(n) : '', attempt > 1 ? T.attempt(attempt) : ''].filter(Boolean).join(' · ')
|
||||
return (
|
||||
<div key={a.id} className="flex items-center gap-2 border-b px-4 py-1 last:border-0 hover:bg-hover"
|
||||
data-artifact-id={a.id}>
|
||||
<div key={a.id} className="flex items-center gap-2 border-b px-4 py-1 last:border-0 hover:bg-hover @max-[35rem]:items-start"
|
||||
data-artifact-id={a.id} data-artifact-class={group}>
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center gap-2 rounded py-0.5 text-left outline-none focus-visible:ring-1 focus-visible:ring-[var(--accent)]"
|
||||
className="flex min-w-0 flex-1 items-center gap-2 rounded py-0.5 text-left outline-none focus-visible:ring-1 focus-visible:ring-[var(--accent)] @max-[35rem]:flex-wrap @max-[35rem]:gap-y-0.5"
|
||||
onClick={() => onOpen({ id: a.id, title })}
|
||||
title={`打开${kind}(读取时按内容校验值重新校验)`}
|
||||
title={T.openTitle(kind, extra ? `${about} ${extra}` : about)}
|
||||
data-action="artifact-open"
|
||||
>
|
||||
<span className={clsx('chip shrink-0', isEvidence(a.kind) && 'text-fg')}
|
||||
style={isEvidence(a.kind) ? { borderColor: 'var(--border-strong)' } : undefined}>
|
||||
<span className={clsx('chip shrink-0', group !== 'output' && 'text-fg')}
|
||||
style={group !== 'output' ? { borderColor: 'var(--border-strong)' } : undefined}
|
||||
data-artifact-kind-label="">
|
||||
{kind}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-xs text-dim">
|
||||
{artifactDescription(a.kind, a.meta)}
|
||||
{numbered && <span className="tnum text-faint"> #{n}</span>}
|
||||
{attempt > 1 && <span className="text-faint"> · 第 {attempt} 次尝试</span>}
|
||||
{/* 宽时排在种类后面、占满中间;窄时换到第二行占满整行(order-last + basis-full),第一行留种类、校验值和大小 */}
|
||||
<span className="flex min-w-0 flex-1 items-baseline gap-1 text-xs text-dim @max-[35rem]:order-last @max-[35rem]:basis-full"
|
||||
data-artifact-about-line="">
|
||||
<span className="min-w-0 truncate" data-artifact-about="">{about}</span>
|
||||
{extra && <span className="tnum shrink-0 text-faint" data-artifact-nth="">{extra}</span>}
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<span className="mono shrink-0 text-2xs text-faint" title={`内容校验值 ${a.id}`}>{a.id.slice(0, 8)}</span>
|
||||
<span className="mono shrink-0 text-2xs text-faint @max-[35rem]:ml-auto" title={`内容校验值 ${a.id}`}>{a.id.slice(0, 8)}</span>
|
||||
<span className="tnum w-16 shrink-0 text-right text-2xs text-faint">{formatBytes(a.size)}</span>
|
||||
</button>
|
||||
{at != null && onMoment ? (
|
||||
@@ -263,7 +271,8 @@ function Group({ nodeId, rows, total, graph, trace, labelOf, t0, t1, onOpen, onM
|
||||
<History size={11} aria-hidden /> T+{formatClock(Math.round(at - t0!))}
|
||||
</button>
|
||||
) : (
|
||||
<span className="tnum w-[86px] shrink-0 text-right text-2xs text-faint" title={formatDateTime(a.created_at ?? null)}>
|
||||
<span className="tnum w-[86px] shrink-0 text-right text-2xs text-faint @max-[35rem]:mt-1"
|
||||
title={formatDateTime(a.created_at ?? null)} data-artifact-time="">
|
||||
{formatTime(a.created_at ?? null)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
RUN_STATUS_ORDER, STATUS, resolveStatus, serverStatusOf, type StatusCode,
|
||||
} from '../../lib/status'
|
||||
import { formatSpan, parseServerTime } from '../../lib/format'
|
||||
import { INQUIRY_TEXT } from '../../lib/terms'
|
||||
import { INQUIRY_TEXT, RUN_ARTIFACTS_TEXT } from '../../lib/terms'
|
||||
import { hasPendingApproval } from '../../store/catalog'
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -336,6 +336,7 @@ export const ARTIFACT_KIND_LABEL: Record<string, string> = {
|
||||
dual_check: '双盲复算对照',
|
||||
challenge: '反方审稿记录',
|
||||
inquiry_answer: INQUIRY_TEXT.artifactKind,
|
||||
metric_input: RUN_ARTIFACTS_TEXT.kindMetricInput,
|
||||
}
|
||||
export const artifactKindLabel = (kind: string): string => ARTIFACT_KIND_LABEL[kind] ?? kind
|
||||
|
||||
@@ -361,12 +362,25 @@ export function artifactDescription(kind: string, meta?: Record<string, any> | n
|
||||
// 审批质询的回答文档(工件行的 meta 里有 {report, unit, question: 问题的前 40 字}):不挂在节点上,靠问题认出是哪一条
|
||||
case 'inquiry_answer': return typeof meta?.question === 'string' && meta.question.trim()
|
||||
? INQUIRY_TEXT.artifactAbout(meta.question) : INQUIRY_TEXT.artifactAboutNone
|
||||
// 口径卡里容器型变量输入的完整值(工件行的 meta 里有 {path})
|
||||
case 'metric_input': return RUN_ARTIFACTS_TEXT.aboutMetricInput(typeof meta?.path === 'string' ? meta.path : null)
|
||||
default: return '工件'
|
||||
}
|
||||
}
|
||||
|
||||
/** 证据:查询、工具、检索的快照。节点产出是过程里的中间值,一次循环就是几十件 */
|
||||
export const isEvidence = (kind: string): boolean => kind !== 'node_output'
|
||||
/**
|
||||
* 工件页签上的三组:evidence 证据快照(查询、工具、检索的快照,口径卡、报告文档、表结构,结论里的数字从这里来);
|
||||
* verify 核查记录(双盲复算、反方审稿、审批质询对结论的核查与问答:不是数字的出处,以前混在证据快照里,和「原始结果」
|
||||
* 的说明对不上);output 节点产出(过程里的中间值,一次循环就是几十件)。按种类分:核查时执行的查询种类仍是查询快照,
|
||||
* 归在证据快照;认不出的种类也算证据快照,宁可醒目
|
||||
*/
|
||||
export type ArtifactGroup = 'evidence' | 'verify' | 'output'
|
||||
const VERIFY_KINDS: ReadonlySet<string> = new Set(['dual_check', 'challenge', 'inquiry_answer'])
|
||||
export const artifactGroup = (kind: string): ArtifactGroup =>
|
||||
kind === 'node_output' ? 'output' : VERIFY_KINDS.has(kind) ? 'verify' : 'evidence'
|
||||
|
||||
/** 证据快照这一组的(见 artifactGroup) */
|
||||
export const isEvidence = (kind: string): boolean => artifactGroup(kind) === 'evidence'
|
||||
|
||||
/** 三种时长写进 title:「总时长 1 分 27 秒 · 执行时长 7.6 s · 等待审批 1 分 14 秒」 */
|
||||
export function timingTitle(t: RunTiming): string {
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
// 不醒目、409 / 422 不照服务端的中文说明显示(或者问题文字丢了)、Esc 关不掉提问框(或者连带收起了别的、焦点掉到页面上)、
|
||||
// 已处理的审批和封存了的正式运行还给入口、封存了的探索运行反而没有;同一次运行的几处问答串要对的审批不同、审批列表
|
||||
// 取不到时后一处一直不列别的审批里问的(头上写着次数,下面一条没有),或者把没能确认的说成「报告其他版本」、不给重试;
|
||||
// 工件页签里质询的回答只写英文的种类名、截断截在空格上的问题摘要不补省略号、封存后追加的工件给一个跳到运行结束之后的
|
||||
// 航迹时刻;窄屏(390px)被撑出横向滚动、浅色主题下看不清。
|
||||
// 工件页签里质询的回答只写英文的种类名、不归在「核查记录」、截断截在空格上的问题摘要不补省略号、悬停里没有整句说明、
|
||||
// 封存后追加的工件给一个跳到运行结束之后的航迹时刻;窄屏(390px)被撑出横向滚动、浅色主题下看不清。
|
||||
//
|
||||
// 跑在预览页上:/preview.html?syn=inquiry-*(用例见 frontend/src/dev/fixtures/inquiry.tsx)。夹具在
|
||||
// frontend/src/dev/fixtures/inquiry.json,报告文档和回答文档是后端 build_catalog + compose_doc 真实组装的(虚构的数据)。
|
||||
@@ -835,12 +835,12 @@ await section('运行详情「证据」页签:封存了的探索运行有入
|
||||
await section('运行详情「工件」页签:质询的回答有名字和说明,不出现英文的工件种类名;封存后追加的不给航迹跳转', async () => {
|
||||
const { ctx, page, errors } = await open('inquiry-run-artifacts')
|
||||
await page.locator('[data-run-artifacts] [data-artifact-id]').first().waitFor()
|
||||
// 每一行:种类(行首的标签)、说明(同一种类有几件时末尾带「#n」,去掉再比)、悬停
|
||||
// 每一行:种类(行首的标签)、说明(同一种类有几件时的「#n」另在截断范围外,不在说明里)、悬停、分组
|
||||
const rows = await page.locator('[data-artifact-id]').evaluateAll((els) => els.map((el) => {
|
||||
const open = el.querySelector('[data-action="artifact-open"]')
|
||||
const spans = open ? [...open.children] : []
|
||||
return { id: el.getAttribute('data-artifact-id'), kind: spans[0]?.textContent?.trim() ?? '',
|
||||
about: (spans[1]?.textContent ?? '').replace(/\s*#\d+$/, '').trim(), title: open?.getAttribute('title') ?? '' }
|
||||
return { id: el.getAttribute('data-artifact-id'), kind: el.querySelector('[data-artifact-kind-label]')?.textContent?.trim() ?? '',
|
||||
about: el.querySelector('[data-artifact-about]')?.textContent?.trim() ?? '', title: open?.getAttribute('title') ?? '',
|
||||
group: el.getAttribute('data-artifact-class') }
|
||||
}))
|
||||
const answers = fx.run_artifacts.filter((a) => a.kind === 'inquiry_answer').map((a) => rows.find((r) => r.id === a.id))
|
||||
check('质询的回答:种类写「质询回答」', answers.length === 4 && answers.every((r) => r?.kind === '质询回答'), JSON.stringify(answers))
|
||||
@@ -864,7 +864,9 @@ await section('运行详情「工件」页签:质询的回答有名字和说
|
||||
await page.locator(`[data-artifact-id="${doc.id}"] [data-action="artifact-moment"]`).click()
|
||||
const jumped = await page.locator('[data-fixture="inquiry-run-artifacts"]').getAttribute('data-fixture-moment')
|
||||
check('……点报告文档的跳转:到它产出的那一刻', Number(jumped) === Date.parse(`${doc.created_at}Z`), jumped)
|
||||
check('悬停写「打开质询回答」', answers.every((r) => r?.title.startsWith('打开质询回答')), answers.map((r) => r?.title).join(' / '))
|
||||
check('悬停写「打开质询回答」,接着是整句说明(行上截断了也看得全)', answers.every((r) => r?.title.startsWith(`打开质询回答:${r.about}`)),
|
||||
answers.map((r) => r?.title).join(' / '))
|
||||
check('质询回答归在「核查记录」,不算证据快照', answers.every((r) => r?.group === 'verify'), answers.map((r) => r?.group).join('、'))
|
||||
const pane = await text(page.locator('[data-run-artifacts]'))
|
||||
check('工件页签里不出现英文的工件种类名', !/inquiry_answer|report_doc|query_snapshot/.test(pane) && rows.every((r) => !/[a-z_]{4,}/.test(r.kind)),
|
||||
rows.map((r) => r.kind).join('、'))
|
||||
@@ -1531,7 +1533,7 @@ await section('窄屏与深浅色', async () => {
|
||||
const row = `[data-artifact-id="${long.id}"] [data-action="artifact-open"]`
|
||||
await page.locator(row).waitFor()
|
||||
const o = await overflow(page, '[data-run-artifacts]')
|
||||
const ratios = { kind: await ratioOf(page, `${row} > span:nth-child(1)`), about: await ratioOf(page, `${row} > span:nth-child(2)`) }
|
||||
const ratios = { kind: await ratioOf(page, `${row} [data-artifact-kind-label]`), about: await ratioOf(page, `${row} [data-artifact-about]`) }
|
||||
check(`390px 工件页签(${theme}):质询回答的长说明截断、不横向溢出,种类和说明对比度 ≥ 4.5`,
|
||||
o.page <= 0 && o.box <= 0 && ratios.kind >= 4.5 && ratios.about >= 4.5,
|
||||
JSON.stringify({ o, kind: Number(ratios.kind.toFixed(2)), about: Number(ratios.about.toFixed(2)) }))
|
||||
|
||||
+163
-5
@@ -22,7 +22,9 @@
|
||||
// 标出,老工件照旧纯文本;
|
||||
// - 出具缺口:证据页的出具横幅(真的 EvidencePane 里那一个 IssuanceBanner,和问数据、画布右栏同一个)和成果节点印章的
|
||||
// 悬停说明把双盲复算、反方审稿报上来的缺口另起「核查未通过」「核查未完成」两组,不放在「校验未全部完成」下;
|
||||
// - 记录页的工件列表:双盲复算对照、反方审稿记录两种工件有名字和说明,不露键名;
|
||||
// - 记录页的工件列表:双盲复算对照、反方审稿记录两种工件有名字和说明,不露键名;和质询回答一起归在「核查记录」一组,
|
||||
// 不再算进说明写着「原始结果」的「证据快照」,筛选的件数、悬停说明跟着改;390px 窄屏上说明换到第二行(以前挤在一行里
|
||||
// 只剩三个字),第几件、第几次尝试在截断范围外看得见,悬停写全说明;
|
||||
// - 检查器:口径卡、指标、口径说明、盲算模型、相对误差、未采用原流程时、启用,每一项都真的写进节点配置;字段的
|
||||
// 叫法和服务端校验报错里的(engine/labels.py 的 DUAL_CHECK_FIELD_LABEL)一字不差;选口径卡、盲算模型、启用三态用的是
|
||||
// 和反方审稿共用的控件(verifyFields.tsx);盲算模型不藏在高级选项里
|
||||
@@ -32,8 +34,8 @@
|
||||
// - 390px 窄屏不横向溢出,深浅色都读得清,键盘能操作。
|
||||
//
|
||||
// 页面都跑在预览页(/preview.html?syn=…,用例在 frontend/src/dev/fixtures/dual_check.tsx,夹具 dual_check.json 两边
|
||||
// 共用)和设置页上,接口一律 page.route 伪造:完整对照的工件、审批回复、设置的读写都在浏览器里答,别的非 GET 拦掉,
|
||||
// 不碰真实的写接口。文案对照那一节不开浏览器:读 backend/app/api/settings.py、backend/app/engine/labels.py,terms.ts 借 vite
|
||||
// 共用;工件页签的用例在 run_artifacts.tsx,工件列表直接交给页签)和设置页上,接口一律 page.route 伪造:完整对照的工件、
|
||||
// 审批回复、设置的读写都在浏览器里答,别的非 GET 拦掉,不碰真实的写接口。文案对照那一节不开浏览器:读 backend/app/api/settings.py、backend/app/engine/labels.py,terms.ts 借 vite
|
||||
// 转译后直接 import。
|
||||
//
|
||||
// node scripts/check-verify.mjs
|
||||
@@ -1203,21 +1205,177 @@ await section('记录页的工件列表:双盲复算对照、反方审稿记
|
||||
const { page, errors } = await preview('dual-check-approval')
|
||||
const got = await page.evaluate(async () => {
|
||||
const m = await import('/src/pages/runs/model.ts')
|
||||
const kinds = ['dual_check', 'challenge', 'inquiry_answer', 'query_snapshot', 'tool_snapshot', 'retrieval_snapshot',
|
||||
'metric_set', 'metric_input', 'report_doc', 'schema_snapshot', 'node_output', 'some_future_kind']
|
||||
return {
|
||||
dual: m.artifactKindLabel('dual_check'), challenge: m.artifactKindLabel('challenge'),
|
||||
dualDesc: m.artifactDescription('dual_check', { metrics: 2 }), dualBare: m.artifactDescription('dual_check', null),
|
||||
challengeDesc: m.artifactDescription('challenge', { report: 'report' }),
|
||||
evidence: m.isEvidence('dual_check') && m.isEvidence('challenge'),
|
||||
input: m.artifactKindLabel('metric_input'), inputDesc: m.artifactDescription('metric_input', { path: 'vars.region_rows' }),
|
||||
inputBare: m.artifactDescription('metric_input', null),
|
||||
groups: Object.fromEntries(kinds.map((k) => [k, m.artifactGroup(k)])),
|
||||
evidence: Object.fromEntries(kinds.map((k) => [k, m.isEvidence(k)])),
|
||||
}
|
||||
})
|
||||
check('种类:双盲复算对照、反方审稿记录,不露键名', got.dual === '双盲复算对照' && got.challenge === '反方审稿记录', JSON.stringify(got))
|
||||
check('……说明写清装的是什么:双盲复算带指标个数,没有 meta 的老数据也有一句', got.dualDesc === '双盲复算:2 个指标两边的 SQL、取值与差异说明'
|
||||
&& got.dualBare === '双盲复算:两边的 SQL、取值与差异说明' && got.challengeDesc === '反方审稿:每句结论的判断、理由和反方执行的查询',
|
||||
JSON.stringify(got))
|
||||
check('……两种都算证据(列表里醒目)', got.evidence)
|
||||
// 核查记录:双盲复算、反方审稿、审批质询对结论的核查与问答。以前算进「证据快照」,那一组的说明写的是「查询、工具调用、
|
||||
// 知识检索的原始结果」,和装的东西对不上。别的种类的归类不变:核查时执行的查询仍是查询快照,认不出的种类宁可醒目
|
||||
const want = { dual_check: 'verify', challenge: 'verify', inquiry_answer: 'verify', node_output: 'output' }
|
||||
const wrong = Object.entries(got.groups).filter(([k, g]) => g !== (want[k] ?? 'evidence'))
|
||||
check('……双盲复算对照、反方审稿记录、质询回答归在「核查记录」;查询、工具、检索的快照,口径卡、报告文档、表结构仍是证据快照',
|
||||
wrong.length === 0, JSON.stringify(wrong))
|
||||
check('……isEvidence 只认证据快照这一组', Object.entries(got.evidence).every(([k, v]) => v === ((want[k] ?? 'evidence') === 'evidence')),
|
||||
JSON.stringify(got.evidence))
|
||||
check('……口径卡里容器型变量输入的完整值(metric_input)也有名字和说明,不露键名', got.input === '指标输入'
|
||||
&& got.inputDesc === '口径卡输入「vars.region_rows」的完整值' && got.inputBare === '口径卡输入的完整值', JSON.stringify(got))
|
||||
check('……页面没有报错', errors.length === 0, errors.join(';'))
|
||||
})
|
||||
|
||||
/** 工件页签里每一行:种类、说明、第几件、悬停、分组,和种类标签、说明、第几件、时刻各自的位置 */
|
||||
const artifactRows = (page) => page.locator('[data-run-artifacts] [data-artifact-id]').evaluateAll((els) => els.map((el) => {
|
||||
const rect = (n) => {
|
||||
if (!n) return null
|
||||
const r = n.getBoundingClientRect()
|
||||
return { left: r.left, right: r.right, top: r.top, bottom: r.bottom, width: r.width }
|
||||
}
|
||||
const box = (sel) => rect(el.querySelector(sel))
|
||||
const about = el.querySelector('[data-artifact-about]')
|
||||
return {
|
||||
id: el.getAttribute('data-artifact-id'), group: el.getAttribute('data-artifact-class'),
|
||||
kind: el.querySelector('[data-artifact-kind-label]')?.textContent?.trim() ?? '',
|
||||
about: about?.textContent?.trim() ?? '', nth: el.querySelector('[data-artifact-nth]')?.textContent?.trim() ?? '',
|
||||
title: el.querySelector('[data-action="artifact-open"]')?.getAttribute('title') ?? '',
|
||||
// 说明看得见的宽度、是否被截断(scrollWidth 比看得见的宽)
|
||||
visible: about ? Math.round(about.getBoundingClientRect().width) : 0, cut: about ? about.scrollWidth > about.clientWidth + 1 : false,
|
||||
row: rect(el), chip: box('[data-artifact-kind-label]'), line: box('[data-artifact-about-line]'),
|
||||
nthBox: box('[data-artifact-nth]'), hash: box('[title^="内容校验值"]'),
|
||||
when: box('[data-action="artifact-moment"]') ?? box('[data-artifact-time]'),
|
||||
}
|
||||
}))
|
||||
|
||||
await section('记录页的工件页签:核查记录单独一组,筛选的件数和说明跟着改', async () => {
|
||||
// 夹具(frontend/src/dev/fixtures/run_artifacts.tsx):22 件,证据快照 10 件(查询快照 6、表结构、指标输入、口径卡、
|
||||
// 报告文档),核查记录 4 件(双盲复算对照、反方审稿记录、两条质询回答),节点产出 8 件
|
||||
const { page, errors } = await preview('run-artifacts')
|
||||
await page.locator('[data-run-artifacts] [data-artifact-id]').first().waitFor()
|
||||
const filters = await page.locator('[data-run-artifacts] [data-artifact-kind]').evaluateAll((els) => els.map((el) => ({
|
||||
key: el.getAttribute('data-artifact-kind'), text: el.textContent.replace(/\s+/g, ' ').trim(), title: el.getAttribute('title') ?? '',
|
||||
})))
|
||||
check('筛选:全部、证据快照、核查记录、节点产出四档,件数对得上', JSON.stringify(filters.map((f) => f.text))
|
||||
=== JSON.stringify(['全部 22', '证据快照 10', '核查记录 4', '节点产出 8']), filters.map((f) => f.text).join('、'))
|
||||
const hint = Object.fromEntries(filters.map((f) => [f.key, f.title]))
|
||||
check('「核查记录」的悬停说清装的是双盲复算、反方审稿、审批质询,核查时执行的查询在证据快照里',
|
||||
['双盲复算', '反方审稿', '审批质询', '证据快照'].every((w) => hint.verify?.includes(w)), hint.verify)
|
||||
check('「证据快照」的悬停不再只说「原始结果」:口径卡、报告文档、表结构也写上,不提核查',
|
||||
['查询', '口径卡', '报告文档', '表结构'].every((w) => hint.evidence?.includes(w)) && !/核查|复算|审稿|质询/.test(hint.evidence ?? ''),
|
||||
hint.evidence)
|
||||
const all = await artifactRows(page)
|
||||
check('「全部」列出 22 件,每件都标了分组', all.length === 22 && all.every((r) => ['evidence', 'verify', 'output'].includes(r.group)),
|
||||
`${all.length} 件`)
|
||||
check('种类都是中文,不露键名(metric_input 写「指标输入」)', all.every((r) => r.kind && !/[a-z_]{4,}/.test(r.kind)),
|
||||
[...new Set(all.map((r) => r.kind))].join('、'))
|
||||
await page.locator('[data-run-artifacts] [data-artifact-kind="verify"]').click()
|
||||
const verify = await artifactRows(page)
|
||||
check('点「核查记录」:只剩双盲复算对照、反方审稿记录、质询回答 4 件', verify.length === 4 && verify.every((r) => r.group === 'verify')
|
||||
&& JSON.stringify([...new Set(verify.map((r) => r.kind))].sort()) === JSON.stringify(['双盲复算对照', '反方审稿记录', '质询回答'].sort()),
|
||||
verify.map((r) => r.kind).join('、'))
|
||||
check('……按节点分组:双盲复算、反方审稿各一组,质询回答在「不属于某个节点」', JSON.stringify(await page.locator('[data-run-artifacts] [data-artifact-group]')
|
||||
.evaluateAll((els) => els.map((e) => e.getAttribute('data-artifact-group')))) === JSON.stringify(['dual', 'review', '']))
|
||||
await page.locator('[data-run-artifacts] [data-artifact-kind="evidence"]').click()
|
||||
const evidence = await artifactRows(page)
|
||||
check('点「证据快照」:10 件,没有核查记录混在里面(核查时执行的查询快照照旧在这里)', evidence.length === 10
|
||||
&& evidence.every((r) => r.group === 'evidence') && !evidence.some((r) => /双盲复算对照|反方审稿记录|质询回答/.test(r.kind))
|
||||
&& evidence.filter((r) => r.kind === '查询快照').length === 6, evidence.map((r) => r.kind).join('、'))
|
||||
await page.locator('[data-run-artifacts] [data-artifact-kind="output"]').click()
|
||||
const output = await artifactRows(page)
|
||||
check('点「节点产出」:8 件,都是节点产出', output.length === 8 && output.every((r) => r.group === 'output' && r.kind === '节点产出'),
|
||||
output.map((r) => r.kind).join('、'))
|
||||
await page.locator('[data-run-artifacts] [data-artifact-kind="all"]').click()
|
||||
// 宽屏一行排下:说明和种类在同一行,第几件紧跟说明
|
||||
const wide = await artifactRows(page)
|
||||
const looped = wide.filter((r) => r.nth)
|
||||
check('宽屏(1280):说明和种类在同一行', wide.every((r) => r.line && r.chip && Math.abs(r.line.top - r.chip.top) < 8),
|
||||
JSON.stringify(wide.filter((r) => !(r.line && r.chip && Math.abs(r.line.top - r.chip.top) < 8)).map((r) => r.kind)))
|
||||
check('宽屏:逐区汇总的三次产出编了号,重试的那次写明第 2 次尝试', JSON.stringify(looped.filter((r) => r.kind === '节点产出').map((r) => r.nth))
|
||||
=== JSON.stringify(['#1', '#2 · 第 2 次尝试', '#3']), looped.map((r) => `${r.kind}${r.nth}`).join('、'))
|
||||
check('宽屏:短的说明不截断', wide.filter((r) => [...r.about].length <= 20).every((r) => !r.cut),
|
||||
wide.filter((r) => [...r.about].length <= 20 && r.cut).map((r) => r.about).join('、'))
|
||||
check('悬停写全说明(打开哪一种、装的是什么、第几件)', wide.every((r) => r.title.startsWith(`打开${r.kind}:${r.about}`)
|
||||
&& (!r.nth || r.title.includes(r.nth))), wide.find((r) => !r.title.startsWith(`打开${r.kind}:${r.about}`))?.title)
|
||||
check('页面没有报错', errors.length === 0, errors.join(';'))
|
||||
})
|
||||
|
||||
await section('记录页的工件页签:390px 说明换到第二行,第几件在截断范围外,不横向溢出、深浅色可读', async () => {
|
||||
for (const theme of ['dark', 'light']) {
|
||||
const { page, errors } = await preview('run-artifacts', { width: 390, height: 844, theme })
|
||||
await page.locator('[data-run-artifacts] [data-artifact-id]').first().waitFor()
|
||||
const rows = await artifactRows(page)
|
||||
const o = await page.evaluate(() => {
|
||||
const doc = document.documentElement
|
||||
const pane = document.querySelector('[data-run-artifacts]')
|
||||
const scroller = pane?.querySelector('.overflow-y-auto')
|
||||
return { page: doc.scrollWidth - doc.clientWidth, pane: pane ? pane.scrollWidth - pane.clientWidth : -1,
|
||||
list: scroller ? scroller.scrollWidth - scroller.clientWidth : -1 }
|
||||
})
|
||||
check(`390px(${theme}):页签不横向溢出`, o.page <= 0 && o.pane <= 0 && o.list <= 0, JSON.stringify(o))
|
||||
// 说明在种类下面另起一行;以前和种类、校验值、大小、时刻挤在一行,只剩三个字宽(约 35px)
|
||||
const below = rows.filter((r) => r.line && r.chip && r.line.top >= r.chip.bottom - 1)
|
||||
check(`390px(${theme}):每一行的说明都换到种类下面的第二行`, below.length === rows.length,
|
||||
rows.filter((r) => !below.includes(r)).map((r) => r.kind).join('、'))
|
||||
const widths = rows.map((r) => r.visible)
|
||||
const narrowest = rows.filter((r) => r.cut).reduce((m, r) => Math.min(m, r.visible), Infinity)
|
||||
check(`390px(${theme}):说明看得见的宽度:被截断的至少 200px,其余整句看得全`,
|
||||
rows.every((r) => !r.cut || r.visible >= 200), `截断的最窄 ${narrowest === Infinity ? '—' : `${narrowest}px`};各行 ${widths.join('/')}`)
|
||||
const long = rows.find((r) => r.kind === '质询回答' && r.about.endsWith('…」的回答'))
|
||||
check(`390px(${theme}):长问题的质询回答截断了,悬停里是整句说明`, !!long && long.cut && long.title.includes(long.about),
|
||||
long ? `${long.visible}px · ${long.title}` : '没找到长问题的那一条')
|
||||
// 第几件:放在截断范围外,整个落在这一行里、看得见
|
||||
const numbered = rows.filter((r) => r.nth)
|
||||
check(`390px(${theme}):第几件、第几次尝试都看得见(不在被截断的说明里,不出这一行)`, numbered.length >= 5
|
||||
&& numbered.every((r) => r.nthBox && r.nthBox.width > 0 && r.nthBox.right <= r.row.right + 0.5 && r.nthBox.left >= r.row.left),
|
||||
numbered.map((r) => `${r.kind}${r.nth}`).join('、'))
|
||||
// 第一行:种类、校验值、大小,时刻(或时间)和它们在同一行
|
||||
check(`390px(${theme}):时刻(或时间)留在第一行,和种类对齐`, rows.every((r) => r.when && r.chip
|
||||
&& Math.abs((r.when.top + r.when.bottom) / 2 - (r.chip.top + r.chip.bottom) / 2) <= 4),
|
||||
JSON.stringify(rows.filter((r) => !(r.when && r.chip && Math.abs((r.when.top + r.when.bottom) / 2 - (r.chip.top + r.chip.bottom) / 2) <= 4))
|
||||
.map((r) => [r.kind, r.when, r.chip]).slice(0, 2)))
|
||||
check(`390px(${theme}):内容校验值还在第一行`, rows.every((r) => r.hash && r.chip && r.hash.bottom <= r.line.top + 1))
|
||||
const c = await page.evaluate(() => {
|
||||
const base = getComputedStyle(document.body).backgroundColor
|
||||
const pick = (sel) => {
|
||||
const el = document.querySelector(sel)
|
||||
if (!el) return null
|
||||
let bg = 'rgba(0, 0, 0, 0)'
|
||||
for (let n = el; n && n !== document.documentElement; n = n.parentElement) {
|
||||
const v = getComputedStyle(n).backgroundColor
|
||||
if (v !== 'rgba(0, 0, 0, 0)' && v !== 'transparent') { bg = v; break }
|
||||
}
|
||||
return { fg: getComputedStyle(el).color, bg }
|
||||
}
|
||||
return { base, kind: pick('[data-artifact-class="verify"] [data-artifact-kind-label]'),
|
||||
about: pick('[data-artifact-class="verify"] [data-artifact-about]'), nth: pick('[data-artifact-nth]'),
|
||||
filter: pick('[data-artifact-kind="verify"]') }
|
||||
})
|
||||
for (const key of ['kind', 'about', 'nth', 'filter']) {
|
||||
const v = c[key]
|
||||
const ratio = v ? contrast(v.fg, v.bg, c.base) : 0
|
||||
check(`${theme}:${({ kind: '种类', about: '说明', nth: '第几件', filter: '「核查记录」筛选' })[key]}对比度 ≥ 4.5`, ratio >= 4.5,
|
||||
`${ratio.toFixed(2)}(${v?.fg} on ${v?.bg})`)
|
||||
}
|
||||
const bar = await page.locator('[data-run-artifacts] [data-artifact-kind]').evaluateAll((els) =>
|
||||
els.every((el) => { const r = el.getBoundingClientRect(); return r.left >= 0 && r.right <= innerWidth && r.width > 0 }))
|
||||
check(`390px(${theme}):四个筛选按钮都在屏幕里`, bar)
|
||||
await shot(page, `run-artifacts-narrow-${theme}`)
|
||||
check(`390px(${theme}):页面没有报错`, errors.length === 0, errors.join(';'))
|
||||
}
|
||||
const wide = await preview('run-artifacts', { width: 1280, height: 900, theme: 'light' })
|
||||
await wide.page.locator('[data-run-artifacts] [data-artifact-id]').first().waitFor()
|
||||
await shot(wide.page, 'run-artifacts-wide-light')
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- 检查器与节点卡片
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user