运行对比页(前端):两次运行的指标变化按数据、口径、模板与 SQL 拆开讲

- 运行详情「更多」加「与上一次对比」:先问 compare/base 找默认基准再跳到对比页;没封存、还在运行的不给点;
  找不到基准时留在原页,提示服务端给的原因,并可去对比页手动选择基准
- 对比页(/runs/compare?target=…&base=…):顶部两次运行(名字、时间、类别与版本、定时来源、未封存标记,可点进
  各自详情)、互换、换基准下拉(运行列表接口按工作流取,未封存的列出但不能选);摘要条八类的个数照服务端的
  summary,点一类只看这一类;指标表用服务端渲染值,能严格拆分的并列口径效应和数据效应、画两截条形,并写明两者
  之和等于总变化,不能拆的写服务端给的原因;展开看变化说明、输入对比、维度归因前 10 项(没有占比时写原因);
  查询区标结构状态,只参数变列出字面量,结构变并排两条 SQL 逐词标出各自独有的部分;模板区写版本、变化的节点和
  配置项;说明照实列出
- 加载、找基准、409(未封存或封存核对不一致)、422(不是同一工作流)、404、找不到基准、空对比都写清楚;请求按
  「本次 + 基准」取,换基准、互换时丢掉过时的答复,开发模式的 StrictMode 下同一份对比也只请求一次
- 窄屏表格在自己的框里横向滚动(指标名一栏钉住,展开的明细按可见宽度排),深浅色下分类标签、差异高亮对比度都
  在 4.5 以上
- check-compare.mjs 换成真检查:page.route 假接口覆盖入口、摘要、分类、拆分、归因、SQL 并排、换基准与互换、
  过时答复、各类错误、窄屏和深浅色
This commit is contained in:
ThearchyHelios
2026-10-05 08:43:32 +08:00
parent 26595b70f7
commit e6c77ea4ee
16 changed files with 2310 additions and 18 deletions
+20 -2
View File
@@ -1,7 +1,25 @@
/**
* 运行对比的接口(/api/runs/{run_id}/…)。现在是空的,运行对比的工作包往里加,挂在 api.compare 上。
* 运行对比的接口(/api/runs/{run_id}/compare…),挂在 api.compare 上。两个接口都只读:比较结果服务端另存工件,
* 不写运行记录。
*
* 请求函数(get / post / del……)和 ApiError 从 ./http 导入,不要从 ./client 导入:client.ts 要导入
* 这个文件把它挂到 api 上,反过来导入就成了循环(见 http.ts 开头的说明)。
*/
export const compare = {}
import type { RunCompareBase, RunComparison } from '../types'
import { get, type RequestOptions } from './http'
const run = (id: string) => `/runs/${encodeURIComponent(id)}`
export const compare = {
/**
* 默认的基准:同一定时任务的上一次运行,没有就同一工作流、同一运行类别的上一次。找不到时回 404,ApiError 的
* message 是给人看的原因
*/
base: (runId: string, opts?: RequestOptions) => get<RunCompareBase>(`${run(runId)}/compare/base`, opts),
/**
* 两次运行的对比(runId 是本次)。任一未封存或封存核对不一致回 409;不是同一工作流、或者是同一次运行回 422;
* 运行不存在回 404。detail 都是一句中文。signal 用来丢掉换了基准之后过时的请求
*/
get: (runId: string, base: string, opts?: RequestOptions) =>
get<RunComparison>(`${run(runId)}/compare?${new URLSearchParams({ base })}`, opts),
}
+156 -4
View File
@@ -2740,12 +2740,164 @@ export const SCHEDULE_TEXT = {
pendingHint: '定时任务尚未开放,完成后可在此处管理',
}
/** 运行对比:记录页里两次运行的对照。配方前后对照另有 COMPARE_TEXT,别混用 */
/**
* 运行对比(/runs/compare,pages/runs/ComparePage.tsx):两次运行的指标变化拆成数据、口径、模板与 SQL 三类说清楚。
* 配方前后对照另有 COMPARE_TEXT,别混用。分类、拆分原因、说明(reasons、decomposition_note、notes)是服务端写的话,
* 原样显示,这里不改写
*/
export const RUN_COMPARE_TEXT = {
title: '运行对比',
hint: '对照两次运行的结论与数据',
pending: PENDING_TEXT,
pendingHint: '运行对比尚未开放,完成后可从记录页选择两次运行进行对照',
hint: '把两次运行的指标变化拆成数据、口径、模板与 SQL 三类',
// 入口:运行详情的「更多」菜单
menu: '与上一次对比',
menuSealedOnly: '运行结束并封存后才能对比',
menuFinding: '正在查找上一次运行…',
pickManually: '手动选择基准',
// 顶部的两次运行
base: '基准',
target: '本次',
swap: '互换',
swapHint: '互换基准与本次',
openRun: (name: string) => `查看「${name}」这次运行的详情`,
scheduled: '定时任务发起',
unsealed: '未封存',
unsealedHint: '这次运行没有封存清单,不能参与对比',
runFailed: '无法读取这次运行',
noBase: '未选择',
reason: {
same_schedule: '默认基准:同一定时任务的上一次运行',
same_workflow: '默认基准:同一工作流、同一运行类别的上一次运行',
} as Record<string, string>,
pickBase: '更换基准',
pickHint: '同一工作流最近的运行,未封存的不能作为基准',
pickLoading: '正在读取同一工作流的运行…',
pickFailed: '无法读取同一工作流的运行',
pickNone: '同一工作流没有其他运行',
pickUnsaved: '临时构建的工作流没有其他运行可选',
pickPlaceholder: '选择一次运行作为基准',
/** 下拉里的一次运行:时间 · 类别和版本 · 短 id,没封存的另标 */
pickOption: (time: string, cls: string, id: string, sealed: boolean) =>
`${time} · ${cls} · ${id}${sealed ? '' : '(未封存)'}`,
// 页面状态
noTarget: '未指定要对比的运行',
noTargetHint: '在运行详情的「更多」菜单中选择「与上一次对比」,即可对照本次与上一次运行',
toRuns: '前往记录',
finding: '正在查找基准运行…',
loading: '正在对比两次运行…',
errSealed: '无法对比:有运行未封存或封存核对未通过',
errSealedHint: '两次运行都需已结束并封存,且封存核对一致。可在运行详情的封存凭证条上核对',
errMismatch: '无法对比这两次运行',
errMismatchHint: '请选择同一工作流的另一次运行作为基准',
errMissing: '运行记录不存在',
errMissingHint: '链接可能已失效,请从记录页重新打开',
errNoBase: '未找到可作为基准的运行',
errNoBaseHint: '可在上方选择同一工作流的其他运行作为基准',
viewBase: '查看基准运行',
viewTarget: '查看本次运行',
// 摘要
summary: '变化摘要',
summaryHint: '点击一类只看这一类的指标',
classLabel: {
data_only: '只数据变',
caliber_and_data: '口径与数据都变',
sql_changed: 'SQL 结构变',
params_only: '只参数变',
added: '新增',
removed: '移除',
incomparable: '无法比较',
unchanged: '未变',
} as Record<string, string>,
classHint: {
data_only: '口径与 SQL 都没有改动,变化来自数据',
caliber_and_data: '口径卡的计算式有改动,数据也变了;能严格拆分的分别给出口径效应和数据效应',
sql_changed: '所依据的查询 SQL 结构有改动,口径不同,不做拆分',
params_only: '所依据的查询 SQL 结构相同,只有参数(如日期)不同',
added: '本次有、基准没有的指标',
removed: '基准有、本次没有的指标',
incomparable: '缺少比较所需的信息,无法判断变化来自哪里',
unchanged: '两次的值相同,口径与 SQL 也没有改动',
} as Record<string, string>,
classOther: '其他',
filterOn: (label: string, n: number) => `只显示「${label}」的 ${formatNumber(n)} 个指标`,
showAll: '显示全部',
// 指标
metrics: '指标',
metricsEmpty: '两次运行的口径卡上都没有指标',
count: (n: number) => `${formatNumber(n)} 个`,
colMetric: '指标',
colBase: '基准',
colTarget: '本次',
colDelta: '变化',
colClass: '分类',
colSplit: '拆分或原因',
legend: '口径效应按本次的数据计算,是只换口径带来的变化;数据效应按基准的口径计算,是只换数据带来的变化。两者之和等于总变化',
caliber: '口径',
data: '数据',
splitSum: (caliber: string, data: string, total: string) =>
`口径效应 ${caliber},数据效应 ${data},两者之和等于总变化 ${total}`,
noChange: '无变化',
expand: (name: string) => `展开「${name}」的输入对比与维度归因`,
collapse: (name: string) => `收起「${name}」的明细`,
reasons: '变化说明',
inputs: '输入对比',
inputsNone: '口径卡没有记录这个指标的输入',
colInput: '输入',
attribution: '维度归因',
attrBy: (measure: string) => `按「${measure}」的变化,从大到小`,
attrTop: (n: number) => `前 ${formatNumber(n)} 项`,
attrMore: (n: number) => `另有 ${formatNumber(n)} 项未列出`,
attrTotal: (value: string) => `合计变化 ${value}`,
attrNone: '没有可按维度归因的明细',
/** 服务端没给占比、也没说为什么时的说法 */
attrNoShare: '无法计算各项占比',
colKey: '维度',
colShare: '占比',
// 查询
queries: '查询',
queriesEmpty: '两次运行都没有执行查询',
structure: {
same: '相同',
params_only: '只参数变',
changed: '结构变',
added: '新增',
removed: '移除',
} as Record<string, string>,
structureHint: {
same: 'SQL 完全相同',
params_only: 'SQL 结构相同,只有字面量不同',
changed: 'SQL 结构不同:表、连接、筛选条件、分组或聚合有改动',
added: '本次新增的查询,基准中没有',
removed: '基准中有、本次没有的查询',
} as Record<string, string>,
nth: (n: number) => `第 ${n} 条`,
rows: (n: number | null | undefined, truncated: boolean | null | undefined) =>
(n == null ? '—' : `${formatNumber(n)} 行${truncated ? '(已截断)' : ''}`),
rowsBoth: (base: string, target: string) => `基准 ${base} → 本次 ${target}`,
rowsBase: (base: string) => `基准 ${base}`,
rowsTarget: (target: string) => `本次 ${target}`,
literals: '变化的参数',
baseSql: '基准的 SQL',
targetSql: '本次的 SQL',
bothSql: '两次相同的 SQL',
onlyBase: '基准独有',
onlyTarget: '本次独有',
showSql: '查看 SQL',
hideSql: '收起 SQL',
diffCoarse: '两条 SQL 差异较大,只标出了大致范围',
// 模板
template: '模板',
version: (v: number | null | undefined) => (v == null ? '未固定版本' : `v${v}`),
versions: (base: string, target: string) => `基准 ${base} → 本次 ${target}`,
sameGraph: '两次运行使用同一张执行图',
nodesNone: '执行图有变化,但没有列出具体节点',
nodeChange: {
added: '新增节点',
removed: '移除节点',
config: '配置变更',
} as Record<string, string>,
keys: '配置项',
// 说明
notes: '说明',
}
/**
+98 -6
View File
@@ -1,21 +1,113 @@
import { useEffect, useState } from 'react'
import { Link, useLocation, useNavigate, useSearchParams } from 'react-router-dom'
import { GitCompareArrows } from 'lucide-react'
import { EmptyState, PageHeader } from '../../components/ui'
import { EmptyState, PageHeader, Skeleton, Spinner } from '../../components/ui'
import { RUN_COMPARE_TEXT } from '../../lib/terms'
import type { RunComparison } from '../../types'
import { useCandidates, useComparison, useDefaultBase, useRunRecord } from './compare/data'
import { comparePath, type CompareVia } from './compare/entry'
import { Metrics } from './compare/Metrics'
import { compareErrorKind } from './compare/model'
import { CompareError } from './compare/parts'
import { Queries } from './compare/Queries'
import { RunPair } from './compare/RunPair'
import { Summary } from './compare/Summary'
import { NotesSection, TemplateSection } from './compare/Template'
/**
* 运行对比(/runs/compare)。现在是占位页,运行对比的工作包补上内容。
* 运行对比(/runs/compare?target=<本次>&base=<基准>):两次运行的指标变化拆成数据、口径、模板与 SQL 三类讲清楚,
* 能看维度归因和查询差异(GET /api/runs/{本次}/compare?base=)。
*
* 地址就是状态:本次和基准都在查询串里,换基准、互换都是改地址(replace,不往历史里堆),对比跟着重新请求,慢的
* 旧答复回来也不认。只带本次时先问默认的基准(compare/base),找到了补进地址并写明是怎么选的;找不到照实说,
* 下拉照样能手动挑。从运行详情「与上一次对比」进来时,菜单已经问过默认基准,来历跟着跳转带过来,这里不再问。
*
* 挂在 /runs 下面,导航照旧点亮「记录」;路由表里排在 /runs/:runId 之前,「compare」不会被当成运行 id。
*/
export function ComparePage() {
const navigate = useNavigate()
const location = useLocation()
const [params] = useSearchParams()
const target = params.get('target') || null
const base = params.get('base') || null
const via = location.state as CompareVia | null
const finder = useDefaultBase(target && !base ? target : null)
// 默认基准的来历:菜单带过来的,或者这里自己找到的;只对那一个基准有效,换了就不写
const reason = via && via.base === base ? via.reason : null
const found = finder.state === 'ok' ? finder.data : undefined
useEffect(() => {
if (!target || base || !found) return
const next: CompareVia = { base: found.base_run_id, reason: found.reason }
navigate(comparePath(target, found.base_run_id), { replace: true, state: next })
}, [target, base, found, navigate])
const targetRun = useRunRecord(target)
const baseRun = useRunRecord(base)
const workflowId = targetRun.data?.workflow_id
const candidates = useCandidates(workflowId)
const cmp = useComparison(target, base)
const go = (t: string, b: string) => navigate(comparePath(t, b), { replace: true })
let state: 'no-target' | 'finding' | 'loading' | 'error' | 'ok'
if (!target) state = 'no-target'
else if (!base) state = finder.state === 'error' ? 'error' : 'finding'
else state = cmp.state === 'ok' ? 'ok' : cmp.state === 'error' ? 'error' : 'loading'
return (
<div className="flex h-full flex-col">
<PageHeader icon={<GitCompareArrows size={13} />} title={RUN_COMPARE_TEXT.title} subtitle={RUN_COMPARE_TEXT.hint} />
<div className="min-h-0 flex-1 overflow-y-auto">
{/* 功能没做出来,和连没连上服务端无关:不走空态的离线说法 */}
<EmptyState icon={<GitCompareArrows size={22} />} title={RUN_COMPARE_TEXT.pending}
body={RUN_COMPARE_TEXT.pendingHint} offline={false} />
<div className="min-h-0 flex-1 overflow-y-auto" data-compare-page="" data-compare-state={state}>
{!target ? (
<EmptyState icon={<GitCompareArrows size={22} />} title={RUN_COMPARE_TEXT.noTarget} body={RUN_COMPARE_TEXT.noTargetHint}
offline={false} action={<Link className="btn btn-sm" to="/runs">{RUN_COMPARE_TEXT.toRuns}</Link>} />
) : (
<div className="mx-auto flex w-full min-w-0 max-w-6xl flex-col gap-3 px-3 py-3 sm:px-4 sm:py-4">
<RunPair
target={target} base={base} targetRun={targetRun} baseRun={baseRun} candidates={candidates}
unsaved={targetRun.state === 'ok' && !workflowId} reason={reason}
onPick={(b) => go(target, b)} onSwap={() => { if (base) go(base, target) }}
/>
{state === 'finding' && <Pending text={RUN_COMPARE_TEXT.finding} />}
{state === 'loading' && <Pending text={RUN_COMPARE_TEXT.loading} />}
{state === 'error' && (
<CompareError
kind={compareErrorKind(base ? cmp.error : finder.error, !base)}
error={base ? cmp.error : finder.error}
onRetry={base ? cmp.reload : finder.reload}
target={target} base={base}
/>
)}
{state === 'ok' && cmp.data && <Comparison key={`${target}|${base}`} data={cmp.data} />}
</div>
)}
</div>
</div>
)
}
/** 找基准、对比的请求在路上:一句在做什么,下面是占位的骨架 */
function Pending({ text }: { text: string }) {
return (
<div className="flex flex-col gap-3" data-compare-loading="" aria-busy="true">
<div role="status" className="flex items-center gap-2 text-xs text-faint"><Spinner size={12} /> {text}</div>
<Skeleton rows={1} cols={4} height={44} />
<Skeleton rows={6} height={20} />
</div>
)
}
/** 对比结果。按「本次|基准」换 key:换了一对运行,筛选、展开都从头来 */
function Comparison({ data }: { data: RunComparison }) {
const [filter, setFilter] = useState<string | null>(null)
return (
<>
<Summary summary={data.summary} filter={filter} onFilter={setFilter} />
<NotesSection notes={data.notes ?? []} />
<Metrics metrics={data.metrics ?? []} filter={filter} onClearFilter={() => setFilter(null)} />
<Queries queries={data.queries ?? []} />
<TemplateSection template={data.template} />
</>
)
}
@@ -30,6 +30,7 @@ import { ClassChip, MoreMenu, RunTabs, TierChip, copyText, type MenuItem, type T
import { ProvenanceBar, Telemetry, runClocks } from './Telemetry'
import { TracePane } from './TracePane'
import { useRunDetail } from './useRunDetail'
import { useCompareEntry } from './compare/entry'
/**
* 一次运行的详情:头部读数、凭证、排错路径,下面分三个视图——
@@ -61,6 +62,8 @@ export function RunDetailView({ runId, onChange, onDeleted }: {
const [feedback, setFeedback] = useState<Feedback | null>(null)
const streamBox = useRef<HTMLDivElement>(null)
const now = useTicker(60_000)
// 「更多」里的「与上一次对比」:查找基准时的状态在这里挂着,菜单项等拿到运行之后再造(下面有提前返回)
const compareEntry = useCompareEntry()
useEffect(() => {
if (d.state !== 'missing') return
@@ -460,6 +463,7 @@ export function RunDetailView({ runId, onChange, onDeleted }: {
key: 'copy-link', label: '复制链接', icon: <Link2 size={12} />,
onSelect: () => void copyText(`${location.origin}/runs/${run.id}`, '链接'),
},
compareEntry(run, live),
{
key: 'delete', label: '删除记录…', icon: <Trash2 size={12} />, danger: true, onSelect: () => void remove(),
disabled: live || busy === 'delete',
+315
View File
@@ -0,0 +1,315 @@
import { Fragment, useEffect, useId, useRef, useState } from 'react'
import clsx from 'clsx'
import { ChevronRight } from 'lucide-react'
import { NONE } from '../../../lib/format'
import { RUN_COMPARE_TEXT as T } from '../../../lib/terms'
import type { RunCompareAttribution, RunCompareDecomposition, RunCompareMetric } from '../../../types'
import { Block, ClassChip } from './parts'
import { metricKey, percent, sameValue, signedNumber, valueText } from './model'
/** 维度归因列前几项:后端按变化从大到小排好,多的只写还有几项 */
const ATTR_TOP = 10
/** 口径效应、数据效应在条形和图例里的颜色:和「口径与数据都变」「只数据变」两个分类标签同色 */
const CALIBER_COLOR = 'var(--warn)'
const DATA_COLOR = 'var(--accent)'
/**
* 指标名一栏:窄屏上表格横着滚时钉在左边(滚到分类、拆分那几栏也知道是哪个指标),右边一道细线和后面的内容隔开。
* 钉住的格子要有自己的底色,否则滚过去的数字会透出来
*/
const STICKY = 'sticky left-0 z-[1] min-w-[9em] shadow-[inset_-1px_0_0_var(--border)]'
/**
* 指标表:基准 → 本次、变化量和百分比(渲染值照服务端给的写)、分类、能严格拆分的口径效应和数据效应(并列的数加一段
* 两截的条形),不能拆的写服务端给的原因。每行能展开:变化说明、输入对比、维度归因前 10 项。
*
* 表格比窄屏宽,在自己的框里横向滚动(框能用键盘聚焦),页面本身不横向溢出。展开的明细贴着框的左边、宽度等于框的
* 可见宽度:窄屏上不用横着滚也读得全
*/
export function Metrics({ metrics, filter, onClearFilter }: {
metrics: RunCompareMetric[]; filter: string | null; onClearFilter: () => void
}) {
const id = useId()
const [open, setOpen] = useState<ReadonlySet<string>>(() => new Set())
const scroller = useRef<HTMLDivElement>(null)
const shown = filter ? metrics.filter((m) => m.classification === filter) : metrics
const split = metrics.some((m) => m.decomposition)
// 明细的宽度跟着框的可见宽度走(CSS 变量),表格多宽都不撑出去
useEffect(() => {
const el = scroller.current
if (!el) return
const sync = () => el.style.setProperty('--compare-view', `${el.clientWidth}px`)
sync()
const ro = new ResizeObserver(sync)
ro.observe(el)
return () => ro.disconnect()
}, [metrics.length])
const toggle = (k: string) => setOpen((s) => {
const next = new Set(s)
if (next.has(k)) next.delete(k)
else next.add(k)
return next
})
return (
<Block id={`${id}-metrics`} title={T.metrics} count={T.count(metrics.length)} attr={{ 'data-compare-metrics': '' }}>
{!metrics.length ? (
<p className="py-4 text-center text-xs text-faint" data-compare-empty="metrics">{T.metricsEmpty}</p>
) : (
<>
{filter && (
<div className="mb-2 flex flex-wrap items-center gap-2 text-2xs text-dim" data-compare-filter="">
<span>{T.filterOn(T.classLabel[filter] ?? T.classOther, shown.length)}</span>
<button type="button" className="btn btn-xs" data-compare-filter-clear="" onClick={onClearFilter}>{T.showAll}</button>
</div>
)}
{split && (
<p className="mb-2 flex gap-2 text-2xs leading-relaxed text-faint" data-compare-legend="">
<span className="mt-1 flex shrink-0 gap-1" aria-hidden>
<i className="block h-2 w-2 rounded-sm" style={{ background: CALIBER_COLOR }} />
<i className="block h-2 w-2 rounded-sm" style={{ background: DATA_COLOR }} />
</span>
<span>{T.legend}</span>
</p>
)}
<div ref={scroller} className="overflow-x-auto rounded border focus-visible:outline-2 focus-visible:outline-[color:var(--accent)]"
data-compare-scroll="" tabIndex={0} role="region" aria-label={T.metrics}>
<table className="w-full min-w-[760px] border-collapse text-xs">
<thead>
<tr className="border-b text-left text-2xs text-faint">
<th scope="col" className={clsx(STICKY, 'bg-panel px-2.5 py-1.5 font-medium')}>{T.colMetric}</th>
<th scope="col" className="px-2.5 py-1.5 text-right font-medium">{T.colBase}</th>
<th scope="col" className="px-2.5 py-1.5 text-right font-medium">{T.colTarget}</th>
<th scope="col" className="px-2.5 py-1.5 text-right font-medium">{T.colDelta}</th>
<th scope="col" className="px-2.5 py-1.5 font-medium">{T.colClass}</th>
<th scope="col" className="px-2.5 py-1.5 font-medium">{T.colSplit}</th>
</tr>
</thead>
<tbody>
{shown.map((m) => {
const k = metricKey(m)
return <MetricRows key={k} m={m} k={k} detailId={`${id}-d-${k}`} open={open.has(k)} onToggle={() => toggle(k)} />
})}
</tbody>
</table>
</div>
</>
)}
</Block>
)
}
function MetricRows({ m, k, detailId, open, onToggle }: {
m: RunCompareMetric; k: string; detailId: string; open: boolean; onToggle: () => void
}) {
const pct = m.delta_pct != null && m.delta_pct !== 0 ? percent(m.delta_pct, true) : null
return (
<Fragment>
<tr className={clsx('border-b align-top last:border-0', open && 'bg-hover')} data-compare-metric={k} data-class={m.classification}>
<th scope="row" className={clsx(STICKY, open ? 'bg-hover' : 'bg-panel', 'px-2.5 py-2 text-left font-normal')}>
<button type="button" data-compare-expand="" aria-expanded={open} aria-controls={detailId}
title={open ? T.collapse(m.name) : T.expand(m.name)} onClick={onToggle}
className="group flex max-w-[220px] items-start gap-1 rounded-sm text-left focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[color:var(--accent)]">
<ChevronRight size={12} aria-hidden
className={clsx('mt-0.5 shrink-0 text-faint transition-transform group-hover:text-dim', open && 'rotate-90')} />
<span className="min-w-0">
<span className="block font-medium text-fg [overflow-wrap:anywhere]">{m.name}</span>
{m.card_title && <span className="block text-2xs text-faint [overflow-wrap:anywhere]">{m.card_title}</span>}
</span>
</button>
</th>
<td className="tnum whitespace-nowrap px-2.5 py-2 text-right text-dim" data-compare-base="">{m.base_rendered || NONE}</td>
<td className="tnum whitespace-nowrap px-2.5 py-2 text-right font-medium text-fg" data-compare-target="">{m.target_rendered || NONE}</td>
<td className="whitespace-nowrap px-2.5 py-2 text-right">
<div className="tnum" data-compare-delta="">{deltaText(m)}</div>
{pct && <div className="tnum text-2xs text-faint" data-compare-pct="">{pct}</div>}
</td>
<td className="px-2.5 py-2"><ClassChip value={m.classification} /></td>
<td className="px-2.5 py-2">
{m.decomposition ? (
<Split d={m.decomposition} total={deltaText(m)} />
) : m.decomposition_note ? (
<p className="max-w-[280px] text-2xs leading-relaxed text-dim [overflow-wrap:anywhere]" data-compare-decomp-note="">
{m.decomposition_note}
</p>
) : (
<span className="text-faint">{NONE}</span>
)}
</td>
</tr>
{open && (
<tr id={detailId} data-compare-detail={k} className="border-b bg-bg last:border-0">
<td colSpan={6} className="p-0">
<MetricDetail m={m} />
</td>
</tr>
)}
</Fragment>
)
}
/** 变化量:服务端渲染的写法;没给(没变化、缺一边)时,是 0 的写「无变化」,其余写「—」 */
function deltaText(m: RunCompareMetric): string {
if (m.delta_rendered) return m.delta_rendered
return m.delta === 0 ? T.noChange : NONE
}
/**
* 能严格拆分的:口径效应、数据效应并列,下面一段两截的条形按两者的绝对值分长短。一正一负(互相抵消)时负的那截画
* 斜纹:长短只说谁的分量大,方向看数的正负号
*/
function Split({ d, total }: { d: RunCompareDecomposition; total: string }) {
const a = Math.abs(d.caliber_effect)
const b = Math.abs(d.data_effect)
const sum = a + b
const fill = (color: string, negative: boolean) => (negative
? `repeating-linear-gradient(135deg, ${color} 0 2px, color-mix(in srgb, ${color} 35%, transparent) 2px 4px)`
: color)
return (
<div data-compare-decomp="" title={T.splitSum(d.caliber_rendered, d.data_rendered, total)}>
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-2xs">
<span className="inline-flex items-center gap-1 whitespace-nowrap" data-compare-caliber="">
<i className="block h-2 w-2 rounded-sm" style={{ background: CALIBER_COLOR }} aria-hidden />
<span className="text-faint">{T.caliber}</span>
<span className="tnum font-medium text-fg">{d.caliber_rendered}</span>
</span>
<span className="inline-flex items-center gap-1 whitespace-nowrap" data-compare-data="">
<i className="block h-2 w-2 rounded-sm" style={{ background: DATA_COLOR }} aria-hidden />
<span className="text-faint">{T.data}</span>
<span className="tnum font-medium text-fg">{d.data_rendered}</span>
</span>
</div>
{sum > 0 && (
<span className="mt-1 flex h-1.5 w-32 overflow-hidden rounded-sm bg-hover" data-compare-bar="" aria-hidden>
<i className="block h-full" style={{ width: `${(a / sum) * 100}%`, background: fill(CALIBER_COLOR, d.caliber_effect < 0) }} />
<i className="block h-full" style={{ width: `${(b / sum) * 100}%`, background: fill(DATA_COLOR, d.data_effect < 0) }} />
</span>
)}
</div>
)
}
/** 展开的明细:变化说明、拆分写成一句话、输入对比、维度归因。贴着滚动框的左边,宽度等于框的可见宽度 */
function MetricDetail({ m }: { m: RunCompareMetric }) {
return (
<div className="sticky left-0 box-border grid gap-4 px-3 py-3 lg:grid-cols-2" style={{ width: 'var(--compare-view, 100%)' }}>
<div className="min-w-0 space-y-3">
{!!m.reasons.length && (
<div>
<h3 className="mb-1 text-2xs font-medium text-faint">{T.reasons}</h3>
<ul className="list-disc space-y-0.5 pl-4 text-xs leading-relaxed text-dim [overflow-wrap:anywhere]" data-compare-reasons="">
{m.reasons.map((r, i) => <li key={i}>{r}</li>)}
</ul>
</div>
)}
{m.decomposition && (
<p className="text-xs leading-relaxed text-fg" data-compare-decomp-sum="">
{T.splitSum(m.decomposition.caliber_rendered, m.decomposition.data_rendered, deltaText(m))}
</p>
)}
<Inputs m={m} />
</div>
<div className="min-w-0">
<Attribution a={m.attribution} />
</div>
</div>
)
}
function Inputs({ m }: { m: RunCompareMetric }) {
return (
<div>
<h3 className="mb-1 text-2xs font-medium text-faint">{T.inputs}</h3>
{!m.inputs.length ? (
<p className="text-2xs text-faint" data-compare-inputs-none="">{T.inputsNone}</p>
) : (
<div className="overflow-x-auto">
<table className="w-full border-collapse text-2xs" data-compare-inputs="">
<thead>
<tr className="border-b text-left text-faint">
<th scope="col" className="py-1 pr-2 font-medium">{T.colInput}</th>
<th scope="col" className="px-2 py-1 text-right font-medium">{T.colBase}</th>
<th scope="col" className="py-1 pl-2 text-right font-medium">{T.colTarget}</th>
</tr>
</thead>
<tbody>
{m.inputs.map((inp) => {
const changed = !sameValue(inp.base, inp.target)
return (
<tr key={inp.path} className="border-b last:border-0" data-compare-input={inp.path} data-changed={changed ? '' : undefined}>
<th scope="row" className="mono py-1 pr-2 text-left font-normal text-dim [overflow-wrap:anywhere]">{inp.path}</th>
<td className="tnum px-2 py-1 text-right text-dim [overflow-wrap:anywhere]">{valueText(inp.base)}</td>
<td className={clsx('tnum py-1 pl-2 text-right [overflow-wrap:anywhere]', changed ? 'font-medium text-fg' : 'text-dim')}>
{changed && <span className="mr-1 inline-block h-1.5 w-1.5 rounded-full align-middle" style={{ background: DATA_COLOR }} aria-hidden />}
{valueText(inp.target)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
)
}
/**
* 维度归因:按键对齐两边查询结果的行,列前 10 项的键、两边的值、变化和占比。占比只在度量列之和等于指标的输入时才有,
* 没有时那一栏写「—」,下面照服务端的说明写原因(没给说明时写一句通用的)
*/
function Attribution({ a }: { a: RunCompareAttribution | null }) {
if (!a) {
return (
<div>
<h3 className="mb-1 text-2xs font-medium text-faint">{T.attribution}</h3>
<p className="text-2xs text-faint" data-compare-attr-none="">{T.attrNone}</p>
</div>
)
}
const rows = a.rows.slice(0, ATTR_TOP)
const more = a.rows.length - rows.length
const note = a.note || (a.rows.some((r) => r.share == null) ? T.attrNoShare : null)
return (
<div data-compare-attr="">
<h3 className="mb-1 flex flex-wrap items-baseline gap-x-2 text-2xs">
<span className="font-medium text-faint">{T.attribution}</span>
<span className="text-faint [overflow-wrap:anywhere]">{T.attrBy(a.measure)}{more > 0 ? ` · ${T.attrTop(ATTR_TOP)}` : ''}</span>
</h3>
<div className="overflow-x-auto">
<table className="w-full border-collapse text-2xs">
<thead>
<tr className="border-b text-left text-faint">
<th scope="col" className="py-1 pr-2 font-medium">{a.columns.length ? a.columns.join(' / ') : T.colKey}</th>
<th scope="col" className="px-2 py-1 text-right font-medium">{T.colBase}</th>
<th scope="col" className="px-2 py-1 text-right font-medium">{T.colTarget}</th>
<th scope="col" className="px-2 py-1 text-right font-medium">{T.colDelta}</th>
<th scope="col" className="py-1 pl-2 text-right font-medium">{T.colShare}</th>
</tr>
</thead>
<tbody>
{rows.map((r, i) => (
<tr key={i} className="border-b last:border-0" data-compare-attr-row="">
<th scope="row" className="min-w-[4.5em] py-1 pr-2 text-left font-normal text-fg [overflow-wrap:anywhere]">
{r.key.map(valueText).join(' / ')}
</th>
<td className="tnum px-2 py-1 text-right text-dim">{valueText(r.base)}</td>
<td className="tnum px-2 py-1 text-right text-dim">{valueText(r.target)}</td>
<td className="tnum px-2 py-1 text-right text-fg">{signedNumber(r.delta)}</td>
<td className="tnum py-1 pl-2 text-right text-dim" data-compare-share="" title={r.share == null ? note ?? undefined : undefined}>
{r.share == null ? NONE : percent(r.share)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="mt-1 space-y-0.5 text-2xs text-faint">
{more > 0 && <p data-compare-attr-more="">{T.attrMore(more)}</p>}
{a.total_delta != null && <p className="tnum" data-compare-attr-total="">{T.attrTotal(signedNumber(a.total_delta))}</p>}
{note && <p className="leading-relaxed text-dim [overflow-wrap:anywhere]" data-compare-attr-note="">{note}</p>}
</div>
</div>
)
}
+131
View File
@@ -0,0 +1,131 @@
import { Fragment, useId, useMemo, useState } from 'react'
import { ChevronRight } from 'lucide-react'
import clsx from 'clsx'
import { RUN_COMPARE_TEXT as T } from '../../../lib/terms'
import type { RunCompareQuery } from '../../../types'
import { Block, StructureChip } from './parts'
import { queryKey, queryNumbers, valueText } from './model'
import { diffSql, type DiffPart } from './sqlDiff'
/** 基准独有、本次独有的高亮:颜色之外,删去的划线、新加的下划线,色弱、灰度打印也分得出 */
const MARK = {
del: {
background: 'color-mix(in srgb, var(--err) 18%, transparent)', color: 'var(--text)',
textDecoration: 'line-through', textDecorationColor: 'color-mix(in srgb, var(--err) 70%, transparent)',
},
add: {
background: 'color-mix(in srgb, var(--ok) 18%, transparent)', color: 'var(--text)',
textDecoration: 'underline', textDecorationColor: 'color-mix(in srgb, var(--ok) 70%, transparent)', textUnderlineOffset: '2px',
},
} as const
/**
* 查询区:每条查询的结构状态和行数。只参数变的列出变化的字面量;结构变的并排两条 SQL(窄屏上下排),逐词标出各自独有的
* 部分;新增、移除的只有一边。相同、只参数变的 SQL 默认收着,点开再看
*/
export function Queries({ queries }: { queries: RunCompareQuery[] }) {
const id = useId()
const numbers = useMemo(() => queryNumbers(queries), [queries])
return (
<Block id={`${id}-queries`} title={T.queries} count={T.count(queries.length)} attr={{ 'data-compare-queries': '' }}>
{!queries.length ? (
<p className="py-4 text-center text-xs text-faint" data-compare-empty="queries">{T.queriesEmpty}</p>
) : (
<ul className="divide-y">
{queries.map((q) => {
const k = queryKey(q)
return <QueryItem key={k} q={q} k={k} nth={numbers.get(k) ?? null} />
})}
</ul>
)}
</Block>
)
}
function QueryItem({ q, k, nth }: { q: RunCompareQuery; k: string; nth: number | null }) {
const sqlId = useId()
const [showSql, setShowSql] = useState(false)
const foldable = q.structure === 'same' || q.structure === 'params_only'
const rows = q.structure === 'added' ? T.rowsTarget(T.rows(q.target_rows, q.target_truncated))
: q.structure === 'removed' ? T.rowsBase(T.rows(q.base_rows, q.base_truncated))
: T.rowsBoth(T.rows(q.base_rows, q.base_truncated), T.rows(q.target_rows, q.target_truncated))
return (
<li className="py-2.5 first:pt-0 last:pb-0" data-compare-query={k} data-structure={q.structure}>
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
<span className="min-w-0 text-xs font-medium [overflow-wrap:anywhere]" title={q.node_id}>{q.node_title || q.node_id}</span>
{nth != null && <span className="text-2xs text-faint" data-compare-nth="">{T.nth(nth)}</span>}
<StructureChip value={q.structure} />
<span className="tnum text-2xs text-faint" data-compare-rows="">{rows}</span>
{foldable && (
<button type="button" className="btn btn-xs btn-ghost text-faint" data-compare-sql-toggle=""
aria-expanded={showSql} aria-controls={sqlId} onClick={() => setShowSql((v) => !v)}>
<ChevronRight size={11} aria-hidden className={clsx('transition-transform', showSql && 'rotate-90')} />
{showSql ? T.hideSql : T.showSql}
</button>
)}
</div>
{q.structure === 'params_only' && !!q.literals.length && (
<div className="mt-1.5">
<div className="mb-0.5 text-2xs text-faint">{T.literals}</div>
<ul className="space-y-0.5 text-2xs" data-compare-literals="">
{q.literals.map((l, i) => (
<li key={i} className="flex min-w-0 flex-wrap items-center gap-1.5">
<code className="mono rounded px-1 [overflow-wrap:anywhere]" style={MARK.del}>{valueText(l.base)}</code>
<span className="text-faint" aria-hidden>→</span>
<code className="mono rounded px-1 [overflow-wrap:anywhere]" style={MARK.add}>{valueText(l.target)}</code>
</li>
))}
</ul>
</div>
)}
{q.structure === 'changed' && <SideBySide base={q.base_sql ?? ''} target={q.target_sql ?? ''} />}
{q.structure === 'added' && <SqlPane which="target" label={T.targetSql} sql={q.target_sql ?? ''} />}
{q.structure === 'removed' && <SqlPane which="base" label={T.baseSql} sql={q.base_sql ?? ''} />}
{foldable && showSql && (
<div id={sqlId}>
{q.structure === 'same'
? <SqlPane which="both" label={T.bothSql} sql={q.target_sql ?? q.base_sql ?? ''} />
: <SideBySide base={q.base_sql ?? ''} target={q.target_sql ?? ''} />}
</div>
)}
</li>
)
}
/** 两条 SQL 并排,逐词标出各自独有的部分 */
function SideBySide({ base, target }: { base: string; target: string }) {
const diff = useMemo(() => diffSql(base, target), [base, target])
return (
<div className="mt-2">
<div className="mb-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-2xs text-faint" aria-hidden>
<span><mark className="rounded px-1" style={MARK.del}>{T.onlyBase}</mark></span>
<span><mark className="rounded px-1" style={MARK.add}>{T.onlyTarget}</mark></span>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<SqlPane which="base" label={T.baseSql} parts={diff.base} />
<SqlPane which="target" label={T.targetSql} parts={diff.target} />
</div>
{diff.coarse && <p className="mt-1 text-2xs text-faint">{T.diffCoarse}</p>}
</div>
)
}
/** 一条 SQL。折行显示,不横向撑开;太长时在框里纵向滚动,框能用键盘聚焦 */
function SqlPane({ which, label, sql, parts }: {
which: 'base' | 'target' | 'both'; label: string; sql?: string; parts?: DiffPart[]
}) {
const kind = which === 'base' ? 'del' : 'add'
return (
<figure className="m-0 mt-1.5 min-w-0">
<figcaption className="mb-0.5 text-2xs text-faint">{label}</figcaption>
<pre className="mono max-h-64 overflow-y-auto whitespace-pre-wrap rounded bg-bg px-2 py-1.5 text-2xs leading-relaxed text-dim [overflow-wrap:anywhere] focus-visible:outline-2 focus-visible:outline-[color:var(--accent)]"
data-compare-sql={which} tabIndex={0} aria-label={label}>
{parts
? parts.map((p, i) => (p.changed
? <mark key={i} data-diff={kind} className="rounded-sm" style={MARK[kind]}>{p.text}</mark>
: <Fragment key={i}>{p.text}</Fragment>))
: sql}
</pre>
</figure>
)
}
+134
View File
@@ -0,0 +1,134 @@
import { useId, type ReactNode } from 'react'
import { Link } from 'react-router-dom'
import { ArrowLeftRight, CalendarClock } from 'lucide-react'
import { Skeleton, useTicker } from '../../../components/ui'
import { formatDateTime, formatTime, shortId } from '../../../lib/format'
import { RUN_COMPARE_TEXT as T, runClassLabel, runName } from '../../../lib/terms'
import type { RunWithSchedule } from '../../../types'
import { ClassChip as RunClassChip } from '../parts'
import type { Loaded } from './data'
/**
* 顶部的两次运行:左边基准、右边本次(窄屏上下排),中间「互换」。每张卡写名字、时间、类别和版本,点名字进各自的
* 运行详情;没封存的标出来(对比会因此被拒)。基准卡底下是换基准的下拉:同一工作流最近的运行,没封存的不能选。
*/
export function RunPair({ target, base, targetRun, baseRun, candidates, unsaved, reason, onPick, onSwap }: {
target: string
base: string | null
targetRun: Loaded<RunWithSchedule>
baseRun: Loaded<RunWithSchedule>
candidates: Loaded<RunWithSchedule[]>
/** 本次的工作流没保存:没有同一工作流的其他运行可选 */
unsaved: boolean
/** 默认基准是怎么选的(只在从「与上一次对比」、或只带本次打开时有) */
reason: string | null
onPick: (id: string) => void
onSwap: () => void
}) {
return (
<div className="flex flex-col gap-1.5">
<div className="grid grid-cols-1 gap-2 md:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)]">
<RunCard role="base" id={base} run={baseRun}>
<BasePicker target={target} base={base} baseRun={baseRun.data} candidates={candidates} unsaved={unsaved}
onPick={onPick} />
</RunCard>
<div className="flex items-center justify-center">
<button type="button" className="btn btn-sm" data-compare-swap="" disabled={!base} onClick={onSwap}
aria-label={T.swapHint} title={T.swapHint}>
{/* 窄屏两张卡上下排,箭头跟着竖过来 */}
<ArrowLeftRight size={12} className="rotate-90 md:rotate-0" aria-hidden /> {T.swap}
</button>
</div>
<RunCard role="target" id={target} run={targetRun} />
</div>
{reason && T.reason[reason] && (
<p className="text-2xs text-faint" data-compare-reason="">{T.reason[reason]}</p>
)}
</div>
)
}
function RunCard({ role, id, run, children }: {
role: 'base' | 'target'; id: string | null; run: Loaded<RunWithSchedule>; children?: ReactNode
}) {
const now = useTicker(60_000)
const r = run.data
const when = r ? r.started_at ?? r.created_at ?? null : null
return (
<div className="min-w-0 rounded-lg border bg-panel px-3 py-2.5" data-compare-run={role}>
<div className="flex min-w-0 flex-wrap items-center gap-1.5 text-2xs">
<span className="font-medium text-faint">{role === 'base' ? T.base : T.target}</span>
{r?.schedule_id && (
<span className="chip" title={T.scheduled}><CalendarClock size={10} aria-hidden /> {T.scheduled}</span>
)}
{r && !r.manifest_hash && (
<span className="chip" title={T.unsealedHint} data-compare-unsealed=""
style={{ color: 'var(--warn)', borderColor: 'color-mix(in srgb, var(--warn) 40%, transparent)' }}>
{T.unsealed}
</span>
)}
</div>
{!id ? (
<div className="mt-1 text-sm text-faint">{T.noBase}</div>
) : r ? (
<>
<Link to={`/runs/${id}`} data-compare-run-link="" title={T.openRun(runName(r))}
className="mt-1 block truncate rounded-sm text-sm font-semibold hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--accent)]">
{runName(r)}
</Link>
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1 text-2xs text-faint">
<time className="tnum" title={formatDateTime(when)}>{formatTime(when, new Date(now))}</time>
<span aria-hidden>·</span>
<RunClassChip runClass={r.run_class} version={r.version} always />
<span aria-hidden>·</span>
<span className="mono" title={id}>{shortId(id)}</span>
</div>
</>
) : run.state === 'error' ? (
// 读不到运行记录(删了、连不上):对比本身照样请求,能不能比由对比接口说
<div className="mt-1 text-xs text-faint">
<Link to={`/runs/${id}`} data-compare-run-link="" className="mono hover:underline">{shortId(id)}</Link>
<span className="ml-1.5">{T.runFailed}</span>
</div>
) : (
<div className="mt-1.5" aria-busy="true"><Skeleton rows={2} height={12} gap={8} /></div>
)}
{children}
</div>
)
}
/**
* 换基准。列的是运行列表接口按工作流取回的最近几次(新的在前),去掉本次;没封存的列出来但不能选,写明原因。
* 当前的基准不在这几次里(更早、或者属于别的工作流)时也放进去,下拉不至于显示成别的运行
*/
function BasePicker({ target, base, baseRun, candidates, unsaved, onPick }: {
target: string; base: string | null; baseRun?: RunWithSchedule; candidates: Loaded<RunWithSchedule[]>; unsaved: boolean
onPick: (id: string) => void
}) {
const id = useId()
const now = useTicker(60_000)
if (unsaved) return <p className="mt-2 text-2xs text-faint">{T.pickUnsaved}</p>
if (candidates.state === 'error') return <p className="mt-2 text-2xs text-faint">{T.pickFailed}</p>
const loading = candidates.state !== 'ok'
const list = (candidates.data ?? []).filter((r) => r.id !== target)
if (base && baseRun && !list.some((r) => r.id === base)) list.unshift(baseRun)
if (!loading && !list.length) return <p className="mt-2 text-2xs text-faint">{T.pickNone}</p>
const label = (r: RunWithSchedule) => T.pickOption(formatTime(r.started_at ?? r.created_at ?? null, new Date(now)),
runClassLabel(r.run_class, r.version), shortId(r.id), !!r.manifest_hash)
return (
<div className="mt-2.5 border-t pt-2">
<label className="label" htmlFor={id}>{T.pickBase}</label>
<select id={id} className="field" data-compare-base-picker="" value={base ?? ''} disabled={loading}
aria-describedby={`${id}-hint`} onChange={(e) => { if (e.target.value) onPick(e.target.value) }}>
{loading && <option value={base ?? ''}>{T.pickLoading}</option>}
{!loading && !base && <option value="" disabled>{T.pickPlaceholder}</option>}
{!loading && list.map((r) => (
// 当前的基准即使没封存也要能显示出来(选中项);别的没封存的一律不能选
<option key={r.id} value={r.id} disabled={!r.manifest_hash && r.id !== base}>{label(r)}</option>
))}
</select>
<p id={`${id}-hint`} className="mt-1 text-2xs text-faint">{T.pickHint}</p>
</div>
)
}
@@ -0,0 +1,47 @@
import clsx from 'clsx'
import { formatNumber } from '../../../lib/format'
import { RUN_COMPARE_TEXT as T } from '../../../lib/terms'
import type { RunCompareSummary } from '../../../types'
import { CLASS_ORDER, classTone } from './model'
/**
* 摘要条:八类指标各有几个,个数照服务端的 summary 写(不按指标表重数)。点一类只看这一类的指标,再点一次回到全部;
* 是 0 的不能点——筛出一张空表没有意义
*/
export function Summary({ summary, filter, onFilter }: {
summary: RunCompareSummary; filter: string | null; onFilter: (k: string | null) => void
}) {
return (
<section aria-label={T.summary} data-compare-summary="">
<div className="grid grid-cols-2 gap-px overflow-hidden rounded-lg border bg-[var(--border)] sm:grid-cols-4">
{CLASS_ORDER.map((k) => {
const n = Number(summary?.[k] ?? 0)
const on = filter === k
const tone = classTone(k)
return (
<button
key={k}
type="button"
data-compare-class={k}
aria-pressed={on}
disabled={n === 0}
title={`${T.classHint[k]}${n ? `\n${T.summaryHint}` : ''}`}
onClick={() => onFilter(on ? null : k)}
className={clsx(
'relative flex min-w-0 flex-col items-start gap-0.5 px-3 py-2 text-left outline-none transition-colors',
'focus-visible:z-10 focus-visible:shadow-[inset_0_0_0_2px_var(--accent)] disabled:cursor-default',
on ? 'bg-hover shadow-[inset_0_-2px_0_var(--accent)]' : 'bg-panel enabled:hover:bg-hover',
)}
>
<span data-compare-count="" className={clsx('mono tnum text-lg leading-6', !n && 'text-faint')}
style={n ? { color: tone.color === 'var(--text-faint)' ? 'var(--text)' : tone.color } : undefined}>
{formatNumber(n)}
</span>
<span className="truncate text-2xs text-dim">{T.classLabel[k]}</span>
</button>
)
})}
</div>
</section>
)
}
@@ -0,0 +1,63 @@
import { useId } from 'react'
import { RUN_COMPARE_TEXT as T } from '../../../lib/terms'
import type { RunCompareTemplate } from '../../../types'
import { Block } from './parts'
const CHANGE_COLOR: Record<string, string> = { added: 'var(--accent)', removed: 'var(--text-dim)', config: 'var(--warn)' }
/**
* 模板区:两次运行各用的版本,执行图变了的话列出变化的节点(新增、移除、配置变更)和改了的配置项。配置项写的是节点
* 配置里的键:同一个键在不同类型的节点里叫法不同,接口又不给节点类型,照原样写比猜一个中文名可靠
*/
export function TemplateSection({ template }: { template: RunCompareTemplate }) {
const id = useId()
return (
<Block id={`${id}-template`} title={T.template} attr={{ 'data-compare-template': '' }}>
<p className="tnum text-xs text-dim" data-compare-versions="">
{T.versions(T.version(template.base_version), T.version(template.target_version))}
</p>
{template.same_graph ? (
<p className="mt-1.5 text-xs text-faint" data-compare-same-graph="">{T.sameGraph}</p>
) : !template.nodes.length ? (
<p className="mt-1.5 text-xs text-faint">{T.nodesNone}</p>
) : (
<ul className="mt-2 divide-y rounded border">
{template.nodes.map((n) => {
const color = CHANGE_COLOR[n.change] ?? 'var(--text-dim)'
return (
<li key={`${n.node_id}:${n.change}`} className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 px-2.5 py-1.5 text-xs"
data-compare-node={n.node_id} data-change={n.change}>
<span className="min-w-0 font-medium [overflow-wrap:anywhere]" title={n.node_id}>{n.title || n.node_id}</span>
<span className="chip shrink-0"
style={{ color, borderColor: `color-mix(in srgb, ${color} 40%, transparent)` }}>
{T.nodeChange[n.change] ?? n.change}
</span>
{!!n.keys.length && (
<span className="flex min-w-0 flex-wrap items-center gap-1 text-2xs text-faint">
<span>{T.keys}</span>
{n.keys.map((k) => (
<code key={k} className="mono rounded bg-bg px-1 text-dim [overflow-wrap:anywhere]" data-compare-key="">{k}</code>
))}
</span>
)}
</li>
)
})}
</ul>
)}
</Block>
)
}
/** 服务端给的说明(截断、缺快照这类会影响读数的事),照原样列出;没有就不画这一节 */
export function NotesSection({ notes }: { notes: string[] }) {
const id = useId()
if (!notes.length) return null
return (
<Block id={`${id}-notes`} title={T.notes} attr={{ 'data-compare-notes': '' }}>
<ul className="list-disc space-y-0.5 pl-4 text-xs leading-relaxed text-dim [overflow-wrap:anywhere]">
{notes.map((n, i) => <li key={i}>{n}</li>)}
</ul>
</Block>
)
}
+77
View File
@@ -0,0 +1,77 @@
import { useCallback, useEffect, useState } from 'react'
import { api } from '../../../api/client'
import type { RunCompareBase, RunComparison, RunWithSchedule } from '../../../types'
/**
* 对比页要的几份数据:默认基准、两次运行各自的记录(运行卡、封存与否、所属工作流)、同一工作流最近的运行(换基准的
* 下拉)、对比本身。每份按键取:键变了(换了基准、互换)就丢掉上一份,过时的答复回来也不认。
*/
export interface Loaded<T> {
/** idle:键是空的,不取 */
state: 'idle' | 'loading' | 'ok' | 'error'
data?: T
error?: unknown
reload: () => void
}
type Slot<T> = { key: string; state: 'loading' | 'ok' | 'error'; data?: T; error?: unknown }
/**
* 按键取一份数据。请求晚一拍(下一个宏任务)才发:开发模式的 StrictMode 会把挂载时的 effect 先跑一遍、立即撤掉再跑,
* 撤掉时把还没发的那一拍清掉,同一份对比就只算一次(对比要读两次运行的工件、核对封存,不便宜)。键变了、卸载了
* 都会取消在路上的请求;答复对不上当前的键就丢掉
*/
function useKeyed<T>(key: string | null, load: (signal: AbortSignal) => Promise<T>): Loaded<T> {
const [slot, setSlot] = useState<Slot<T> | null>(null)
const [nonce, setNonce] = useState(0)
useEffect(() => {
if (!key) return
const ctrl = new AbortController()
const timer = setTimeout(() => {
setSlot({ key, state: 'loading' })
load(ctrl.signal).then(
(data) => { if (!ctrl.signal.aborted) setSlot({ key, state: 'ok', data }) },
(error: unknown) => {
if (ctrl.signal.aborted || (error as { name?: string } | null)?.name === 'AbortError') return
setSlot({ key, state: 'error', error })
},
)
}, 0)
return () => {
clearTimeout(timer)
ctrl.abort()
}
// load 由键决定:键里已经写着它用到的全部参数
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key, nonce])
const reload = useCallback(() => setNonce((n) => n + 1), [])
if (!key) return { state: 'idle', reload }
// 键刚变、请求还没发出去的那一下也算加载中:不拿上一份(另一对运行的)充数
if (!slot || slot.key !== key) return { state: 'loading', reload }
return { state: slot.state, data: slot.data, error: slot.error, reload }
}
/** 默认基准(只带了本次时才找)。找不到是 404,交给页面照实说 */
export function useDefaultBase(target: string | null): Loaded<RunCompareBase> {
return useKeyed(target, (signal) => api.compare.base(target!, { signal }))
}
/** 一次运行的记录:运行卡上的名字、时间、版本、类别、封存与否,和它属于哪个工作流 */
export function useRunRecord(id: string | null): Loaded<RunWithSchedule> {
return useKeyed(id, () => api.runs.get(id!) as Promise<RunWithSchedule>)
}
/** 换基准的下拉:同一工作流最近的运行(新的在前)。没保存的工作流(workflow_id 为空)不取 */
export const CANDIDATE_LIMIT = 50
export function useCandidates(workflowId: string | null | undefined): Loaded<RunWithSchedule[]> {
return useKeyed(workflowId || null, () =>
api.runs.list({ workflow_id: workflowId!, limit: CANDIDATE_LIMIT }) as Promise<RunWithSchedule[]>)
}
/** 对比本身 */
export function useComparison(target: string | null, base: string | null): Loaded<RunComparison> {
return useKeyed(target && base ? JSON.stringify([target, base]) : null,
(signal) => api.compare.get(target!, base!, { signal }))
}
+49
View File
@@ -0,0 +1,49 @@
import { useCallback, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { GitCompareArrows } from 'lucide-react'
import { ApiError, api } from '../../../api/client'
import { toast } from '../../../components/ui'
import { RUN_COMPARE_TEXT as T } from '../../../lib/terms'
import type { Run } from '../../../types'
import type { MenuItem } from '../parts'
/** 对比页的地址。via 是默认基准的来历(同一定时任务 / 同一工作流),只跟着这一次跳转走,换了基准就不再显示 */
export interface CompareVia { base: string; reason: string }
export const comparePath = (target: string, base?: string | null) =>
`/runs/compare?${new URLSearchParams(base ? { target, base } : { target })}`
/**
* 运行详情「更多」菜单里的「与上一次对比」。先问服务端默认的基准(同一定时任务的上一次,没有就同一工作流、同一类别的
* 上一次),找到了再跳到对比页;找不到时留在原页,提示服务端给的原因,并给「手动选择基准」去对比页自己挑。
* 没封存、还在运行的不给点:对比只认已封存的运行,点了也是 409。
*
* 写成 hook(返回造菜单项的函数):运行详情里有提前返回,菜单项本身只能在拿到运行之后造,查找中的状态得在那之前就挂上
*/
export function useCompareEntry(): (run: Run, live: boolean) => MenuItem {
const navigate = useNavigate()
const [busy, setBusy] = useState(false)
return useCallback((run: Run, live: boolean): MenuItem => {
const blocked = live || !run.manifest_hash
return {
key: 'compare',
label: T.menu,
icon: <GitCompareArrows size={12} />,
disabled: blocked || busy,
hint: blocked ? T.menuSealedOnly : busy ? T.menuFinding : undefined,
onSelect: () => {
setBusy(true)
api.compare.base(run.id).then((found) => {
const via: CompareVia = { base: found.base_run_id, reason: found.reason }
navigate(comparePath(run.id, found.base_run_id), { state: via })
}, (e: unknown) => {
if (e instanceof ApiError && e.kind === 'http' && e.status === 404) {
toast.info(e.message, { action: { label: T.pickManually, onClick: () => navigate(comparePath(run.id)) } })
} else {
toast.error(e)
}
}).finally(() => setBusy(false))
},
}
}, [navigate, busy])
}
+118
View File
@@ -0,0 +1,118 @@
import { ApiError } from '../../../api/client'
import { NONE } from '../../../lib/format'
import type { RunCompareClass, RunCompareMetric, RunCompareQuery } from '../../../types'
/**
* 运行对比页的纯函数:分类的顺序和颜色、数怎么写、查询怎么编号、错误归哪一类。数值本身都是服务端算好的
* (渲染值原样显示),这里只管服务端没替界面写好的那几样:变化百分比、占比、输入和归因里的原始数。
*/
/** 摘要条和分类的顺序:要人看的变化在前,未变放最后 */
export const CLASS_ORDER: RunCompareClass[] = [
'data_only', 'caliber_and_data', 'sql_changed', 'params_only', 'added', 'removed', 'incomparable', 'unchanged',
]
/**
* 分类的颜色。颜色只表达「要不要多看一眼」:口径变了、SQL 结构变了是警示色并铺底(数不能直接比);只数据变、新增
* 用强调色;只参数变、移除、无法比较、未变是中性色。标签上都写着字,去掉颜色也分得出
*/
const CLASS_TONE: Record<RunCompareClass, { color: string; soft: boolean }> = {
data_only: { color: 'var(--accent)', soft: false },
caliber_and_data: { color: 'var(--warn)', soft: true },
sql_changed: { color: 'var(--warn)', soft: true },
params_only: { color: 'var(--text-dim)', soft: false },
added: { color: 'var(--accent)', soft: false },
removed: { color: 'var(--text-dim)', soft: false },
incomparable: { color: 'var(--text-faint)', soft: false },
unchanged: { color: 'var(--text-faint)', soft: false },
}
export function classTone(k: string): { color: string; soft: boolean } {
return CLASS_TONE[k as RunCompareClass] ?? { color: 'var(--text-dim)', soft: false }
}
/** 一个指标在表里的键:同一张卡里指标 id 唯一,几张卡之间可能重名 */
export const metricKey = (m: Pick<RunCompareMetric, 'card_id' | 'metric_id'>) => `${m.card_id}:${m.metric_id}`
/** 一条查询的键:同一节点里按服务端的序号区分 */
export const queryKey = (q: Pick<RunCompareQuery, 'node_id' | 'ordinal'>) => `${q.node_id}:${q.ordinal}`
const isNum = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v)
/** 原始的数(输入、归因里的值):千分位,小数最多四位。服务端没给渲染值的地方用它 */
export function plainNumber(v: number): string {
return v.toLocaleString('en-US', { maximumFractionDigits: 4 })
}
/** 输入、归因键里的一个值:数照原样加千分位,缺的写「—」,列表和对象压成一行 */
export function valueText(v: unknown): string {
if (v == null) return NONE
if (isNum(v)) return plainNumber(v)
if (typeof v === 'boolean') return v ? 'true' : 'false'
if (typeof v === 'string') return v
try {
const s = JSON.stringify(v)
return s.length > 120 ? `${s.slice(0, 119)}…` : s
} catch {
return String(v)
}
}
/** 带正负号的原始变化量:「+1,012.6」「-3」「0」 */
export function signedNumber(v: number | null | undefined): string {
if (!isNum(v)) return NONE
if (v === 0) return '0'
return `${v > 0 ? '+' : '-'}${plainNumber(Math.abs(v))}`
}
/**
* 小数写成百分比(一位小数)。signed 时带正负号:变化比例 0.125 →「+12.5%」;占比 0.4 →「40.0%」。
* 不是零、却小到一位小数写不出来的写「<0.1%」,不写成看着像没变的「0.0%」
*/
export function percent(v: number | null | undefined, signed = false): string {
if (!isNum(v)) return NONE
const pct = Math.abs(v) * 100
const sign = signed && v !== 0 ? (v > 0 ? '+' : '-') : v < 0 ? '-' : ''
if (v !== 0 && pct < 0.05) return `${sign}<0.1%`
return `${sign}${pct.toFixed(1)}%`
}
/** 两个值算不算一样(输入对比里标「变了」用):数按值比,别的按 JSON 比 */
export function sameValue(a: unknown, b: unknown): boolean {
if (isNum(a) && isNum(b)) return a === b
try {
return JSON.stringify(a) === JSON.stringify(b)
} catch {
return a === b
}
}
/**
* 同一节点有几条查询时,界面按 1 起数写「第 N 条」。服务端的 ordinal 只用来排先后:它从 0 还是从 1 数、中间有没有
* 空号(只有一边有的那条),都不该漏到界面上
*/
export function queryNumbers(queries: RunCompareQuery[]): Map<string, number | null> {
const byNode = new Map<string, RunCompareQuery[]>()
for (const q of queries) byNode.set(q.node_id, [...(byNode.get(q.node_id) ?? []), q])
const out = new Map<string, number | null>()
for (const list of byNode.values()) {
const sorted = [...list].sort((a, b) => a.ordinal - b.ordinal)
sorted.forEach((q, i) => out.set(queryKey(q), list.length > 1 ? i + 1 : null))
}
return out
}
/**
* 对比失败归哪一类:409 未封存或封存核对不一致、422 不是同一工作流或同一次运行、404 运行不存在;找默认基准时的 404
* 是「找不到基准」。其余(网络、服务端出错)按通用的出错态画,带重试
*/
export type CompareErrorKind = '409' | '422' | '404' | 'no-base' | 'other'
export function compareErrorKind(e: unknown, finding: boolean): CompareErrorKind {
if (!(e instanceof ApiError) || e.kind !== 'http') return 'other'
if (finding) return e.status === 404 ? 'no-base' : 'other'
if (e.status === 409) return '409'
if (e.status === 422) return '422'
if (e.status === 404) return '404'
return 'other'
}
+105
View File
@@ -0,0 +1,105 @@
import type { ReactNode } from 'react'
import { Link } from 'react-router-dom'
import { AlertTriangle } from 'lucide-react'
import { ErrorState } from '../../../components/ui'
import { RUN_COMPARE_TEXT as T } from '../../../lib/terms'
import { classTone, type CompareErrorKind } from './model'
/** 对比页的一节:带标题的卡片。count 写在标题旁(「11 个」),hint 是一句说明 */
export function Block({ id, title, count, hint, children, attr }: {
id: string; title: string; count?: string; hint?: ReactNode; children: ReactNode
attr?: Record<string, string>
}) {
return (
<section aria-labelledby={`${id}-title`} className="min-w-0 rounded-lg border bg-panel" {...attr}>
<header className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5 border-b px-3 py-2">
<h2 id={`${id}-title`} className="text-sm font-semibold">{title}</h2>
{count && <span className="tnum text-2xs text-faint">{count}</span>}
{hint && <p className="min-w-0 text-2xs leading-relaxed text-faint">{hint}</p>}
</header>
<div className="min-w-0 p-3">{children}</div>
</section>
)
}
/**
* 标签的配色:字和边框用这个颜色;soft 时再铺一层 12% 的底。铺了底的字往正文色里调一成半:浅色主题下警示色的字压在
* 自己的浅底上只有 4.4:1,调过之后深浅两套都在 4.5 以上(深色里正文色是浅的,调完字更亮)
*/
function toneStyle(color: string, soft: boolean) {
return {
color: soft ? `color-mix(in srgb, ${color} 85%, var(--text))` : color,
borderColor: `color-mix(in srgb, ${color} 40%, transparent)`,
background: soft ? `color-mix(in srgb, ${color} 12%, transparent)` : undefined,
}
}
/** 指标的分类标签。要多看一眼的(口径、SQL 结构变了)铺一层底色,和出具档位的标签同一种画法 */
export function ClassChip({ value }: { value: string }) {
const tone = classTone(value)
return (
<span className="chip shrink-0" data-compare-chip="" title={T.classHint[value]} style={toneStyle(tone.color, tone.soft)}>
{T.classLabel[value] ?? T.classOther}
</span>
)
}
/** 查询的结构状态。结构变了用警示色并铺底(口径不同),新增用强调色,其余中性色 */
export function StructureChip({ value }: { value: string }) {
const warn = value === 'changed'
const color = warn ? 'var(--warn)' : value === 'added' ? 'var(--accent)' : 'var(--text-dim)'
return (
<span className="chip shrink-0" data-compare-structure="" title={T.structureHint[value]} style={toneStyle(color, warn)}>
{T.structure[value] ?? value}
</span>
)
}
const ERROR_TEXT: Record<Exclude<CompareErrorKind, 'other'>, { title: string; hint: string }> = {
'409': { title: T.errSealed, hint: T.errSealedHint },
'422': { title: T.errMismatch, hint: T.errMismatchHint },
'404': { title: T.errMissing, hint: T.errMissingHint },
'no-base': { title: T.errNoBase, hint: T.errNoBaseHint },
}
/**
* 对比失败时的说明:先说是哪一种(未封存、不是同一工作流、运行不存在、找不到基准),再原样带上服务端的原因,
* 最后一句下一步。未封存、封存核对不一致的,给两次运行详情的入口:封存凭证条上能核对。别的错误按通用的出错态画
*/
export function CompareError({ kind, error, onRetry, target, base }: {
kind: CompareErrorKind; error: unknown; onRetry: () => void; target: string | null; base: string | null
}) {
if (kind === 'other') {
return (
<div data-compare-error="other">
<ErrorState error={error} onRetry={onRetry} compact />
</div>
)
}
const { title, hint } = ERROR_TEXT[kind]
const reason = error instanceof Error ? error.message : ''
return (
<div
role="alert"
data-compare-error={kind}
className="flex items-start gap-2 rounded-lg border px-3 py-2.5 text-xs leading-relaxed"
style={{
borderColor: 'color-mix(in srgb, var(--warn) 40%, var(--border))',
background: 'color-mix(in srgb, var(--warn) 7%, transparent)',
}}
>
<AlertTriangle size={14} className="mt-0.5 shrink-0" style={{ color: 'var(--warn)' }} aria-hidden />
<div className="min-w-0 flex-1 [overflow-wrap:anywhere]">
<div className="font-medium text-fg" data-compare-error-title="">{title}</div>
{reason && reason !== title && <div className="mt-0.5 text-dim">{reason}</div>}
<div className="mt-0.5 text-faint">{hint}</div>
{kind === '409' && (target || base) && (
<div className="mt-1.5 flex flex-wrap gap-2">
{base && <Link className="btn btn-sm" to={`/runs/${base}`}>{T.viewBase}</Link>}
{target && <Link className="btn btn-sm" to={`/runs/${target}`}>{T.viewTarget}</Link>}
</div>
)}
</div>
</div>
)
}
+121
View File
@@ -0,0 +1,121 @@
/**
* 两条 SQL 逐词对比:并排显示时标出各自独有的部分(结构变了的那条查询,差在哪一眼能看到)。
*
* 按词(名字、关键字、数、字符串、运算符)而不是按字符比:按字符比会把 created_at 和 paid_at 里碰巧相同的「_at」
* 当成没变,读起来支离破碎;按行比又太粗,模型写的 SQL 常常一整条写在一行里。关键字和不加引号的名字不分大小写
* (服务端的规范化也不算它们不同),字符串、加引号的名字照原样比。空白不参与比较,只是两个变了的词之间的空白也
* 跟着标,免得一段新加的条件被切成好几截。
*
* 先去掉两头相同的部分,中间再求最长公共子序列。中间太长(两边词数之积超过 CELL_LIMIT)时不逐词算,整段标出——
* 页面不能为一条超长的 SQL 卡住,coarse 为 true,界面照实说只标了大致范围。
*/
export interface DiffPart {
text: string
/** 这一段只在自己这边有 */
changed: boolean
}
export interface SqlDiff {
base: DiffPart[]
target: DiffPart[]
/** 中间太长、没有逐词比,整段标出 */
coarse: boolean
}
/** 逐词比较时动态规划表的格数上限(Uint32Array,4 MB) */
const CELL_LIMIT = 1_000_000
// 空白、字符串('' 转义)、加引号的名字("…"、`…`、[…])、名字和关键字、数、两个字符的运算符、其余单个字符
const TOKEN = /\s+|'(?:[^']|'')*'?|"(?:[^"]|"")*"?|`[^`]*`?|\[[^\]\s]*\]?|[\p{L}_][\p{L}\p{N}_$]*|\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|<=|>=|<>|!=|\|\||::|[\s\S]/gu
interface Token {
text: string
space: boolean
/** 比较用的写法 */
key: string
}
export function tokenize(sql: string): Token[] {
const out: Token[] = []
for (const m of sql.matchAll(TOKEN)) {
const text = m[0]
const space = /^\s+$/.test(text)
const word = /^[\p{L}_]/u.test(text)
out.push({ text, space, key: word ? text.toLowerCase() : text })
}
return out
}
/** 非空白的词里,哪些和对面配上了(最长公共子序列)。返回两个布尔数组:true 是配上了 */
function matchWords(a: string[], b: string[]): { a: boolean[]; b: boolean[]; coarse: boolean } {
const ma = a.map(() => false)
const mb = b.map(() => false)
let lo = 0
while (lo < a.length && lo < b.length && a[lo] === b[lo]) { ma[lo] = true; mb[lo] = true; lo++ }
let ea = a.length
let eb = b.length
while (ea > lo && eb > lo && a[ea - 1] === b[eb - 1]) { ea--; eb--; ma[ea] = true; mb[eb] = true }
const n = ea - lo
const m = eb - lo
if (n === 0 || m === 0) return { a: ma, b: mb, coarse: false }
if ((n + 1) * (m + 1) > CELL_LIMIT) return { a: ma, b: mb, coarse: true }
// dp[i][j]:a 从 lo+i、b 从 lo+j 起到中段末尾的最长公共子序列长度,从后往前填,回溯时从前往后走
const w = m + 1
const dp = new Uint32Array((n + 1) * w)
for (let i = n - 1; i >= 0; i--) {
for (let j = m - 1; j >= 0; j--) {
dp[i * w + j] = a[lo + i] === b[lo + j]
? dp[(i + 1) * w + j + 1] + 1
: Math.max(dp[(i + 1) * w + j], dp[i * w + j + 1])
}
}
let i = 0
let j = 0
while (i < n && j < m) {
if (a[lo + i] === b[lo + j]) {
ma[lo + i] = true
mb[lo + j] = true
i++
j++
} else if (dp[(i + 1) * w + j] >= dp[i * w + j + 1]) {
i++
} else {
j++
}
}
return { a: ma, b: mb, coarse: false }
}
/** 词和配对结果 → 一段段文字。夹在两个变了的词之间的空白也算变了,相邻同类的段并成一段 */
function parts(tokens: Token[], matched: boolean[]): DiffPart[] {
const flags: (boolean | null)[] = []
let w = 0
for (const t of tokens) flags.push(t.space ? null : !matched[w++])
// 每个位置往后最近的一个词变没变(空白看前后两个词),从后往前扫一遍,不在每个空白处再各找一次
const next: boolean[] = new Array(tokens.length).fill(false)
let ahead = false
for (let i = tokens.length - 1; i >= 0; i--) {
next[i] = ahead
if (flags[i] !== null) ahead = flags[i] as boolean
}
const out: DiffPart[] = []
let behind = false
tokens.forEach((t, i) => {
const flag = flags[i]
const changed = flag ?? (behind && next[i])
if (flag !== null) behind = flag
const last = out[out.length - 1]
if (last && last.changed === changed) last.text += t.text
else out.push({ text: t.text, changed })
})
return out
}
export function diffSql(base: string, target: string): SqlDiff {
const tb = tokenize(base)
const tt = tokenize(target)
const words = (ts: Token[]) => ts.filter((t) => !t.space).map((t) => t.key)
const { a, b, coarse } = matchWords(words(tb), words(tt))
return { base: parts(tb, a), target: parts(tt, b), coarse }
}
+148
View File
@@ -3167,3 +3167,151 @@ export interface WhatIfResult {
unsupported: WhatIfUnsupported[]
affected_cards: WhatIfAffectedCard[]
}
// ---------------------------------------------------------------------------
// 运行对比(GET /api/runs/{id}/compare/base、/compare?base=):两次运行的指标变化按数据 / 口径 / 模板与 SQL 拆开说。
// 形状照前后端共用的接口约定,字段名一个不改;数值都是服务端算好的,界面只排版,渲染值原样显示
// ---------------------------------------------------------------------------
/** 对比里的一次运行。版本是空的:没从固定版本发起(探索运行);schedule_id 是空的:手动发起 */
export interface RunCompareRef {
id: string
workflow_name: string
version: number | null
run_class: 'formal' | 'exploratory' | null
started_at: string | null
finished_at: string | null
schedule_id: string | null
}
/** 默认基准从哪来:同一定时任务的上一次,没有就同一工作流、同一运行类别的上一次 */
export type RunCompareBaseReason = 'same_schedule' | 'same_workflow'
/** GET /runs/{id}/compare/base。找不到时 404,detail 是一句原因 */
export interface RunCompareBase {
base_run_id: string
reason: RunCompareBaseReason | (string & {})
base: RunCompareRef
}
/**
* 指标的分类。unchanged:未变;data_only:只数据变;caliber_and_data:口径与数据都变(能严格拆时给口径效应、数据效应);
* sql_changed:所依据的查询 SQL 结构变了(口径不同,不拆);params_only:SQL 只差字面量(定时运行的日期);
* added / removed:只有本次 / 只有基准有;incomparable:缺比较所需的信息
*/
export type RunCompareClass =
| 'unchanged' | 'data_only' | 'caliber_and_data' | 'sql_changed' | 'params_only' | 'added' | 'removed' | 'incomparable'
/** 各类指标的个数 */
export type RunCompareSummary = Record<RunCompareClass, number>
export interface RunCompareTemplateNode {
node_id: string
title: string
change: 'added' | 'removed' | 'config' | (string & {})
/** 改了的配置项(节点配置里的键) */
keys: string[]
}
export interface RunCompareTemplate {
base_version: number | null
target_version: number | null
/** 两次运行的执行图完全相同 */
same_graph: boolean
nodes: RunCompareTemplateNode[]
}
/**
* 严格拆分:口径效应 = 新口径(本次的数据) − 旧口径(本次的数据),数据效应 = 旧口径(本次的数据) − 旧口径(基准的数据),
* 两者之和正好是总变化。*_rendered 是服务端按口径卡格式写好的(带正负号)
*/
export interface RunCompareDecomposition {
caliber_effect: number
data_effect: number
caliber_rendered: string
data_rendered: string
}
/** 一个输入在两次运行里的值(容器写大概,缺的是 null) */
export interface RunCompareInput {
path: string
base: unknown
target: unknown
}
export interface RunCompareAttributionRow {
/** 各维度列上的取值,和 columns 一一对应 */
key: unknown[]
base: number | null
target: number | null
delta: number | null
/** 这一项的变化占总变化的比例(小数,0.4 是 40%)。度量列之和对不上指标的输入时为 null,原因在 note */
share: number | null
}
/** 维度归因:按键对齐两边查询结果的行,变化最大的排在前面 */
export interface RunCompareAttribution {
columns: string[]
measure: string
rows: RunCompareAttributionRow[]
total_delta: number | null
note: string | null
}
export interface RunCompareMetric {
card_id: string
card_title: string
metric_id: string
name: string
unit: string
decimals: number | null
base: number | null
target: number | null
base_rendered: string | null
target_rendered: string | null
delta: number | null
/** 带正负号;比率、百分数写「个百分点」。没变化、不是数时可能为 null */
delta_rendered: string | null
/** 相对基准的变化比例(小数,0.125 是 +12.5%);基准为 0、缺值时为 null */
delta_pct: number | null
classification: RunCompareClass | (string & {})
reasons: string[]
decomposition: RunCompareDecomposition | null
/** 没法严格拆分的原因;能拆、或者用不着拆(只数据变、未变)时为 null */
decomposition_note: string | null
inputs: RunCompareInput[]
attribution: RunCompareAttribution | null
}
/** same:完全相同;params_only:规范化之后相同,只差字面量;changed:结构不同;added / removed:只有一边有 */
export type RunCompareStructure = 'same' | 'params_only' | 'changed' | 'added' | 'removed'
export interface RunCompareQuery {
node_id: string
node_title: string
/** 同一节点里的第几条查询(服务端的序号,只用来排序和区分,界面另按 1 起数) */
ordinal: number
structure: RunCompareStructure | (string & {})
base_sql: string | null
target_sql: string | null
/** 不同的字面量(SQL 写法,字符串带引号),按出现的顺序 */
literals: { base: unknown; target: unknown }[]
base_rows: number | null
target_rows: number | null
base_truncated: boolean | null
target_truncated: boolean | null
}
/** GET /runs/{id}/compare?base=。任一未封存或封存核对不一致 409;不是同一工作流、是同一次运行 422 */
export interface RunComparison {
target: RunCompareRef
base: RunCompareRef
summary: RunCompareSummary
template: RunCompareTemplate
metrics: RunCompareMetric[]
queries: RunCompareQuery[]
notes: string[]
}
/** 运行接口回的运行带着发起它的定时任务(RunOut.schedule_id);Run 上没写这一项,对比页的运行卡要用 */
export type RunWithSchedule = Run & { schedule_id?: string | null }
+724 -6
View File
@@ -1,8 +1,726 @@
// 运行对比的检查。现在只是占位:功能还在开发,这里只打一行通过,让 check-all 的清单先排上它。
// 运行对比的工作包把它换成真检查:沿用别的检查的输出格式——每项缩进两格、以 ✓ / ✗ 开头,最后顶格一行
// 总结,没过的退出码非 0(check-all 按缩进的 ✓ / ✗ 计数)。
// 运行对比页(/runs/compare?target=…&base=…,pages/runs/ComparePage.tsx)的回归检查。
//
// 守的是这些「看着没坏、其实用不了」的地方:运行详情「更多」里没有入口、点了不先找基准就乱跳;摘要的个数和服务端
// 给的对不上;分类标签张冠李戴;口径效应和数据效应只摆两个数,不说两者之和等于总变化(或者自己算了一遍、和服务端
// 渲染的写法对不上);不能拆的一声不吭;展开后归因只给数没有占比时不说为什么;SQL 结构变了只给一条、差异不标;
// 换了基准、互换了却不重新请求,或者慢的旧答复盖掉新的;409(未封存、封存核对不一致)、422(不是同一工作流)、
// 404(找不到基准)只弹一句通用报错;窄屏(390px)被表格撑出横向滚动;浅色下分类标签、差异高亮看不清。
//
// 全部用 page.route 假接口:虚构的「区域销售周报」三次已封存的正式运行和一次没封存的,运行详情、运行列表、
// compare/base、compare 都在浏览器层答掉,别的 GET 放行(外壳要的工作流、待审批),非 GET 一律拦掉。
// 运行对比的两个接口只读,这里的假答复严格照接口约定的字段名和形状。
//
// node scripts/check-compare.mjs
console.log('=== 运行对比 ===')
console.log(' ✓ 占位:功能开发中')
console.log('\n✓ 运行对比:占位通过')
// 对别的实例跑时带上地址:AGENTLAB_WEB=http://localhost:<前端端口> node scripts/check-compare.mjs
// COMPARE_SHOTS=<目录> 时亮暗、宽窄各截几张,供人眼复核。
import { mkdirSync } from 'node:fs'
import { chromium } from '../frontend/node_modules/playwright-core/index.mjs'
const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273'
const CHROME = process.env.CHROME_PATH ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
const SHOTS = process.env.COMPARE_SHOTS ?? ''
let failed = 0
const check = (name, cond, detail = '') => {
console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`)
if (!cond) failed++
}
const opened = new Set()
async function section(name, fn) {
console.log(`\n=== ${name} ===`)
try {
await fn()
} catch (e) {
check(`${name} 中途出错`, false, String(e?.message ?? e).split('\n')[0])
} finally {
for (const c of opened) await c.close().catch(() => {})
opened.clear()
}
}
// ---------------------------------------------------------------- 夹具(虚构)
const WF = 'wf0cmp0sample'
const T = 'c0ffee0target00000000000000000001'
const B = 'c0ffee0base0000000000000000000002'
const B2 = 'c0ffee0older000000000000000000003'
const U = 'c0ffee0open0000000000000000000004'
const NAME = '区域销售周报'
const day = (d, h = 9) => `2026-${d}T0${h - 8}:00:00Z`
const runOf = (id, over = {}) => ({
id, workflow_id: WF, workflow_name: NAME, status: 'succeeded', input: {}, output: {}, error: null,
usage: { wall_ms: 4000, active_ms: 4000, wait_ms: 0 }, run_class: 'formal', version: 4, version_hash: 'ab'.repeat(32),
manifest_hash: 'cd'.repeat(32), manifest_seq: 40, started_by: '值班分析员', error_node_id: null, memory_scope: null,
collection: null, schedule_id: 'sch0weekly', created_at: day('10-05'), started_at: day('10-05'),
finished_at: '2026-10-05T01:02:00Z', ...over,
})
const RUNS = {
[T]: runOf(T),
[B]: runOf(B, { version: 3, created_at: day('09-28'), started_at: day('09-28'), finished_at: '2026-09-28T01:02:00Z' }),
[B2]: runOf(B2, { version: 3, created_at: day('09-21'), started_at: day('09-21'), finished_at: '2026-09-21T01:02:00Z' }),
// 没封存的一次:能出现在下拉里,但不能选
[U]: runOf(U, { run_class: 'exploratory', version: null, manifest_hash: null, manifest_seq: null, schedule_id: null,
created_at: day('10-03'), started_at: day('10-03'), finished_at: '2026-10-03T01:02:00Z' }),
}
const refOf = (id) => {
const r = RUNS[id]
return { id, workflow_name: r.workflow_name, version: r.version, run_class: r.run_class, started_at: r.started_at,
finished_at: r.finished_at, schedule_id: r.schedule_id }
}
const REGIONS = ['青禾区', '白石区', '云杉区', '赤松区', '蓝湾区', '金穗区', '银杏区', '翠竹区', '月湖区', '星港区', '石桥区', '柳岸区']
// 各区的变化之和正好是总变化 2,531.5;占比按服务端的约定给小数(0.4 是 40%)
const DELTAS = [1012.6, 506.3, 379.7, 253.2, 126.6, 101.3, 63.3, 38.0, 25.3, 12.6, 7.6, 5.0]
const ATTR_ROWS = REGIONS.map((name, i) => ({
key: [name], base: 3000 + i * 100, target: 3000 + i * 100 + DELTAS[i], delta: DELTAS[i],
share: Math.round(DELTAS[i] / 2531.5 * 10000) / 10000,
}))
const metric = (over) => ({
card_id: 'caliber', card_title: '周报口径', unit: '', decimals: 1, reasons: [], decomposition: null,
decomposition_note: null, inputs: [], attribution: null, delta_pct: null, ...over,
})
const METRICS = [
metric({ metric_id: 'gmv', name: '销售额', unit: '元', base: 45678.5, target: 48210, base_rendered: '45,678.5元',
target_rendered: '48,210.0元', delta: 2531.5, delta_rendered: '+2,531.5元', delta_pct: 0.0554, classification: 'data_only',
reasons: ['口径与 SQL 均未改动,变化来自数据'],
inputs: [{ path: 'vars.kpi.gmv', base: 45678.5, target: 48210 }],
attribution: { columns: ['区域'], measure: 'amount', rows: ATTR_ROWS, total_delta: 2531.5, note: null } }),
metric({ metric_id: 'orders', name: '订单数', unit: '单', decimals: 0, base: 1234, target: 1301, base_rendered: '1,234单',
target_rendered: '1,301单', delta: 67, delta_rendered: '+67单', delta_pct: 0.0543, classification: 'data_only' }),
metric({ metric_id: 'aov', name: '客单价', unit: '元', base: 37.0, target: 41.5, base_rendered: '37.0元', target_rendered: '41.5元',
delta: 4.5, delta_rendered: '+4.5元', delta_pct: 0.1216, classification: 'caliber_and_data',
reasons: ['口径卡「周报口径」中客单价的计算式有改动'],
decomposition: { caliber_effect: 5.8, data_effect: -1.3, caliber_rendered: '+5.8元', data_rendered: '-1.3元' },
inputs: [{ path: 'vars.kpi.gmv', base: 45678.5, target: 48210 }, { path: 'vars.kpi.order_cnt', base: 1234, target: 1234 }],
attribution: { columns: ['区域', '渠道'], measure: 'order_cnt', total_delta: 67,
note: '各区域订单数之和与指标的输入对不上,不给占比',
rows: [{ key: ['青禾区', '门店'], base: 400, target: 440, delta: 40, share: null },
{ key: ['白石区', '线上'], base: 300, target: 327, delta: 27, share: null }] } }),
metric({ metric_id: 'refund_rate', name: '退货率', unit: '%', decimals: 2, base: 0.0235, target: 0.0198, base_rendered: '2.35%',
target_rendered: '1.98%', delta: -0.0037, delta_rendered: '-0.37 个百分点', delta_pct: -0.1574, classification: 'sql_changed',
reasons: ['所依据的查询「退货明细」的筛选条件有改动'],
decomposition_note: '所依据的查询「退货明细」SQL 结构有改动,口径不同,不做拆分' }),
metric({ metric_id: 'visitors', name: '日均访客', unit: '人', decimals: 0, base: 5200, target: 5480, base_rendered: '5,200人',
target_rendered: '5,480人', delta: 280, delta_rendered: '+280人', delta_pct: 0.0538, classification: 'params_only',
reasons: ['「拉取销量」只有日期参数不同'] }),
metric({ metric_id: 'new_users', name: '新客数', unit: '人', decimals: 0, base: null, target: 320, base_rendered: '—',
target_rendered: '320人', delta: null, delta_rendered: null, classification: 'added', reasons: ['本次新增的指标'] }),
metric({ metric_id: 'members', name: '会员数', unit: '人', decimals: 0, base: 8800, target: null, base_rendered: '8,800人',
target_rendered: '—', delta: null, delta_rendered: null, classification: 'removed', reasons: ['本次没有这个指标'] }),
metric({ metric_id: 'margin', name: '毛利率', unit: '%', decimals: 1, base: 0.31, target: 0.33, base_rendered: '31.0%',
target_rendered: '33.0%', delta: 0.02, delta_rendered: '+2.0 个百分点', delta_pct: 0.0645, classification: 'incomparable',
reasons: ['基准运行的口径卡没有记录输入'], decomposition_note: '基准运行的口径卡没有记录输入,无法拆分' }),
...[['stores', '门店数', '家', 56], ['complaints', '客诉数', '件', 12], ['sku', 'SKU 数', '个', 1500]].map(([id, name, unit, v]) =>
metric({ metric_id: id, name, unit, decimals: 0, base: v, target: v, base_rendered: `${v.toLocaleString('en-US')}${unit}`,
target_rendered: `${v.toLocaleString('en-US')}${unit}`, delta: 0, delta_rendered: null, delta_pct: 0, classification: 'unchanged' })),
]
const SUMMARY = { unchanged: 3, data_only: 2, caliber_and_data: 1, sql_changed: 1, params_only: 1, added: 1, removed: 1, incomparable: 1 }
const PULL_BASE = "SELECT region, SUM(amount) AS gmv FROM sales WHERE sold_on >= '2026-09-21' AND sold_on < '2026-09-28' GROUP BY region"
const PULL_TARGET = "SELECT region, SUM(amount) AS gmv FROM sales WHERE sold_on >= '2026-09-28' AND sold_on < '2026-10-05' GROUP BY region"
const QUERIES = [
{ node_id: 'pull', node_title: '拉取销量', ordinal: 0, structure: 'params_only', base_sql: PULL_BASE, target_sql: PULL_TARGET,
literals: [{ base: "'2026-09-21'", target: "'2026-09-28'" }, { base: "'2026-09-28'", target: "'2026-10-05'" }],
base_rows: 12, target_rows: 12, base_truncated: false, target_truncated: false },
{ node_id: 'refund', node_title: '退货明细', ordinal: 0, structure: 'changed',
base_sql: "SELECT COUNT(*) AS n\nFROM refunds r\nWHERE r.created_at >= '2026-09-21'",
target_sql: "SELECT COUNT(*) AS n\nFROM refunds r\nWHERE r.paid_at >= '2026-09-28' AND r.status = 'done'",
literals: [], base_rows: 500, target_rows: 488, base_truncated: true, target_truncated: false },
{ node_id: 'stores', node_title: '门店清单', ordinal: 0, structure: 'same', base_sql: 'SELECT COUNT(*) AS n FROM stores',
target_sql: 'SELECT COUNT(*) AS n FROM stores', literals: [], base_rows: 1, target_rows: 1, base_truncated: false, target_truncated: false },
{ node_id: 'pull', node_title: '拉取销量', ordinal: 1, structure: 'same', base_sql: 'SELECT COUNT(*) AS n FROM visits',
target_sql: 'SELECT COUNT(*) AS n FROM visits', literals: [], base_rows: 1, target_rows: 1, base_truncated: false, target_truncated: false },
{ node_id: 'newbie', node_title: '新客查询', ordinal: 0, structure: 'added', base_sql: null,
target_sql: "SELECT COUNT(*) AS n FROM users WHERE first_order_on >= '2026-09-28'", literals: [], base_rows: null, target_rows: 1,
base_truncated: null, target_truncated: false },
{ node_id: 'member', node_title: '会员查询', ordinal: 0, structure: 'removed', base_sql: 'SELECT COUNT(*) AS n FROM members',
target_sql: null, literals: [], base_rows: 1, target_rows: null, base_truncated: false, target_truncated: null },
]
const TEMPLATE = { base_version: 3, target_version: 4, same_graph: false, nodes: [
{ node_id: 'caliber', title: '周报口径', change: 'config', keys: ['metrics'] },
{ node_id: 'refund', title: '退货明细', change: 'config', keys: ['sql', 'timeout'] },
{ node_id: 'newbie', title: '新客查询', change: 'added', keys: [] },
{ node_id: 'member', title: '会员查询', change: 'removed', keys: [] },
] }
const NOTE = '基准运行中「退货明细」的查询结果已截断(只保存了前 500 行),按已保存的行比较'
/** 以更早的一次(B2)为基准时多一条说明:换基准、过时答复的检查靠它认出页面上是哪一份 */
const NOTE_B2 = '以更早的一次运行为基准'
function comparison(target, base) {
return { target: refOf(target), base: refOf(base), summary: SUMMARY, template: TEMPLATE, metrics: METRICS, queries: QUERIES,
notes: base === B2 ? [NOTE, NOTE_B2] : [NOTE] }
}
const LABEL = { unchanged: '未变', data_only: '只数据变', caliber_and_data: '口径与数据都变', sql_changed: 'SQL 结构变',
params_only: '只参数变', added: '新增', removed: '移除', incomparable: '无法比较' }
const ORDER = ['data_only', 'caliber_and_data', 'sql_changed', 'params_only', 'added', 'removed', 'incomparable', 'unchanged']
const REASON = '默认基准:同一定时任务的上一次运行'
// ---------------------------------------------------------------- 假接口
function events() {
const t0 = Date.parse(day('10-05')) / 1000
return [
{ seq: 1, type: 'run.started', node_id: null, ts: t0, data: { nodes: 2 } },
{ seq: 2, type: 'run.finished', node_id: null, ts: t0 + 4, data: { output: {}, usage: {}, timing: { wall_ms: 4000, active_ms: 4000, wait_ms: 0 } } },
]
}
const GRAPH = { nodes: [
{ id: 'pull', type: 'tool', position: { x: 0, y: 0 }, data: { label: '拉取销量', config: {} } },
{ id: 'caliber', type: 'metrics', position: { x: 200, y: 0 }, data: { label: '周报口径', config: {} } },
], edges: [{ source: 'pull', target: 'caliber' }] }
/**
* 默认的假接口。opts.base(runId) / opts.compare(target, base, n) 可以换掉对应接口的答复,返回 {status, json, wait}
* (wait 是一个 Promise:等它落定再答,用来压住某一次请求)
*/
function defaults(opts) {
return async (method, url, n) => {
const p = url.pathname
if (method !== 'GET') return null
if (p === '/api/runs') {
const wf = url.searchParams.get('workflow_id')
const list = Object.values(RUNS).filter((r) => !wf || r.workflow_id === wf)
.sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at))
return { json: list }
}
let m = p.match(/^\/api\/runs\/([^/]+)\/compare\/base$/)
if (m) {
if (opts.base) return opts.base(m[1])
return m[1] === T ? { json: { base_run_id: B, reason: 'same_schedule', base: refOf(B) } }
: { status: 404, json: { detail: '没有找到可作为基准的运行' } }
}
m = p.match(/^\/api\/runs\/([^/]+)\/compare$/)
if (m) {
const base = url.searchParams.get('base')
if (opts.compare) return opts.compare(m[1], base, n)
return { json: comparison(m[1], base) }
}
m = p.match(/^\/api\/runs\/([^/]+)(\/[a-z]+)?$/)
if (m && RUNS[m[1]]) {
if (!m[2]) return { json: RUNS[m[1]] }
if (m[2] === '/events') return { json: events() }
if (m[2] === '/graph') return { json: { graph: GRAPH, workflow_id: WF, version: RUNS[m[1]].version } }
if (m[2] === '/artifacts') return { json: [] }
}
if (p === '/api/approvals' && url.searchParams.get('run_id')) return { json: [] }
return null
}
}
const browser = await chromium.launch({ executablePath: CHROME })
/**
* 开一页:/api 的请求先问 defaults(再由 opts 换掉其中几个),答不了的 GET 放行到服务端,非 GET 一律拦掉。
* 记下每次请求(「GET /api/runs/…?base=…」)和页面报错;原生对话框都算失败
*/
async function open(path, { width = 1280, height = 900, theme = 'dark', waitUntil = 'networkidle', ...opts } = {}) {
const ctx = await browser.newContext({ viewport: { width, height }, colorScheme: theme, timezoneId: 'Asia/Shanghai' })
opened.add(ctx)
ctx.setDefaultTimeout(6000)
ctx.setDefaultNavigationTimeout(30000)
await ctx.addInitScript((t) => {
try {
localStorage.setItem('agentlab.theme', t)
localStorage.removeItem('agentlab.health')
} catch { /* noop */ }
}, theme)
const page = await ctx.newPage()
const log = []
const blocked = []
const errors = []
page.on('pageerror', (e) => errors.push(e.message))
page.on('dialog', (d) => { errors.push(`原生对话框:${d.message()}`); void d.dismiss() })
const answer = defaults(opts)
let n = 0
await page.route((u) => new URL(u).pathname.startsWith('/api/'), async (route) => {
const req = route.request()
const url = new URL(req.url())
const key = `${req.method()} ${url.pathname}${url.search}`
log.push(key)
const out = await answer(req.method(), url, ++n)
if (out) {
if (out.wait) await out.wait
return route.fulfill({ status: out.status ?? 200, json: out.json }).catch(() => {})
}
if (req.method() === 'GET') return route.continue()
blocked.push(key)
return route.abort()
})
await page.goto(`${WEB}${path}`, { waitUntil })
// 设置接口回来时会按服务端存的主题再切一次:之后照 lib/theme.ts 的 applyTheme 切到要查的这一套——切的那一帧关掉
// 过渡,不然带 transition 的按钮、摘要格还在慢慢变色,量出来、截下来的是半路的颜色
await page.evaluate((t) => {
const root = document.documentElement
root.classList.add('theme-switching')
root.setAttribute('data-theme', t)
void getComputedStyle(root).backgroundColor
requestAnimationFrame(() => root.classList.remove('theme-switching'))
}, theme)
return { ctx, page, log, blocked, errors }
}
const comparePath = (target, base) => `/runs/compare?target=${target}${base ? `&base=${base}` : ''}`
/** 等对比页落定:state 是 ok / error / no-target / finding… */
const settled = (page, state = 'ok') => page.locator(`[data-compare-page][data-compare-state="${state}"]`).waitFor({ timeout: 8000 })
const text = (page, sel) => page.locator(sel).first().innerText()
const texts = (page, sel) => page.locator(sel).allInnerTexts()
const has = (page, sel) => page.locator(sel).count().then((c) => c > 0)
const compareHits = (log, target, base) => log.filter((k) => k === `GET /api/runs/${target}/compare?base=${base}`).length
const until = async (fn, ms = 6000, step = 100) => {
const end = Date.now() + ms
let v = await fn()
while (!v && Date.now() < end) { await new Promise((r) => setTimeout(r, step)); v = await fn() }
return v
}
const shot = async (page, name) => {
if (!SHOTS) return
mkdirSync(SHOTS, { recursive: true })
await page.screenshot({ path: `${SHOTS}/${name}.png`, fullPage: false })
}
// ---------------------------------------------------------------- 检查
await section('从运行详情进入', async () => {
const { page, log, blocked, errors } = await open(`/runs/${T}`)
await page.locator(`[data-run-detail="${T}"]`).waitFor({ timeout: 10000 })
await page.getByRole('button', { name: '更多操作' }).click()
const item = page.locator('[data-menu=compare]')
check('「更多」菜单里有「与上一次对比」,可点', await item.count() === 1 && (await item.innerText()).includes('与上一次对比')
&& !(await item.isDisabled()))
await item.click()
await page.waitForURL((u) => u.pathname === '/runs/compare', { timeout: 8000 })
await settled(page)
const url = new URL(page.url())
check('跳到 /runs/compare,带着本次和基准', url.searchParams.get('target') === T && url.searchParams.get('base') === B, url.search)
const iBase = log.indexOf(`GET /api/runs/${T}/compare/base`)
const iCmp = log.indexOf(`GET /api/runs/${T}/compare?base=${B}`)
check('先请求 compare/base,再请求 compare', iBase >= 0 && iCmp > iBase, `${iBase} → ${iCmp}`)
check('compare/base 只请求一次(页面不再自己找一遍)', log.filter((k) => k.startsWith(`GET /api/runs/${T}/compare/base`)).length === 1)
check('写明基准是怎么选的', (await text(page, '[data-compare-reason]')).trim() === REASON, await text(page, '[data-compare-reason]').catch(() => ''))
check('没有发出写请求', blocked.length === 0, blocked.join(';'))
check('页面没有报错', errors.length === 0, errors.join(';'))
})
await section('未封存的运行:入口不可点并说明原因', async () => {
const saved = RUNS[T]
RUNS[T] = { ...saved, manifest_hash: null, manifest_seq: null }
try {
const { page, log } = await open(`/runs/${T}`)
await page.locator(`[data-run-detail="${T}"]`).waitFor({ timeout: 10000 })
await page.getByRole('button', { name: '更多操作' }).click()
const item = page.locator('[data-menu=compare]')
check('未封存:「与上一次对比」不可点,写明要先封存', await item.isDisabled() && (await item.innerText()).includes('封存'))
check('未封存:没有请求 compare/base', !log.some((k) => k.includes('/compare')))
} finally {
RUNS[T] = saved
}
})
await section('两次运行与摘要', async () => {
const { page, errors } = await open(comparePath(T, B))
await settled(page)
const baseCard = page.locator('[data-compare-run="base"]')
const targetCard = page.locator('[data-compare-run="target"]')
check('基准、本次各一张卡,写着名字、版本和类别', (await baseCard.innerText()).includes(NAME)
&& (await baseCard.innerText()).includes('正式运行 v3') && (await targetCard.innerText()).includes('正式运行 v4'),
`${(await baseCard.innerText()).replace(/\s+/g, ' ')} | ${(await targetCard.innerText()).replace(/\s+/g, ' ')}`)
check('两张卡都能点进各自的运行详情', await baseCard.locator('a[data-compare-run-link]').getAttribute('href') === `/runs/${B}`
&& await targetCard.locator('a[data-compare-run-link]').getAttribute('href') === `/runs/${T}`)
check('定时任务发起的运行标出来', (await targetCard.innerText()).includes('定时任务发起'))
check('直接打开带基准的地址:不写「默认基准」', await page.locator('[data-compare-reason]').count() === 0)
const tiles = await page.locator('[data-compare-summary] [data-compare-class]').evaluateAll((els) =>
els.map((e) => [e.getAttribute('data-compare-class'), e.querySelector('[data-compare-count]')?.textContent?.trim(), e.textContent]))
check('摘要按约定的八类排好', JSON.stringify(tiles.map((t) => t[0])) === JSON.stringify(ORDER), tiles.map((t) => t[0]).join(','))
check('摘要的个数和服务端给的一致', tiles.every(([k, n]) => Number(n) === SUMMARY[k]), tiles.map(([k, n]) => `${k}=${n}`).join(' '))
check('摘要的标签写对', tiles.every(([k, , t]) => t.includes(LABEL[k])))
await shot(page, 'wide-dark')
check('页面没有报错', errors.length === 0, errors.join(';'))
})
await section('指标表', async () => {
const { page } = await open(comparePath(T, B))
await settled(page)
const rows = page.locator('tr[data-compare-metric]')
check('每个指标一行', await rows.count() === METRICS.length, String(await rows.count()))
const chips = await rows.evaluateAll((els) => els.map((e) => [e.getAttribute('data-compare-metric'),
e.getAttribute('data-class'), e.querySelector('[data-compare-chip]')?.textContent?.trim()]))
check('分类标签和分类对得上', chips.every(([id, k, label]) => METRICS.find((m) => `caliber:${m.metric_id}` === id)?.classification === k
&& label === LABEL[k]), chips.map((c) => c.join('/')).join(' '))
const gmv = page.locator('tr[data-compare-metric="caliber:gmv"]')
check('基准 → 本次、变化量用服务端渲染的写法', (await gmv.locator('[data-compare-base]').innerText()) === '45,678.5元'
&& (await gmv.locator('[data-compare-target]').innerText()) === '48,210.0元'
&& (await gmv.locator('[data-compare-delta]').innerText()) === '+2,531.5元')
check('变化百分比带正负号(0.0554 → +5.5%)', (await gmv.locator('[data-compare-pct]').innerText()) === '+5.5%',
await gmv.locator('[data-compare-pct]').innerText().catch(() => ''))
const refund = page.locator('tr[data-compare-metric="caliber:refund_rate"]')
check('下降的百分比写负号', (await refund.locator('[data-compare-pct]').innerText()) === '-15.7%')
const aov = page.locator('tr[data-compare-metric="caliber:aov"]')
check('能拆分的:口径效应、数据效应并列', (await aov.locator('[data-compare-caliber]').innerText()).includes('+5.8元')
&& (await aov.locator('[data-compare-data]').innerText()).includes('-1.3元'),
(await aov.locator('[data-compare-decomp]').innerText().catch(() => '')).replace(/\s+/g, ' '))
check('能拆分的:画了两段条形', await aov.locator('[data-compare-decomp] [data-compare-bar] > i').count() === 2)
check('表头说明两者之和等于总变化', (await text(page, '[data-compare-legend]')).includes('两者之和等于总变化'))
check('不能拆的:写出服务端给的原因', (await refund.locator('[data-compare-decomp-note]').innerText())
=== '所依据的查询「退货明细」SQL 结构有改动,口径不同,不做拆分')
check('无法比较的也写原因', (await page.locator('tr[data-compare-metric="caliber:margin"] [data-compare-decomp-note]').innerText())
.includes('没有记录输入'))
check('新增的:基准写「—」,本次照写', (await page.locator('tr[data-compare-metric="caliber:new_users"] [data-compare-base]').innerText()) === '—'
&& (await page.locator('tr[data-compare-metric="caliber:new_users"] [data-compare-target]').innerText()) === '320人')
check('未变的:变化写「无变化」', (await page.locator('tr[data-compare-metric="caliber:stores"] [data-compare-delta]').innerText()) === '无变化')
check('只数据变、不拆分的:不编造拆分', await gmv.locator('[data-compare-decomp], [data-compare-decomp-note]').count() === 0)
})
await section('展开:输入对比与维度归因', async () => {
const { page, errors } = await open(comparePath(T, B))
await settled(page)
const gmvBtn = page.locator('tr[data-compare-metric="caliber:gmv"] button[data-compare-expand]')
check('默认收起', (await gmvBtn.getAttribute('aria-expanded')) === 'false' && await page.locator('[data-compare-detail]').count() === 0)
await gmvBtn.click()
const detail = page.locator('tr[data-compare-detail="caliber:gmv"]')
await detail.waitFor()
check('展开后标为展开、指向明细', (await gmvBtn.getAttribute('aria-expanded')) === 'true'
&& (await gmvBtn.getAttribute('aria-controls')) === await detail.getAttribute('id'))
check('列出变化说明', (await detail.locator('[data-compare-reasons]').innerText()).includes('变化来自数据'))
const input = detail.locator('[data-compare-inputs] tr[data-compare-input="vars.kpi.gmv"]')
check('输入对比:路径、两边的值,变了的标出来', (await input.innerText()).includes('45,678.5') && (await input.innerText()).includes('48,210')
&& (await input.getAttribute('data-changed')) !== null, (await input.innerText().catch(() => '')).replace(/\s+/g, ' '))
const attrRows = detail.locator('[data-compare-attr] tr[data-compare-attr-row]')
check('维度归因只列前 10 项', await attrRows.count() === 10, String(await attrRows.count()))
check('写明另有几项没列', (await detail.locator('[data-compare-attr-more]').innerText()).includes('2'))
const first = attrRows.first()
check('归因一行:键、两边的值、变化、占比', (await first.innerText()).includes('青禾区') && (await first.innerText()).includes('+1,012.6')
&& (await first.locator('[data-compare-share]').innerText()) === '40.0%', (await first.innerText()).replace(/\s+/g, ' '))
check('写明按哪一列归因', (await detail.locator('[data-compare-attr]').innerText()).includes('amount'))
check('有占比时不写缺占比的原因', await detail.locator('[data-compare-attr-note]').count() === 0)
// 键盘:Tab 到客单价的展开按钮,回车展开
const aovBtn = page.locator('tr[data-compare-metric="caliber:aov"] button[data-compare-expand]')
await aovBtn.focus()
await page.keyboard.press('Enter')
const aov = page.locator('tr[data-compare-detail="caliber:aov"]')
await aov.waitFor()
check('键盘回车也能展开', (await aovBtn.getAttribute('aria-expanded')) === 'true')
check('拆分写成一句:口径效应、数据效应,两者之和等于总变化',
(await aov.locator('[data-compare-decomp-sum]').innerText()).trim() === '口径效应 +5.8元,数据效应 -1.3元,两者之和等于总变化 +4.5元',
await aov.locator('[data-compare-decomp-sum]').innerText().catch(() => ''))
check('没有变的输入不标', (await aov.locator('tr[data-compare-input="vars.kpi.order_cnt"]').getAttribute('data-changed')) === null)
const shares = await aov.locator('[data-compare-attr] [data-compare-share]').allInnerTexts()
check('没有占比:占比一栏写「—」', shares.length === 2 && shares.every((s) => s.trim() === '—'), shares.join(','))
check('没有占比:写出原因', (await aov.locator('[data-compare-attr-note]').innerText()).includes('各区域订单数之和与指标的输入对不上'))
check('多个维度列:键按列写全', (await aov.locator('[data-compare-attr] tr[data-compare-attr-row]').first().innerText()).includes('门店'))
await shot(page, 'expanded-dark')
// 再按一次收起
await aovBtn.press('Enter')
check('再按一次收起', await page.locator('tr[data-compare-detail="caliber:aov"]').count() === 0)
// 没有归因、没有输入的指标:照实说
await page.locator('tr[data-compare-metric="caliber:orders"] button[data-compare-expand]').click()
const orders = page.locator('tr[data-compare-detail="caliber:orders"]')
check('没有维度归因、没有输入时照实说', (await orders.locator('[data-compare-attr-none]').count()) === 1
&& (await orders.locator('[data-compare-inputs-none]').count()) === 1)
check('页面没有报错', errors.length === 0, errors.join(';'))
})
await section('摘要筛选', async () => {
const { page } = await open(comparePath(T, B))
await settled(page)
const tile = page.locator('[data-compare-summary] [data-compare-class="sql_changed"]')
await tile.click()
check('点一类:只剩这一类的指标,按钮标为按下', await page.locator('tr[data-compare-metric]').count() === 1
&& (await tile.getAttribute('aria-pressed')) === 'true'
&& await page.locator('tr[data-compare-metric="caliber:refund_rate"]').count() === 1)
check('写明正在筛选', (await text(page, '[data-compare-filter]')).includes('SQL 结构变'))
await tile.press('Enter')
check('再按一次(键盘)回到全部', await page.locator('tr[data-compare-metric]').count() === METRICS.length
&& (await tile.getAttribute('aria-pressed')) === 'false')
await page.locator('[data-compare-summary] [data-compare-class="unchanged"]').click()
await page.locator('[data-compare-filter-clear]').click()
check('「显示全部」清掉筛选', await page.locator('tr[data-compare-metric]').count() === METRICS.length)
})
await section('查询', async () => {
const { page } = await open(comparePath(T, B))
await settled(page)
const qs = page.locator('[data-compare-query]')
check('每条查询一项', await qs.count() === QUERIES.length, String(await qs.count()))
const refund = page.locator('[data-compare-query="refund:0"]')
check('结构变:标「结构变」', (await refund.getAttribute('data-structure')) === 'changed'
&& (await refund.locator('[data-compare-structure]').innerText()).trim() === '结构变')
const baseSql = refund.locator('[data-compare-sql="base"]')
const targetSql = refund.locator('[data-compare-sql="target"]')
const [bb, tb] = [await baseSql.boundingBox(), await targetSql.boundingBox()]
check('结构变:两条 SQL 并排(基准在左、本次在右)', !!bb && !!tb && Math.abs(bb.y - tb.y) < 2 && bb.x + bb.width <= tb.x + 1,
JSON.stringify({ bb, tb }))
check('两边都是原文', (await baseSql.innerText()).includes('r.created_at') && (await targetSql.innerText()).includes("r.status = 'done'"))
const del = (await baseSql.locator('mark[data-diff="del"]').allInnerTexts()).join(' ')
const add = (await targetSql.locator('mark[data-diff="add"]').allInnerTexts()).join(' ')
check('高亮基准独有的部分', del.includes('created_at') && del.includes("'2026-09-21'") && !del.includes('SELECT'), del)
check('高亮本次独有的部分', add.includes('paid_at') && add.includes("r.status = 'done'") && !add.includes('FROM'), add)
check('相同的部分不高亮', !(await baseSql.locator('mark').allInnerTexts()).join(' ').includes('refunds'))
check('截断照实标', (await refund.locator('[data-compare-rows]').innerText()).includes('已截断'))
const pull = page.locator('[data-compare-query="pull:0"]')
const lits = await pull.locator('[data-compare-literals] li').allInnerTexts()
check('只参数变:列出变化的参数', lits.length === 2 && lits[0].includes("'2026-09-21'") && lits[0].includes("'2026-09-28'")
&& lits[1].includes("'2026-10-05'"), lits.join(' | '))
check('只参数变:默认不并排摆两条 SQL', await pull.locator('[data-compare-sql]').count() === 0)
const same = page.locator('[data-compare-query="stores:0"]')
check('相同的:默认不展开 SQL', (await same.locator('[data-compare-structure]').innerText()).trim() === '相同'
&& await same.locator('[data-compare-sql]').count() === 0)
await same.locator('button[data-compare-sql-toggle]').click()
check('相同的:点开能看 SQL', (await same.locator('[data-compare-sql]').first().innerText()).includes('FROM stores'))
check('同一节点的第二条查询写「第 2 条」', (await page.locator('[data-compare-query="pull:1"] [data-compare-nth]').innerText()).includes('第 2 条')
&& (await pull.locator('[data-compare-nth]').innerText()).includes('第 1 条'))
check('只有一条查询的节点不写第几条', await refund.locator('[data-compare-nth]').count() === 0)
const added = page.locator('[data-compare-query="newbie:0"]')
check('新增、移除的查询各只有一边的 SQL', await added.locator('[data-compare-sql="target"]').count() === 1
&& await added.locator('[data-compare-sql="base"]').count() === 0
&& await page.locator('[data-compare-query="member:0"] [data-compare-sql="base"]').count() === 1)
await refund.scrollIntoViewIfNeeded()
await shot(page, 'queries-dark')
})
await section('模板与说明', async () => {
const { page } = await open(comparePath(T, B))
await settled(page)
check('模板:版本 v3 → v4', (await text(page, '[data-compare-versions]')).includes('v3') && (await text(page, '[data-compare-versions]')).includes('v4'))
const node = page.locator('[data-compare-node="refund"]')
check('变化的节点:名字、变化类型、配置项', (await node.getAttribute('data-change')) === 'config' && (await node.innerText()).includes('退货明细')
&& (await node.innerText()).includes('配置变更')
&& JSON.stringify(await node.locator('[data-compare-key]').allInnerTexts()) === JSON.stringify(['sql', 'timeout']))
check('新增、移除的节点写明', (await text(page, '[data-compare-node="newbie"]')).includes('新增节点')
&& (await text(page, '[data-compare-node="member"]')).includes('移除节点'))
check('说明照实列出', JSON.stringify(await texts(page, '[data-compare-notes] li')) === JSON.stringify([NOTE]))
})
await section('换基准、互换', async () => {
const { page, log } = await open(comparePath(T, B))
await settled(page)
const picker = page.locator('select[data-compare-base-picker]')
const options = await picker.locator('option').evaluateAll((els) => els.map((o) => [o.value, o.disabled, o.textContent]))
check('下拉列出同一工作流的其他运行(不含本次)', options.some(([v]) => v === B) && options.some(([v]) => v === B2)
&& !options.some(([v]) => v === T), options.map((o) => o[0].slice(0, 12)).join(','))
const open_ = options.find(([v]) => v === U)
check('没封存的那次不能选,写明原因', !!open_ && open_[1] === true && open_[2].includes('未封存'), JSON.stringify(open_))
check('当前基准是选中的', await picker.inputValue() === B)
check('列运行用的是运行列表接口(按工作流)', log.some((k) => k.startsWith('GET /api/runs?') && k.includes(`workflow_id=${WF}`)))
await picker.selectOption(B2)
await until(async () => new URL(page.url()).searchParams.get('base') === B2)
await until(async () => (await texts(page, '[data-compare-notes] li')).includes(NOTE_B2))
check('换基准:地址跟着变', new URL(page.url()).searchParams.get('base') === B2)
check('换基准:重新请求对比', compareHits(log, T, B2) === 1)
check('换基准:页面换成新的一份', (await texts(page, '[data-compare-notes] li')).includes(NOTE_B2))
check('换基准:基准卡跟着换', (await text(page, '[data-compare-run="base"]')).includes('9/21'))
await page.locator('button[data-compare-swap]').click()
await until(async () => new URL(page.url()).searchParams.get('target') === B2)
await until(async () => compareHits(log, B2, T) === 1)
const u = new URL(page.url())
check('互换:本次和基准对调', u.searchParams.get('target') === B2 && u.searchParams.get('base') === T, u.search)
check('互换:重新请求对比', compareHits(log, B2, T) === 1)
await settled(page)
check('互换:两张卡跟着对调', (await page.locator('[data-compare-run="target"] a[data-compare-run-link]').getAttribute('href')) === `/runs/${B2}`)
})
await section('过时的答复不盖掉新的', async () => {
let release
const gate = new Promise((r) => { release = r })
const { page, log } = await open(comparePath(T, B), { waitUntil: 'domcontentloaded', compare: async (target, base) => {
if (base === B) return { json: comparison(target, base), wait: gate }
return { json: comparison(target, base) }
} })
await page.locator('[data-compare-page]').waitFor({ timeout: 10000 })
const loading = await until(() => has(page, '[data-compare-loading][aria-busy="true"]'))
check('加载中:标为忙', loading)
await until(async () => (await page.locator('select[data-compare-base-picker] option').count()) > 1)
await page.locator('select[data-compare-base-picker]').selectOption(B2)
await settled(page)
release()
await page.waitForTimeout(600)
check('慢的旧答复回来后,页面仍是新基准的那一份', (await texts(page, '[data-compare-notes] li')).includes(NOTE_B2)
&& new URL(page.url()).searchParams.get('base') === B2)
check('两次都发出去了', compareHits(log, T, B) === 1 && compareHits(log, T, B2) === 1)
})
/** 「其他错误」那一节:第一次对比回 500,之后照常 */
let flakyCalls = 0
await section('出错与找不到基准', async () => {
const sealed = await open(comparePath(T, B), { compare: () => ({ status: 409, json: { detail: '基准运行的封存核对不一致,无法对比' } }) })
await settled(sealed.page, 'error')
const e409 = sealed.page.locator('[data-compare-error="409"]')
check('409:写明未封存或封存核对不一致,带上服务端的原因', (await e409.locator('[data-compare-error-title]').innerText()).includes('封存')
&& (await e409.innerText()).includes('基准运行的封存核对不一致,无法对比'), (await e409.innerText().catch(() => '')).replace(/\s+/g, ' '))
check('409:给出去两次运行详情核对的入口', await e409.locator(`a[href="/runs/${B}"]`).count() === 1
&& await e409.locator(`a[href="/runs/${T}"]`).count() === 1)
check('409:两张运行卡仍在,下拉仍能换基准', await sealed.page.locator('[data-compare-run="base"]').count() === 1
&& await sealed.page.locator('select[data-compare-base-picker]').isEnabled())
check('409:不显示摘要、指标', await sealed.page.locator('[data-compare-summary], tr[data-compare-metric]').count() === 0)
const other = await open(comparePath(T, B), { compare: () => ({ status: 422, json: { detail: '两次运行不属于同一个工作流' } }) })
await settled(other.page, 'error')
const e422 = other.page.locator('[data-compare-error="422"]')
check('422:写明无法对比,带上原因和下一步', (await e422.innerText()).includes('两次运行不属于同一个工作流')
&& (await e422.innerText()).includes('同一工作流'), (await e422.innerText().catch(() => '')).replace(/\s+/g, ' '))
const gone = await open(comparePath(T, B), { compare: () => ({ status: 404, json: { detail: '运行记录不存在' } }) })
await settled(gone.page, 'error')
check('404:写明运行记录不存在', (await gone.page.locator('[data-compare-error="404"]').innerText()).includes('运行记录不存在'))
const flaky = await open(comparePath(T, B), { compare: () => (flakyCalls++ === 0
? { status: 500, json: { detail: '对比服务暂时不可用' } } : { json: comparison(T, B) }) })
await settled(flaky.page, 'error')
await flaky.page.locator('[data-compare-error] button', { hasText: '重试' }).click()
await settled(flaky.page)
check('其他错误:给「重试」,重试后正常显示', await flaky.page.locator('tr[data-compare-metric]').count() === METRICS.length)
// 只带本次:页面自己找基准,找不到时写原因,下拉照样能选
const lone = await open(comparePath(T), { base: () => ({ status: 404, json: { detail: '同一工作流没有其他已封存的正式运行' } }) })
await settled(lone.page, 'error')
const noBase = lone.page.locator('[data-compare-error="no-base"]')
check('找不到基准:写明原因', (await noBase.locator('[data-compare-error-title]').innerText()).includes('未找到可作为基准的运行')
&& (await noBase.innerText()).includes('同一工作流没有其他已封存的正式运行'))
check('找不到基准:仍能手动选', await lone.page.locator('select[data-compare-base-picker]').isEnabled())
await lone.page.locator('select[data-compare-base-picker]').selectOption(B2)
await settled(lone.page)
check('手动选了之后照常对比', new URL(lone.page.url()).searchParams.get('base') === B2 && compareHits(lone.log, T, B2) === 1)
// 只带本次、找得到:自己补上基准,写明怎么选的
const auto = await open(comparePath(T))
await settled(auto.page)
check('只带本次:自己找到基准、补进地址', new URL(auto.page.url()).searchParams.get('base') === B
&& (await text(auto.page, '[data-compare-reason]')).trim() === REASON)
// 运行详情里找不到基准:提示原因,给「手动选择基准」
const menu = await open(`/runs/${T}`, { base: () => ({ status: 404, json: { detail: '同一工作流没有其他已封存的正式运行' } }) })
await menu.page.locator(`[data-run-detail="${T}"]`).waitFor({ timeout: 10000 })
await menu.page.getByRole('button', { name: '更多操作' }).click()
await menu.page.locator('[data-menu=compare]').click()
const toast = menu.page.locator('[data-toast]', { hasText: '同一工作流没有其他已封存的正式运行' })
check('运行详情里找不到基准:提示原因、留在原页', await until(() => toast.count().then((c) => c === 1))
&& new URL(menu.page.url()).pathname === `/runs/${T}`)
await toast.getByRole('button', { name: '手动选择基准' }).click()
await menu.page.waitForURL((u) => u.pathname === '/runs/compare', { timeout: 8000 })
check('「手动选择基准」去到对比页,只带本次', new URL(menu.page.url()).searchParams.get('target') === T
&& !new URL(menu.page.url()).searchParams.get('base'))
const none = await open('/runs/compare')
await settled(none.page, 'no-target')
check('什么都没带:说明从哪里进来', (await text(none.page, '[data-compare-page]')).includes('与上一次对比'))
})
await section('空的对比', async () => {
const { page } = await open(comparePath(T, B), { compare: (t, b) => ({ json: { ...comparison(t, b), metrics: [], queries: [], notes: [],
summary: Object.fromEntries(ORDER.map((k) => [k, 0])), template: { base_version: 4, target_version: 4, same_graph: true, nodes: [] } } }) })
await settled(page)
check('没有指标:照实说', await page.locator('[data-compare-empty="metrics"]').count() === 1)
check('没有查询:照实说', await page.locator('[data-compare-empty="queries"]').count() === 1)
check('执行图相同:照实说', await page.locator('[data-compare-same-graph]').count() === 1)
check('没有说明时不留空的「说明」一节', await page.locator('[data-compare-notes]').count() === 0)
check('摘要全是 0,不能点', (await page.locator('[data-compare-summary] [data-compare-class]:not([disabled])').count()) === 0)
})
/** 对比页自己有没有横向滚动(表格、SQL 在自己的框里横向滚动不算),以及它的右边有没有出视口 */
const overflow = (page) => page.evaluate(() => {
const root = document.querySelector('[data-compare-page]')
if (!root) return { root: 99, right: 99 }
const r = root.getBoundingClientRect()
const wide = [...root.querySelectorAll('*')].filter((el) => {
const b = el.getBoundingClientRect()
return b.width > 0 && b.right > r.right + 1 && !el.closest('[data-compare-scroll]')
}).map((el) => el.tagName + (el.getAttribute('data-compare-run') ?? ''))
return { root: root.scrollWidth - root.clientWidth, right: Math.round(r.right - innerWidth), wide: wide.slice(0, 3) }
})
await section('窄屏', async () => {
for (const theme of ['dark', 'light']) {
const { page, errors } = await open(comparePath(T, B), { width: 390, height: 844, theme })
await settled(page)
let o = await overflow(page)
check(`390px(${theme}):页面不横向溢出`, o.root <= 1 && o.right <= 0 && !o.wide.length, JSON.stringify(o))
await page.locator('tr[data-compare-metric="caliber:gmv"] button[data-compare-expand]').click()
await page.locator('tr[data-compare-metric="caliber:aov"] button[data-compare-expand]').click()
o = await overflow(page)
check(`390px(${theme}):展开归因后也不横向溢出`, o.root <= 1 && o.right <= 0 && !o.wide.length, JSON.stringify(o))
const scroller = await page.evaluate(() => {
const box = document.querySelector('[data-compare-metrics] [data-compare-scroll]')
return box ? { sw: box.scrollWidth, cw: box.clientWidth, focusable: box.tabIndex >= 0 } : null
})
check(`390px(${theme}):指标表在自己的框里横向滚动,键盘可聚焦`, !!scroller && scroller.sw > scroller.cw && scroller.focusable,
JSON.stringify(scroller))
const refund = page.locator('[data-compare-query="refund:0"]')
const [bb, tb] = [await refund.locator('[data-compare-sql="base"]').boundingBox(), await refund.locator('[data-compare-sql="target"]').boundingBox()]
check(`390px(${theme}):两条 SQL 上下排、都在屏幕里`, !!bb && !!tb && tb.y >= bb.y + bb.height - 1 && bb.x >= 0 && bb.x + bb.width <= 390,
JSON.stringify({ bb, tb }))
o = await overflow(page)
check(`390px(${theme}):查询区不横向溢出`, o.root <= 1 && !o.wide.length, JSON.stringify(o))
await shot(page, `narrow-${theme}`)
check(`390px(${theme}):页面没有报错`, errors.length === 0, errors.join(';'))
}
})
/** 两个颜色的对比度(WCAG):颜色是 getComputedStyle 给的 rgb / rgba / color(srgb …),半透明的叠在底色上 */
function contrast(fg, bg, base) {
const parse = (c) => {
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.]+))?\)/)
return n ? [Number(n[1]), Number(n[2]), Number(n[3]), n[4] == null ? 1 : Number(n[4])] : null
}
const over = (top, under) => top.slice(0, 3).map((v, i) => v * top[3] + under[i] * (1 - top[3]))
const lum = ([r, g, b]) => {
const f = (v) => { const s = v / 255; return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4 }
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b)
}
const b0 = parse(base); const b1 = parse(bg); const f0 = parse(fg)
if (!b0 || !b1 || !f0) return 0
const under = over(b1, b0.slice(0, 3))
const a = lum(over(f0, under)); const b = lum(under)
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05)
}
await section('深浅色可读', async () => {
for (const theme of ['dark', 'light']) {
const { page } = await open(comparePath(T, B), { theme })
await settled(page)
const colors = await page.evaluate(() => {
/** 往上找第一个不透明的底色 */
const baseOf = (el) => {
for (let p = el.parentElement; p; p = p.parentElement) {
const c = getComputedStyle(p).backgroundColor
if (c && !/rgba\(0, 0, 0, 0\)|transparent/.test(c)) return c
}
return getComputedStyle(document.body).backgroundColor
}
const pick = (sel) => {
const el = document.querySelector(sel)
if (!el) return null
const s = getComputedStyle(el)
return { fg: s.color, bg: s.backgroundColor, base: baseOf(el) }
}
return {
chips: Object.fromEntries([...document.querySelectorAll('tr[data-compare-metric] [data-compare-chip]')]
.map((el) => [el.closest('tr').getAttribute('data-class'), (() => {
const s = getComputedStyle(el)
return { fg: s.color, bg: s.backgroundColor, base: baseOf(el) }
})()])),
del: pick('[data-compare-sql="base"] mark[data-diff="del"]'),
add: pick('[data-compare-sql="target"] mark[data-diff="add"]'),
note: pick('[data-compare-decomp-note]'),
}
})
const low = Object.entries(colors.chips).map(([k, c]) => [k, contrast(c.fg, c.bg, c.base)]).filter(([, r]) => r < 4.5)
check(`${theme}:分类标签的文字对比度都 ≥ 4.5`, Object.keys(colors.chips).length === 8 && low.length === 0,
low.map(([k, r]) => `${k} ${r.toFixed(2)}`).join(','))
for (const k of ['del', 'add', 'note']) {
const c = colors[k]
const r = c ? contrast(c.fg, c.bg, c.base) : 0
check(`${theme}:${{ del: '基准独有的高亮', add: '本次独有的高亮', note: '不能拆分的原因' }[k]}对比度 ≥ 4.5`, r >= 4.5,
c ? `${r.toFixed(2)}(${c.fg} on ${c.bg} / ${c.base})` : '没找到')
}
await shot(page, `wide-${theme}`)
}
})
await browser.close()
console.log(failed ? `\n✗ 运行对比:${failed} 项未通过` : '\n✓ 运行对比:全部通过')
process.exit(failed ? 1 : 0)