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

@ -2,6 +2,8 @@
from __future__ import annotations from __future__ import annotations
import io
import zipfile
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
from uuid import uuid4 from uuid import uuid4
@ -763,6 +765,110 @@ def run_compare(db: Session, job_id: str, *, force_after_batch_id: str = "") ->
return get_run(db, run.id) return get_run(db, run.id)
def _csv_cell(v: Any) -> str:
s = "" if v is None else str(v)
if any(ch in s for ch in ",\"\n\r"):
return '"' + s.replace('"', '""') + '"'
return s
def _sheet_csv(sheet: dict[str, Any]) -> str:
keys = list(sheet.get("key_fields") or [])
compare = list(sheet.get("compare_fields") or [])
headers = ["kind", *keys]
for f in compare:
headers.append(f"{f}__pre")
headers.append(f"{f}__post")
lines = [",".join(_csv_cell(h) for h in headers)]
for d in list(sheet.get("diffs") or []):
kind = str(d.get("kind") or "")
pre = dict(d.get("mapped_before") or d.get("before") or {})
post = dict(d.get("after") or {})
key = dict(d.get("key") or {})
row = [kind]
for k in keys:
row.append(key.get(k, pre.get(k, post.get(k, ""))))
for f in compare:
if kind == "added":
row.append("")
row.append(post.get(f, ""))
elif kind == "removed":
row.append(pre.get(f, ""))
row.append("")
else:
row.append(pre.get(f, ""))
row.append(post.get(f, ""))
lines.append(",".join(_csv_cell(x) for x in row))
return "\ufeff" + "\n".join(lines) + "\n"
def _enrich_summary(summary: dict[str, Any], sheets: list[dict[str, Any]]) -> dict[str, Any]:
added = int(summary.get("added") or 0)
removed = int(summary.get("removed") or 0)
changed = int(summary.get("changed") or 0)
unchanged = int(summary.get("unchanged") or 0)
before_count = int(summary.get("before_count") or 0)
after_count = int(summary.get("after_count") or 0)
total = added + removed + changed + unchanged
matched = changed + unchanged
diff_count = added + removed + changed
pass_rate = round((unchanged / matched) * 100, 1) if matched else (100.0 if total == 0 else 0.0)
diff_rate = round((diff_count / total) * 100, 1) if total else 0.0
field_counts: dict[str, int] = {}
sheet_cards: list[dict[str, Any]] = []
for sh in sheets:
ss = dict(sh.get("summary") or {})
sa = int(ss.get("added") or 0)
sr = int(ss.get("removed") or 0)
sc = int(ss.get("changed") or 0)
su = int(ss.get("unchanged") or 0)
st = sa + sr + sc + su
sm = sc + su
sheet_cards.append(
{
"metric_id": sh.get("metric_id") or "",
"mode": sh.get("mode") or ("presence" if not sh.get("compare_fields") else "fields"),
"added": sa,
"removed": sr,
"changed": sc,
"unchanged": su,
"before_count": int(ss.get("before_count") or 0),
"after_count": int(ss.get("after_count") or 0),
"diff_count": sa + sr + sc,
"pass_rate": round((su / sm) * 100, 1) if sm else (100.0 if st == 0 else 0.0),
}
)
for d in list(sh.get("diffs") or []):
if str(d.get("kind") or "") != "changed":
continue
for fname in (d.get("changes") or {}):
field_counts[str(fname)] = field_counts.get(str(fname), 0) + 1
top_fields = sorted(
[{"field": k, "count": v} for k, v in field_counts.items()],
key=lambda x: (-int(x["count"]), str(x["field"])),
)[:8]
return {
"added": added,
"removed": removed,
"changed": changed,
"unchanged": unchanged,
"before_count": before_count,
"after_count": after_count,
"sheet_count": int(summary.get("sheet_count") or len(sheets) or 0),
"total_rows": total,
"matched_rows": matched,
"diff_count": diff_count,
"pass_rate": pass_rate,
"diff_rate": diff_rate,
"ok": diff_count == 0,
"sheet_cards": sheet_cards,
"top_changed_fields": top_fields,
}
def get_run(db: Session, run_id: str) -> dict[str, Any]: def get_run(db: Session, run_id: str) -> dict[str, Any]:
r = db.get(BizCompareRun, run_id) r = db.get(BizCompareRun, run_id)
if not r: if not r:
@ -779,10 +885,14 @@ def get_run(db: Session, run_id: str) -> dict[str, Any]:
"iface_fields": list((tpl.iface_fields if tpl else None) or []), "iface_fields": list((tpl.iface_fields if tpl else None) or []),
"compare_fields": list((tpl.compare_fields if tpl else None) or []), "compare_fields": list((tpl.compare_fields if tpl else None) or []),
"mode": "fields", "mode": "fields",
"summary": {k: summary.get(k, 0) for k in ("added", "removed", "changed", "unchanged", "before_count", "after_count")}, "summary": {
k: summary.get(k, 0)
for k in ("added", "removed", "changed", "unchanged", "before_count", "after_count")
},
"diffs": list(r.diffs_json or []), "diffs": list(r.diffs_json or []),
} }
] ]
enriched = _enrich_summary(summary, sheets)
return { return {
"id": r.id, "id": r.id,
"job_id": r.job_id, "job_id": r.job_id,
@ -792,10 +902,7 @@ def get_run(db: Session, run_id: str) -> dict[str, Any]:
"after_batch_id": r.after_batch_id, "after_batch_id": r.after_batch_id,
"metric_id": r.metric_id, "metric_id": r.metric_id,
"status": r.status, "status": r.status,
"summary": { "summary": enriched,
k: summary.get(k, 0)
for k in ("added", "removed", "changed", "unchanged", "before_count", "after_count", "sheet_count")
},
"sheets": sheets, "sheets": sheets,
"diffs": list(r.diffs_json or []), "diffs": list(r.diffs_json or []),
"mapping_stats": r.mapping_stats_json or {}, "mapping_stats": r.mapping_stats_json or {},
@ -805,6 +912,60 @@ def get_run(db: Session, run_id: str) -> dict[str, Any]:
} }
def export_run_zip(db: Session, run_id: str) -> bytes:
detail = get_run(db, run_id)
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
s = detail.get("summary") or {}
manifest = [
f"run_id={detail.get('id')}",
f"job_id={detail.get('job_id')}",
f"before_batch_id={detail.get('before_batch_id')}",
f"after_batch_id={detail.get('after_batch_id')}",
f"created_at={detail.get('created_at')}",
f"pass_rate={s.get('pass_rate')}%",
f"diff_count={s.get('diff_count')}",
f"added={s.get('added')} removed={s.get('removed')} "
f"changed={s.get('changed')} unchanged={s.get('unchanged')}",
f"before_count={s.get('before_count')} after_count={s.get('after_count')}",
"",
"sheets:",
]
for card in list(s.get("sheet_cards") or []):
manifest.append(
f"- {card.get('metric_id')}: diff={card.get('diff_count')} "
f"pass={card.get('pass_rate')}% "
f"+{card.get('added')}/-{card.get('removed')}/~{card.get('changed')}/= {card.get('unchanged')}"
)
zf.writestr("manifest.txt", "\n".join(manifest) + "\n")
for sheet in list(detail.get("sheets") or []):
mid = str(sheet.get("metric_id") or "sheet")
safe = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in mid)[:80] or "sheet"
zf.writestr(f"tables/{safe}.csv", _sheet_csv(sheet))
# summary table
sum_lines = ["metric_id,mode,before,after,added,removed,changed,unchanged,diff_count,pass_rate"]
for card in list(s.get("sheet_cards") or []):
sum_lines.append(
",".join(
_csv_cell(x)
for x in (
card.get("metric_id"),
card.get("mode"),
card.get("before_count"),
card.get("after_count"),
card.get("added"),
card.get("removed"),
card.get("changed"),
card.get("unchanged"),
card.get("diff_count"),
card.get("pass_rate"),
)
)
)
zf.writestr("tables/_summary.csv", "\ufeff" + "\n".join(sum_lines) + "\n")
return buf.getvalue()
def list_runs(db: Session, job_id: str, *, limit: int = 20) -> list[dict[str, Any]]: def list_runs(db: Session, job_id: str, *, limit: int = 20) -> list[dict[str, Any]]:
rows = ( rows = (
db.query(BizCompareRun) db.query(BizCompareRun)

View file

@ -369,3 +369,13 @@ def api_list_runs(job_id: str, limit: int = 20, db: Session = Depends(get_db)) -
@router.get("/compare/runs/{run_id}") @router.get("/compare/runs/{run_id}")
def api_get_run(run_id: str, db: Session = Depends(get_db)) -> dict[str, Any]: def api_get_run(run_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
return cmp_svc.get_run(db, run_id) return cmp_svc.get_run(db, run_id)
@router.get("/compare/runs/{run_id}/export")
def api_export_run(run_id: str, db: Session = Depends(get_db)) -> StreamingResponse:
data = cmp_svc.export_run_zip(db, run_id)
return StreamingResponse(
iter([data]),
media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="biz_compare_{run_id}.zip"'},
)

View file

@ -272,6 +272,7 @@ const en = {
sheetsUnit: "sheets", sheetsUnit: "sheets",
presenceShort: "presence", presenceShort: "presence",
modeFields: "Mode: compare selected field values", modeFields: "Mode: compare selected field values",
modeFieldsShort: "fields",
modePresence: "Mode: presence-only (entry set match)", modePresence: "Mode: presence-only (entry set match)",
mapping: "Port mapping (optional)", mapping: "Port mapping (optional)",
newMapping: "New mapping…", newMapping: "New mapping…",
@ -326,6 +327,21 @@ const en = {
kindChangedShort: "Changed", kindChangedShort: "Changed",
kindUnchangedShort: "Same", kindUnchangedShort: "Same",
diffCount: "{{n}} diffs", 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", emptyJobs: "No compare jobs yet",
created: "Compare job created", created: "Compare job created",
ran: "Compare finished", ran: "Compare finished",

View file

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

View file

@ -10015,11 +10015,261 @@ html.login-page--paused .login-page__flare {
margin-bottom: 4px; margin-bottom: 4px;
} }
/* biz_state compare result workbook */ /* biz_state compare result workbook / board */
.bs-cmp-job-body { .bs-cmp-job-body {
min-height: min(64vh, 680px); 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 { .bs-cmp-result {
min-height: 0; min-height: 0;
} }
@ -10118,10 +10368,22 @@ html.login-page--paused .login-page__flare {
color: #1d4ed8; 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 { .bs-cmp-result-table {
flex: 1 1 auto; flex: 1 1 auto;
min-height: 280px; 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 { .bs-cmp-diff-table {
@ -10139,6 +10401,11 @@ html.login-page--paused .login-page__flare {
white-space: nowrap; 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 { .bs-cmp-col-kind {
width: 72px; width: 72px;
white-space: nowrap; white-space: nowrap;
@ -10236,7 +10503,12 @@ html.login-page--paused .login-page__flare {
font-weight: 600; 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 { .bs-cmp-kpis {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }

View file

@ -1,5 +1,5 @@
import { Button, Input, Modal } from "@heroui/react"; 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 { AppModalShell } from "../../components/ui/AppModalShell";
import { FieldSelect } from "../../components/ui/FieldSelect"; import { FieldSelect } from "../../components/ui/FieldSelect";
import { useDebouncedValue } from "../../hooks/useDebouncedValue"; import { useDebouncedValue } from "../../hooks/useDebouncedValue";
@ -11,6 +11,7 @@ import {
bizCompareCreateTemplate, bizCompareCreateTemplate,
bizCompareDeleteJob, bizCompareDeleteJob,
bizCompareDeleteTemplate, bizCompareDeleteTemplate,
bizCompareDownloadRun,
bizCompareGetRun, bizCompareGetRun,
bizCompareListJobs, bizCompareListJobs,
bizCompareListMappings, bizCompareListMappings,
@ -200,6 +201,8 @@ export function BizComparePage() {
const [kindFilter, setKindFilter] = useState<KindFilter>("diff"); const [kindFilter, setKindFilter] = useState<KindFilter>("diff");
const [resultKw, setResultKw] = useState(""); const [resultKw, setResultKw] = useState("");
const debouncedResultKw = useDebouncedValue(resultKw, 200); const debouncedResultKw = useDebouncedValue(resultKw, 200);
const boardRef = useRef<HTMLDivElement | null>(null);
const [boardFs, setBoardFs] = useState(false);
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
const [taskRes, tpl, maps, j, met] = await Promise.all([ const [taskRes, tpl, maps, j, met] = await Promise.all([
@ -355,6 +358,55 @@ export function BizComparePage() {
}; };
const summary = runDetail?.summary || {}; 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>) => { const updateActiveSheet = (patch: Partial<MetricSheet>) => {
setTplSheets((prev) => setTplSheets((prev) =>
@ -1180,7 +1232,12 @@ export function BizComparePage() {
</AppModalShell> </AppModalShell>
{/* Job detail */} {/* 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.Header>
<Modal.Heading> <Modal.Heading>
{name || t("bizCompare.detail")} · {jobId.slice(0, 8)}… {name || t("bizCompare.detail")} · {jobId.slice(0, 8)}…
@ -1210,8 +1267,11 @@ export function BizComparePage() {
{jobDetailTab === "config" ? ( {jobDetailTab === "config" ? (
renderJobForm(false) renderJobForm(false)
) : ( ) : (
<div className="bs-workbook-body bs-cmp-result flex flex-col gap-3" style={{ flex: 1, minHeight: 0 }}> <div
<div className="bs-cmp-result__toolbar"> ref={boardRef}
className={`bs-cmp-board${boardFs ? " is-fullscreen" : ""}`}
>
<div className="bs-cmp-board__toolbar">
<FieldSelect <FieldSelect
label={t("bizCompare.pickBatchRun")} label={t("bizCompare.pickBatchRun")}
value={runDetail?.id || ""} value={runDetail?.id || ""}
@ -1219,20 +1279,128 @@ export function BizComparePage() {
const id = e.target.value; const id = e.target.value;
if (id) void loadRun(id); if (id) void loadRun(id);
}} }}
fullWidth
> >
<option value="">{t("bizCompare.pickRun")}</option> <option value="">{t("bizCompare.pickRun")}</option>
{runs.map((r) => ( {runs.map((r) => (
<option key={r.id} value={r.id}> <option key={r.id} value={r.id}>
{fmtTime(r.created_at)} · +{r.summary?.added ?? 0} / −{r.summary?.removed ?? 0} / ~ {fmtTime(r.created_at)} · Δ{r.summary?.added ?? 0}/
{r.summary?.changed ?? 0} {r.summary?.removed ?? 0}/{r.summary?.changed ?? 0}
</option> </option>
))} ))}
</FieldSelect> </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> </div>
{runDetail ? ( {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"> <div className="bs-cmp-kpis">
<button <button
type="button" type="button"
@ -1374,7 +1542,9 @@ export function BizComparePage() {
<tr> <tr>
<td <td
colSpan={ 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> <div className="pt-list-empty">{t("bizCompare.resultEmpty")}</div>
@ -1400,7 +1570,7 @@ export function BizComparePage() {
{s.metric_id} {s.metric_id}
<span className="bs-sheet-tab__count"> <span className="bs-sheet-tab__count">
{dirty > 0 {dirty > 0
? `${t("bizCompare.diffCount", { n: String(dirty) })}` ? t("bizCompare.diffCount", { n: String(dirty) })
: t("bizCompare.kindUnchangedShort")} : t("bizCompare.kindUnchangedShort")}
{s.mode === "presence" ? ` · ${t("bizCompare.presenceShort")}` : ""} {s.mode === "presence" ? ` · ${t("bizCompare.presenceShort")}` : ""}
</span> </span>
@ -1426,9 +1596,19 @@ export function BizComparePage() {
</Button> </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}> <Button size="sm" variant="ghost" onPress={closeJob}>
{t("bizState.cancel")} {t("bizState.cancel")}

View file

@ -1911,3 +1911,23 @@ export const bizCompareListRuns = (jobId: string, limit = 20) =>
export const bizCompareGetRun = (runId: string) => export const bizCompareGetRun = (runId: string) =>
apiGet<Record<string, unknown>>(`/v1/biz-state/compare/runs/${encodeURIComponent(runId)}`); 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);
}
};