mirror of
https://github.com/hansjone/netx.git
synced 2026-10-08 23:33:21 +08:00
Add compare result board, richer KPIs, and zip table export.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
e9b8042ca7
commit
854b0950f5
7 changed files with 695 additions and 20 deletions
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import zipfile
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
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)
|
||||
|
||||
|
||||
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]:
|
||||
r = db.get(BizCompareRun, run_id)
|
||||
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 []),
|
||||
"compare_fields": list((tpl.compare_fields if tpl else None) or []),
|
||||
"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 []),
|
||||
}
|
||||
]
|
||||
enriched = _enrich_summary(summary, sheets)
|
||||
return {
|
||||
"id": r.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,
|
||||
"metric_id": r.metric_id,
|
||||
"status": r.status,
|
||||
"summary": {
|
||||
k: summary.get(k, 0)
|
||||
for k in ("added", "removed", "changed", "unchanged", "before_count", "after_count", "sheet_count")
|
||||
},
|
||||
"summary": enriched,
|
||||
"sheets": sheets,
|
||||
"diffs": list(r.diffs_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]]:
|
||||
rows = (
|
||||
db.query(BizCompareRun)
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
def api_get_run(run_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
|
||||
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"'},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: "比对完成",
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue