Add compare result board, richer KPIs, and zip table export.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-17 19:23:26 +08:00
parent e9b8042ca7
commit 854b0950f5
7 changed files with 695 additions and 20 deletions

View file

@ -272,6 +272,7 @@ const en = {
sheetsUnit: "sheets",
presenceShort: "presence",
modeFields: "Mode: compare selected field values",
modeFieldsShort: "fields",
modePresence: "Mode: presence-only (entry set match)",
mapping: "Port mapping (optional)",
newMapping: "New mapping…",
@ -326,6 +327,21 @@ const en = {
kindChangedShort: "Changed",
kindUnchangedShort: "Same",
diffCount: "{{n}} diffs",
passRate: "Pass rate",
passOk: "In sync",
passWarn: "Has diffs",
diffCountLabel: "Diff rows",
diffRate: "Diff rate",
beforeCount: "Before rows",
afterCount: "After rows",
matchedRows: "Matched",
totalRows: "Total rows",
sheetCount: "Sheets",
topChangedFields: "Top changed fields",
exportTables: "Download tables",
exportOk: "Compare tables exported",
fullscreen: "Fullscreen board",
exitFullscreen: "Exit fullscreen",
emptyJobs: "No compare jobs yet",
created: "Compare job created",
ran: "Compare finished",

View file

@ -272,6 +272,7 @@ const zh = {
sheetsUnit: "张表",
presenceShort: "仅条目",
modeFields: "模式:按比对字段检查值",
modeFieldsShort: "字段比对",
modePresence: "模式:仅校验前后条目一致(不比具体值)",
mapping: "端口映射(可选)",
newMapping: "新建映射…",
@ -325,6 +326,21 @@ const zh = {
kindChangedShort: "变更",
kindUnchangedShort: "未变",
diffCount: "差异 {{n}}",
passRate: "通过率",
passOk: "前后一致",
passWarn: "存在差异",
diffCountLabel: "差异条目",
diffRate: "差异率",
beforeCount: "操作前行数",
afterCount: "操作后行数",
matchedRows: "对齐条目",
totalRows: "合计条目",
sheetCount: "监控表",
topChangedFields: "变更字段 Top",
exportTables: "下载表格",
exportOk: "已导出比对表格",
fullscreen: "大屏全屏",
exitFullscreen: "退出全屏",
emptyJobs: "暂无比对任务",
created: "比对任务已创建",
ran: "比对完成",

View file

@ -10015,11 +10015,261 @@ html.login-page--paused .login-page__flare {
margin-bottom: 4px;
}
/* biz_state compare result workbook */
/* biz_state compare result workbook / board */
.bs-cmp-job-body {
min-height: min(64vh, 680px);
}
.bs-cmp-board-modal .modal__dialog,
.bs-cmp-board-modal .modal__dialog--lg,
.bs-cmp-board-modal .modal__dialog--md,
.bs-cmp-board-modal .modal__dialog--cover {
width: min(96vw, 1600px) !important;
max-width: min(96vw, 1600px) !important;
}
.bs-cmp-board-modal .app-heroui-modal__dialog [data-slot="modal-body"],
.bs-cmp-board-modal .app-heroui-modal__dialog .modal__body {
max-height: min(90vh, 980px);
}
.bs-cmp-board {
display: flex;
flex-direction: column;
gap: 10px;
flex: 1 1 auto;
min-height: 0;
padding: 4px;
border-radius: 12px;
background:
radial-gradient(1200px 420px at 10% -10%, rgba(37, 99, 235, 0.08), transparent 55%),
radial-gradient(900px 360px at 90% 0%, rgba(16, 185, 129, 0.06), transparent 50%),
rgba(148, 163, 184, 0.04);
}
.bs-cmp-board.is-fullscreen,
.bs-cmp-board:fullscreen {
background: #0b1220;
color: #e2e8f0;
padding: 16px 18px;
gap: 12px;
overflow: auto;
}
.bs-cmp-board__toolbar {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: flex-end;
justify-content: space-between;
}
.bs-cmp-board__hero {
display: grid;
grid-template-columns: minmax(160px, 220px) 1fr;
gap: 10px;
align-items: stretch;
}
.bs-cmp-hero-pass {
border-radius: 14px;
border: 1px solid rgba(148, 163, 184, 0.28);
padding: 14px 16px;
background: rgba(255, 255, 255, 0.55);
display: flex;
flex-direction: column;
justify-content: center;
}
.bs-cmp-board.is-fullscreen .bs-cmp-hero-pass,
.bs-cmp-board:fullscreen .bs-cmp-hero-pass {
background: rgba(15, 23, 42, 0.85);
border-color: rgba(148, 163, 184, 0.25);
}
.bs-cmp-hero-pass.is-ok {
border-color: rgba(34, 197, 94, 0.45);
box-shadow: inset 0 0 0 1px rgba(34, 197, 94, 0.12);
}
.bs-cmp-hero-pass.is-warn {
border-color: rgba(245, 158, 11, 0.5);
box-shadow: inset 0 0 0 1px rgba(245, 158, 11, 0.12);
}
.bs-cmp-hero-pass__label {
font-size: 12px;
opacity: 0.7;
}
.bs-cmp-hero-pass__value {
font-size: 42px;
font-weight: 800;
line-height: 1.05;
font-variant-numeric: tabular-nums;
letter-spacing: -0.03em;
}
.bs-cmp-hero-pass.is-ok .bs-cmp-hero-pass__value {
color: #16a34a;
}
.bs-cmp-hero-pass.is-warn .bs-cmp-hero-pass__value {
color: #d97706;
}
.bs-cmp-hero-pass__unit {
font-size: 18px;
margin-left: 2px;
font-weight: 700;
}
.bs-cmp-hero-pass__sub {
margin-top: 4px;
font-size: 12px;
opacity: 0.75;
}
.bs-cmp-hero-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.bs-cmp-hero-metric {
border-radius: 12px;
border: 1px solid rgba(148, 163, 184, 0.25);
background: rgba(255, 255, 255, 0.5);
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 2px;
}
.bs-cmp-board.is-fullscreen .bs-cmp-hero-metric,
.bs-cmp-board:fullscreen .bs-cmp-hero-metric {
background: rgba(15, 23, 42, 0.75);
}
.bs-cmp-hero-metric__label {
font-size: 11px;
opacity: 0.68;
}
.bs-cmp-hero-metric__value {
font-size: 24px;
font-weight: 700;
font-variant-numeric: tabular-nums;
line-height: 1.15;
}
.bs-cmp-hero-metric__sub {
font-size: 11px;
opacity: 0.65;
}
.bs-cmp-sheet-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 8px;
}
.bs-cmp-sheet-card {
appearance: none;
text-align: left;
border: 1px solid rgba(148, 163, 184, 0.3);
border-radius: 12px;
background: rgba(255, 255, 255, 0.55);
color: inherit;
padding: 10px 12px;
cursor: pointer;
}
.bs-cmp-board.is-fullscreen .bs-cmp-sheet-card,
.bs-cmp-board:fullscreen .bs-cmp-sheet-card {
background: rgba(15, 23, 42, 0.8);
}
.bs-cmp-sheet-card.is-active {
border-color: rgba(37, 99, 235, 0.65);
box-shadow: 0 0 0 1px rgba(37, 99, 235, 0.25);
}
.bs-cmp-sheet-card.has-diff {
border-color: rgba(245, 158, 11, 0.45);
}
.bs-cmp-sheet-card.is-clean {
border-color: rgba(34, 197, 94, 0.35);
}
.bs-cmp-sheet-card__title {
font-size: 12px;
font-weight: 600;
margin-bottom: 4px;
word-break: break-all;
}
.bs-cmp-sheet-card__pass {
font-size: 22px;
font-weight: 800;
font-variant-numeric: tabular-nums;
line-height: 1.1;
}
.bs-cmp-sheet-card.is-clean .bs-cmp-sheet-card__pass {
color: #16a34a;
}
.bs-cmp-sheet-card.has-diff .bs-cmp-sheet-card__pass {
color: #d97706;
}
.bs-cmp-sheet-card__stats {
display: flex;
gap: 8px;
margin-top: 6px;
font-size: 11px;
font-variant-numeric: tabular-nums;
opacity: 0.8;
}
.bs-cmp-sheet-card__meta {
margin-top: 4px;
font-size: 11px;
}
.bs-cmp-top-fields {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.bs-cmp-top-fields__label {
font-size: 12px;
opacity: 0.7;
margin-right: 4px;
}
.bs-cmp-top-fields__chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 8px;
border-radius: 999px;
border: 1px solid rgba(148, 163, 184, 0.3);
font-size: 12px;
background: rgba(245, 158, 11, 0.08);
}
.bs-cmp-top-fields__chip em {
font-style: normal;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: #b45309;
}
.bs-cmp-result {
min-height: 0;
}
@ -10118,10 +10368,22 @@ html.login-page--paused .login-page__flare {
color: #1d4ed8;
}
.bs-cmp-board.is-fullscreen .bs-cmp-kind-pill.is-active,
.bs-cmp-board:fullscreen .bs-cmp-kind-pill.is-active {
color: #93c5fd;
}
.bs-cmp-result-table {
flex: 1 1 auto;
min-height: 280px;
max-height: min(48vh, 560px);
max-height: min(42vh, 480px);
}
.bs-cmp-board.is-fullscreen .bs-cmp-result-table,
.bs-cmp-board:fullscreen .bs-cmp-result-table {
max-height: none;
min-height: 40vh;
flex: 1 1 auto;
}
.bs-cmp-diff-table {
@ -10139,6 +10401,11 @@ html.login-page--paused .login-page__flare {
white-space: nowrap;
}
.bs-cmp-board.is-fullscreen .bs-cmp-diff-table thead th,
.bs-cmp-board:fullscreen .bs-cmp-diff-table thead th {
background: #111827;
}
.bs-cmp-col-kind {
width: 72px;
white-space: nowrap;
@ -10236,7 +10503,12 @@ html.login-page--paused .login-page__flare {
font-weight: 600;
}
@media (max-width: 720px) {
@media (max-width: 900px) {
.bs-cmp-board__hero {
grid-template-columns: 1fr;
}
.bs-cmp-hero-metrics,
.bs-cmp-kpis {
grid-template-columns: repeat(2, minmax(0, 1fr));
}

View file

@ -1,5 +1,5 @@
import { Button, Input, Modal } from "@heroui/react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { AppModalShell } from "../../components/ui/AppModalShell";
import { FieldSelect } from "../../components/ui/FieldSelect";
import { useDebouncedValue } from "../../hooks/useDebouncedValue";
@ -11,6 +11,7 @@ import {
bizCompareCreateTemplate,
bizCompareDeleteJob,
bizCompareDeleteTemplate,
bizCompareDownloadRun,
bizCompareGetRun,
bizCompareListJobs,
bizCompareListMappings,
@ -200,6 +201,8 @@ export function BizComparePage() {
const [kindFilter, setKindFilter] = useState<KindFilter>("diff");
const [resultKw, setResultKw] = useState("");
const debouncedResultKw = useDebouncedValue(resultKw, 200);
const boardRef = useRef<HTMLDivElement | null>(null);
const [boardFs, setBoardFs] = useState(false);
const refresh = useCallback(async () => {
const [taskRes, tpl, maps, j, met] = await Promise.all([
@ -355,6 +358,55 @@ export function BizComparePage() {
};
const summary = runDetail?.summary || {};
const sheetCards = (summary.sheet_cards || []) as Array<{
metric_id: string;
mode?: string;
added?: number;
removed?: number;
changed?: number;
unchanged?: number;
before_count?: number;
after_count?: number;
diff_count?: number;
pass_rate?: number;
}>;
const topChangedFields = (summary.top_changed_fields || []) as Array<{
field: string;
count: number;
}>;
useEffect(() => {
const syncFs = () => {
const el = boardRef.current;
setBoardFs(Boolean(el && document.fullscreenElement === el));
};
document.addEventListener("fullscreenchange", syncFs);
return () => document.removeEventListener("fullscreenchange", syncFs);
}, []);
const toggleBoardFullscreen = async () => {
const el = boardRef.current;
if (!el) return;
try {
if (document.fullscreenElement === el) await document.exitFullscreen();
else await el.requestFullscreen();
} catch (e) {
showError(formatErr(e));
}
};
const downloadRunTables = async () => {
if (!runDetail?.id) return;
setBusy(true);
try {
await bizCompareDownloadRun(String(runDetail.id));
showOk(t("bizCompare.exportOk"));
} catch (e) {
showError(formatErr(e));
} finally {
setBusy(false);
}
};
const updateActiveSheet = (patch: Partial<MetricSheet>) => {
setTplSheets((prev) =>
@ -1180,7 +1232,12 @@ export function BizComparePage() {
</AppModalShell>
{/* Job detail */}
<AppModalShell open={Boolean(jobId)} onClose={closeJob} size="lg" className="app-heroui-modal--xl">
<AppModalShell
open={Boolean(jobId)}
onClose={closeJob}
size={jobDetailTab === "result" ? "cover" : "lg"}
className={`app-heroui-modal--xl${jobDetailTab === "result" ? " bs-cmp-board-modal" : ""}`}
>
<Modal.Header>
<Modal.Heading>
{name || t("bizCompare.detail")} · {jobId.slice(0, 8)}…
@ -1210,8 +1267,11 @@ export function BizComparePage() {
{jobDetailTab === "config" ? (
renderJobForm(false)
) : (
<div className="bs-workbook-body bs-cmp-result flex flex-col gap-3" style={{ flex: 1, minHeight: 0 }}>
<div className="bs-cmp-result__toolbar">
<div
ref={boardRef}
className={`bs-cmp-board${boardFs ? " is-fullscreen" : ""}`}
>
<div className="bs-cmp-board__toolbar">
<FieldSelect
label={t("bizCompare.pickBatchRun")}
value={runDetail?.id || ""}
@ -1219,20 +1279,128 @@ export function BizComparePage() {
const id = e.target.value;
if (id) void loadRun(id);
}}
fullWidth
>
<option value="">{t("bizCompare.pickRun")}</option>
{runs.map((r) => (
<option key={r.id} value={r.id}>
{fmtTime(r.created_at)} · +{r.summary?.added ?? 0} / −{r.summary?.removed ?? 0} / ~
{r.summary?.changed ?? 0}
{fmtTime(r.created_at)} · Δ{r.summary?.added ?? 0}/
{r.summary?.removed ?? 0}/{r.summary?.changed ?? 0}
</option>
))}
</FieldSelect>
<div className="btn-row">
<Button
size="sm"
variant="secondary"
isDisabled={busy || !runDetail?.id}
onPress={() => void downloadRunTables()}
>
{t("bizCompare.exportTables")}
</Button>
<Button
size="sm"
variant="secondary"
isDisabled={!runDetail}
onPress={() => void toggleBoardFullscreen()}
>
{boardFs ? t("bizCompare.exitFullscreen") : t("bizCompare.fullscreen")}
</Button>
</div>
</div>
{runDetail ? (
<>
<div className="bs-cmp-board__hero">
<div
className={`bs-cmp-hero-pass${summary.ok ? " is-ok" : " is-warn"}`}
>
<div className="bs-cmp-hero-pass__label">{t("bizCompare.passRate")}</div>
<div className="bs-cmp-hero-pass__value">
{Number(summary.pass_rate ?? 0).toFixed(1)}
<span className="bs-cmp-hero-pass__unit">%</span>
</div>
<div className="bs-cmp-hero-pass__sub">
{summary.ok ? t("bizCompare.passOk") : t("bizCompare.passWarn")}
</div>
</div>
<div className="bs-cmp-hero-metrics">
<div className="bs-cmp-hero-metric">
<span className="bs-cmp-hero-metric__label">{t("bizCompare.diffCountLabel")}</span>
<span className="bs-cmp-hero-metric__value">{summary.diff_count ?? 0}</span>
<span className="bs-cmp-hero-metric__sub">
{t("bizCompare.diffRate")}: {Number(summary.diff_rate ?? 0).toFixed(1)}%
</span>
</div>
<div className="bs-cmp-hero-metric">
<span className="bs-cmp-hero-metric__label">{t("bizCompare.beforeCount")}</span>
<span className="bs-cmp-hero-metric__value">{summary.before_count ?? 0}</span>
<span className="bs-cmp-hero-metric__sub">
{t("bizCompare.afterCount")}: {summary.after_count ?? 0}
</span>
</div>
<div className="bs-cmp-hero-metric">
<span className="bs-cmp-hero-metric__label">{t("bizCompare.matchedRows")}</span>
<span className="bs-cmp-hero-metric__value">{summary.matched_rows ?? 0}</span>
<span className="bs-cmp-hero-metric__sub">
{t("bizCompare.totalRows")}: {summary.total_rows ?? 0}
</span>
</div>
<div className="bs-cmp-hero-metric">
<span className="bs-cmp-hero-metric__label">{t("bizCompare.sheetCount")}</span>
<span className="bs-cmp-hero-metric__value">
{summary.sheet_count ?? runSheets.length}
</span>
<span className="bs-cmp-hero-metric__sub">
{fmtTime(runDetail.created_at)}
</span>
</div>
</div>
</div>
{sheetCards.length ? (
<div className="bs-cmp-sheet-cards">
{sheetCards.map((c) => (
<button
key={c.metric_id}
type="button"
className={`bs-cmp-sheet-card${
resultSheetId === c.metric_id ? " is-active" : ""
}${Number(c.diff_count || 0) > 0 ? " has-diff" : " is-clean"}`}
onClick={() => setResultSheetId(c.metric_id)}
>
<div className="bs-cmp-sheet-card__title">{c.metric_id}</div>
<div className="bs-cmp-sheet-card__pass">
{Number(c.pass_rate ?? 0).toFixed(1)}%
</div>
<div className="bs-cmp-sheet-card__stats">
<span>+{c.added ?? 0}</span>
<span>−{c.removed ?? 0}</span>
<span>~{c.changed ?? 0}</span>
<span>={c.unchanged ?? 0}</span>
</div>
<div className="bs-cmp-sheet-card__meta muted">
{c.mode === "presence"
? t("bizCompare.presenceShort")
: t("bizCompare.modeFieldsShort")}{" "}
· {c.before_count ?? 0}→{c.after_count ?? 0}
</div>
</button>
))}
</div>
) : null}
{topChangedFields.length ? (
<div className="bs-cmp-top-fields">
<span className="bs-cmp-top-fields__label">{t("bizCompare.topChangedFields")}</span>
{topChangedFields.map((f) => (
<span key={f.field} className="bs-cmp-top-fields__chip">
{f.field}
<em>{f.count}</em>
</span>
))}
</div>
) : null}
<div className="bs-cmp-kpis">
<button
type="button"
@ -1374,7 +1542,9 @@ export function BizComparePage() {
<tr>
<td
colSpan={
1 + resultColumns.keys.length + Math.max(resultColumns.compare.length, 0)
1 +
resultColumns.keys.length +
Math.max(resultColumns.compare.length, 0)
}
>
<div className="pt-list-empty">{t("bizCompare.resultEmpty")}</div>
@ -1400,7 +1570,7 @@ export function BizComparePage() {
{s.metric_id}
<span className="bs-sheet-tab__count">
{dirty > 0
? `${t("bizCompare.diffCount", { n: String(dirty) })}`
? t("bizCompare.diffCount", { n: String(dirty) })
: t("bizCompare.kindUnchangedShort")}
{s.mode === "presence" ? ` · ${t("bizCompare.presenceShort")}` : ""}
</span>
@ -1426,9 +1596,19 @@ export function BizComparePage() {
</Button>
</>
) : (
<Button size="sm" variant="primary" isDisabled={busy} onPress={() => void runNow()}>
{t("bizCompare.runNow")}
</Button>
<>
<Button
size="sm"
variant="secondary"
isDisabled={busy || !runDetail?.id}
onPress={() => void downloadRunTables()}
>
{t("bizCompare.exportTables")}
</Button>
<Button size="sm" variant="primary" isDisabled={busy} onPress={() => void runNow()}>
{t("bizCompare.runNow")}
</Button>
</>
)}
<Button size="sm" variant="ghost" onPress={closeJob}>
{t("bizState.cancel")}

View file

@ -1911,3 +1911,23 @@ export const bizCompareListRuns = (jobId: string, limit = 20) =>
export const bizCompareGetRun = (runId: string) =>
apiGet<Record<string, unknown>>(`/v1/biz-state/compare/runs/${encodeURIComponent(runId)}`);
export const bizCompareDownloadRun = async (runId: string): Promise<void> => {
const path = `/v1/biz-state/compare/runs/${encodeURIComponent(runId)}/export`;
const res = await fetch(path, { method: "GET", credentials: fetchCreds, headers: authHeaders() });
if (res.status === 401) {
handleUnauthorized(path);
throw new Error("unauthorized");
}
if (!res.ok) throw new Error(`${res.status} export`);
const blob = await res.blob();
const url = URL.createObjectURL(blob);
try {
const a = document.createElement("a");
a.href = url;
a.download = `biz_compare_${runId}.zip`;
a.click();
} finally {
URL.revokeObjectURL(url);
}
};