From e6c77ea4ee2bb7fd7cc5a61f6254f286a6a4ef6f Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 5 Oct 2026 08:43:32 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E8=BF=90=E8=A1=8C=E5=AF=B9=E6=AF=94?= =?UTF-8?q?=E9=A1=B5=EF=BC=88=E5=89=8D=E7=AB=AF=EF=BC=89=EF=BC=9A=E4=B8=A4?= =?UTF-8?q?=E6=AC=A1=E8=BF=90=E8=A1=8C=E7=9A=84=E6=8C=87=E6=A0=87=E5=8F=98?= =?UTF-8?q?=E5=8C=96=E6=8C=89=E6=95=B0=E6=8D=AE=E3=80=81=E5=8F=A3=E5=BE=84?= =?UTF-8?q?=E3=80=81=E6=A8=A1=E6=9D=BF=E4=B8=8E=20SQL=20=E6=8B=86=E5=BC=80?= =?UTF-8?q?=E8=AE=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 运行详情「更多」加「与上一次对比」:先问 compare/base 找默认基准再跳到对比页;没封存、还在运行的不给点; 找不到基准时留在原页,提示服务端给的原因,并可去对比页手动选择基准 - 对比页(/runs/compare?target=…&base=…):顶部两次运行(名字、时间、类别与版本、定时来源、未封存标记,可点进 各自详情)、互换、换基准下拉(运行列表接口按工作流取,未封存的列出但不能选);摘要条八类的个数照服务端的 summary,点一类只看这一类;指标表用服务端渲染值,能严格拆分的并列口径效应和数据效应、画两截条形,并写明两者 之和等于总变化,不能拆的写服务端给的原因;展开看变化说明、输入对比、维度归因前 10 项(没有占比时写原因); 查询区标结构状态,只参数变列出字面量,结构变并排两条 SQL 逐词标出各自独有的部分;模板区写版本、变化的节点和 配置项;说明照实列出 - 加载、找基准、409(未封存或封存核对不一致)、422(不是同一工作流)、404、找不到基准、空对比都写清楚;请求按 「本次 + 基准」取,换基准、互换时丢掉过时的答复,开发模式的 StrictMode 下同一份对比也只请求一次 - 窄屏表格在自己的框里横向滚动(指标名一栏钉住,展开的明细按可见宽度排),深浅色下分类标签、差异高亮对比度都 在 4.5 以上 - check-compare.mjs 换成真检查:page.route 假接口覆盖入口、摘要、分类、拆分、归因、SQL 并排、换基准与互换、 过时答复、各类错误、窄屏和深浅色 --- frontend/src/api/compare.ts | 22 +- frontend/src/lib/terms.ts | 160 +++- frontend/src/pages/runs/ComparePage.tsx | 104 ++- frontend/src/pages/runs/RunDetailView.tsx | 4 + frontend/src/pages/runs/compare/Metrics.tsx | 315 ++++++++ frontend/src/pages/runs/compare/Queries.tsx | 131 ++++ frontend/src/pages/runs/compare/RunPair.tsx | 134 ++++ frontend/src/pages/runs/compare/Summary.tsx | 47 ++ frontend/src/pages/runs/compare/Template.tsx | 63 ++ frontend/src/pages/runs/compare/data.ts | 77 ++ frontend/src/pages/runs/compare/entry.tsx | 49 ++ frontend/src/pages/runs/compare/model.ts | 118 +++ frontend/src/pages/runs/compare/parts.tsx | 105 +++ frontend/src/pages/runs/compare/sqlDiff.ts | 121 +++ frontend/src/types.ts | 148 ++++ scripts/check-compare.mjs | 730 ++++++++++++++++++- 16 files changed, 2310 insertions(+), 18 deletions(-) create mode 100644 frontend/src/pages/runs/compare/Metrics.tsx create mode 100644 frontend/src/pages/runs/compare/Queries.tsx create mode 100644 frontend/src/pages/runs/compare/RunPair.tsx create mode 100644 frontend/src/pages/runs/compare/Summary.tsx create mode 100644 frontend/src/pages/runs/compare/Template.tsx create mode 100644 frontend/src/pages/runs/compare/data.ts create mode 100644 frontend/src/pages/runs/compare/entry.tsx create mode 100644 frontend/src/pages/runs/compare/model.ts create mode 100644 frontend/src/pages/runs/compare/parts.tsx create mode 100644 frontend/src/pages/runs/compare/sqlDiff.ts diff --git a/frontend/src/api/compare.ts b/frontend/src/api/compare.ts index 94bdb756..9d29b2e1 100644 --- a/frontend/src/api/compare.ts +++ b/frontend/src/api/compare.ts @@ -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(`${run(runId)}/compare/base`, opts), + /** + * 两次运行的对比(runId 是本次)。任一未封存或封存核对不一致回 409;不是同一工作流、或者是同一次运行回 422; + * 运行不存在回 404。detail 都是一句中文。signal 用来丢掉换了基准之后过时的请求 + */ + get: (runId: string, base: string, opts?: RequestOptions) => + get(`${run(runId)}/compare?${new URLSearchParams({ base })}`, opts), +} diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index da35a65f..7485af5d 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -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, + 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, + classHint: { + data_only: '口径与 SQL 都没有改动,变化来自数据', + caliber_and_data: '口径卡的计算式有改动,数据也变了;能严格拆分的分别给出口径效应和数据效应', + sql_changed: '所依据的查询 SQL 结构有改动,口径不同,不做拆分', + params_only: '所依据的查询 SQL 结构相同,只有参数(如日期)不同', + added: '本次有、基准没有的指标', + removed: '基准有、本次没有的指标', + incomparable: '缺少比较所需的信息,无法判断变化来自哪里', + unchanged: '两次的值相同,口径与 SQL 也没有改动', + } as Record, + 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, + structureHint: { + same: 'SQL 完全相同', + params_only: 'SQL 结构相同,只有字面量不同', + changed: 'SQL 结构不同:表、连接、筛选条件、分组或聚合有改动', + added: '本次新增的查询,基准中没有', + removed: '基准中有、本次没有的查询', + } as Record, + 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, + keys: '配置项', + // 说明 + notes: '说明', } /** diff --git a/frontend/src/pages/runs/ComparePage.tsx b/frontend/src/pages/runs/ComparePage.tsx index 5a80ba6f..508b3004 100644 --- a/frontend/src/pages/runs/ComparePage.tsx +++ b/frontend/src/pages/runs/ComparePage.tsx @@ -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 (
} title={RUN_COMPARE_TEXT.title} subtitle={RUN_COMPARE_TEXT.hint} /> -
- {/* 功能没做出来,和连没连上服务端无关:不走空态的离线说法 */} - } title={RUN_COMPARE_TEXT.pending} - body={RUN_COMPARE_TEXT.pendingHint} offline={false} /> +
+ {!target ? ( + } title={RUN_COMPARE_TEXT.noTarget} body={RUN_COMPARE_TEXT.noTargetHint} + offline={false} action={{RUN_COMPARE_TEXT.toRuns}} /> + ) : ( +
+ go(target, b)} onSwap={() => { if (base) go(base, target) }} + /> + {state === 'finding' && } + {state === 'loading' && } + {state === 'error' && ( + + )} + {state === 'ok' && cmp.data && } +
+ )}
) } + +/** 找基准、对比的请求在路上:一句在做什么,下面是占位的骨架 */ +function Pending({ text }: { text: string }) { + return ( +
+
{text}
+ + +
+ ) +} + +/** 对比结果。按「本次|基准」换 key:换了一对运行,筛选、展开都从头来 */ +function Comparison({ data }: { data: RunComparison }) { + const [filter, setFilter] = useState(null) + return ( + <> + + + setFilter(null)} /> + + + + ) +} diff --git a/frontend/src/pages/runs/RunDetailView.tsx b/frontend/src/pages/runs/RunDetailView.tsx index 98a64ef1..636d005e 100644 --- a/frontend/src/pages/runs/RunDetailView.tsx +++ b/frontend/src/pages/runs/RunDetailView.tsx @@ -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(null) const streamBox = useRef(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: , onSelect: () => void copyText(`${location.origin}/runs/${run.id}`, '链接'), }, + compareEntry(run, live), { key: 'delete', label: '删除记录…', icon: , danger: true, onSelect: () => void remove(), disabled: live || busy === 'delete', diff --git a/frontend/src/pages/runs/compare/Metrics.tsx b/frontend/src/pages/runs/compare/Metrics.tsx new file mode 100644 index 00000000..5d7d9896 --- /dev/null +++ b/frontend/src/pages/runs/compare/Metrics.tsx @@ -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>(() => new Set()) + const scroller = useRef(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 ( + + {!metrics.length ? ( +

{T.metricsEmpty}

+ ) : ( + <> + {filter && ( +
+ {T.filterOn(T.classLabel[filter] ?? T.classOther, shown.length)} + +
+ )} + {split && ( +

+ + + + + {T.legend} +

+ )} +
+ + + + + + + + + + + + + {shown.map((m) => { + const k = metricKey(m) + return toggle(k)} /> + })} + +
{T.colMetric}{T.colBase}{T.colTarget}{T.colDelta}{T.colClass}{T.colSplit}
+
+ + )} +
+ ) +} + +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 ( + + + + + + {m.base_rendered || NONE} + {m.target_rendered || NONE} + +
{deltaText(m)}
+ {pct &&
{pct}
} + + + + {m.decomposition ? ( + + ) : m.decomposition_note ? ( +

+ {m.decomposition_note} +

+ ) : ( + {NONE} + )} + + + {open && ( + + + + + + )} +
+ ) +} + +/** 变化量:服务端渲染的写法;没给(没变化、缺一边)时,是 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 ( +
+
+ + + {T.caliber} + {d.caliber_rendered} + + + + {T.data} + {d.data_rendered} + +
+ {sum > 0 && ( + + + + + )} +
+ ) +} + +/** 展开的明细:变化说明、拆分写成一句话、输入对比、维度归因。贴着滚动框的左边,宽度等于框的可见宽度 */ +function MetricDetail({ m }: { m: RunCompareMetric }) { + return ( +
+
+ {!!m.reasons.length && ( +
+

{T.reasons}

+
    + {m.reasons.map((r, i) =>
  • {r}
  • )} +
+
+ )} + {m.decomposition && ( +

+ {T.splitSum(m.decomposition.caliber_rendered, m.decomposition.data_rendered, deltaText(m))} +

+ )} + +
+
+ +
+
+ ) +} + +function Inputs({ m }: { m: RunCompareMetric }) { + return ( +
+

{T.inputs}

+ {!m.inputs.length ? ( +

{T.inputsNone}

+ ) : ( +
+ + + + + + + + + + {m.inputs.map((inp) => { + const changed = !sameValue(inp.base, inp.target) + return ( + + + + + + ) + })} + +
{T.colInput}{T.colBase}{T.colTarget}
{inp.path}{valueText(inp.base)} + {changed && } + {valueText(inp.target)} +
+
+ )} +
+ ) +} + +/** + * 维度归因:按键对齐两边查询结果的行,列前 10 项的键、两边的值、变化和占比。占比只在度量列之和等于指标的输入时才有, + * 没有时那一栏写「—」,下面照服务端的说明写原因(没给说明时写一句通用的) + */ +function Attribution({ a }: { a: RunCompareAttribution | null }) { + if (!a) { + return ( +
+

{T.attribution}

+

{T.attrNone}

+
+ ) + } + 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 ( +
+

+ {T.attribution} + {T.attrBy(a.measure)}{more > 0 ? ` · ${T.attrTop(ATTR_TOP)}` : ''} +

+
+ + + + + + + + + + + + {rows.map((r, i) => ( + + + + + + + + ))} + +
{a.columns.length ? a.columns.join(' / ') : T.colKey}{T.colBase}{T.colTarget}{T.colDelta}{T.colShare}
+ {r.key.map(valueText).join(' / ')} + {valueText(r.base)}{valueText(r.target)}{signedNumber(r.delta)} + {r.share == null ? NONE : percent(r.share)} +
+
+
+ {more > 0 &&

{T.attrMore(more)}

} + {a.total_delta != null &&

{T.attrTotal(signedNumber(a.total_delta))}

} + {note &&

{note}

} +
+
+ ) +} diff --git a/frontend/src/pages/runs/compare/Queries.tsx b/frontend/src/pages/runs/compare/Queries.tsx new file mode 100644 index 00000000..8eb1838d --- /dev/null +++ b/frontend/src/pages/runs/compare/Queries.tsx @@ -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 ( + + {!queries.length ? ( +

{T.queriesEmpty}

+ ) : ( +
    + {queries.map((q) => { + const k = queryKey(q) + return + })} +
+ )} +
+ ) +} + +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 ( +
  • +
    + {q.node_title || q.node_id} + {nth != null && {T.nth(nth)}} + + {rows} + {foldable && ( + + )} +
    + {q.structure === 'params_only' && !!q.literals.length && ( +
    +
    {T.literals}
    +
      + {q.literals.map((l, i) => ( +
    • + {valueText(l.base)} + → + {valueText(l.target)} +
    • + ))} +
    +
    + )} + {q.structure === 'changed' && } + {q.structure === 'added' && } + {q.structure === 'removed' && } + {foldable && showSql && ( +
    + {q.structure === 'same' + ? + : } +
    + )} +
  • + ) +} + +/** 两条 SQL 并排,逐词标出各自独有的部分 */ +function SideBySide({ base, target }: { base: string; target: string }) { + const diff = useMemo(() => diffSql(base, target), [base, target]) + return ( +
    +
    + {T.onlyBase} + {T.onlyTarget} +
    +
    + + +
    + {diff.coarse &&

    {T.diffCoarse}

    } +
    + ) +} + +/** 一条 SQL。折行显示,不横向撑开;太长时在框里纵向滚动,框能用键盘聚焦 */ +function SqlPane({ which, label, sql, parts }: { + which: 'base' | 'target' | 'both'; label: string; sql?: string; parts?: DiffPart[] +}) { + const kind = which === 'base' ? 'del' : 'add' + return ( +
    +
    {label}
    +
    +        {parts
    +          ? parts.map((p, i) => (p.changed
    +            ? {p.text}
    +            : {p.text}))
    +          : sql}
    +      
    +
    + ) +} diff --git a/frontend/src/pages/runs/compare/RunPair.tsx b/frontend/src/pages/runs/compare/RunPair.tsx new file mode 100644 index 00000000..0cc011ee --- /dev/null +++ b/frontend/src/pages/runs/compare/RunPair.tsx @@ -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 + baseRun: Loaded + candidates: Loaded + /** 本次的工作流没保存:没有同一工作流的其他运行可选 */ + unsaved: boolean + /** 默认基准是怎么选的(只在从「与上一次对比」、或只带本次打开时有) */ + reason: string | null + onPick: (id: string) => void + onSwap: () => void +}) { + return ( +
    +
    + + + +
    + +
    + +
    + {reason && T.reason[reason] && ( +

    {T.reason[reason]}

    + )} +
    + ) +} + +function RunCard({ role, id, run, children }: { + role: 'base' | 'target'; id: string | null; run: Loaded; children?: ReactNode +}) { + const now = useTicker(60_000) + const r = run.data + const when = r ? r.started_at ?? r.created_at ?? null : null + return ( +
    +
    + {role === 'base' ? T.base : T.target} + {r?.schedule_id && ( + {T.scheduled} + )} + {r && !r.manifest_hash && ( + + {T.unsealed} + + )} +
    + {!id ? ( +
    {T.noBase}
    + ) : r ? ( + <> + + {runName(r)} + +
    + + · + + · + {shortId(id)} +
    + + ) : run.state === 'error' ? ( + // 读不到运行记录(删了、连不上):对比本身照样请求,能不能比由对比接口说 +
    + {shortId(id)} + {T.runFailed} +
    + ) : ( +
    + )} + {children} +
    + ) +} + +/** + * 换基准。列的是运行列表接口按工作流取回的最近几次(新的在前),去掉本次;没封存的列出来但不能选,写明原因。 + * 当前的基准不在这几次里(更早、或者属于别的工作流)时也放进去,下拉不至于显示成别的运行 + */ +function BasePicker({ target, base, baseRun, candidates, unsaved, onPick }: { + target: string; base: string | null; baseRun?: RunWithSchedule; candidates: Loaded; unsaved: boolean + onPick: (id: string) => void +}) { + const id = useId() + const now = useTicker(60_000) + if (unsaved) return

    {T.pickUnsaved}

    + if (candidates.state === 'error') return

    {T.pickFailed}

    + 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

    {T.pickNone}

    + 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 ( +
    + + +

    {T.pickHint}

    +
    + ) +} diff --git a/frontend/src/pages/runs/compare/Summary.tsx b/frontend/src/pages/runs/compare/Summary.tsx new file mode 100644 index 00000000..06611da1 --- /dev/null +++ b/frontend/src/pages/runs/compare/Summary.tsx @@ -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 ( +
    +
    + {CLASS_ORDER.map((k) => { + const n = Number(summary?.[k] ?? 0) + const on = filter === k + const tone = classTone(k) + return ( + + ) + })} +
    +
    + ) +} diff --git a/frontend/src/pages/runs/compare/Template.tsx b/frontend/src/pages/runs/compare/Template.tsx new file mode 100644 index 00000000..687d73d2 --- /dev/null +++ b/frontend/src/pages/runs/compare/Template.tsx @@ -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 = { added: 'var(--accent)', removed: 'var(--text-dim)', config: 'var(--warn)' } + +/** + * 模板区:两次运行各用的版本,执行图变了的话列出变化的节点(新增、移除、配置变更)和改了的配置项。配置项写的是节点 + * 配置里的键:同一个键在不同类型的节点里叫法不同,接口又不给节点类型,照原样写比猜一个中文名可靠 + */ +export function TemplateSection({ template }: { template: RunCompareTemplate }) { + const id = useId() + return ( + +

    + {T.versions(T.version(template.base_version), T.version(template.target_version))} +

    + {template.same_graph ? ( +

    {T.sameGraph}

    + ) : !template.nodes.length ? ( +

    {T.nodesNone}

    + ) : ( +
      + {template.nodes.map((n) => { + const color = CHANGE_COLOR[n.change] ?? 'var(--text-dim)' + return ( +
    • + {n.title || n.node_id} + + {T.nodeChange[n.change] ?? n.change} + + {!!n.keys.length && ( + + {T.keys} + {n.keys.map((k) => ( + {k} + ))} + + )} +
    • + ) + })} +
    + )} +
    + ) +} + +/** 服务端给的说明(截断、缺快照这类会影响读数的事),照原样列出;没有就不画这一节 */ +export function NotesSection({ notes }: { notes: string[] }) { + const id = useId() + if (!notes.length) return null + return ( + +
      + {notes.map((n, i) =>
    • {n}
    • )} +
    +
    + ) +} diff --git a/frontend/src/pages/runs/compare/data.ts b/frontend/src/pages/runs/compare/data.ts new file mode 100644 index 00000000..cea7942e --- /dev/null +++ b/frontend/src/pages/runs/compare/data.ts @@ -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 { + /** idle:键是空的,不取 */ + state: 'idle' | 'loading' | 'ok' | 'error' + data?: T + error?: unknown + reload: () => void +} + +type Slot = { key: string; state: 'loading' | 'ok' | 'error'; data?: T; error?: unknown } + +/** + * 按键取一份数据。请求晚一拍(下一个宏任务)才发:开发模式的 StrictMode 会把挂载时的 effect 先跑一遍、立即撤掉再跑, + * 撤掉时把还没发的那一拍清掉,同一份对比就只算一次(对比要读两次运行的工件、核对封存,不便宜)。键变了、卸载了 + * 都会取消在路上的请求;答复对不上当前的键就丢掉 + */ +function useKeyed(key: string | null, load: (signal: AbortSignal) => Promise): Loaded { + const [slot, setSlot] = useState | 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 { + return useKeyed(target, (signal) => api.compare.base(target!, { signal })) +} + +/** 一次运行的记录:运行卡上的名字、时间、版本、类别、封存与否,和它属于哪个工作流 */ +export function useRunRecord(id: string | null): Loaded { + return useKeyed(id, () => api.runs.get(id!) as Promise) +} + +/** 换基准的下拉:同一工作流最近的运行(新的在前)。没保存的工作流(workflow_id 为空)不取 */ +export const CANDIDATE_LIMIT = 50 + +export function useCandidates(workflowId: string | null | undefined): Loaded { + return useKeyed(workflowId || null, () => + api.runs.list({ workflow_id: workflowId!, limit: CANDIDATE_LIMIT }) as Promise) +} + +/** 对比本身 */ +export function useComparison(target: string | null, base: string | null): Loaded { + return useKeyed(target && base ? JSON.stringify([target, base]) : null, + (signal) => api.compare.get(target!, base!, { signal })) +} diff --git a/frontend/src/pages/runs/compare/entry.tsx b/frontend/src/pages/runs/compare/entry.tsx new file mode 100644 index 00000000..bbe611f2 --- /dev/null +++ b/frontend/src/pages/runs/compare/entry.tsx @@ -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: , + 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]) +} diff --git a/frontend/src/pages/runs/compare/model.ts b/frontend/src/pages/runs/compare/model.ts new file mode 100644 index 00000000..3de37f28 --- /dev/null +++ b/frontend/src/pages/runs/compare/model.ts @@ -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 = { + 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) => `${m.card_id}:${m.metric_id}` + +/** 一条查询的键:同一节点里按服务端的序号区分 */ +export const queryKey = (q: Pick) => `${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 { + const byNode = new Map() + for (const q of queries) byNode.set(q.node_id, [...(byNode.get(q.node_id) ?? []), q]) + const out = new Map() + 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' +} diff --git a/frontend/src/pages/runs/compare/parts.tsx b/frontend/src/pages/runs/compare/parts.tsx new file mode 100644 index 00000000..fb493b15 --- /dev/null +++ b/frontend/src/pages/runs/compare/parts.tsx @@ -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 +}) { + return ( +
    +
    +

    {title}

    + {count && {count}} + {hint &&

    {hint}

    } +
    +
    {children}
    +
    + ) +} + +/** + * 标签的配色:字和边框用这个颜色;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 ( + + {T.classLabel[value] ?? T.classOther} + + ) +} + +/** 查询的结构状态。结构变了用警示色并铺底(口径不同),新增用强调色,其余中性色 */ +export function StructureChip({ value }: { value: string }) { + const warn = value === 'changed' + const color = warn ? 'var(--warn)' : value === 'added' ? 'var(--accent)' : 'var(--text-dim)' + return ( + + {T.structure[value] ?? value} + + ) +} + +const ERROR_TEXT: Record, { 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 ( +
    + +
    + ) + } + const { title, hint } = ERROR_TEXT[kind] + const reason = error instanceof Error ? error.message : '' + return ( +
    + +
    +
    {title}
    + {reason && reason !== title &&
    {reason}
    } +
    {hint}
    + {kind === '409' && (target || base) && ( +
    + {base && {T.viewBase}} + {target && {T.viewTarget}} +
    + )} +
    +
    + ) +} diff --git a/frontend/src/pages/runs/compare/sqlDiff.ts b/frontend/src/pages/runs/compare/sqlDiff.ts new file mode 100644 index 00000000..d7540910 --- /dev/null +++ b/frontend/src/pages/runs/compare/sqlDiff.ts @@ -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 } +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 39866717..b628f779 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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 + +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 } diff --git a/scripts/check-compare.mjs b/scripts/check-compare.mjs index bad24d65..44550943 100644 --- a/scripts/check-compare.mjs +++ b/scripts/check-compare.mjs @@ -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) From 72da2e178afb5622b097baee52e3719e70926416 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 5 Oct 2026 08:58:34 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=E8=BF=90=E8=A1=8C=E5=AF=B9=E6=AF=94?= =?UTF-8?q?=E9=A1=B5=E5=AF=B9=E9=BD=90=E6=9C=8D=E5=8A=A1=E7=AB=AF=E7=9A=84?= =?UTF-8?q?=E5=AE=9E=E9=99=85=E8=BE=93=E5=87=BA=EF=BC=9A=E5=8F=98=E5=8C=96?= =?UTF-8?q?=E7=99=BE=E5=88=86=E6=AF=94=E6=8C=89=E7=99=BE=E5=88=86=E6=95=B0?= =?UTF-8?q?=E5=86=99=EF=BC=8C=E5=BD=92=E5=9B=A0=E3=80=81=E9=81=AE=E7=BD=A9?= =?UTF-8?q?=E3=80=81=E5=B7=AE=E5=BC=82=E8=AF=B4=E6=98=8E=E7=85=A7=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E7=AB=AF=E7=BB=99=E7=9A=84=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对照运行对比后端的实现核过一遍字段的写法: - delta_pct 服务端已经乘过 100(12.5 是 +12.5%),占比 share 仍是小数(0.4 是 40%),两处分开格式化;原先把 delta_pct 也当小数,会写成「+1250.0%」 - 维度归因服务端只给前 10 项、另有几项写在 note 里,照写;归不了因(查询结果截断、度量列遮罩、快照读不出来)时 给空的 rows 和一句说明,只写说明、不画空表;万一多给了仍只列 10 项并写明另有几项 - 约定之外服务端顺带给的几项按可选字段用上:输入的 masked(两边写「已遮罩」,不标变了)、查询的 differences (结构变了差在哪,列在并排的 SQL 上面)和 problem(快照读不出来、与哈希不一致)、模板配置项的 labels(按节点 类型翻好的叫法,键名放进悬停提示;没给或对不上时照写键名) - 找默认基准时的 404 分两种:本次这条运行不存在(机读码 run_not_found)写「运行记录不存在」,其余写「未找到 可作为基准的运行」 - check-compare 的夹具照服务端的写法改,补上述几种情形;菜单入口另查「问到基准之后才跳」(先跳到只带本次的地址、 再让对比页去找,原先查不出来);check-all 登记实测耗时 --- frontend/src/lib/terms.ts | 2 + frontend/src/pages/runs/compare/Metrics.tsx | 30 +++--- frontend/src/pages/runs/compare/Queries.tsx | 15 ++- frontend/src/pages/runs/compare/Template.tsx | 16 +++- frontend/src/pages/runs/compare/model.ts | 30 ++++-- frontend/src/types.ts | 10 +- scripts/check-all.mjs | 2 +- scripts/check-compare.mjs | 97 +++++++++++++++----- 8 files changed, 148 insertions(+), 54 deletions(-) diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 7485af5d..2738f2e2 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -2841,6 +2841,8 @@ export const RUN_COMPARE_TEXT = { reasons: '变化说明', inputs: '输入对比', inputsNone: '口径卡没有记录这个指标的输入', + /** 取自设置了遮罩的列:两边的值都不显示 */ + masked: '已遮罩', colInput: '输入', attribution: '维度归因', attrBy: (measure: string) => `按「${measure}」的变化,从大到小`, diff --git a/frontend/src/pages/runs/compare/Metrics.tsx b/frontend/src/pages/runs/compare/Metrics.tsx index 5d7d9896..be97860b 100644 --- a/frontend/src/pages/runs/compare/Metrics.tsx +++ b/frontend/src/pages/runs/compare/Metrics.tsx @@ -5,7 +5,7 @@ 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' +import { changeText, metricKey, sameValue, shareText, signedNumber, valueText } from './model' /** 维度归因列前几项:后端按变化从大到小排好,多的只写还有几项 */ const ATTR_TOP = 10 @@ -103,7 +103,7 @@ export function Metrics({ metrics, filter, onClearFilter }: { 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 + const pct = m.delta_pct != null && m.delta_pct !== 0 ? changeText(m.delta_pct) : null return ( @@ -235,14 +235,17 @@ function Inputs({ m }: { m: RunCompareMetric }) { {m.inputs.map((inp) => { - const changed = !sameValue(inp.base, inp.target) + // 遮罩列:两边的值服务端都不给,写「已遮罩」,也不说变没变 + const changed = !inp.masked && !sameValue(inp.base, inp.target) + const shown = (v: unknown) => (inp.masked ? T.masked : valueText(v)) return ( - + {inp.path} - {valueText(inp.base)} + {shown(inp.base)} {changed && } - {valueText(inp.target)} + {shown(inp.target)} ) @@ -257,7 +260,8 @@ function Inputs({ m }: { m: RunCompareMetric }) { /** * 维度归因:按键对齐两边查询结果的行,列前 10 项的键、两边的值、变化和占比。占比只在度量列之和等于指标的输入时才有, - * 没有时那一栏写「—」,下面照服务端的说明写原因(没给说明时写一句通用的) + * 没有时那一栏写「—」,下面照服务端的说明写原因(没给说明时写一句通用的)。归不了因的(查询结果截断、度量列遮罩、 + * 快照读不出来)服务端给空的 rows 和一句说明:不画空表,只写这句话 */ function Attribution({ a }: { a: RunCompareAttribution | null }) { if (!a) { @@ -270,14 +274,14 @@ function Attribution({ a }: { a: RunCompareAttribution | null }) { } 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) + const note = a.note || (!rows.length ? T.attrNone : a.rows.some((r) => r.share == null) ? T.attrNoShare : null) return (

    {T.attribution} {T.attrBy(a.measure)}{more > 0 ? ` · ${T.attrTop(ATTR_TOP)}` : ''}

    -
    + {!!rows.length &&
    @@ -298,16 +302,18 @@ function Attribution({ a }: { a: RunCompareAttribution | null }) { ))}
    {valueText(r.target)} {signedNumber(r.delta)} - {r.share == null ? NONE : percent(r.share)} + {r.share == null ? NONE : shareText(r.share)}
    -
    +
    }
    {more > 0 &&

    {T.attrMore(more)}

    } - {a.total_delta != null &&

    {T.attrTotal(signedNumber(a.total_delta))}

    } + {!!rows.length && a.total_delta != null && ( +

    {T.attrTotal(signedNumber(a.total_delta))}

    + )} {note &&

    {note}

    }
    diff --git a/frontend/src/pages/runs/compare/Queries.tsx b/frontend/src/pages/runs/compare/Queries.tsx index 8eb1838d..cf62f44e 100644 --- a/frontend/src/pages/runs/compare/Queries.tsx +++ b/frontend/src/pages/runs/compare/Queries.tsx @@ -20,8 +20,9 @@ const MARK = { } as const /** - * 查询区:每条查询的结构状态和行数。只参数变的列出变化的字面量;结构变的并排两条 SQL(窄屏上下排),逐词标出各自独有的 - * 部分;新增、移除的只有一边。相同、只参数变的 SQL 默认收着,点开再看 + * 查询区:每条查询的结构状态和行数。只参数变的列出变化的字面量;结构变的先写服务端说的差在哪(有的话),再并排两条 + * SQL(窄屏上下排),逐词标出各自独有的部分;新增、移除的只有一边。相同、只参数变的 SQL 默认收着,点开再看。 + * 快照读不出来、与哈希不一致的,照服务端的说明写在这一条上 */ export function Queries({ queries }: { queries: RunCompareQuery[] }) { const id = useId() @@ -64,6 +65,16 @@ function QueryItem({ q, k, nth }: { q: RunCompareQuery; k: string; nth: number | )}
    + {q.problem && ( +

    + {q.problem} +

    + )} + {q.structure === 'changed' && !!q.differences?.length && ( +
      + {q.differences.map((d, i) =>
    • {d}
    • )} +
    + )} {q.structure === 'params_only' && !!q.literals.length && (
    {T.literals}
    diff --git a/frontend/src/pages/runs/compare/Template.tsx b/frontend/src/pages/runs/compare/Template.tsx index 687d73d2..ecc43326 100644 --- a/frontend/src/pages/runs/compare/Template.tsx +++ b/frontend/src/pages/runs/compare/Template.tsx @@ -6,8 +6,9 @@ import { Block } from './parts' const CHANGE_COLOR: Record = { added: 'var(--accent)', removed: 'var(--text-dim)', config: 'var(--warn)' } /** - * 模板区:两次运行各用的版本,执行图变了的话列出变化的节点(新增、移除、配置变更)和改了的配置项。配置项写的是节点 - * 配置里的键:同一个键在不同类型的节点里叫法不同,接口又不给节点类型,照原样写比猜一个中文名可靠 + * 模板区:两次运行各用的版本,执行图变了的话列出变化的节点(新增、移除、配置变更)和改了的配置项。配置项的叫法用 + * 服务端按节点类型翻好的 labels(同一个键在不同类型的节点里叫法不同,界面没有节点类型猜不准);没给、或者和键对不上 + * 时照写键名,宁可露键名也不编一个界面上没有的名字。键名放在悬停提示里 */ export function TemplateSection({ template }: { template: RunCompareTemplate }) { const id = useId() @@ -35,9 +36,14 @@ export function TemplateSection({ template }: { template: RunCompareTemplate }) {!!n.keys.length && ( {T.keys} - {n.keys.map((k) => ( - {k} - ))} + {n.keys.map((k, i) => { + const label = n.labels?.length === n.keys.length ? n.labels[i] : undefined + return label && label !== k ? ( + {label} + ) : ( + {k} + ) + })} )} diff --git a/frontend/src/pages/runs/compare/model.ts b/frontend/src/pages/runs/compare/model.ts index 3de37f28..58e14092 100644 --- a/frontend/src/pages/runs/compare/model.ts +++ b/frontend/src/pages/runs/compare/model.ts @@ -65,16 +65,21 @@ export function signedNumber(v: number | null | undefined): string { 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 { +/** 百分数(已经乘过 100)写成一位小数;不是零、却小到一位小数写不出来的写「<0.1%」,不写成看着像没变的「0.0%」 */ +function pct(abs: number, sign: string): string { + return abs !== 0 && abs < 0.05 ? `${sign}<0.1%` : `${sign}${abs.toFixed(1)}%` +} + +/** 变化百分比:服务端给的 delta_pct 已经乘过 100(12.5 是涨了 12.5%),带正负号:「+12.5%」「-3.0%」 */ +export function changeText(v: number | null | undefined): 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)}%` + return pct(Math.abs(v), v > 0 ? '+' : v < 0 ? '-' : '') +} + +/** 归因的占比:服务端给的是小数(0.4 是 40%)。和总变化反向的一项占比是负的:「-5.0%」 */ +export function shareText(v: number | null | undefined): string { + if (!isNum(v)) return NONE + return pct(Math.abs(v) * 100, v < 0 ? '-' : '') } /** 两个值算不算一样(输入对比里标「变了」用):数按值比,别的按 JSON 比 */ @@ -108,9 +113,14 @@ export function queryNumbers(queries: RunCompareQuery[]): Map { } const REGIONS = ['青禾区', '白石区', '云杉区', '赤松区', '蓝湾区', '金穗区', '银杏区', '翠竹区', '月湖区', '星港区', '石桥区', '柳岸区'] -// 各区的变化之和正好是总变化 2,531.5;占比按服务端的约定给小数(0.4 是 40%) +// 各区的变化之和正好是总变化 2,531.5;占比照服务端给小数(0.4 是 40%)。服务端按变化的绝对值只给前 10 项, +// 另有几项写在 note 里(ALL_ROWS 给「服务端多给了」那一节用) 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) => ({ +const ALL_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 MORE_KEYS = '共 12 个维度值,列出变化最大的 10 个' const metric = (over) => ({ card_id: 'caliber', card_title: '周报口径', unit: '', decimals: 1, reasons: [], decomposition: null, @@ -83,34 +85,39 @@ const metric = (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 均未改动,变化来自数据'], + target_rendered: '48,210.0元', delta: 2531.5, delta_rendered: '+2,531.5元', delta_pct: 5.54, 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 } }), + attribution: { columns: ['区域'], measure: 'amount', rows: ALL_ROWS.slice(0, 10), total_delta: 2531.5, note: MORE_KEYS } }), 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' }), + target_rendered: '1,301单', delta: 67, delta_rendered: '+67单', delta_pct: 5.43, 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: ['口径卡「周报口径」中客单价的计算式有改动'], + delta: 4.5, delta_rendered: '+4.5元', delta_pct: 12.16, classification: 'caliber_and_data', + reasons: ['表达式由「vars.kpi.gmv / vars.kpi.order_cnt」改为「vars.kpi.gmv / vars.kpi.paid_cnt」'], 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 }], + // 遮罩列的输入:服务端两边都不给值,另带 masked + inputs: [{ path: 'vars.kpi.gmv', base: 45678.5, target: 48210 }, { path: 'vars.kpi.order_cnt', base: 1234, target: 1234 }, + { path: 'vars.kpi.cost', base: null, target: null, masked: true }], 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', + target_rendered: '1.98%', delta: -0.0037, delta_rendered: '-0.37 个百分点', delta_pct: -15.74, 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: ['「拉取销量」只有日期参数不同'] }), + target_rendered: '5,480人', delta: 280, delta_rendered: '+280人', delta_pct: 5.38, classification: 'params_only', + reasons: ['「拉取销量」只有日期参数不同'], + // 归不了因:服务端给空的 rows 和一句说明 + attribution: { columns: [], measure: 'visits', rows: [], total_delta: null, + note: '查询结果被截断(基准运行只取回了前 500 行),不做维度归因' } }), 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', + target_rendered: '33.0%', delta: 0.02, delta_rendered: '+2.0 个百分点', delta_pct: 6.45, 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}`, @@ -126,7 +133,8 @@ const QUERIES = [ { 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 }, + literals: [], base_rows: 500, target_rows: 488, base_truncated: true, target_truncated: false, + differences: ['时间条件的列不同:基准按 refunds.created_at,这次按 refunds.paid_at', "这次多了筛选条件 refunds.status = 'done'"] }, { 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', @@ -135,11 +143,12 @@ const QUERIES = [ 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 }, + target_sql: null, literals: [], base_rows: 1, target_rows: null, base_truncated: false, target_truncated: null, + problem: '基准运行的查询快照无法读取' }, ] 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: 'refund', title: '退货明细', change: 'config', keys: ['sql', 'timeout'], labels: ['SQL', '超时(秒)'] }, { node_id: 'newbie', title: '新客查询', change: 'added', keys: [] }, { node_id: 'member', title: '会员查询', change: 'removed', keys: [] }, ] } @@ -285,6 +294,9 @@ const shot = async (page, name) => { await section('从运行详情进入', async () => { const { page, log, blocked, errors } = await open(`/runs/${T}`) await page.locator(`[data-run-detail="${T}"]`).waitFor({ timeout: 10000 }) + // 走过的地址(含 pushState / replaceState):菜单要先问到基准再跳,不能先跳到只带本次的地址、再让对比页去找 + const visited = [] + page.on('framenavigated', (f) => { if (f === page.mainFrame()) visited.push(f.url()) }) await page.getByRole('button', { name: '更多操作' }).click() const item = page.locator('[data-menu=compare]') check('「更多」菜单里有「与上一次对比」,可点', await item.count() === 1 && (await item.innerText()).includes('与上一次对比') @@ -298,6 +310,9 @@ await section('从运行详情进入', async () => { 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) + const compareUrls = visited.map((u) => new URL(u)).filter((u) => u.pathname === '/runs/compare') + check('菜单问到基准之后才跳,一步到带基准的地址', compareUrls.length >= 1 && compareUrls.every((u) => u.searchParams.get('base') === B), + compareUrls.map((u) => u.search).join(' → ')) 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(';')) @@ -352,7 +367,7 @@ await section('指标表', async () => { 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%', + check('变化百分比照服务端的百分数写、带正负号(5.54 → +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%') @@ -382,18 +397,20 @@ await section('展开:输入对比与维度归因', async () => { 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('变化来自数据')) + 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')) + check('服务端说另有几项没列:照写', (await detail.locator('[data-compare-attr-note]').innerText()) === MORE_KEYS + && await detail.locator('[data-compare-attr-more]').count() === 0) 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) + check('有占比时不写缺占比的原因', !(await detail.locator('[data-compare-attr-note]').innerText()).includes('占比')) + check('写出合计变化', (await detail.locator('[data-compare-attr-total]').innerText()).includes('+2,531.5')) // 键盘:Tab 到客单价的展开按钮,回车展开 const aovBtn = page.locator('tr[data-compare-metric="caliber:aov"] button[data-compare-expand]') @@ -406,6 +423,9 @@ await section('展开:输入对比与维度归因', async () => { (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 masked = aov.locator('tr[data-compare-input="vars.kpi.cost"]') + check('遮罩列的输入:两边都写「已遮罩」,不标变了', (await masked.locator('td').allInnerTexts()).every((t) => t.trim() === '已遮罩') + && (await masked.getAttribute('data-changed')) === null, (await masked.innerText().catch(() => '')).replace(/\s+/g, ' ')) 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('各区域订单数之和与指标的输入对不上')) @@ -420,9 +440,27 @@ await section('展开:输入对比与维度归因', async () => { 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) + // 归不了因(查询结果截断):服务端给空的 rows 和一句说明,只写说明,不画空表 + await page.locator('tr[data-compare-metric="caliber:visitors"] button[data-compare-expand]').click() + const visitors = page.locator('tr[data-compare-detail="caliber:visitors"]') + check('归不了因:写服务端的说明,不画空表', (await visitors.locator('[data-compare-attr-note]').innerText()).includes('被截断') + && await visitors.locator('[data-compare-attr] table').count() === 0 && await visitors.locator('[data-compare-attr-total]').count() === 0) check('页面没有报错', errors.length === 0, errors.join(';')) }) +await section('归因超过 10 项时只列前 10 项', async () => { + const { page } = await open(comparePath(T, B), { compare: (t, b) => { + const data = comparison(t, b) + return { json: { ...data, metrics: data.metrics.map((m) => (m.metric_id === 'gmv' + ? { ...m, attribution: { ...m.attribution, rows: ALL_ROWS, note: null } } : m)) } } + } }) + await settled(page) + await page.locator('tr[data-compare-metric="caliber:gmv"] button[data-compare-expand]').click() + const detail = page.locator('tr[data-compare-detail="caliber:gmv"]') + check('只列 10 项', await detail.locator('[data-compare-attr] tr[data-compare-attr-row]').count() === 10) + check('写明另有几项没列', (await detail.locator('[data-compare-attr-more]').innerText()).includes('另有 2 项')) +}) + await section('摘要筛选', async () => { const { page } = await open(comparePath(T, B)) await settled(page) @@ -460,6 +498,10 @@ await section('查询', async () => { 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('已截断')) + check('结构变:列出服务端说的差在哪', JSON.stringify(await refund.locator('[data-compare-differences] li').allInnerTexts()) + === JSON.stringify(QUERIES[1].differences)) + check('快照读不出来:照服务端的说明写在那一条上', (await page.locator('[data-compare-query="member:0"] [data-compare-query-problem]').innerText()) + === '基准运行的查询快照无法读取') 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'") @@ -486,9 +528,13 @@ await section('模板与说明', async () => { 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 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', '超时(秒)']) + && JSON.stringify(await node.locator('[data-compare-key]').evaluateAll((els) => els.map((e) => e.getAttribute('title')))) + === JSON.stringify(['sql', 'timeout'])) + check('没给叫法的配置项照写键名', JSON.stringify(await page.locator('[data-compare-node="caliber"] [data-compare-key]').allInnerTexts()) + === JSON.stringify(['metrics'])) 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])) @@ -584,6 +630,11 @@ await section('出错与找不到基准', async () => { await settled(lone.page) check('手动选了之后照常对比', new URL(lone.page.url()).searchParams.get('base') === B2 && compareHits(lone.log, T, B2) === 1) + const missing = await open(comparePath(T), { base: () => ({ status: 404, json: { detail: '运行记录不存在', code: 'run_not_found' } }) }) + await settled(missing.page, 'error') + check('找基准时本次就不存在:写运行记录不存在', await missing.page.locator('[data-compare-error="404"]').count() === 1 + && (await missing.page.locator('[data-compare-error-title]').innerText()) === '运行记录不存在') + // 只带本次、找得到:自己补上基准,写明怎么选的 const auto = await open(comparePath(T)) await settled(auto.page) From a0837cc82c7f61003a03d4a72808a51bdcf59cca Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 5 Oct 2026 09:06:18 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=E8=BF=90=E8=A1=8C=E5=AF=B9=E6=AF=94?= =?UTF-8?q?=E9=A1=B5=E5=A4=8D=E6=9F=A5=E4=BF=AE=E6=AD=A3=EF=BC=9A=E4=BA=92?= =?UTF-8?q?=E6=8D=A2=E6=97=B6=E4=B8=8D=E9=87=8D=E5=8F=96=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E5=88=97=E8=A1=A8=EF=BC=8C=E6=8B=86=E5=88=86=E5=8F=A5=E7=9A=84?= =?UTF-8?q?=E6=80=BB=E5=8F=98=E5=8C=96=E4=B8=BA=200=20=E6=97=B6=E5=86=99?= =?UTF-8?q?=E3=80=8C0=E3=80=8D=EF=BC=8C=E5=B1=95=E5=BC=80=E6=8E=A7?= =?UTF-8?q?=E4=BB=B6=E5=8F=AA=E6=8C=87=E5=90=91=E5=9C=A8=E5=9C=BA=E7=9A=84?= =?UTF-8?q?=E6=98=8E=E7=BB=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 互换、换了本次之后,新的运行记录还在路上时沿用上一次知道的工作流:下拉不再先退回「正在读取」、再把同一工作流 的运行列表取一遍(能比的两次运行本来就是同一个工作流);check-compare 补这一项 - 口径变了、数值却碰巧没变(两效应正好抵消)时服务端不给变化量的写法,拆分那句话的总变化写「0」,不再写成 「两者之和等于总变化 无变化」 - 指标展开、查看 SQL 的按钮只在明细展开时才带 aria-controls,不指向页面上不存在的元素;参数前后两个值之间的箭头 对读屏补一句「改为」 --- frontend/src/lib/terms.ts | 2 ++ frontend/src/pages/runs/ComparePage.tsx | 8 ++++++-- frontend/src/pages/runs/compare/Metrics.tsx | 15 ++++++++++++--- frontend/src/pages/runs/compare/Queries.tsx | 4 +++- scripts/check-compare.mjs | 5 +++++ 5 files changed, 28 insertions(+), 6 deletions(-) diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 2738f2e2..1ce0fe37 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -2878,6 +2878,8 @@ export const RUN_COMPARE_TEXT = { rowsBase: (base: string) => `基准 ${base}`, rowsTarget: (target: string) => `本次 ${target}`, literals: '变化的参数', + /** 参数前后两个值之间的箭头,读屏念这个 */ + changedTo: '改为', baseSql: '基准的 SQL', targetSql: '本次的 SQL', bothSql: '两次相同的 SQL', diff --git a/frontend/src/pages/runs/ComparePage.tsx b/frontend/src/pages/runs/ComparePage.tsx index 508b3004..30b9e8cf 100644 --- a/frontend/src/pages/runs/ComparePage.tsx +++ b/frontend/src/pages/runs/ComparePage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { Link, useLocation, useNavigate, useSearchParams } from 'react-router-dom' import { GitCompareArrows } from 'lucide-react' import { EmptyState, PageHeader, Skeleton, Spinner } from '../../components/ui' @@ -44,7 +44,11 @@ export function ComparePage() { const targetRun = useRunRecord(target) const baseRun = useRunRecord(base) - const workflowId = targetRun.data?.workflow_id + // 换基准的下拉按本次的工作流取。互换、换了本次之后新的运行记录还在路上时沿用上一次知道的工作流:能比的两次运行 + // 本来就是同一个工作流,不必让下拉先退回「正在读取」、再把同一份列表取一遍 + const lastWorkflow = useRef(undefined) + if (targetRun.state === 'ok') lastWorkflow.current = targetRun.data?.workflow_id ?? null + const workflowId = targetRun.state === 'ok' ? targetRun.data?.workflow_id : lastWorkflow.current const candidates = useCandidates(workflowId) const cmp = useComparison(target, base) diff --git a/frontend/src/pages/runs/compare/Metrics.tsx b/frontend/src/pages/runs/compare/Metrics.tsx index be97860b..319c6bee 100644 --- a/frontend/src/pages/runs/compare/Metrics.tsx +++ b/frontend/src/pages/runs/compare/Metrics.tsx @@ -108,7 +108,7 @@ function MetricRows({ m, k, detailId, open, onToggle }: { - @@ -82,7 +82,9 @@ function QueryItem({ q, k, nth }: { q: RunCompareQuery; k: string; nth: number | {q.literals.map((l, i) => (
  • {valueText(l.base)} + {/* 箭头只给眼睛看,读屏念「改为」 */} → + {T.changedTo} {valueText(l.target)}
  • ))} diff --git a/scripts/check-compare.mjs b/scripts/check-compare.mjs index e4bb797d..a010b0b3 100644 --- a/scripts/check-compare.mjs +++ b/scripts/check-compare.mjs @@ -558,6 +558,8 @@ await section('换基准、互换', async () => { 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')) + const lists = () => log.filter((k) => k.startsWith('GET /api/runs?') && k.includes(`workflow_id=${WF}`)).length + const listsBefore = lists() 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) @@ -566,6 +568,9 @@ await section('换基准、互换', async () => { 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}`) + check('互换:同一工作流的运行列表不重取,下拉照常可用', lists() === listsBefore + && await page.locator('select[data-compare-base-picker]').isEnabled() && await page.locator('select[data-compare-base-picker]').inputValue() === T, + `${listsBefore} → ${lists()}`) }) await section('过时的答复不盖掉新的', async () => { From c7a91164245f36e40973314ddced140104e8f425 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 5 Oct 2026 10:16:15 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=E8=BF=90=E8=A1=8C=E5=AF=B9=E6=AF=94?= =?UTF-8?q?=E9=A1=B5=E5=A4=8D=E6=A0=B8=E4=BF=AE=E6=AD=A3=EF=BC=88=E7=AC=AC?= =?UTF-8?q?=201=20=E8=BD=AE=EF=BC=89=EF=BC=9A=E7=85=A7=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E7=AB=AF=E7=9A=84=E5=AE=9E=E9=99=85=E8=BE=93=E5=87=BA=E6=94=B9?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E7=BC=96=E5=8F=B7=E3=80=81=E6=97=A0=E6=B3=95?= =?UTF-8?q?=E6=AF=94=E8=BE=83=E7=9A=84=E5=8E=9F=E5=9B=A0=E3=80=81=E8=AF=BB?= =?UTF-8?q?=E4=B8=8D=E5=88=B0=E7=9A=84=E5=9F=BA=E5=87=86=E5=92=8C=E6=9C=AA?= =?UTF-8?q?=E4=BF=9D=E5=AD=98=E5=B7=A5=E4=BD=9C=E6=B5=81=E7=9A=84=E8=AF=B4?= =?UTF-8?q?=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 查询区不再拿服务端的 (node_id, ordinal) 当键和编号:配上的一对取本次的序号、落单的基准查询取基准的序号, Agent 换了查询先后时两者会撞上,React 报键冲突,「第 N 条」重复或缺号。改按同一节点在列表里出现的先后编号, 键是「节点:第几」。 - 「拆分或原因」一栏:无法比较、新增、移除的指标,服务端只把原因放在 reasons 里,这一栏原来只剩「—」,现在写 reasons 的第一句。不能拆的「为什么没拆」也写进展开的明细,窄屏上那一格横着滚也看不全。服务端有意不做维度归因 的几类,写明为什么不做。检查夹具改成服务端的实际形状(decomposition_note 为 null):原来那一项是靠编出来的字段 通过的。 - 换基准下拉:当前的基准读不到时单列一项,写短 id 和「记录不存在」或「无法读取」,不再显示成另一次运行被选中、 选它又没反应。本次的记录读不到时写明原因并给重试,不再一直停在「正在读取」。运行卡读取失败也给重试,记录不存在 的照实写。 - 不属于已保存工作流的运行(没保存过,或工作流已删除):「与上一次对比」不可点并写明原因,对比页也不再叫人去上方 选基准。服务端对这两种运行都不比。 - 错误说明:409 按机读码分「尚未结束并封存」和「封存核对不一致」,去掉界面上没有的「封存凭证条」。基准不存在时 提示在上方另选。找不到默认基准时,下拉里有能选的运行才说「可在上方选择」。 - 菜单查找基准超过 300 毫秒就挂一条提示;离开运行详情时取消请求,晚到的答复不再把人拽到对比页。 - 一边的 SQL 拿不到时不做逐词比较,另一边不再整条标成「独有」。SQL 注释整段算一个词,注释里的撇号不再吞掉后面的 语句。 - 分类、结构的悬停说明照服务端的分类口径改写;查询按「条」计数,和「第 N 条」一致。 - check-compare 按服务端的实际输出补上这些场景,并加段过滤 COMPARE_ONLY;check-all 里 compare 的耗时估计按实测 改为 45 秒。 --- frontend/src/lib/terms.ts | 49 ++- frontend/src/pages/runs/ComparePage.tsx | 20 +- frontend/src/pages/runs/compare/Metrics.tsx | 35 +- frontend/src/pages/runs/compare/Queries.tsx | 55 ++-- frontend/src/pages/runs/compare/RunPair.tsx | 64 +++- frontend/src/pages/runs/compare/entry.tsx | 47 ++- frontend/src/pages/runs/compare/model.ts | 55 +++- frontend/src/pages/runs/compare/parts.tsx | 72 ++++- frontend/src/pages/runs/compare/sqlDiff.ts | 8 +- scripts/check-all.mjs | 2 +- scripts/check-compare.mjs | 336 +++++++++++++++++--- 11 files changed, 610 insertions(+), 133 deletions(-) diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 1ce0fe37..ad6de84c 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -2751,8 +2751,11 @@ export const RUN_COMPARE_TEXT = { // 入口:运行详情的「更多」菜单 menu: '与上一次对比', menuSealedOnly: '运行结束并封存后才能对比', + /** 没有 workflow_id 的运行:画布、问数据里没保存就运行的,或者工作流删掉之后被置空的。服务端只比同一个已保存的工作流 */ + menuNoWorkflow: '工作流未保存或已删除,无法对比', menuFinding: '正在查找上一次运行…', pickManually: '手动选择基准', + retry: '重试', // 顶部的两次运行 base: '基准', target: '本次', @@ -2763,6 +2766,7 @@ export const RUN_COMPARE_TEXT = { unsealed: '未封存', unsealedHint: '这次运行没有封存清单,不能参与对比', runFailed: '无法读取这次运行', + runMissing: '运行记录不存在', noBase: '未选择', reason: { same_schedule: '默认基准:同一定时任务的上一次运行', @@ -2773,25 +2777,37 @@ export const RUN_COMPARE_TEXT = { pickLoading: '正在读取同一工作流的运行…', pickFailed: '无法读取同一工作流的运行', pickNone: '同一工作流没有其他运行', - pickUnsaved: '临时构建的工作流没有其他运行可选', + pickUnsaved: '工作流未保存或已删除,没有可对比的运行', pickPlaceholder: '选择一次运行作为基准', /** 下拉里的一次运行:时间 · 类别和版本 · 短 id,没封存的另标 */ pickOption: (time: string, cls: string, id: string, sealed: boolean) => `${time} · ${cls} · ${id}${sealed ? '' : '(未封存)'}`, + /** 当前的基准读不到时下拉里的那一项(短 id):如实显示选的是哪一次 */ + pickGone: (id: string, missing: boolean) => `${id}(${missing ? '记录不存在' : '无法读取'})`, // 页面状态 noTarget: '未指定要对比的运行', noTargetHint: '在运行详情的「更多」菜单中选择「与上一次对比」,即可对照本次与上一次运行', toRuns: '前往记录', finding: '正在查找基准运行…', loading: '正在对比两次运行…', - errSealed: '无法对比:有运行未封存或封存核对未通过', - errSealedHint: '两次运行都需已结束并封存,且封存核对一致。可在运行详情的封存凭证条上核对', + // 对比失败:标题说是哪一种,服务端的原因原样跟在后面,最后一句下一步只写界面上真有的入口,服务端已经写了的不再重复 + errSealed: '无法对比:有运行未封存或封存核对不一致', + errUnsealed: '无法对比:有运行尚未结束并封存', + errSealBroken: '无法对比:封存核对不一致', + /** 409 时不知道(或者知道不是)本次的问题:服务端的原因里写着是哪一次 */ + errSealedHint: '若是基准运行的问题,可在上方更换基准', errMismatch: '无法对比这两次运行', - errMismatchHint: '请选择同一工作流的另一次运行作为基准', + errMismatchHint: '可在上方选择同一工作流的另一次运行作为基准', + /** 本次没有 workflow_id:哪一次都比不了,不给下一步 */ + errUnsaved: '无法对比:本次运行的工作流未保存或已删除', errMissing: '运行记录不存在', errMissingHint: '链接可能已失效,请从记录页重新打开', + /** 本次的记录读得到、对比却回 404:缺的是基准 */ + errBaseMissingHint: '可在上方选择另一次运行作为基准', errNoBase: '未找到可作为基准的运行', errNoBaseHint: '可在上方选择同一工作流的其他运行作为基准', + /** 下拉里一次能选的都没有(只有本次、或者都没封存) */ + errNoBaseNone: '同一工作流还没有其他已封存的运行', viewBase: '查看基准运行', viewTarget: '查看本次运行', // 摘要 @@ -2807,11 +2823,12 @@ export const RUN_COMPARE_TEXT = { incomparable: '无法比较', unchanged: '未变', } as Record, + // 照服务端(engine/compare.py)的分类口径写:口径变了含小数位、单位、显示格式,SQL 结构变含上游节点改配置 classHint: { data_only: '口径与 SQL 都没有改动,变化来自数据', - caliber_and_data: '口径卡的计算式有改动,数据也变了;能严格拆分的分别给出口径效应和数据效应', - sql_changed: '所依据的查询 SQL 结构有改动,口径不同,不做拆分', - params_only: '所依据的查询 SQL 结构相同,只有参数(如日期)不同', + caliber_and_data: '口径(计算式、口径版本、小数位、单位或显示格式)有改动,数据可能也有变化;能严格拆分的分别给出口径效应和数据效应', + sql_changed: '取数的 SQL 或上游节点的配置有改动,无法在口径与数据之间拆分', + params_only: '所依据的查询 SQL 结构相同,只有参数(如日期)或运行输入不同', added: '本次有、基准没有的指标', removed: '基准有、本次没有的指标', incomparable: '缺少比较所需的信息,无法判断变化来自哪里', @@ -2824,6 +2841,8 @@ export const RUN_COMPARE_TEXT = { metrics: '指标', metricsEmpty: '两次运行的口径卡上都没有指标', count: (n: number) => `${formatNumber(n)} 个`, + /** 查询按「条」数,和「第 N 条」一致 */ + queryCount: (n: number) => `${formatNumber(n)} 条`, colMetric: '指标', colBase: '基准', colTarget: '本次', @@ -2850,6 +2869,17 @@ export const RUN_COMPARE_TEXT = { attrMore: (n: number) => `另有 ${formatNumber(n)} 项未列出`, attrTotal: (value: string) => `合计变化 ${value}`, attrNone: '没有可按维度归因的明细', + /** + * 服务端只对只数据变、只参数变、口径与数据都变的指标做维度归因(两边的行说的是同一回事)。别的类不是「没有明细」, + * 是有意不做:写明为什么 + */ + attrSkipped: { + sql_changed: '取数的 SQL 或上游配置有改动,两边的行不能直接对照,不做维度归因', + incomparable: '无法判断变化来源,不做维度归因', + added: '基准没有这个指标,不做维度归因', + removed: '本次没有这个指标,不做维度归因', + unchanged: '数值没有变化,不做维度归因', + } as Record, /** 服务端没给占比、也没说为什么时的说法 */ attrNoShare: '无法计算各项占比', colKey: '维度', @@ -2867,7 +2897,8 @@ export const RUN_COMPARE_TEXT = { structureHint: { same: 'SQL 完全相同', params_only: 'SQL 结构相同,只有字面量不同', - changed: 'SQL 结构不同:表、连接、筛选条件、分组或聚合有改动', + // 服务端在只换了数据源、有一边的 SQL 读不出来时也标「结构变」 + changed: 'SQL 结构或数据源不同,或有一边的 SQL 无法读取', added: '本次新增的查询,基准中没有', removed: '基准中有、本次没有的查询', } as Record, @@ -2883,6 +2914,8 @@ export const RUN_COMPARE_TEXT = { baseSql: '基准的 SQL', targetSql: '本次的 SQL', bothSql: '两次相同的 SQL', + /** 快照读不出来、又拿不到执行时的参数:这一边没有 SQL,不能拿空串去和另一边比 */ + sqlUnreadable: '这一边的 SQL 无法读取', onlyBase: '基准独有', onlyTarget: '本次独有', showSql: '查看 SQL', diff --git a/frontend/src/pages/runs/ComparePage.tsx b/frontend/src/pages/runs/ComparePage.tsx index 30b9e8cf..2e28c161 100644 --- a/frontend/src/pages/runs/ComparePage.tsx +++ b/frontend/src/pages/runs/ComparePage.tsx @@ -7,8 +7,8 @@ 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 { compareErrorKind, isGone } from './compare/model' +import { CompareError, type CompareErrorContext } from './compare/parts' import { Queries } from './compare/Queries' import { RunPair } from './compare/RunPair' import { Summary } from './compare/Summary' @@ -51,6 +51,8 @@ export function ComparePage() { const workflowId = targetRun.state === 'ok' ? targetRun.data?.workflow_id : lastWorkflow.current const candidates = useCandidates(workflowId) const cmp = useComparison(target, base) + // 不属于已保存的工作流:没保存过,或者工作流删掉之后 workflow_id 被置空(名字还是原来的)。服务端两种都不比 + const unsaved = targetRun.state === 'ok' && !workflowId const go = (t: string, b: string) => navigate(comparePath(t, b), { replace: true }) @@ -59,6 +61,16 @@ export function ComparePage() { else if (!base) state = finder.state === 'error' ? 'error' : 'finding' else state = cmp.state === 'ok' ? 'ok' : cmp.state === 'error' ? 'error' : 'loading' + // 错误说明的下一步按页面已经知道的挑(parts.tsx 的 errorCopy):下拉里没有能选的就不叫人去上方选 + const errorContext: CompareErrorContext = { + targetKnown: targetRun.state === 'ok', + targetUnsaved: unsaved, + targetUnsealed: targetRun.state === 'ok' && !targetRun.data?.manifest_hash, + targetGone: targetRun.state === 'error' && isGone(targetRun.error), + baseGone: baseRun.state === 'error' && isGone(baseRun.error), + pickable: candidates.state === 'ok' ? (candidates.data ?? []).some((r) => r.id !== target && !!r.manifest_hash) : null, + } + return (
    } title={RUN_COMPARE_TEXT.title} subtitle={RUN_COMPARE_TEXT.hint} /> @@ -70,7 +82,7 @@ export function ComparePage() {
    go(target, b)} onSwap={() => { if (base) go(base, target) }} /> {state === 'finding' && } @@ -80,7 +92,7 @@ export function ComparePage() { kind={compareErrorKind(base ? cmp.error : finder.error, !base)} error={base ? cmp.error : finder.error} onRetry={base ? cmp.reload : finder.reload} - target={target} base={base} + target={target} base={base} context={errorContext} /> )} {state === 'ok' && cmp.data && } diff --git a/frontend/src/pages/runs/compare/Metrics.tsx b/frontend/src/pages/runs/compare/Metrics.tsx index 319c6bee..67486653 100644 --- a/frontend/src/pages/runs/compare/Metrics.tsx +++ b/frontend/src/pages/runs/compare/Metrics.tsx @@ -5,7 +5,7 @@ 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 { changeText, metricKey, sameValue, shareText, signedNumber, valueText } from './model' +import { changeText, metricKey, sameValue, shareText, signedNumber, splitReason, valueText } from './model' /** 维度归因列前几项:后端按变化从大到小排好,多的只写还有几项 */ const ATTR_TOP = 10 @@ -20,7 +20,7 @@ const STICKY = 'sticky left-0 z-[1] min-w-[9em] shadow-[inset_-1px_0_0_var(--bor /** * 指标表:基准 → 本次、变化量和百分比(渲染值照服务端给的写)、分类、能严格拆分的口径效应和数据效应(并列的数加一段 - * 两截的条形),不能拆的写服务端给的原因。每行能展开:变化说明、输入对比、维度归因前 10 项。 + * 两截的条形),不能拆、比不了的写原因(splitReason)。每行能展开:变化说明、输入对比、维度归因前 10 项。 * * 表格比窄屏宽,在自己的框里横向滚动(框能用键盘聚焦),页面本身不横向溢出。展开的明细贴着框的左边、宽度等于框的 * 可见宽度:窄屏上不用横着滚也读得全 @@ -104,6 +104,7 @@ 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 ? changeText(m.delta_pct) : null + const why = m.decomposition ? null : splitReason(m) return ( @@ -129,9 +130,10 @@ function MetricRows({ m, k, detailId, open, onToggle }: { {m.decomposition ? ( - ) : m.decomposition_note ? ( -

    - {m.decomposition_note} + ) : why ? ( +

    + {why}

    ) : ( {NONE} @@ -199,7 +201,11 @@ function Split({ d, total }: { d: RunCompareDecomposition; total: string }) { ) } -/** 展开的明细:变化说明、拆分写成一句话、输入对比、维度归因。贴着滚动框的左边,宽度等于框的可见宽度 */ +/** + * 展开的明细:变化说明、拆分写成一句话(不能拆的写服务端说的为什么)、输入对比、维度归因。贴着滚动框的左边,宽度 + * 等于框的可见宽度:窄屏上表格里「拆分或原因」那一格比钉在左边的指标名右侧剩下的地方宽,横着滚到哪儿都看不全, + * 这里读得全 + */ function MetricDetail({ m }: { m: RunCompareMetric }) { return (
    @@ -212,15 +218,17 @@ function MetricDetail({ m }: { m: RunCompareMetric }) {
    )} - {m.decomposition && ( + {m.decomposition ? (

    {T.splitSum(m.decomposition.caliber_rendered, m.decomposition.data_rendered, totalText(m))}

    - )} + ) : m.decomposition_note ? ( +

    {m.decomposition_note}

    + ) : null}
    - +
    ) @@ -270,14 +278,17 @@ function Inputs({ m }: { m: RunCompareMetric }) { /** * 维度归因:按键对齐两边查询结果的行,列前 10 项的键、两边的值、变化和占比。占比只在度量列之和等于指标的输入时才有, * 没有时那一栏写「—」,下面照服务端的说明写原因(没给说明时写一句通用的)。归不了因的(查询结果截断、度量列遮罩、 - * 快照读不出来)服务端给空的 rows 和一句说明:不画空表,只写这句话 + * 快照读不出来)服务端给空的 rows 和一句说明:不画空表,只写这句话。服务端有意不做归因的几类(SQL 结构变、无法 + * 比较、新增、移除、未变)写为什么不做,不说成「没有明细」 */ -function Attribution({ a }: { a: RunCompareAttribution | null }) { +function Attribution({ a, classification }: { a: RunCompareAttribution | null; classification: string }) { if (!a) { return (

    {T.attribution}

    -

    {T.attrNone}

    +

    + {T.attrSkipped[classification] ?? T.attrNone} +

    ) } diff --git a/frontend/src/pages/runs/compare/Queries.tsx b/frontend/src/pages/runs/compare/Queries.tsx index 1b982898..a12ec827 100644 --- a/frontend/src/pages/runs/compare/Queries.tsx +++ b/frontend/src/pages/runs/compare/Queries.tsx @@ -4,7 +4,7 @@ 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 { queryItems, valueText } from './model' import { diffSql, type DiffPart } from './sqlDiff' /** 基准独有、本次独有的高亮:颜色之外,删去的划线、新加的下划线,色弱、灰度打印也分得出 */ @@ -26,17 +26,14 @@ const MARK = { */ export function Queries({ queries }: { queries: RunCompareQuery[] }) { const id = useId() - const numbers = useMemo(() => queryNumbers(queries), [queries]) + const items = useMemo(() => queryItems(queries), [queries]) return ( - + {!queries.length ? (

    {T.queriesEmpty}

    ) : (
      - {queries.map((q) => { - const k = queryKey(q) - return - })} + {items.map((it) => )}
    )}
    @@ -91,22 +88,37 @@ function QueryItem({ q, k, nth }: { q: RunCompareQuery; k: string; nth: number |
    )} - {q.structure === 'changed' && } - {q.structure === 'added' && } - {q.structure === 'removed' && } + {q.structure === 'changed' && } + {q.structure === 'added' && } + {q.structure === 'removed' && } {foldable && showSql && (
    {q.structure === 'same' - ? - : } + ? + : }
    )} ) } -/** 两条 SQL 并排,逐词标出各自独有的部分 */ -function SideBySide({ base, target }: { base: string; target: string }) { +/** + * 两条 SQL 并排,逐词标出各自独有的部分。有一边拿不到 SQL(快照读不出来、又没有执行时的参数)时不比:拿空串去比, + * 另一边会整条被标成「独有」,像是整条新写的,其实只是不知道。这时两边原样摆着,拿不到的那一边写明无法读取 + */ +function SideBySide({ base, target }: { base: string | null; target: string | null }) { + if (base == null || target == null) { + return ( +
    + + +
    + ) + } + return +} + +function Diffed({ base, target }: { base: string; target: string }) { const diff = useMemo(() => diffSql(base, target), [base, target]) return (
    @@ -123,21 +135,26 @@ function SideBySide({ base, target }: { base: string; target: string }) { ) } -/** 一条 SQL。折行显示,不横向撑开;太长时在框里纵向滚动,框能用键盘聚焦 */ +/** + * 一条 SQL。折行显示,不横向撑开;太长时在框里纵向滚动,框能用键盘聚焦。sql 为 null(这一边拿不到 SQL)时 + * 框里写明无法读取,不摆一个空框 + */ function SqlPane({ which, label, sql, parts }: { - which: 'base' | 'target' | 'both'; label: string; sql?: string; parts?: DiffPart[] + which: 'base' | 'target' | 'both'; label: string; sql?: string | null; parts?: DiffPart[] }) { const kind = which === 'base' ? 'del' : 'add' + const missing = !parts && sql == null return (
    {label}
    -
    +      
             {parts
               ? parts.map((p, i) => (p.changed
                 ? {p.text}
                 : {p.text}))
    -          : sql}
    +          : missing ? T.sqlUnreadable : sql}
           
    ) diff --git a/frontend/src/pages/runs/compare/RunPair.tsx b/frontend/src/pages/runs/compare/RunPair.tsx index 0cc011ee..b32719d8 100644 --- a/frontend/src/pages/runs/compare/RunPair.tsx +++ b/frontend/src/pages/runs/compare/RunPair.tsx @@ -1,12 +1,13 @@ import { useId, type ReactNode } from 'react' import { Link } from 'react-router-dom' -import { ArrowLeftRight, CalendarClock } from 'lucide-react' +import { ArrowLeftRight, CalendarClock, RotateCw } 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' +import { isGone } from './model' /** * 顶部的两次运行:左边基准、右边本次(窄屏上下排),中间「互换」。每张卡写名字、时间、类别和版本,点名字进各自的 @@ -18,7 +19,7 @@ export function RunPair({ target, base, targetRun, baseRun, candidates, unsaved, targetRun: Loaded baseRun: Loaded candidates: Loaded - /** 本次的工作流没保存:没有同一工作流的其他运行可选 */ + /** 本次不属于已保存的工作流(没保存过、或者工作流已删除):没有同一工作流的其他运行可选 */ unsaved: boolean /** 默认基准是怎么选的(只在从「与上一次对比」、或只带本次打开时有) */ reason: string | null @@ -29,8 +30,8 @@ export function RunPair({ target, base, targetRun, baseRun, candidates, unsaved,
    - +
    + ) +} + /** * 换基准。列的是运行列表接口按工作流取回的最近几次(新的在前),去掉本次;没封存的列出来但不能选,写明原因。 - * 当前的基准不在这几次里(更早、或者属于别的工作流)时也放进去,下拉不至于显示成别的运行 + * + * 当前的基准不在这几次里(更早、属于别的工作流、读不到)时也单列一项:受控的下拉值对不上任何选项时浏览器会选中 + * 第一项,看着像另一次运行被选中,而原生下拉再选当前显示的那一项不触发 change,最自然的补救点了没反应。读不到的 + * 写短 id 加原因,下拉如实反映当前选的是哪一次,其余每一项都能触发 change。 + * + * 列不出候选时说清楚、给重试,不停在「正在读取」:同一工作流的运行没取到;或者本次的记录没读到,不知道是哪个工作流 + * (候选的键是空的,不会自己去取) */ -function BasePicker({ target, base, baseRun, candidates, unsaved, onPick }: { - target: string; base: string | null; baseRun?: RunWithSchedule; candidates: Loaded; unsaved: boolean - onPick: (id: string) => void +function BasePicker({ target, base, baseRun, targetRun, candidates, unsaved, onPick }: { + target: string; base: string | null; baseRun: Loaded; targetRun: Loaded + candidates: Loaded; unsaved: boolean; onPick: (id: string) => void }) { const id = useId() const now = useTicker(60_000) - if (unsaved) return

    {T.pickUnsaved}

    - if (candidates.state === 'error') return

    {T.pickFailed}

    + if (unsaved) return

    {T.pickUnsaved}

    + const failed = candidates.state === 'error' ? candidates.reload + : candidates.state === 'idle' && targetRun.state === 'error' ? targetRun.reload : null + if (failed) { + return ( +
    + {T.pickFailed} + +
    + ) + } 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

    {T.pickNone}

    + if (!loading && !list.length) return

    {T.pickNone}

    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) + const current = base && !list.some((r) => r.id === base) ? base : null + const currentLabel = (b: string) => (baseRun.data ? label(baseRun.data) + : baseRun.state === 'error' ? T.pickGone(shortId(b), isGone(baseRun.error)) : shortId(b)) return (
    @@ -123,6 +154,7 @@ function BasePicker({ target, base, baseRun, candidates, unsaved, onPick }: { aria-describedby={`${id}-hint`} onChange={(e) => { if (e.target.value) onPick(e.target.value) }}> {loading && } {!loading && !base && } + {!loading && current && } {!loading && list.map((r) => ( // 当前的基准即使没封存也要能显示出来(选中项);别的没封存的一律不能选 diff --git a/frontend/src/pages/runs/compare/entry.tsx b/frontend/src/pages/runs/compare/entry.tsx index bbe611f2..22b64843 100644 --- a/frontend/src/pages/runs/compare/entry.tsx +++ b/frontend/src/pages/runs/compare/entry.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { useNavigate } from 'react-router-dom' import { GitCompareArrows } from 'lucide-react' import { ApiError, api } from '../../../api/client' @@ -13,36 +13,69 @@ export interface CompareVia { base: string; reason: string } export const comparePath = (target: string, base?: string | null) => `/runs/compare?${new URLSearchParams(base ? { target, base } : { target })}` +/** 查找超过这么久才挂「正在查找」的提示:答复快的不闪一下 */ +const NOTE_DELAY_MS = 300 + /** * 运行详情「更多」菜单里的「与上一次对比」。先问服务端默认的基准(同一定时任务的上一次,没有就同一工作流、同一类别的 * 上一次),找到了再跳到对比页;找不到时留在原页,提示服务端给的原因,并给「手动选择基准」去对比页自己挑。 - * 没封存、还在运行的不给点:对比只认已封存的运行,点了也是 409。 + * 这几种不给点,点了也只会被拒:没封存、还在运行的(对比只认已封存的运行,409);不属于已保存工作流的——画布、问数据 + * 里没保存就运行的,和工作流删掉之后 workflow_id 被置空的(服务端找不到同一工作流的运行,手动选也是 422)。 + * + * 菜单点完就收起:查找慢于一眨眼时挂一条提示,接口慢的时候不至于像没反应。离开运行详情(组件卸载)时取消请求、 + * 收起提示,晚到的答复不再把人带去对比页。 * * 写成 hook(返回造菜单项的函数):运行详情里有提前返回,菜单项本身只能在拿到运行之后造,查找中的状态得在那之前就挂上 */ export function useCompareEntry(): (run: Run, live: boolean) => MenuItem { const navigate = useNavigate() const [busy, setBusy] = useState(false) + // 在路上的那一次:stop 收起(或者不再挂出)「正在查找」的提示 + const pending = useRef<{ ctrl: AbortController; stop: () => void } | null>(null) + useEffect(() => () => { + const p = pending.current + pending.current = null + p?.ctrl.abort() + p?.stop() + }, []) return useCallback((run: Run, live: boolean): MenuItem => { - const blocked = live || !run.manifest_hash + const orphan = !run.workflow_id + const unsealed = live || !run.manifest_hash return { key: 'compare', label: T.menu, icon: , - disabled: blocked || busy, - hint: blocked ? T.menuSealedOnly : busy ? T.menuFinding : undefined, + disabled: orphan || unsealed || busy, + hint: orphan ? T.menuNoWorkflow : unsealed ? T.menuSealedOnly : busy ? T.menuFinding : undefined, onSelect: () => { + const ctrl = new AbortController() + let note: number | null = null + const timer = setTimeout(() => { + note = toast.info(T.menuFinding, { sticky: true, key: `compare-finding:${run.id}` }) + }, NOTE_DELAY_MS) + const stop = () => { + clearTimeout(timer) + if (note != null) toast.dismiss(note) + } + pending.current = { ctrl, stop } setBusy(true) - api.compare.base(run.id).then((found) => { + api.compare.base(run.id, { signal: ctrl.signal }).then((found) => { + if (ctrl.signal.aborted) return const via: CompareVia = { base: found.base_run_id, reason: found.reason } navigate(comparePath(run.id, found.base_run_id), { state: via }) }, (e: unknown) => { + if (ctrl.signal.aborted) return 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)) + }).finally(() => { + stop() + if (pending.current?.ctrl !== ctrl) return + pending.current = null + setBusy(false) + }) }, } }, [navigate, busy]) diff --git a/frontend/src/pages/runs/compare/model.ts b/frontend/src/pages/runs/compare/model.ts index 58e14092..dee06273 100644 --- a/frontend/src/pages/runs/compare/model.ts +++ b/frontend/src/pages/runs/compare/model.ts @@ -34,8 +34,19 @@ export function classTone(k: string): { color: string; soft: boolean } { /** 一个指标在表里的键:同一张卡里指标 id 唯一,几张卡之间可能重名 */ export const metricKey = (m: Pick) => `${m.card_id}:${m.metric_id}` -/** 一条查询的键:同一节点里按服务端的序号区分 */ -export const queryKey = (q: Pick) => `${q.node_id}:${q.ordinal}` +/** + * 「拆分或原因」一栏没有拆分时写的那句话:服务端写了「为什么没拆」就用它。无法比较、新增、移除的服务端不写这一项, + * 原因只在 reasons 里——退回写 reasons 的第一句(全部在展开的变化说明里),不让这一栏只剩一个「—」、要展开才知道 + * 为什么。SQL 结构变的服务端一向写着,万一没写,reasons 说的就是哪条查询变了,也是不拆的原因。只数据变、只参数变、 + * 未变的本来就用不着拆;口径与数据都变的服务端总会给拆分或原因,它的 reasons 说的是口径改了什么、不是为什么不拆, + * 都不退回 + */ +const REASON_IN_CELL = new Set(['incomparable', 'added', 'removed', 'sql_changed']) + +export function splitReason(m: Pick): string | null { + if (m.decomposition_note) return m.decomposition_note + return REASON_IN_CELL.has(m.classification) && m.reasons?.length ? m.reasons[0] : null +} const isNum = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v) @@ -92,19 +103,29 @@ export function sameValue(a: unknown, b: unknown): boolean { } } +/** 查询区的一项:位置键(同一对比里唯一)和同一节点里的「第 N 条」(节点只有一条时为 null) */ +export interface QueryItem { + q: RunCompareQuery + key: string + nth: number | null +} + /** - * 同一节点有几条查询时,界面按 1 起数写「第 N 条」。服务端的 ordinal 只用来排先后:它从 0 还是从 1 数、中间有没有 - * 空号(只有一边有的那条),都不该漏到界面上 + * 查询区按服务端给的先后列:服务端已经按本次的先后排好,基准多出来的接在后面。同一节点有几条时,按在列表里出现的 + * 先后从 1 数「第 N 条」,键是「节点:第几」。 + * + * 不拿服务端的 ordinal 当键、也不按它编号:配上的一对取本次的序号,落单的基准查询取基准的序号,Agent 换了查询的先后 + * 时两者会撞上(同一节点两条都是 1),按它当键 React 会报键冲突、编号会重复或缺号 */ -export function queryNumbers(queries: RunCompareQuery[]): Map { - const byNode = new Map() - for (const q of queries) byNode.set(q.node_id, [...(byNode.get(q.node_id) ?? []), q]) - const out = new Map() - 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 +export function queryItems(queries: RunCompareQuery[]): QueryItem[] { + const total = new Map() + for (const q of queries) total.set(q.node_id, (total.get(q.node_id) ?? 0) + 1) + const seen = new Map() + return queries.map((q) => { + const n = (seen.get(q.node_id) ?? 0) + 1 + seen.set(q.node_id, n) + return { q, key: `${q.node_id}:${n}`, nth: (total.get(q.node_id) ?? 0) > 1 ? n : null } + }) } /** @@ -115,6 +136,14 @@ export type CompareErrorKind = '409' | '422' | '404' | 'no-base' | 'other' /** 服务端「运行记录不存在」的机读码(api/evidence.py 的 RUN_NOT_FOUND,运行对比的接口沿用) */ const RUN_NOT_FOUND = 'run_not_found' +/** 409 的两种机读码(api/compare.py):没结束封存、封存核对不一致。标题按它分开写 */ +export const UNSEALED = 'compare_unsealed' +export const SEAL_BROKEN = 'compare_seal_broken' + +/** 请求回的是 404(记录不存在):和连不上、服务端出错分开说,前者重试也没用 */ +export function isGone(e: unknown): boolean { + return e instanceof ApiError && e.kind === 'http' && e.status === 404 +} export function compareErrorKind(e: unknown, finding: boolean): CompareErrorKind { if (!(e instanceof ApiError) || e.kind !== 'http') return 'other' diff --git a/frontend/src/pages/runs/compare/parts.tsx b/frontend/src/pages/runs/compare/parts.tsx index fb493b15..944ef95a 100644 --- a/frontend/src/pages/runs/compare/parts.tsx +++ b/frontend/src/pages/runs/compare/parts.tsx @@ -1,9 +1,10 @@ import type { ReactNode } from 'react' import { Link } from 'react-router-dom' import { AlertTriangle } from 'lucide-react' +import { ApiError } from '../../../api/client' import { ErrorState } from '../../../components/ui' import { RUN_COMPARE_TEXT as T } from '../../../lib/terms' -import { classTone, type CompareErrorKind } from './model' +import { SEAL_BROKEN, UNSEALED, classTone, type CompareErrorKind } from './model' /** 对比页的一节:带标题的卡片。count 写在标题旁(「11 个」),hint 是一句说明 */ export function Block({ id, title, count, hint, children, attr }: { @@ -44,7 +45,7 @@ export function ClassChip({ value }: { value: string }) { ) } -/** 查询的结构状态。结构变了用警示色并铺底(口径不同),新增用强调色,其余中性色 */ +/** 查询的结构状态。结构变了用警示色并铺底(数不能直接比),新增用强调色,其余中性色 */ export function StructureChip({ value }: { value: string }) { const warn = value === 'changed' const color = warn ? 'var(--warn)' : value === 'added' ? 'var(--accent)' : 'var(--text-dim)' @@ -55,19 +56,61 @@ export function StructureChip({ value }: { value: string }) { ) } -const ERROR_TEXT: Record, { 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 interface CompareErrorContext { + /** 本次的运行记录读到了 */ + targetKnown: boolean + /** 本次不属于已保存的工作流(没保存过、或者工作流已删除):和哪一次都比不了 */ + targetUnsaved: boolean + /** 本次的运行记录上没有封存清单 */ + targetUnsealed: boolean + /** 本次、基准的运行记录是不是 404 */ + targetGone: boolean + baseGone: boolean + /** 换基准的下拉里有没有能选的运行(已封存、不是本次);还不知道(在读、读失败)时为 null */ + pickable: boolean | null } /** - * 对比失败时的说明:先说是哪一种(未封存、不是同一工作流、运行不存在、找不到基准),再原样带上服务端的原因, - * 最后一句下一步。未封存、封存核对不一致的,给两次运行详情的入口:封存凭证条上能核对。别的错误按通用的出错态画 + * 标题和下一步。标题说是哪一种;服务端的原因原样跟在标题后面(CompareError 里),下一步只写界面上真有的入口、 + * 服务端已经写了的不再重复: + * - 409:按机读码分没结束封存、封存核对不一致。本次自己没封存时换基准也没用,不给下一步;否则说出在基准上时可以 + * 在上方换(服务端的原因里写着是哪一次)。去记录页核对的入口是下面两个「查看」按钮,服务端的原因里也写了「核对」; + * - 422:本次不属于已保存的工作流时哪一次都比不了,标题直说、不给下一步;否则在上方另选同一工作流的一次; + * - 404:缺的是本次(targetGone)才是链接失效;缺的是基准(基准的记录 404,或者本次的记录读得到)就在上方另选; + * 还不知道是哪一边时先不说; + * - 找不到默认基准:本次不属于已保存的工作流同 422;下拉里有能选的才说可以在上方选,一次都没有就照实说 */ -export function CompareError({ kind, error, onRetry, target, base }: { +function errorCopy(kind: Exclude, error: unknown, targetGone: boolean, ctx: CompareErrorContext): { + title: string; hint: string | null +} { + const code = error instanceof ApiError ? error.code : undefined + switch (kind) { + case '409': { + const title = code === SEAL_BROKEN ? T.errSealBroken : code === UNSEALED ? T.errUnsealed : T.errSealed + return { title, hint: code === UNSEALED && ctx.targetUnsealed ? null : T.errSealedHint } + } + case '422': + return ctx.targetUnsaved ? { title: T.errUnsaved, hint: null } : { title: T.errMismatch, hint: T.errMismatchHint } + case '404': + return { + title: T.errMissing, + hint: targetGone ? T.errMissingHint : ctx.baseGone || ctx.targetKnown ? T.errBaseMissingHint : null, + } + case 'no-base': + if (ctx.targetUnsaved) return { title: T.errUnsaved, hint: null } + return { title: T.errNoBase, hint: ctx.pickable == null ? null : ctx.pickable ? T.errNoBaseHint : T.errNoBaseNone } + } +} + +/** + * 对比失败时的说明:先说是哪一种(未封存、封存核对不一致、不是同一工作流、运行不存在、找不到基准),再原样带上 + * 服务端的原因,最后一句下一步(errorCopy)。未封存、封存核对不一致的,给两次运行详情的入口(运行详情上有「核对」); + * 本次的记录不存在时给去记录页的入口。别的错误按通用的出错态画,带重试 + */ +export function CompareError({ kind, error, onRetry, target, base, context }: { kind: CompareErrorKind; error: unknown; onRetry: () => void; target: string | null; base: string | null + context: CompareErrorContext }) { if (kind === 'other') { return ( @@ -76,7 +119,9 @@ export function CompareError({ kind, error, onRetry, target, base }: {
    ) } - const { title, hint } = ERROR_TEXT[kind] + // 缺的是不是本次:只带本次的地址(还没有基准)出的错来自找默认基准,那里的 404 只会是本次 + const targetGone = !base || context.targetGone + const { title, hint } = errorCopy(kind, error, targetGone, context) const reason = error instanceof Error ? error.message : '' return (
    {title}
    {reason && reason !== title &&
    {reason}
    } -
    {hint}
    + {hint &&
    {hint}
    } {kind === '409' && (target || base) && (
    {base && {T.viewBase}} {target && {T.viewTarget}}
    )} + {kind === '404' && targetGone && ( +
    {T.toRuns}
    + )}
    ) diff --git a/frontend/src/pages/runs/compare/sqlDiff.ts b/frontend/src/pages/runs/compare/sqlDiff.ts index d7540910..fa8ab74e 100644 --- a/frontend/src/pages/runs/compare/sqlDiff.ts +++ b/frontend/src/pages/runs/compare/sqlDiff.ts @@ -1,7 +1,7 @@ /** * 两条 SQL 逐词对比:并排显示时标出各自独有的部分(结构变了的那条查询,差在哪一眼能看到)。 * - * 按词(名字、关键字、数、字符串、运算符)而不是按字符比:按字符比会把 created_at 和 paid_at 里碰巧相同的「_at」 + * 按词(名字、关键字、数、字符串、运算符、注释)而不是按字符比:按字符比会把 created_at 和 paid_at 里碰巧相同的「_at」 * 当成没变,读起来支离破碎;按行比又太粗,模型写的 SQL 常常一整条写在一行里。关键字和不加引号的名字不分大小写 * (服务端的规范化也不算它们不同),字符串、加引号的名字照原样比。空白不参与比较,只是两个变了的词之间的空白也 * 跟着标,免得一段新加的条件被切成好几截。 @@ -26,8 +26,10 @@ export interface SqlDiff { /** 逐词比较时动态规划表的格数上限(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 +// 空白、注释(-- 到行尾、/* … */,没收尾的到末尾)、字符串('' 转义)、加引号的名字("…"、`…`、[…])、名字和关键字、 +// 数、两个字符的运算符、其余单个字符。注释整段算一个词,而且排在字符串前面:模型写的英文注释里常有 it's、user's, +// 把里面的撇号当成字符串的开头,会一直吞到下一个引号,后面没改的语句全被标成不同 +const TOKEN = /\s+|--[^\n]*|\/\*[\s\S]*?(?:\*\/|$)|'(?:[^']|'')*'?|"(?:[^"]|"")*"?|`[^`]*`?|\[[^\]\s]*\]?|[\p{L}_][\p{L}\p{N}_$]*|\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|<=|>=|<>|!=|\|\||::|[\s\S]/gu interface Token { text: string diff --git a/scripts/check-all.mjs b/scripts/check-all.mjs index 33c35682..30f1973f 100644 --- a/scripts/check-all.mjs +++ b/scripts/check-all.mjs @@ -96,7 +96,7 @@ const ESTIMATE = { stream: 173, chat: 160, studio: 149, manage: 145, runs: 112, publish: 92, shell: 87, evidence: 91, versions: 60, catalog: 45, 'ui-kit': 70, ui: 61, guards: 8, 'canvas-layout': 2, copy: 2, tokens: 1, trace: 1, decode: 1, // 占位,换成真检查时按实测改 - schedules: 1, compare: 30, verify: 1, inquiry: 1, bundle: 16, whatif: 33, + schedules: 1, compare: 45, verify: 1, inquiry: 1, bundle: 16, whatif: 33, } // 最慢的两项按段拆成几份。用的是它们自己的段过滤:最后一份用 *_SKIP 兜住「其余全部」,以后新加的段 // 不用改这里也跑得到(拆出去的那几份要是一段都没对上,那一份会报「一项都没跑」)。 diff --git a/scripts/check-compare.mjs b/scripts/check-compare.mjs index a010b0b3..3abf62d8 100644 --- a/scripts/check-compare.mjs +++ b/scripts/check-compare.mjs @@ -5,6 +5,9 @@ // 渲染的写法对不上);不能拆的一声不吭;展开后归因只给数没有占比时不说为什么;SQL 结构变了只给一条、差异不标; // 换了基准、互换了却不重新请求,或者慢的旧答复盖掉新的;409(未封存、封存核对不一致)、422(不是同一工作流)、 // 404(找不到基准)只弹一句通用报错;窄屏(390px)被表格撑出横向滚动;浅色下分类标签、差异高亮看不清。 +// 还有几处照服务端(engine/compare.py、api/compare.py)的实际输出才看得出来的:无法比较的指标原因只在 reasons 里; +// 同一节点两条查询的 (node_id, ordinal) 相同;一边的 SQL 是 null;基准运行读不到;本次的记录读不到;不属于已保存 +// 工作流的运行;查找基准期间离开运行详情。下一步的提示要和页面上真能做的事对得上。 // // 全部用 page.route 假接口:虚构的「区域销售周报」三次已封存的正式运行和一次没封存的,运行详情、运行列表、 // compare/base、compare 都在浏览器层答掉,别的 GET 放行(外壳要的工作流、待审批),非 GET 一律拦掉。 @@ -12,7 +15,7 @@ // // node scripts/check-compare.mjs // 对别的实例跑时带上地址:AGENTLAB_WEB=http://localhost:<前端端口> node scripts/check-compare.mjs -// COMPARE_SHOTS=<目录> 时亮暗、宽窄各截几张,供人眼复核。 +// COMPARE_SHOTS=<目录> 时亮暗、宽窄各截几张,供人眼复核。COMPARE_ONLY=查询,窄屏 只跑标题里带这几截的段。 import { mkdirSync } from 'node:fs' import { chromium } from '../frontend/node_modules/playwright-core/index.mjs' @@ -21,12 +24,17 @@ const CHROME = process.env.CHROME_PATH ?? '/Applications/Google Chrome.app/Conte const SHOTS = process.env.COMPARE_SHOTS ?? '' let failed = 0 +let ran = 0 const check = (name, cond, detail = '') => { console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`) + ran++ if (!cond) failed++ } +// 只跑其中几段:COMPARE_ONLY=<段标题里的一截>,<…>,改样式、补用例时省时间(check-all 不往下传) +const ONLY = (process.env.COMPARE_ONLY ?? '').split(',').filter(Boolean) const opened = new Set() async function section(name, fn) { + if (ONLY.length && !ONLY.some((part) => name.includes(part))) return console.log(`\n=== ${name} ===`) try { await fn() @@ -79,6 +87,13 @@ const ALL_ROWS = REGIONS.map((name, i) => ({ })) const MORE_KEYS = '共 12 个维度值,列出变化最大的 10 个' +// 服务端(engine/compare.py)的原话:SQL 结构变的一律带这句「为什么没拆」;无法比较的只写 reasons, +// decomposition_note 为 null(新增、移除也一样)——夹具照这个形状给,检查才走得到真实的路径 +const SQL_NOT_SPLIT = '取数的 SQL 或上游配置有变化,变化无法在口径与数据之间拆分' +const NO_LINEAGE = '口径卡没有记录输入(生成于系统升级前),无法判断变化来源' +const ADDED_WHY = '基准运行的口径卡「周报口径」里没有这个指标' +const REMOVED_WHY = '这次运行的口径卡「周报口径」里没有这个指标' + const metric = (over) => ({ card_id: 'caliber', card_title: '周报口径', unit: '', decimals: 1, reasons: [], decomposition: null, decomposition_note: null, inputs: [], attribution: null, delta_pct: null, ...over, @@ -104,8 +119,7 @@ const METRICS = [ { 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: -15.74, classification: 'sql_changed', - reasons: ['所依据的查询「退货明细」的筛选条件有改动'], - decomposition_note: '所依据的查询「退货明细」SQL 结构有改动,口径不同,不做拆分' }), + reasons: ['查询「退货明细」的 SQL 结构有变化:时间条件的列不同'], decomposition_note: SQL_NOT_SPLIT }), 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: 5.38, classification: 'params_only', reasons: ['「拉取销量」只有日期参数不同'], @@ -113,12 +127,12 @@ const METRICS = [ attribution: { columns: [], measure: 'visits', rows: [], total_delta: null, note: '查询结果被截断(基准运行只取回了前 500 行),不做维度归因' } }), 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: ['本次新增的指标'] }), + target_rendered: '320人', delta: null, delta_rendered: null, classification: 'added', reasons: [ADDED_WHY] }), 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: ['本次没有这个指标'] }), + target_rendered: '—', delta: null, delta_rendered: null, classification: 'removed', reasons: [REMOVED_WHY] }), 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: 6.45, classification: 'incomparable', - reasons: ['基准运行的口径卡没有记录输入'], decomposition_note: '基准运行的口径卡没有记录输入,无法拆分' }), + reasons: [NO_LINEAGE] }), ...[['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' })), @@ -126,23 +140,24 @@ const METRICS = [ 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" +// ordinal 照服务端从 1 数(同一节点里第几次查询);查询项的位置键(data-compare-query)是「节点:同一节点里排第几」 const QUERIES = [ - { node_id: 'pull', node_title: '拉取销量', ordinal: 0, structure: 'params_only', base_sql: PULL_BASE, target_sql: PULL_TARGET, + { node_id: 'pull', node_title: '拉取销量', ordinal: 1, 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', + { node_id: 'refund', node_title: '退货明细', ordinal: 1, 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, differences: ['时间条件的列不同:基准按 refunds.created_at,这次按 refunds.paid_at', "这次多了筛选条件 refunds.status = 'done'"] }, - { node_id: 'stores', node_title: '门店清单', ordinal: 0, structure: 'same', base_sql: 'SELECT COUNT(*) AS n FROM stores', + { node_id: 'stores', node_title: '门店清单', ordinal: 1, 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', + { node_id: 'pull', node_title: '拉取销量', ordinal: 2, 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, + { node_id: 'newbie', node_title: '新客查询', ordinal: 1, 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', + { node_id: 'member', node_title: '会员查询', ordinal: 1, 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, problem: '基准运行的查询快照无法读取' }, ] @@ -165,6 +180,7 @@ const LABEL = { unchanged: '未变', data_only: '只数据变', caliber_and_data params_only: '只参数变', added: '新增', removed: '移除', incomparable: '无法比较' } const ORDER = ['data_only', 'caliber_and_data', 'sql_changed', 'params_only', 'added', 'removed', 'incomparable', 'unchanged'] const REASON = '默认基准:同一定时任务的上一次运行' +const FINDING = '正在查找上一次运行' // ---------------------------------------------------------------- 假接口 @@ -180,9 +196,15 @@ const GRAPH = { nodes: [ { id: 'caliber', type: 'metrics', position: { x: 200, y: 0 }, data: { label: '周报口径', config: {} } }, ], edges: [{ source: 'pull', target: 'caliber' }] } +/** 运行对比的接口说「运行记录不存在」:带机读码(api/evidence.py 的 RUN_NOT_FOUND) */ +const RUN_GONE = { status: 404, json: { detail: '运行记录不存在', code: 'run_not_found' } } +/** 读一次运行(GET /api/runs/{id})时记录不存在:api/runs.py 只给 detail,不带机读码 */ +const RECORD_GONE = { status: 404, json: { detail: '运行记录不存在' } } + /** - * 默认的假接口。opts.base(runId) / opts.compare(target, base, n) 可以换掉对应接口的答复,返回 {status, json, wait} - * (wait 是一个 Promise:等它落定再答,用来压住某一次请求) + * 默认的假接口。opts.base(runId) / opts.compare(target, base, n) / opts.run(runId, n) / opts.list(workflowId) 可以换掉 + * 对应接口的答复(返回 null 的照默认答),返回 {status, json, wait}(wait 是一个 Promise:等它落定再答,用来压住 + * 某一次请求)。不在夹具里的运行一律答 404,不放行到服务端 */ function defaults(opts) { return async (method, url, n) => { @@ -190,6 +212,8 @@ function defaults(opts) { if (method !== 'GET') return null if (p === '/api/runs') { const wf = url.searchParams.get('workflow_id') + const own = opts.list?.(wf) + if (own) return own 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 } @@ -207,6 +231,11 @@ function defaults(opts) { return { json: comparison(m[1], base) } } m = p.match(/^\/api\/runs\/([^/]+)(\/[a-z]+)?$/) + if (m && !m[2]) { + const own = opts.run?.(m[1], n) + if (own) return own + if (!RUNS[m[1]]) return RECORD_GONE + } if (m && RUNS[m[1]]) { if (!m[2]) return { json: RUNS[m[1]] } if (m[2] === '/events') return { json: events() } @@ -222,7 +251,8 @@ const browser = await chromium.launch({ executablePath: CHROME }) /** * 开一页:/api 的请求先问 defaults(再由 opts 换掉其中几个),答不了的 GET 放行到服务端,非 GET 一律拦掉。 - * 记下每次请求(「GET /api/runs/…?base=…」)和页面报错;原生对话框都算失败 + * 记下每次请求(「GET /api/runs/…?base=…」)和页面报错;原生对话框都算失败。console 里的报错另记一份 + * (React 的列表键冲突只打在 console 上,不抛错) */ 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' }) @@ -239,8 +269,10 @@ async function open(path, { width = 1280, height = 900, theme = 'dark', waitUnti const log = [] const blocked = [] const errors = [] + const consoleErrors = [] page.on('pageerror', (e) => errors.push(e.message)) page.on('dialog', (d) => { errors.push(`原生对话框:${d.message()}`); void d.dismiss() }) + page.on('console', (msg) => { if (msg.type() === 'error') consoleErrors.push(msg.text().slice(0, 200)) }) const answer = defaults(opts) let n = 0 await page.route((u) => new URL(u).pathname.startsWith('/api/'), async (route) => { @@ -267,14 +299,19 @@ async function open(path, { width = 1280, height = 900, theme = 'dark', waitUnti void getComputedStyle(root).backgroundColor requestAnimationFrame(() => root.classList.remove('theme-switching')) }, theme) - return { ctx, page, log, blocked, errors } + return { ctx, page, log, blocked, errors, consoleErrors } } +/** React 的「两个子元素用了同一个键」告警 */ +const keyClashes = (consoleErrors) => consoleErrors.filter((t) => t.includes('same key')) + 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 maybeText = (loc) => loc.count().then((c) => (c ? loc.first().innerText() : '')) 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) => { @@ -314,10 +351,62 @@ await section('从运行详情进入', async () => { check('菜单问到基准之后才跳,一步到带基准的地址', compareUrls.length >= 1 && compareUrls.every((u) => u.searchParams.get('base') === B), compareUrls.map((u) => u.search).join(' → ')) check('写明基准是怎么选的', (await text(page, '[data-compare-reason]')).trim() === REASON, await text(page, '[data-compare-reason]').catch(() => '')) + check('跳过去之后「正在查找」的提示收起', await until(async () => await page.locator('[data-toast]', { hasText: FINDING }).count() === 0)) check('没有发出写请求', blocked.length === 0, blocked.join(';')) check('页面没有报错', errors.length === 0, errors.join(';')) }) +await section('查找基准期间:看得见在做事,离开之后不再跳走', async () => { + let release + const gate = new Promise((r) => { release = r }) + const { page, log, errors } = await open(`/runs/${T}`, + { base: () => ({ json: { base_run_id: B, reason: 'same_schedule', base: refOf(B) }, wait: gate }) }) + // compare/base 这一请求了结(答完,或者被取消) + const ended = new Promise((resolve) => { + const done = (req) => { if (new URL(req.url()).pathname.endsWith('/compare/base')) resolve() } + page.on('requestfinished', done) + page.on('requestfailed', done) + }) + await page.locator(`[data-run-detail="${T}"]`).waitFor({ timeout: 10000 }) + await page.getByRole('button', { name: '更多操作' }).click() + await page.locator('[data-menu=compare]').click() + // 菜单点完就收起了:查找期间要有一条看得见的提示,接口慢的时候不像点了没反应 + check('点了之后有「正在查找上一次运行」的提示', await until(async () => + await page.locator('[data-toast]', { hasText: FINDING }).count() === 1, 3000)) + await until(async () => log.includes(`GET /api/runs/${T}/compare/base`), 3000) + // 改主意去了别的页面:答复晚到也不能把人拽回对比页 + await page.evaluate(() => { history.pushState({}, '', '/data'); dispatchEvent(new PopStateEvent('popstate')) }) + await page.waitForURL((u) => u.pathname.startsWith('/data'), { timeout: 5000 }) + release() + // 等请求了结,再给跳转留足时间:第一次进 /data 要现编那一页,主线程忙的时候晚到的跳转会落在固定的等待之后 + await Promise.race([ended, new Promise((r) => setTimeout(r, 4000))]) + const jumped = await until(async () => new URL(page.url()).pathname === '/runs/compare', 2500) + const at = new URL(page.url()) + check('离开运行详情之后答复才回来:留在原地,不跳到对比页', !jumped && at.pathname.startsWith('/data'), at.pathname + at.search) + check('离开之后「正在查找」的提示也收起', await page.locator('[data-toast]', { hasText: FINDING }).count() === 0) + check('页面没有报错', errors.length === 0, errors.join(';')) +}) + +await section('不属于已保存工作流的运行:入口不可点并说明原因', async () => { + const saved = RUNS[T] + // 两种都没有 workflow_id:画布上没保存就运行的,和工作流删掉之后被置空的(名字还是原来的) + for (const [what, over] of [['未保存的工作流', { workflow_name: '未保存的工作流' }], ['工作流已删除', {}]]) { + RUNS[T] = { ...saved, workflow_id: null, ...over } + 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]') + const label = (await item.innerText()).replace(/\s+/g, ' ') + check(`${what}:「与上一次对比」不可点,写明工作流未保存或已删除`, await item.isDisabled() && label.includes('未保存或已删除'), label) + check(`${what}:不给「手动选择基准」,也不请求 compare/base`, !log.some((k) => k.includes('/compare')) + && await page.getByRole('button', { name: '手动选择基准' }).count() === 0) + } finally { + RUNS[T] = saved + } + } +}) + await section('未封存的运行:入口不可点并说明原因', async () => { const saved = RUNS[T] RUNS[T] = { ...saved, manifest_hash: null, manifest_seq: null } @@ -377,10 +466,16 @@ await section('指标表', async () => { (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 refund.locator('[data-compare-decomp-note]').innerText()) === SQL_NOT_SPLIT) + // 「拆分或原因」一栏(第 5 个 td):无法比较、新增、移除的服务端只给 reasons、不给 decomposition_note, + // 这一栏不能只剩一个「—」,要展开才知道为什么 + const splitCell = (id) => page.locator(`tr[data-compare-metric="caliber:${id}"] td`).nth(4).innerText().then((s) => s.trim()) + check('无法比较的:「拆分或原因」写出原因(服务端只放在 reasons 里)', await splitCell('margin') === NO_LINEAGE, await splitCell('margin')) + check('新增、移除的:「拆分或原因」写出原因', await splitCell('new_users') === ADDED_WHY && await splitCell('members') === REMOVED_WHY, + `${await splitCell('new_users')} | ${await splitCell('members')}`) + check('分类的悬停说明和服务端的分类口径一致', (await aov.locator('[data-compare-chip]').getAttribute('title')).includes('小数位') + && (await refund.locator('[data-compare-chip]').getAttribute('title')).includes('上游'), + `${await aov.locator('[data-compare-chip]').getAttribute('title')} | ${await refund.locator('[data-compare-chip]').getAttribute('title')}`) 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()) === '无变化') @@ -445,6 +540,19 @@ await section('展开:输入对比与维度归因', async () => { const visitors = page.locator('tr[data-compare-detail="caliber:visitors"]') check('归不了因:写服务端的说明,不画空表', (await visitors.locator('[data-compare-attr-note]').innerText()).includes('被截断') && await visitors.locator('[data-compare-attr] table').count() === 0 && await visitors.locator('[data-compare-attr-total]').count() === 0) + // 不能拆的:明细里也写「为什么没拆」——窄屏上表格那一格横着滚也看不全,明细按框的可见宽度排 + await page.locator('tr[data-compare-metric="caliber:refund_rate"] button[data-compare-expand]').click() + const refundDetail = page.locator('tr[data-compare-detail="caliber:refund_rate"]') + await refundDetail.waitFor() + check('不能拆的:明细里也写出原因', (await maybeText(refundDetail.locator('[data-compare-detail-note]'))).trim() === SQL_NOT_SPLIT, + await maybeText(refundDetail.locator('[data-compare-detail-note]'))) + // 服务端只对只数据变、只参数变、口径与数据都变的做维度归因:别的类不是「没有明细」,是有意不做,写明为什么 + check('SQL 结构变的:写明为什么不做维度归因', (await maybeText(refundDetail.locator('[data-compare-attr-none]'))).includes('不做维度归因'), + await maybeText(refundDetail.locator('[data-compare-attr-none]'))) + await page.locator('tr[data-compare-metric="caliber:margin"] button[data-compare-expand]').click() + const marginNone = page.locator('tr[data-compare-detail="caliber:margin"] [data-compare-attr-none]') + await marginNone.waitFor() + check('无法比较的:写明为什么不做维度归因', (await maybeText(marginNone)).includes('无法判断变化来源'), await maybeText(marginNone)) check('页面没有报错', errors.length === 0, errors.join(';')) }) @@ -483,7 +591,7 @@ await section('查询', async () => { 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"]') + const refund = page.locator('[data-compare-query="refund:1"]') check('结构变:标「结构变」', (await refund.getAttribute('data-structure')) === 'changed' && (await refund.locator('[data-compare-structure]').innerText()).trim() === '结构变') const baseSql = refund.locator('[data-compare-sql="base"]') @@ -500,29 +608,85 @@ await section('查询', async () => { check('截断照实标', (await refund.locator('[data-compare-rows]').innerText()).includes('已截断')) check('结构变:列出服务端说的差在哪', JSON.stringify(await refund.locator('[data-compare-differences] li').allInnerTexts()) === JSON.stringify(QUERIES[1].differences)) - check('快照读不出来:照服务端的说明写在那一条上', (await page.locator('[data-compare-query="member:0"] [data-compare-query-problem]').innerText()) + check('快照读不出来:照服务端的说明写在那一条上', (await page.locator('[data-compare-query="member:1"] [data-compare-query-problem]').innerText()) === '基准运行的查询快照无法读取') - const pull = page.locator('[data-compare-query="pull:0"]') + const pull = page.locator('[data-compare-query="pull:1"]') 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"]') + const same = page.locator('[data-compare-query="stores:1"]') 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 条') + check('同一节点的第二条查询写「第 2 条」', (await page.locator('[data-compare-query="pull:2"] [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"]') + const added = page.locator('[data-compare-query="newbie:1"]') 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 page.locator('[data-compare-query="member:1"] [data-compare-sql="base"]').count() === 1) + const head = (await page.locator('[data-compare-queries] header').innerText()).replace(/\s+/g, ' ') + check('查询的个数按「条」写(和「第 N 条」一致)', head.includes(`${QUERIES.length} 条`), head) + check('「结构变」的悬停说明也算上数据源不同、SQL 读不出来', (await refund.locator('[data-compare-structure]').getAttribute('title')).includes('数据源'), + await refund.locator('[data-compare-structure]').getAttribute('title')) await refund.scrollIntoViewIfNeeded() await shot(page, 'queries-dark') }) +await section('查询的边界:照服务端配对、读快照的实际输出', async () => { + // 服务端配对(engine/compare.py 的 _pair):配上的一对用本次的 node_id / ordinal,落单的基准查询用基准的。Agent 在基准里 + // 查了两次、这次只查一次且和基准的第 2 次同构时,配上的一对是 agent·1,落单的基准第 1 次也是 agent·1——(node_id, ordinal) + // 不唯一。一边的 SQL 拿不到时服务端标「结构变」,那一边的 SQL 是 null + const AGENT = [ + { node_id: 'agent', node_title: '分析助手', ordinal: 1, structure: 'same', base_sql: 'SELECT channel, SUM(amount) AS gmv FROM sales GROUP BY channel', + target_sql: 'SELECT channel, SUM(amount) AS gmv FROM sales GROUP BY channel', literals: [], base_rows: 3, target_rows: 3, + base_truncated: false, target_truncated: false, differences: [] }, + { node_id: 'agent', node_title: '分析助手', ordinal: 1, structure: 'removed', base_sql: 'SELECT region, SUM(amount) AS gmv FROM sales GROUP BY region', + target_sql: null, literals: [], base_rows: 12, target_rows: null, base_truncated: false, target_truncated: null, differences: [] }, + { node_id: 'pull', node_title: '拉取销量', ordinal: 1, structure: 'changed', base_sql: null, target_sql: 'SELECT region, amount FROM sales WHERE sold_on >= 20260928', + literals: [], base_rows: null, target_rows: 12, base_truncated: null, target_truncated: false, + differences: ['基准运行的查询快照无法读取,无法比较 SQL'], problem: '基准运行的查询快照无法读取' }, + ] + const { page, errors, consoleErrors } = await open(comparePath(T, B), { compare: (t, b) => ({ json: { ...comparison(t, b), queries: AGENT } }) }) + await settled(page) + const items = await page.locator('[data-compare-query]').evaluateAll((els) => els.map((e) => ({ + key: e.getAttribute('data-compare-query'), structure: e.getAttribute('data-structure'), nth: e.querySelector('[data-compare-nth]')?.textContent?.trim() ?? null, + }))) + const agent = items.filter((i) => i.key?.startsWith('agent')) + check('同一节点的两条查询 (node_id, ordinal) 相同:各写「第 1 条」「第 2 条」,按列表的先后数', + JSON.stringify(agent.map((i) => i.nth)) === JSON.stringify(['第 1 条', '第 2 条']), JSON.stringify(items)) + check('每一项的位置键都不重复', new Set(items.map((i) => i.key)).size === items.length, items.map((i) => i.key).join(',')) + check('没有 React 列表键冲突的告警', keyClashes(consoleErrors).length === 0, keyClashes(consoleErrors)[0] ?? '') + const pull = page.locator('[data-compare-query="pull:1"]') + const baseText = (await maybeText(pull.locator('[data-compare-sql="base"]'))).trim() + const marks = await pull.locator('mark[data-diff]').count() + check('一边的 SQL 读不出来:不做逐词比较,另一边不标「独有」', marks === 0, `${marks} 处标记`) + check('一边的 SQL 读不出来:这一边写明无法读取', baseText.includes('无法读取'), baseText) + check('一边的 SQL 读不出来:另一边照原文显示', (await maybeText(pull.locator('[data-compare-sql="target"]'))).includes('sold_on >= 20260928')) + check('页面没有报错', errors.length === 0, errors.join(';')) +}) + +await section('SQL 逐词比较:注释里的撇号不吞掉后面的语句', async () => { + const { page } = await open(comparePath(T, B)) + await settled(page) + // 纯函数:直接 import Vite 现编的 sqlDiff.ts。模型写的英文注释里常有 it's、user's + const out = await page.evaluate(async () => { + const { diffSql } = await import('/src/pages/runs/compare/sqlDiff.ts') + const changed = (parts) => parts.filter((p) => p.changed).map((p) => p.text.trim()) + const line = diffSql("SELECT a -- it's a note\nFROM t WHERE x = 1", "SELECT a -- it's a note\nFROM t WHERE x = 2") + const block = diffSql("SELECT a /* user's pick */ FROM t WHERE x = 1", "SELECT a /* user's pick */ FROM t WHERE x = 2") + const edited = diffSql('SELECT a -- old note\nFROM t', 'SELECT a -- new note\nFROM t') + return { line: [changed(line.base), changed(line.target)], block: [changed(block.base), changed(block.target)], + edited: [changed(edited.base), changed(edited.target)] } + }) + check('行注释里有撇号:只标出真正不同的 1 和 2', JSON.stringify(out.line) === JSON.stringify([['1'], ['2']]), JSON.stringify(out.line)) + check('块注释里有撇号:只标出真正不同的 1 和 2', JSON.stringify(out.block) === JSON.stringify([['1'], ['2']]), JSON.stringify(out.block)) + check('注释改了:整段注释算一处不同,后面没改的语句不标', JSON.stringify(out.edited) === JSON.stringify([['-- old note'], ['-- new note']]), + JSON.stringify(out.edited)) +}) + await section('模板与说明', async () => { const { page } = await open(comparePath(T, B)) await settled(page) @@ -596,24 +760,44 @@ await section('过时的答复不盖掉新的', async () => { /** 「其他错误」那一节:第一次对比回 500,之后照常 */ let flakyCalls = 0 await section('出错与找不到基准', async () => { - const sealed = await open(comparePath(T, B), { compare: () => ({ status: 409, json: { detail: '基准运行的封存核对不一致,无法对比' } }) }) + // 409 照服务端(api/compare.py)的原话和机读码:封存核对不一致的原因里已经写了去记录页点「核对」 + const broken = '作为基准的运行的封存核对不一致:封存后有事件被修改、删除或插入,不能用于对比。可在记录页点「核对」查看核对结果' + const sealed = await open(comparePath(T, B), { compare: () => ({ status: 409, json: { detail: broken, code: 'compare_seal_broken' } }) }) 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, ' ')) + const t409 = (await e409.innerText().catch(() => '')).replace(/\s+/g, ' ') + check('409 封存核对不一致:标题照实写,带上服务端的原因', (await e409.locator('[data-compare-error-title]').innerText()) === '无法对比:封存核对不一致' + && t409.includes(broken), t409) + check('409:下一步只指向界面上有的入口(不提「封存凭证条」,可在上方更换基准)', !t409.includes('封存凭证条') && t409.includes('上方更换基准'), t409) 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: '两次运行不属于同一个工作流' } }) }) + // 本次自己没封存:换基准也没用,服务端的原因里已经写了要等运行结束,不再叫人去换基准 + const savedT = RUNS[T] + RUNS[T] = { ...savedT, manifest_hash: null, manifest_seq: null } + try { + const open409 = await open(comparePath(T, B), { compare: () => ({ status: 409, + json: { detail: '这次运行尚未结束并封存:对比只用封存的运行记录,运行结束后才能对比', code: 'compare_unsealed' } }) }) + await settled(open409.page, 'error') + const u409 = open409.page.locator('[data-compare-error="409"]') + const tu = (await u409.innerText().catch(() => '')).replace(/\s+/g, ' ') + check('409 本次未封存:标题写「尚未结束并封存」,不叫人去换基准', (await u409.locator('[data-compare-error-title]').innerText()).includes('尚未结束并封存') + && !tu.includes('更换基准'), tu) + } finally { + RUNS[T] = savedT + } + + const other = await open(comparePath(T, B), { compare: () => ({ status: 422, + json: { detail: '两次运行不属于同一个工作流(「区域销售周报」与「门店日报」),不能对比', code: 'compare_mismatch' } }) }) 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, ' ')) + && (await e422.innerText()).includes('同一工作流的另一次运行'), (await e422.innerText().catch(() => '')).replace(/\s+/g, ' ')) - const gone = await open(comparePath(T, B), { compare: () => ({ status: 404, json: { detail: '运行记录不存在' } }) }) + const gone = await open(comparePath(T, B), { compare: () => RUN_GONE }) await settled(gone.page, 'error') check('404:写明运行记录不存在', (await gone.page.locator('[data-compare-error="404"]').innerText()).includes('运行记录不存在')) @@ -631,14 +815,27 @@ await section('出错与找不到基准', async () => { 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()) + check('找不到基准、下拉里有能选的:说可在上方选', await until(async () => (await noBase.innerText()).includes('可在上方选择')), + (await noBase.innerText()).replace(/\s+/g, ' ')) + // 同一工作流只有一次没封存的:下拉里没有能选的,不能再叫人去上方选 + const bare = await open(comparePath(T), { base: () => ({ status: 404, json: { detail: '同一工作流没有其他已封存的正式运行', code: 'compare_no_base' } }), + list: () => ({ json: [RUNS[T], RUNS[U]] }) }) + await settled(bare.page, 'error') + const bareBox = bare.page.locator('[data-compare-error="no-base"]') + await until(async () => (await bare.page.locator('select[data-compare-base-picker] option').count()) > 1) + const tb = (await bareBox.innerText()).replace(/\s+/g, ' ') + check('找不到基准、下拉里没有能选的:不说「可在上方选择」,写明同一工作流没有已封存的运行', !tb.includes('可在上方') && tb.includes('已封存'), tb) 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 missing = await open(comparePath(T), { base: () => ({ status: 404, json: { detail: '运行记录不存在', code: 'run_not_found' } }) }) + const missing = await open(comparePath(T), { base: () => RUN_GONE, run: (id) => (id === T ? RECORD_GONE : null) }) await settled(missing.page, 'error') - check('找基准时本次就不存在:写运行记录不存在', await missing.page.locator('[data-compare-error="404"]').count() === 1 - && (await missing.page.locator('[data-compare-error-title]').innerText()) === '运行记录不存在') + const gone404 = missing.page.locator('[data-compare-error="404"]') + check('找基准时本次就不存在:写运行记录不存在、链接可能已失效,给去记录页的入口', await gone404.count() === 1 + && (await missing.page.locator('[data-compare-error-title]').innerText()) === '运行记录不存在' + && (await gone404.innerText()).includes('链接可能已失效') && await gone404.locator('a[href="/runs"]').count() === 1, + (await gone404.innerText().catch(() => '')).replace(/\s+/g, ' ')) // 只带本次、找得到:自己补上基准,写明怎么选的 const auto = await open(comparePath(T)) @@ -664,6 +861,67 @@ await section('出错与找不到基准', async () => { check('什么都没带:说明从哪里进来', (await text(none.page, '[data-compare-page]')).includes('与上一次对比')) }) +await section('基准运行读不到(删了、链接过期)', async () => { + const GONE = 'c0ffee0gone0000000000000000000009' + const { page, log, errors } = await open(comparePath(T, GONE), { compare: (t, b) => (b === GONE + ? { status: 404, json: { detail: '作为对比基准的运行记录不存在', code: 'run_not_found' } } : { json: comparison(t, b) }) }) + await settled(page, 'error') + const picker = page.locator('select[data-compare-base-picker]') + await until(async () => (await picker.locator('option').count()) > 1) + const current = await picker.evaluate((s) => ({ value: s.value, text: s.selectedOptions[0]?.textContent ?? '' })) + // 受控下拉的值对不上任何选项时浏览器会选中第一项:显示成另一次运行被选中,而选它又不触发 change + check('下拉如实显示当前的基准(短 id,写明读不到),不显示成别的运行', current.value === GONE && current.text.includes('不存在'), JSON.stringify(current)) + const box = (await page.locator('[data-compare-error="404"]').innerText()).replace(/\s+/g, ' ') + check('缺的是基准:提示在上方另选基准,不叫人回记录页', box.includes('在上方选择另一次运行作为基准') && !box.includes('链接可能已失效'), box) + await picker.selectOption(B) + await settled(page) + check('另选一次之后照常对比', new URL(page.url()).searchParams.get('base') === B && compareHits(log, T, B) === 1) + check('页面没有报错', errors.length === 0, errors.join(';')) +}) + +await section('本次的运行记录读不到:换基准不一直停在「正在读取」', async () => { + let calls = 0 + const { page, errors } = await open(comparePath(T, B), { run: (id) => (id === T && calls++ === 0 + ? { status: 500, json: { detail: '服务暂时不可用' } } : null) }) + await settled(page) + const failed = page.locator('[data-compare-run="base"] [data-compare-pick-failed]') + check('读不到本次的记录、不知道是哪个工作流:写明读不到同一工作流的运行,不显示加载中', + await until(() => failed.count().then((c) => c === 1), 3000) + && await page.locator('select[data-compare-base-picker]').count() === 0, + (await page.locator('[data-compare-run="base"]').innerText()).replace(/\s+/g, ' ')) + await failed.getByRole('button', { name: '重试' }).click() + const picker = page.locator('select[data-compare-base-picker]') + check('点「重试」重新读本次的记录,之后下拉照常可用', await until(async () => (await picker.count()) === 1 && await picker.isEnabled(), 4000) + && await picker.inputValue() === B) + check('页面没有报错', errors.length === 0, errors.join(';')) +}) + +await section('不属于已保存工作流的运行:对比页的说明前后一致', async () => { + const saved = RUNS[T] + RUNS[T] = { ...saved, workflow_id: null, workflow_name: '未保存的工作流' } + try { + // 只带本次:服务端找不到基准(这种运行没有可比的) + const lone = await open(comparePath(T), { base: () => ({ status: 404, json: { + detail: '没有找到可以对比的上一次运行:需要同一工作流、同一运行类别(定时任务发起的优先找同一定时任务)、更早结束并已封存的运行', + code: 'compare_no_base' } }) }) + await settled(lone.page, 'error') + await until(async () => (await lone.page.locator('[data-compare-run="base"]').innerText()).includes('未保存或已删除'), 3000) + const card = (await lone.page.locator('[data-compare-run="base"]').innerText()).replace(/\s+/g, ' ') + const box = (await lone.page.locator('[data-compare-error]').innerText()).replace(/\s+/g, ' ') + check('基准卡:写明工作流未保存或已删除(不说「临时构建」)', card.includes('未保存或已删除') && !card.includes('临时构建'), card) + check('说明框:写明原因,不叫人去上方选', box.includes('未保存或已删除') && !box.includes('上方'), box) + // 地址里带了基准:服务端回 422 + const other = await open(comparePath(T, B), { compare: () => ({ status: 422, + json: { detail: '这次运行不属于任何已保存的工作流,无法确认两次运行是同一个工作流,不能对比', code: 'compare_mismatch' } }) }) + await settled(other.page, 'error') + await until(async () => (await other.page.locator('[data-compare-error]').innerText()).includes('未保存或已删除'), 3000) + const b422 = (await other.page.locator('[data-compare-error]').innerText()).replace(/\s+/g, ' ') + check('422:不再叫人选另一次运行作为基准', b422.includes('未保存或已删除') && !b422.includes('作为基准') && !b422.includes('上方'), b422) + } finally { + RUNS[T] = saved + } +}) + 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: [] } } }) }) @@ -703,7 +961,7 @@ await section('窄屏', async () => { }) check(`390px(${theme}):指标表在自己的框里横向滚动,键盘可聚焦`, !!scroller && scroller.sw > scroller.cw && scroller.focusable, JSON.stringify(scroller)) - const refund = page.locator('[data-compare-query="refund:0"]') + const refund = page.locator('[data-compare-query="refund:1"]') 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 })) @@ -778,5 +1036,7 @@ await section('深浅色可读', async () => { }) await browser.close() -console.log(failed ? `\n✗ 运行对比:${failed} 项未通过` : '\n✓ 运行对比:全部通过') +// 段过滤一段都没对上:报成没通过,不让一个写错的过滤词顶着「全部通过」 +if (!ran) failed++ +console.log(failed ? `\n✗ 运行对比:${ran ? `${failed} 项未通过` : '一项都没有检查'}` : '\n✓ 运行对比:全部通过') process.exit(failed ? 1 : 0)