Refine business compare configuration and history UI

This commit is contained in:
oliver 2026-10-10 23:50:06 +08:00
parent 4d5101ec3a
commit 051a10ad95
16 changed files with 1107 additions and 236 deletions

View file

@ -1,4 +1,4 @@
import type { ReactNode, SelectHTMLAttributes } from "react";
import { useId, type ReactNode, type SelectHTMLAttributes } from "react";
import { Label } from "@heroui/react";
/** Compact labeled native select — HeroUI Select is heavy for dense ops forms. */
@ -9,6 +9,8 @@ export function FieldSelect({
fullWidth,
className = "",
children,
id,
"aria-describedby": describedBy,
...props
}: {
label?: ReactNode;
@ -18,17 +20,26 @@ export function FieldSelect({
className?: string;
children: ReactNode;
} & SelectHTMLAttributes<HTMLSelectElement>) {
const generatedId = useId();
const selectId = id || generatedId;
const hintId = `${selectId}-hint`;
return (
<label className={`ui-field${fullWidth ? " ui-field--full" : ""}${className ? ` ${className}` : ""}`}>
<div className={`ui-field${fullWidth ? " ui-field--full" : ""}${className ? ` ${className}` : ""}`}>
{label ? (
<Label isRequired={required} className="ui-field__label">
<Label htmlFor={selectId} isRequired={required} className="ui-field__label">
{label}
</Label>
) : null}
<select className="ui-field__select" required={required} {...props}>
<select
className="ui-field__select"
required={required}
{...props}
id={selectId}
aria-describedby={[describedBy, hint ? hintId : null].filter(Boolean).join(" ") || undefined}
>
{children}
</select>
{hint ? <span className="ui-field__hint">{hint}</span> : null}
</label>
{hint ? <span id={hintId} className="ui-field__hint">{hint}</span> : null}
</div>
);
}

View file

@ -562,6 +562,29 @@ const en = {
tabConfig: "Job config",
tabRuns: "Compare batches",
tabResult: "Result",
detailTabs: "Business comparison workspace",
jobReady: "Ready",
configBasics: "Job parameters",
configSources: "Before and after sources",
configScope: "Check scope",
selectMapping: "Select mapping",
mappingPairs: "Interface pairs",
sourceBaseline: "Fixed baseline",
sourceTarget: "Acceptance target",
sourceLatest: "Latest batch",
sourceRows: "{{n}} collected rows",
autoSourceHint: "Keep the before batch fixed. Compare against the latest completed batch from the after task.",
runHistoryTitle: "Execution history",
runHistoryHint: "Showing the latest 50 runs. Execution status and business verdicts are displayed separately. Open results to inspect.",
refreshRuns: "Refresh history",
loadedRuns: "Loaded runs",
activeRuns: "In progress",
completedRuns: "Completed",
stoppedRuns: "Failed / cancelled",
runFinished: "Completed",
runStatusQueued: "Queued",
runsLoading: "Loading execution history…",
runsLoadError: "Could not load execution history",
runs: "Run history",
runsHint: "Each “Run now” creates a batch. Stuck “Running” after restart is auto-cancelled; you can also cancel manually and re-run.",
noRuns: "No runs yet — run a compare first",

View file

@ -561,6 +561,29 @@ const zh = {
tabConfig: "任务配置",
tabRuns: "对比批次",
tabResult: "比对结果",
detailTabs: "业务对比工作区",
jobReady: "已就绪",
configBasics: "作业参数",
configSources: "前后数据源",
configScope: "检查范围",
selectMapping: "选择映射",
mappingPairs: "接口对应关系",
sourceBaseline: "固定基准",
sourceTarget: "验收目标",
sourceLatest: "最新批次",
sourceRows: "采集 {{n}} 条",
autoSourceHint: "保持操作前批次固定;操作后任务采集完成时,自动取最新批次进行对比。",
runHistoryTitle: "执行记录",
runHistoryHint: "展示最近 50 条记录。执行状态与业务判定分别显示,可打开结果核查。",
refreshRuns: "刷新记录",
loadedRuns: "已载入批次",
activeRuns: "正在执行",
completedRuns: "执行完成",
stoppedRuns: "失败 / 取消",
runFinished: "已完成",
runStatusQueued: "排队中",
runsLoading: "正在读取执行记录…",
runsLoadError: "执行记录读取失败",
runs: "历史比对",
runsHint: "每次「立即比对」生成一条批次。重启后卡住的「比对中」会自动标为已取消;也可手动终止后重跑。",
noRuns: "尚无比对记录,请先执行比对",

View file

@ -11856,6 +11856,594 @@ html.login-page--paused .login-page__flare {
overflow: hidden;
}
/* Business compare workspace: compact, flat ops surfaces. */
.bs-cmp-workspace,
.bs-cmp-job-modal,
.bs-cmp-create {
--cmp-line: #2b3748;
--cmp-muted: #9aa9bc;
--cmp-surface: #111c2a;
--cmp-accent: #6ba9ec;
}
.panel.bs-cmp-workspace {
border-radius: 6px;
border-color: var(--cmp-line);
background: #0c1521;
}
.bs-cmp-workspace > .pt-list > .pt-list-table-wrap {
border-radius: 4px;
border-color: var(--cmp-line);
}
.bs-cmp-workspace > .pt-list > .pt-list-table-wrap .pt-list-table td {
border-bottom: 1px solid var(--cmp-line);
}
.bs-cmp-workspace .button,
.bs-cmp-workspace .input {
border-radius: 4px;
box-shadow: none;
}
.bs-cmp-workspace .bs-cmp-runs__delete {
color: #fca5a5;
}
.bs-cmp-job-modal .modal__dialog,
.bs-cmp-create .modal__dialog {
border-radius: 6px;
background: #0c1521;
border-color: var(--cmp-line);
max-height: min(92dvh, 1000px);
}
.bs-cmp-job-modal:not(.bs-cmp-board-modal) .modal__dialog {
width: min(1180px, 96vw);
max-width: min(1180px, 96vw);
display: flex;
flex-direction: column;
}
.bs-cmp-job-modal:not(.bs-cmp-board-modal) .modal__body.bs-cmp-job-body {
min-height: 0;
max-height: none;
flex: 1 1 auto;
overflow: auto;
}
.bs-cmp-job-modal .button,
.bs-cmp-create .button,
.bs-cmp-job-modal .ui-field__select,
.bs-cmp-create .ui-field__select,
.bs-cmp-job-modal .input,
.bs-cmp-create .input {
border-radius: 4px;
box-shadow: none;
}
.bs-cmp-job-heading {
display: flex;
flex-direction: column;
gap: 5px;
min-width: 0;
overflow-wrap: anywhere;
font-size: 16px;
}
.bs-cmp-job-heading__id {
color: var(--cmp-muted);
font: 10px/1.4 ui-monospace, SFMono-Regular, Consolas, monospace;
letter-spacing: 0.08em;
}
.bs-cmp-job-modal .bs-cmp-job-tabs {
margin: 0;
gap: 0;
flex-wrap: nowrap;
border-bottom: 1px solid var(--cmp-line);
}
.bs-cmp-job-tabs .button {
flex: 0 1 auto;
min-width: 0;
height: 38px;
padding: 0 15px;
border: 0;
border-bottom: 2px solid transparent;
border-radius: 0;
background: transparent;
color: var(--cmp-muted);
}
.bs-cmp-job-modal .bs-cmp-job-tabs .button.is-active {
outline: none;
box-shadow: none;
border-bottom-color: var(--cmp-accent);
color: #edf5ff;
background: rgba(107, 169, 236, 0.08);
}
.bs-cmp-job-tabs__index {
font: 10px ui-monospace, SFMono-Regular, Consolas, monospace;
opacity: 0.7;
}
.bs-cmp-job-tabs__count {
padding: 1px 5px;
background: #263347;
border-radius: 3px;
font-size: 10px;
}
.bs-cmp-config {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.bs-cmp-config__section {
min-width: 0;
padding: 14px;
border: 1px solid var(--cmp-line);
border-radius: 4px;
background: var(--cmp-surface);
}
.bs-cmp-config .is-wide,
.bs-cmp-config__fields > .is-wide {
grid-column: 1 / -1;
}
.bs-cmp-config__title {
display: flex;
align-items: center;
gap: 9px;
margin: 0 0 14px;
color: #e7eef7;
font-size: 12px;
font-weight: 650;
}
.bs-cmp-config__title > span {
color: var(--cmp-accent);
font: 11px ui-monospace, SFMono-Regular, Consolas, monospace;
}
.bs-cmp-config__fields,
.bs-cmp-mapping__fields,
.bs-cmp-sources {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px 16px;
min-width: 0;
}
.bs-cmp-config__fields > .ui-field--full,
.bs-cmp-mapping__fields > .ui-field--full {
grid-column: auto;
}
.bs-cmp-config__hint {
color: var(--cmp-muted);
font-size: 11px;
line-height: 1.65;
margin: 0;
}
.bs-cmp-source {
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
padding: 12px;
border: 1px solid var(--cmp-line);
border-radius: 3px;
background: #0c1521;
}
.bs-cmp-source__head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
font-size: 12px;
}
.bs-cmp-source__head > span:last-child {
margin-left: auto;
color: var(--cmp-muted);
font-size: 10px;
}
.bs-cmp-source__tag {
padding: 2px 5px;
border: 1px solid #465569;
color: #c6d3e3;
font: 10px ui-monospace, SFMono-Regular, Consolas, monospace;
}
.bs-cmp-source.is-after .bs-cmp-source__tag {
border-color: #3f6a97;
color: #93c5fd;
}
.bs-cmp-source__meta {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 5px;
margin-top: auto;
padding-top: 9px;
border-top: 1px solid var(--cmp-line);
color: var(--cmp-muted);
font: 11px/1.4 ui-monospace, SFMono-Regular, Consolas, monospace;
}
.bs-cmp-source__auto {
min-height: 58px;
}
.bs-cmp-scope__heading {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
}
.bs-cmp-scope__count {
color: var(--cmp-accent);
font: 12px ui-monospace, SFMono-Regular, Consolas, monospace;
}
.bs-cmp-scope .bm-hint {
margin: 8px 0 12px;
font-size: 11px;
line-height: 1.55;
}
.bs-cmp-scope__items {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(180px, 100%), 1fr));
gap: 6px;
}
.bs-cmp-scope__item {
display: flex;
align-items: center;
gap: 9px;
min-width: 0;
padding: 9px 10px;
border: 1px solid var(--cmp-line);
border-radius: 3px;
background: #0c1521;
color: var(--cmp-muted);
text-align: left;
cursor: pointer;
}
.bs-cmp-scope__item.is-on {
border-color: #3c5f89;
background: rgba(61, 108, 160, 0.1);
color: #dbeafe;
}
.bs-cmp-scope__item:disabled {
cursor: default;
}
.bs-cmp-scope__check {
display: grid;
place-items: center;
flex: 0 0 15px;
height: 15px;
border: 1px solid #53677f;
border-radius: 2px;
color: var(--cmp-accent);
font-size: 11px;
}
.bs-cmp-scope__content {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
overflow-wrap: anywhere;
}
.bs-cmp-scope__content strong {
font-size: 12px;
font-weight: 550;
}
.bs-cmp-scope__content > span {
color: var(--cmp-muted);
font-size: 10px;
}
.bs-cmp-mapping {
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
}
.bs-cmp-mapping__editor {
width: 100%;
min-height: 100px;
padding: 8px 10px;
border: 1px solid var(--cmp-line);
border-radius: 3px;
background: #0a121d;
color: #dce8f6;
resize: vertical;
font: 11px/1.7 ui-monospace, SFMono-Regular, Consolas, monospace;
}
.bs-cmp-mapping__actions {
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
gap: 6px;
}
.bs-cmp-create .bm-create__pane,
.bs-cmp-create .bm-steps__item {
border-radius: 4px;
box-shadow: none;
}
.bs-cmp-job-modal .bs-cmp-runs {
gap: 14px;
}
.bs-cmp-runs__intro {
flex: 1 1 300px;
}
.bs-cmp-runs__intro h3 {
margin: 0 0 5px;
color: #e7eef7;
font-size: 14px;
}
.bs-cmp-job-modal .bs-cmp-runs__hint {
color: var(--cmp-muted);
font-size: 11px;
line-height: 1.55;
}
.bs-cmp-run-stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
margin: 0;
border: 1px solid var(--cmp-line);
border-radius: 4px;
background: var(--cmp-surface);
}
.bs-cmp-run-stats > div {
padding: 12px 15px;
border-right: 1px solid var(--cmp-line);
}
.bs-cmp-run-stats > div:last-child {
border-right: 0;
}
.bs-cmp-run-stats dt {
color: var(--cmp-muted);
font-size: 11px;
}
.bs-cmp-run-stats dd {
margin: 5px 0 0;
color: #e7eef7;
font: 24px/1.2 ui-monospace, SFMono-Regular, Consolas, monospace;
font-variant-numeric: tabular-nums;
}
.bs-cmp-run-stats .is-active dd {
color: #93c5fd;
}
.bs-cmp-run-stats .is-stopped dd {
color: #fbbf24;
}
.bs-cmp-job-modal .bs-cmp-runs .pt-list-table-wrap {
max-height: min(54vh, 580px);
overflow: auto;
border: 1px solid var(--cmp-line);
border-radius: 4px;
}
.bs-cmp-runs__table {
min-width: 1000px;
table-layout: fixed;
}
.bs-cmp-runs__table th:nth-child(1) { width: 12%; }
.bs-cmp-runs__table th:nth-child(2) { width: 18%; }
.bs-cmp-runs__table th:nth-child(3) { width: 27%; }
.bs-cmp-runs__table th:nth-child(4) { width: 25%; }
.bs-cmp-runs__table th:nth-child(5) { width: 18%; }
.bs-cmp-job-modal .bs-cmp-runs__table th {
position: sticky;
top: 0;
z-index: 1;
background: #162232;
color: #a7b6ca;
font-size: 11px;
font-weight: 550;
}
.bs-cmp-job-modal .bs-cmp-runs__table td {
height: 80px;
border-bottom: 1px solid var(--cmp-line);
background: #0c1521;
vertical-align: middle;
font-size: 12px;
}
.bs-cmp-job-modal .bs-cmp-runs__table tr.is-selected td {
background: #142237;
}
.bs-cmp-runs__table tr td:first-child {
border-left: 3px solid transparent;
}
.bs-cmp-runs__table tr.is-selected td:first-child,
.bs-cmp-runs__table tr[data-status="running"] td:first-child,
.bs-cmp-runs__table tr[data-status="queued"] td:first-child {
border-left-color: var(--cmp-accent);
}
.bs-cmp-runs__table tr[data-status="failed"] td:first-child {
border-left-color: #f87171;
}
.bs-cmp-runs__id {
display: block;
margin-top: 5px;
color: var(--cmp-muted);
font-size: 10px;
}
.bs-cmp-job-modal .bs-cmp-runs__sides {
display: grid;
gap: 8px;
max-width: none;
font-size: 11px;
}
.bs-cmp-runs__sides > div {
display: grid;
grid-template-columns: 42px minmax(0, 1fr) auto;
align-items: center;
gap: 6px;
}
.bs-cmp-runs__side-tag,
.bs-cmp-runs__sides time {
color: var(--cmp-muted);
font-size: 10px;
white-space: nowrap;
}
.bs-cmp-runs__sides strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 550;
}
.bs-cmp-run-verdict {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.bs-cmp-run-verdict > span {
display: flex;
flex-direction: column;
gap: 4px;
color: var(--cmp-muted);
font-size: 10px;
}
.bs-cmp-run-verdict strong {
color: #dbe7f5;
font: 16px/1.2 ui-monospace, SFMono-Regular, Consolas, monospace;
font-variant-numeric: tabular-nums;
}
.bs-cmp-run-verdict .is-fail strong { color: #fca5a5; }
.bs-cmp-run-verdict .is-pass strong { color: #86efac; }
.bs-cmp-job-modal .bs-cmp-runs__summary,
.bs-cmp-job-modal .bs-cmp-run-prog__meta,
.bs-cmp-job-modal .bs-cmp-runs__elapsed {
color: var(--cmp-muted);
font-size: 11px;
overflow-wrap: anywhere;
}
.bs-cmp-runs__summary.is-stopped {
color: #fbbf24;
}
.bs-cmp-job-modal .bs-cmp-runs__delete {
color: #fca5a5;
}
.bs-cmp-runs__error {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 8px;
padding: 10px;
border: 1px solid #7f4343;
border-radius: 4px;
color: #fecaca;
background: rgba(127, 67, 67, 0.1);
font-size: 12px;
overflow-wrap: anywhere;
}
.bs-cmp-job-modal .bs-cmp-board,
.bs-cmp-job-modal .bs-cmp-strip,
.bs-cmp-job-modal .bs-cmp-sides,
.bs-cmp-job-modal .bs-cmp-nav,
.bs-cmp-job-modal .bs-cmp-progress {
border-radius: 4px;
background-image: none;
}
.bs-cmp-job-modal .bs-cmp-progress__track,
.bs-cmp-job-modal .bs-cmp-run-prog__track {
border-radius: 2px;
}
.bs-cmp-job-modal .bs-cmp-progress__fill,
.bs-cmp-job-modal .bs-cmp-run-prog__fill {
background: #5795d9;
}
.bs-cmp-scope__item:focus-visible,
.bs-cmp-job-modal .bs-cmp-job-tabs .button:focus-visible,
.bs-cmp-mapping__editor:focus-visible {
outline: 2px solid var(--cmp-accent);
outline-offset: 2px;
}
@media (max-width: 800px) {
.bs-cmp-config { grid-template-columns: minmax(0, 1fr); }
.bs-cmp-job-tabs .button { padding: 0 10px; }
}
@media (max-width: 600px) {
.bs-cmp-sources,
.bs-cmp-config__fields,
.bs-cmp-mapping__fields { grid-template-columns: minmax(0, 1fr); }
.bs-cmp-config__section { padding: 11px; }
.bs-cmp-run-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.bs-cmp-run-stats > div:nth-child(2) { border-right: 0; }
.bs-cmp-run-stats > div:nth-child(-n+2) { border-bottom: 1px solid var(--cmp-line); }
.bs-cmp-job-tabs__index { display: none; }
.bs-cmp-job-modal .bs-cmp-job-tabs .button { flex: 1 1 0; padding: 0 7px; font-size: 12px; }
}
@media (prefers-reduced-motion: reduce) {
.bs-cmp-job-modal .bs-cmp-progress__fill,
.bs-cmp-job-modal .bs-cmp-run-prog__fill { transition: none; }
.bs-cmp-job-modal .is-indeterminate { animation: none; }
}
/* Native :fullscreen + CSS immersive fallback (class only when FS API blocked) */
.bs-cmp-board.is-fullscreen,
.bs-cmp-board:fullscreen {

View file

@ -1,5 +1,6 @@
import { Button, Input, Modal } from "@heroui/react";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
import { ListPager } from "../../components/ListPager";
import { AppModalShell } from "../../components/ui/AppModalShell";
import { FieldSelect } from "../../components/ui/FieldSelect";
@ -145,6 +146,21 @@ type RunSheet = {
diffs?: DiffRow[];
};
type RunRecord = {
id: string;
status?: string;
created_at?: string;
before?: SideInfo;
after?: SideInfo;
summary?: {
added?: number; removed?: number; changed?: number; unchanged?: number;
before_count?: number; after_count?: number; duration_ms?: number;
progress?: RunProgressInfo;
};
progress?: RunProgressInfo;
message?: string;
};
function fmtTime(v?: string | null) {
if (!v) return "—";
return formatSystemTime(v) || v;
@ -947,7 +963,9 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
const [mapName, setMapName] = useState("端口映射");
const [mapText, setMapText] = useState("");
const [validateOut, setValidateOut] = useState<any>(null);
const [runs, setRuns] = useState<any[]>([]);
const [runs, setRuns] = useState<RunRecord[]>([]);
const [runsLoading, setRunsLoading] = useState(false);
const [runsError, setRunsError] = useState("");
const [runDetail, setRunDetail] = useState<any>(null);
const [resultSheetId, setResultSheetId] = useState("");
@ -1055,6 +1073,8 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
setCreateStep(0);
setJobId("");
setRuns([]);
setRunsLoading(false);
setRunsError("");
setRunDetail(null);
setResultSheetId("");
if (document.fullscreenElement === boardRef.current) {
@ -1083,21 +1103,22 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
return () => { cancelled = true; };
}, [beforeTaskId, showError]);
const effectiveAfterTaskId = afterTaskId || beforeTaskId;
useEffect(() => {
let cancelled = false;
setAfterBatches([]);
void (async () => {
if (!afterTaskId) return;
if (!effectiveAfterTaskId) return;
try {
const b = await cutoverCachedGet(`bizCompare:batches:${afterTaskId}`,
() => bizStateListBatches(afterTaskId), { ttlMs: 5000 });
const b = await cutoverCachedGet(`bizCompare:batches:${effectiveAfterTaskId}`,
() => bizStateListBatches(effectiveAfterTaskId), { ttlMs: 5000 });
if (!cancelled) setAfterBatches((b.items || []) as BatchOpt[]);
} catch (e) {
if (!cancelled) showError(formatErr(e));
}
})();
return () => { cancelled = true; };
}, [afterTaskId, showError]);
}, [effectiveAfterTaskId, showError]);
const templatesById = useMemo(() => new Map(templates.map((x) => [x.id, x])), [templates]);
@ -2034,6 +2055,8 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
setJobDetailTab("config");
setRunDetail(null);
setRuns([]);
setRunsLoading(true);
setRunsError("");
setResultSheetId("");
setKindFilter("diff");
setResultKw("");
@ -2042,20 +2065,53 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
try {
const r = await bizCompareListRuns(id, 50);
if (request !== jobRequestRef.current) return;
const items = r.items || [];
const items = (r.items || []) as RunRecord[];
setRuns(items);
if (items.length) {
const latest = await bizCompareGetRun(String((items as any[])[0].id));
const latest = await bizCompareGetRun(items[0].id);
if (request !== jobRequestRef.current) return;
setRunDetail(latest);
// Land on batch list so stuck/cancelled runs are visible and actionable
setJobDetailTab("runs");
}
} catch (e) {
if (request === jobRequestRef.current) showError(formatErr(e));
if (request === jobRequestRef.current) {
setRunsError(formatErr(e));
showError(formatErr(e));
}
} finally {
if (request === jobRequestRef.current) setRunsLoading(false);
}
};
const refreshRuns = async () => {
if (!jobId || runsLoading) return;
const request = jobRequestRef.current;
setRunsLoading(true);
setRunsError("");
try {
const response = await bizCompareListRuns(jobId, 50);
if (request === jobRequestRef.current) setRuns((response.items || []) as RunRecord[]);
} catch (e) {
if (request === jobRequestRef.current) setRunsError(formatErr(e));
} finally {
if (request === jobRequestRef.current) setRunsLoading(false);
}
};
const handleJobTabKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
const tabs: JobDetailTab[] = ["config", "runs", "result"];
const current = tabs.indexOf(jobDetailTab);
const next = event.key === "ArrowRight" ? (current + 1) % tabs.length
: event.key === "ArrowLeft" ? (current + tabs.length - 1) % tabs.length
: event.key === "Home" ? 0 : event.key === "End" ? tabs.length - 1 : -1;
if (next < 0) return;
event.preventDefault();
setJobDetailTab(tabs[next]);
if (tabs[next] === "runs") void refreshRuns();
event.currentTarget.querySelector<HTMLButtonElement>(`#compare-${tabs[next]}-tab`)?.focus();
};
const closeJob = () => {
jobRequestRef.current += 1;
runRequestRef.current += 1;
@ -2065,6 +2121,8 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
setBoardFs(false);
setJobId("");
setRuns([]);
setRunsLoading(false);
setRunsError("");
setRunDetail(null);
setResultSheetId("");
};
@ -2090,9 +2148,15 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
const runStatus = String(runDetail?.status || "");
const runIsActive = runStatus === "running" || runStatus === "queued";
const jobHasActiveRun = runs.some((r) => {
const st = String((r as any).status || "");
const st = String(r.status || "");
return st === "running" || st === "queued";
});
const runCounts = useMemo(() => ({
total: runs.length,
active: runs.filter((r) => r.status === "running" || r.status === "queued").length,
completed: runs.filter((r) => r.status === "success").length,
stopped: runs.filter((r) => r.status === "failed" || r.status === "cancelled").length,
}), [runs]);
const runNow = async () => {
if (!jobId) return;
@ -2118,7 +2182,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
showOk(t("bizCompare.runStarted"));
const r = await bizCompareListRuns(jobId, 50);
if (request !== jobRequestRef.current) return;
setRuns(r.items || []);
setRuns((r.items || []) as RunRecord[]);
await refresh({ force: true });
} catch (e) {
if (request === jobRequestRef.current) {
@ -2148,7 +2212,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
runIsActive && runId ? bizCompareGetRun(runId) : Promise.resolve(null),
]);
if (cancelled || jobRequest !== jobRequestRef.current) return;
setRuns(r.items || []);
setRuns((r.items || []) as RunRecord[]);
if (!d || runRequest !== runRequestRef.current) return;
setRunDetail((prev: typeof runDetail) => String(prev?.id || "") === runId ? d : prev);
const st = String(d.status || "");
@ -2205,7 +2269,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
if (jobId) {
const r = await bizCompareListRuns(jobId, 50);
if (request !== jobRequestRef.current) return;
setRuns(r.items || []);
setRuns((r.items || []) as RunRecord[]);
}
showOk(t("bizCompare.runCancelled"));
} catch (e) {
@ -2229,7 +2293,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
if (jobId) {
const r = await bizCompareListRuns(jobId, 50);
if (request !== jobRequestRef.current) return;
nextRuns = r.items || [];
nextRuns = (r.items || []) as RunRecord[];
} else {
nextRuns = (runs || []).filter((r) => String(r.id) !== runId);
}
@ -2249,15 +2313,15 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
};
const renderSheetChips = () => (
<div className="bm-create__pane" style={{ padding: 0, border: "none", background: "transparent" }}>
<div className="bm-mapping__label">
<div className="bs-cmp-scope">
<div className="bs-cmp-scope__heading">
{t("bizCompare.enabledSheets")}
<span className="muted" style={{ fontWeight: 400, marginLeft: 8 }}>
<span className="bs-cmp-scope__count">
{enabledJobSheetCount}/{jobSheetAllIds.length || 0}
</span>
</div>
<p className="muted bm-hint">{t("bizCompare.enabledSheetsHint")}</p>
<div className="bm-metric-chips">
<div className="bs-cmp-scope__items">
{jobTemplateSheets.map((s) => {
const sid = sheetIdentity(s);
const on = isJobSheetOn(sid);
@ -2265,11 +2329,17 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
<button
key={sid}
type="button"
className={`bm-metric-chip${on ? " is-on" : ""}`}
className={`bs-cmp-scope__item${on ? " is-on" : ""}`}
aria-pressed={on}
disabled={on && enabledJobSheetCount === 1}
onClick={() => toggleJobSheet(sid)}
title={s.metric_id !== sid ? s.metric_id : undefined}
>
{sheetLabel(s)}
<span className="bs-cmp-scope__check" aria-hidden="true">{on ? "✓" : ""}</span>
<span className="bs-cmp-scope__content">
<strong>{sheetLabel(s)}</strong>
<span>{s.compare_fields.length ? t("bizCompare.modeFieldsShort") : t("bizCompare.presenceShort")}</span>
</span>
</button>
);
})}
@ -2285,10 +2355,10 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
const renderMappingBlock = () => (
<div className="bs-cmp-mapping">
<h4 style={{ margin: "8px 0" }}>{t("bizCompare.mapping")}</h4>
<p className="muted">{t("bizCompare.mappingOptionalHint")}</p>
<div className="filter-inline" style={{ marginBottom: 8 }}>
<p className="bs-cmp-config__hint">{t("bizCompare.mappingOptionalHint")}</p>
<div className="bs-cmp-mapping__fields">
<FieldSelect
label={t("bizCompare.selectMapping")}
value={mappingId}
onChange={(e) => {
const id = e.target.value;
@ -2306,7 +2376,23 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
</option>
))}
</FieldSelect>
<Input value={mapName} placeholder={t("bizCompare.mapName")} onChange={(e) => setMapName(e.target.value)} />
<label className="ui-field ui-field--full">
<span className="ui-field__label">{t("bizCompare.mapName")}</span>
<Input value={mapName} onChange={(e) => setMapName(e.target.value)} />
</label>
</div>
<label className="ui-field ui-field--full">
<span className="ui-field__label">{t("bizCompare.mappingPairs")}</span>
<textarea
className="bs-cmp-mapping__editor"
value={mapText}
onChange={(e) => setMapText(e.target.value)}
placeholder={t("bizCompare.mapHint")}
rows={4}
spellCheck={false}
/>
</label>
<div className="bs-cmp-mapping__actions">
<Button size="sm" variant="secondary" isDisabled={busy} onPress={() => void saveMapping()}>
{t("bizCompare.saveMapping")}
</Button>
@ -2314,13 +2400,6 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
{t("bizCompare.validateMapping")}
</Button>
</div>
<textarea
value={mapText}
onChange={(e) => setMapText(e.target.value)}
placeholder={t("bizCompare.mapHint")}
rows={5}
style={{ width: "100%", fontFamily: "ui-monospace, monospace" }}
/>
{validateOut ? (
<pre className="muted" style={{ fontSize: 12, maxHeight: 120, overflow: "auto" }}>
{JSON.stringify(validateOut, null, 2)}
@ -2330,68 +2409,97 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
);
const renderBatchFields = () => (
<>
<FieldSelect
label={t("bizCompare.beforeTask")}
value={beforeTaskId}
onChange={(e) => setBeforeTaskId(e.target.value)}
fullWidth
>
<option value="">{t("bizCompare.pick")}</option>
{tasks.map((row) => (
<option key={row.id} value={row.id}>
{taskLabel(row)}
</option>
))}
</FieldSelect>
<FieldSelect
label={t("bizCompare.beforeBatch")}
value={beforeBatchId}
onChange={(e) => setBeforeBatchId(e.target.value)}
fullWidth
>
<option value="">{t("bizCompare.pick")}</option>
{beforeBatches.map((b) => (
<option key={b.id} value={b.id}>
{batchOptLabel(b)}
</option>
))}
</FieldSelect>
<FieldSelect
label={t("bizCompare.afterTask")}
value={afterTaskId}
onChange={(e) => setAfterTaskId(e.target.value)}
fullWidth
>
<option value="">{t("bizCompare.sameAsBefore")}</option>
{tasks.map((row) => (
<option key={row.id} value={row.id}>
{taskLabel(row)}
</option>
))}
</FieldSelect>
{mode === "manual" ? (
<div className="bs-cmp-sources">
<section className="bs-cmp-source" aria-label={t("bizCompare.sideBefore")}>
<header className="bs-cmp-source__head">
<span className="bs-cmp-source__tag" aria-hidden="true">PRE</span>
<strong>{t("bizCompare.sideBefore")}</strong>
<span>{t("bizCompare.sourceBaseline")}</span>
</header>
<FieldSelect
label={t("bizCompare.afterBatch")}
value={afterBatchId}
onChange={(e) => setAfterBatchId(e.target.value)}
label={t("bizCompare.beforeTask")}
value={beforeTaskId}
onChange={(e) => {
setBeforeTaskId(e.target.value);
setBeforeBatchId("");
if (!afterTaskId) setAfterBatchId("");
}}
fullWidth
>
<option value="">{t("bizCompare.pick")}</option>
{afterBatches.map((b) => (
{tasks.map((row) => (
<option key={row.id} value={row.id}>
{taskLabel(row)}
</option>
))}
</FieldSelect>
<FieldSelect
label={t("bizCompare.beforeBatch")}
value={beforeBatchId}
onChange={(e) => setBeforeBatchId(e.target.value)}
fullWidth
>
<option value="">{t("bizCompare.pick")}</option>
{beforeBatches.map((b) => (
<option key={b.id} value={b.id}>
{batchOptLabel(b)}
</option>
))}
</FieldSelect>
) : (
<p className="muted">{t("bizCompare.autoHint")}</p>
)}
</>
<div className="bs-cmp-source__meta">
<span>{tasks.find((row) => row.id === beforeTaskId)?.ne_ip || "—"}</span>
<span>{t("bizCompare.sourceRows", { n: beforeBatches.find((b) => b.id === beforeBatchId)?.row_count?.toLocaleString() ?? "—" })}</span>
</div>
</section>
<section className="bs-cmp-source is-after" aria-label={t("bizCompare.sideAfter")}>
<header className="bs-cmp-source__head">
<span className="bs-cmp-source__tag" aria-hidden="true">POST</span>
<strong>{t("bizCompare.sideAfter")}</strong>
<span>{mode === "auto" ? t("bizCompare.sourceLatest") : t("bizCompare.sourceTarget")}</span>
</header>
<FieldSelect
label={t("bizCompare.afterTask")}
value={afterTaskId}
onChange={(e) => {
setAfterTaskId(e.target.value);
setAfterBatchId("");
}}
fullWidth
>
<option value="">{t("bizCompare.sameAsBefore")}</option>
{tasks.map((row) => (
<option key={row.id} value={row.id}>
{taskLabel(row)}
</option>
))}
</FieldSelect>
{mode === "manual" ? (
<FieldSelect
label={t("bizCompare.afterBatch")}
value={afterBatchId}
onChange={(e) => setAfterBatchId(e.target.value)}
fullWidth
>
<option value="">{t("bizCompare.pick")}</option>
{afterBatches.map((b) => (
<option key={b.id} value={b.id}>
{batchOptLabel(b)}
</option>
))}
</FieldSelect>
) : (
<p className="bs-cmp-config__hint bs-cmp-source__auto">{t("bizCompare.autoSourceHint")}</p>
)}
<div className="bs-cmp-source__meta">
<span>{tasks.find((row) => row.id === (afterTaskId || beforeTaskId))?.ne_ip || "—"}</span>
<span>{mode === "auto" ? t("bizCompare.sourceLatest") : t("bizCompare.sourceRows", { n: afterBatches.find((b) => b.id === afterBatchId)?.row_count?.toLocaleString() ?? "—" })}</span>
</div>
</section>
</div>
);
const renderJobForm = () => (
<div className="bs-cmp-form" style={{ display: "grid", gap: 8 }}>
const renderBasicFields = () => (
<div className="bs-cmp-config__fields">
<label className="ui-field ui-field--full">
<span className="ui-field__label">{t("bizCompare.jobName")}</span>
<Input value={name} onChange={(e) => setName(e.target.value)} />
@ -2421,15 +2529,33 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
<option value="auto">{t("bizCompare.modeAuto")}</option>
</FieldSelect>
{renderStoreUnchangedField()}
<p className="muted bm-hint">{t("bizCompare.storeUnchangedHint")}</p>
{renderSheetChips()}
{renderBatchFields()}
{renderMappingBlock()}
<p className="bs-cmp-config__hint is-wide">{t("bizCompare.storeUnchangedHint")}</p>
</div>
);
const renderJobForm = () => (
<div className="bs-cmp-form bs-cmp-config" role="tabpanel" id="compare-config-panel" aria-labelledby="compare-config-tab">
<section className="bs-cmp-config__section is-wide" aria-labelledby="compare-basic-title">
<h3 className="bs-cmp-config__title" id="compare-basic-title"><span>01</span>{t("bizCompare.configBasics")}</h3>
{renderBasicFields()}
</section>
<section className="bs-cmp-config__section is-wide" aria-labelledby="compare-source-title">
<h3 className="bs-cmp-config__title" id="compare-source-title"><span>02</span>{t("bizCompare.configSources")}</h3>
{renderBatchFields()}
</section>
<section className="bs-cmp-config__section" aria-labelledby="compare-scope-title">
<h3 className="bs-cmp-config__title" id="compare-scope-title"><span>03</span>{t("bizCompare.configScope")}</h3>
{renderSheetChips()}
</section>
<section className="bs-cmp-config__section" aria-labelledby="compare-mapping-title">
<h3 className="bs-cmp-config__title" id="compare-mapping-title"><span>04</span>{t("bizCompare.mapping")}</h3>
{renderMappingBlock()}
</section>
</div>
);
return (
<section className="panel nm-page-panel">
<section className={`panel nm-page-panel${activeTab === "jobs" ? " bs-cmp-workspace" : ""}`}>
<div className="panel__toolbar">
<h2>
{pageMode === "templates" ? t("bizCompare.templates") : t("bizCompare.title")}
@ -2492,6 +2618,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
<div className="filter-inline">
<Input
value={listKw}
aria-label={activeTab === "jobs" ? t("bizCompare.jobFilterPh") : t("bizCompare.templateFilterPh")}
placeholder={
activeTab === "jobs" ? t("bizCompare.jobFilterPh") : t("bizCompare.templateFilterPh")
}
@ -2591,14 +2718,17 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
<tr key={j.id}>
<td>
<div className="pt-list-task-name">{j.name}</div>
<code className="bs-cmp-runs__id" title={j.id}>{j.id.slice(0, 12)}</code>
</td>
<td className="muted">
{tpl?.name || j.template_id.slice(0, 8)}
{n ? ` · ${n} ${t("bizCompare.sheetsUnit")}` : ""}
</td>
<td>{j.mode}</td>
<td>{j.mode === "auto" ? t("bizCompare.modeAuto") : t("bizCompare.modeManual")}</td>
<td>
<NmStatusChip color={jobChipColor(j.status)}>{j.status}</NmStatusChip>
<NmStatusChip color={jobChipColor(j.status)}>
{j.status === "ready" ? t("bizCompare.jobReady") : j.status === "auto" ? t("bizCompare.modeAuto") : j.status}
</NmStatusChip>
</td>
<td>
<div className="pt-list-actions">
@ -2607,7 +2737,8 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
</Button>
<Button
size="sm"
variant="danger"
variant="ghost"
className="bs-cmp-runs__delete"
isDisabled={busy}
onPress={() => void removeJob(j.id)}
>
@ -3014,7 +3145,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
open={jobCreateOpen}
onClose={closeCreateJob}
size="lg"
className="bm-create-modal"
className="bm-create-modal bs-cmp-create"
>
<Modal.Header>
<Modal.Heading>{t("bizCompare.createCompare")}</Modal.Heading>
@ -3059,45 +3190,12 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
})}
</nav>
{createStep === 0 ? (
<div className="bm-create__pane bs-cmp-form" style={{ display: "grid", gap: 8 }}>
<label className="ui-field ui-field--full">
<span className="ui-field__label">{t("bizCompare.jobName")}</span>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</label>
<FieldSelect
label={t("bizCompare.template")}
value={templateId}
onChange={(e) => setJobTemplateAndSheets(e.target.value)}
fullWidth
>
{templates.map((tpl) => {
const n = templateSheets(tpl).length;
return (
<option key={tpl.id} value={tpl.id}>
{tpl.name} ({n} {t("bizCompare.sheetsUnit")})
</option>
);
})}
</FieldSelect>
<FieldSelect
label={t("bizCompare.mode")}
value={mode}
onChange={(e) => setMode(e.target.value as "manual" | "auto")}
fullWidth
>
<option value="manual">{t("bizCompare.modeManual")}</option>
<option value="auto">{t("bizCompare.modeAuto")}</option>
</FieldSelect>
{renderStoreUnchangedField()}
<p className="muted bm-hint">{t("bizCompare.storeUnchangedHint")}</p>
</div>
) : null}
{createStep === 0 ? <div className="bm-create__pane">{renderBasicFields()}</div> : null}
{createStep === 1 ? <div className="bm-create__pane">{renderSheetChips()}</div> : null}
{createStep === 2 ? (
<div className="bm-create__pane bs-cmp-form" style={{ display: "grid", gap: 8 }}>
<div className="bm-create__pane bs-cmp-form">
{renderBatchFields()}
</div>
) : null}
@ -3132,68 +3230,98 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
onClose={closeJob}
dismissible={!boardFs}
size={jobDetailTab === "result" ? "cover" : "lg"}
className={`app-heroui-modal--xl${jobDetailTab === "result" ? " bs-cmp-board-modal" : ""}`}
className={`app-heroui-modal--xl bs-cmp-job-modal${jobDetailTab === "result" ? " bs-cmp-board-modal" : ""}`}
>
<Modal.Header>
<Modal.Heading>
{name || t("bizCompare.detail")} · {jobId.slice(0, 8)}…
<Modal.Header className="bs-cmp-job-header">
<Modal.Heading className="bs-cmp-job-heading">
<span className="bs-cmp-job-heading__id" title={jobId}>JOB / {jobId.slice(0, 12)}</span>
<span>{name || t("bizCompare.detail")}</span>
</Modal.Heading>
<Modal.CloseTrigger />
</Modal.Header>
<Modal.Body className="flex flex-col gap-3 bs-cmp-job-body">
<div className="btn-row nm-config-modal__tabs" role="tablist">
<Button
size="sm"
variant={jobDetailTab === "config" ? "primary" : "secondary"}
className={jobDetailTab === "config" ? "is-active" : undefined}
onPress={() => setJobDetailTab("config")}
<div className="btn-row nm-config-modal__tabs bs-cmp-job-tabs" role="tablist" aria-label={t("bizCompare.detailTabs")} onKeyDown={handleJobTabKeyDown}>
<button
type="button"
role="tab"
id="compare-config-tab"
aria-selected={jobDetailTab === "config"}
aria-controls="compare-config-panel"
tabIndex={jobDetailTab === "config" ? 0 : -1}
className={`button${jobDetailTab === "config" ? " is-active" : ""}`}
onClick={() => setJobDetailTab("config")}
>
{t("bizCompare.tabConfig")}
</Button>
<Button
size="sm"
variant={jobDetailTab === "runs" ? "primary" : "secondary"}
className={jobDetailTab === "runs" ? "is-active" : undefined}
onPress={() => {
<span className="bs-cmp-job-tabs__index" aria-hidden="true">01</span>{t("bizCompare.tabConfig")}
</button>
<button
type="button"
role="tab"
id="compare-runs-tab"
aria-label={t("bizCompare.tabRuns")}
aria-selected={jobDetailTab === "runs"}
aria-controls="compare-runs-panel"
tabIndex={jobDetailTab === "runs" ? 0 : -1}
className={`button${jobDetailTab === "runs" ? " is-active" : ""}`}
onClick={() => {
setJobDetailTab("runs");
const request = jobRequestRef.current;
if (jobId) void bizCompareListRuns(jobId, 50).then((r) => {
if (request === jobRequestRef.current) setRuns(r.items || []);
}).catch((e) => {
if (request === jobRequestRef.current) showError(formatErr(e));
});
void refreshRuns();
}}
>
{t("bizCompare.tabRuns")}
{runs.length ? ` (${runs.length})` : ""}
</Button>
<Button
size="sm"
variant={jobDetailTab === "result" ? "primary" : "secondary"}
className={jobDetailTab === "result" ? "is-active" : undefined}
onPress={() => setJobDetailTab("result")}
<span className="bs-cmp-job-tabs__index" aria-hidden="true">02</span>{t("bizCompare.tabRuns")}
{runs.length ? <span className="bs-cmp-job-tabs__count">{runs.length}</span> : null}
</button>
<button
type="button"
role="tab"
id="compare-result-tab"
aria-selected={jobDetailTab === "result"}
aria-controls="compare-result-panel"
tabIndex={jobDetailTab === "result" ? 0 : -1}
className={`button${jobDetailTab === "result" ? " is-active" : ""}`}
onClick={() => setJobDetailTab("result")}
>
{t("bizCompare.tabResult")}
</Button>
<span className="bs-cmp-job-tabs__index" aria-hidden="true">03</span>{t("bizCompare.tabResult")}
</button>
</div>
{jobDetailTab === "config" ? (
renderJobForm()
) : jobDetailTab === "runs" ? (
<div className="bs-cmp-runs">
<div className="bs-cmp-runs" role="tabpanel" id="compare-runs-panel" aria-labelledby="compare-runs-tab" aria-busy={runsLoading}>
<div className="bs-cmp-runs__toolbar">
<p className="muted bm-hint bs-cmp-runs__hint">{t("bizCompare.runsHint")}</p>
<Button
size="sm"
variant="primary"
isDisabled={busy || jobHasActiveRun}
onPress={() => void runNow()}
>
{jobHasActiveRun ? t("bizCompare.runStatusRunning") : t("bizCompare.runNow")}
</Button>
<div className="bs-cmp-runs__intro">
<h3>{t("bizCompare.runHistoryTitle")}</h3>
<p className="bs-cmp-runs__hint">{t("bizCompare.runHistoryHint")}</p>
</div>
<div className="btn-row">
<Button size="sm" variant="secondary" isDisabled={busy || runsLoading} isPending={runsLoading} onPress={() => void refreshRuns()}>
{t("bizCompare.refreshRuns")}
</Button>
<Button
size="sm"
variant="primary"
isDisabled={busy || jobHasActiveRun}
onPress={() => void runNow()}
>
{jobHasActiveRun ? t("bizCompare.runStatusRunning") : t("bizCompare.runNow")}
</Button>
</div>
</div>
<dl className="bs-cmp-run-stats">
<div><dt>{t("bizCompare.loadedRuns")}</dt><dd>{runCounts.total}</dd></div>
<div className={runCounts.active ? "is-active" : ""}><dt>{t("bizCompare.activeRuns")}</dt><dd>{runCounts.active}</dd></div>
<div><dt>{t("bizCompare.completedRuns")}</dt><dd>{runCounts.completed}</dd></div>
<div className={runCounts.stopped ? "is-stopped" : ""}><dt>{t("bizCompare.stoppedRuns")}</dt><dd>{runCounts.stopped}</dd></div>
</dl>
{runsError ? (
<div className="bs-cmp-runs__error" role="alert">
<span>{t("bizCompare.runsLoadError")} · {runsError}</span>
<Button size="sm" variant="secondary" isDisabled={runsLoading} onPress={() => void refreshRuns()}>{t("bizCompare.retry")}</Button>
</div>
) : null}
{!runs.length ? (
<div className="pt-list-empty">{t("bizCompare.noRuns")}</div>
runsLoading ? <div className="pt-list-empty" role="status">{t("bizCompare.runsLoading")}</div>
: !runsError ? <div className="pt-list-empty">{t("bizCompare.noRuns")}</div> : null
) : (
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table bs-cmp-runs__table">
@ -3208,68 +3336,64 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
</thead>
<tbody>
{runs.map((r) => {
const st = String((r as any).status || "");
const st = String(r.status || "");
const active = st === "running" || st === "queued";
const selected = String(runDetail?.id || "") === String(r.id);
const before = enrichSide(
(r as any).before,
r.before,
beforeTaskId,
tasks,
beforeBatches,
);
const after = enrichSide(
(r as any).after,
r.after,
afterTaskId || beforeTaskId,
tasks,
afterBatches.length ? afterBatches : beforeBatches,
);
const when = formatSystemTime((r as any).created_at) || "—";
const sum = ((r as any).summary || {}) as Record<string, number>;
const when = formatSystemTime(r.created_at) || "—";
const sum = r.summary || {};
const fail =
Number(sum.removed || 0) + Number(sum.changed || 0);
const ok = Number(sum.unchanged || 0);
const durMs = Number(sum.duration_ms || 0);
const prog = ((r as any).progress ||
(r as any).summary?.progress ||
const prog = (r.progress ||
r.summary?.progress ||
{}) as RunProgressInfo;
const pct = active ? runProgressPercent(prog) : 100;
const elapsedSec = Math.round(Number(prog.elapsed_ms || 0) / 1000);
const stLabel =
st === "running" || st === "queued"
? t("bizCompare.runStatusRunning")
st === "queued" ? t("bizCompare.runStatusQueued")
: st === "running" ? t("bizCompare.runStatusRunning")
: st === "failed"
? t("bizCompare.runStatusFailed")
: st === "cancelled"
? t("bizCompare.runStatusCancelled")
: t("bizCompare.runStatusDoneSec", {
s: String(durMs > 0 ? Math.round(durMs / 1000) : 0),
});
: st === "success" ? t("bizCompare.runFinished") : st || "—";
const chipColor = active
? "accent"
: st === "cancelled"
? "warning"
: jobChipColor(st);
: st === "failed" ? "danger" : "default";
const meta = active ? runProgressMetaLine(prog, t) : "";
return (
<tr key={String(r.id)} className={selected ? "is-selected" : undefined}>
<tr key={String(r.id)} className={selected ? "is-selected" : undefined} data-status={st} aria-selected={selected}>
<td>
<NmStatusChip color={chipColor}>{stLabel}</NmStatusChip>
{active && elapsedSec > 0 ? (
{(active && elapsedSec > 0) || (!active && durMs > 0) ? (
<div className="bs-cmp-runs__elapsed">
{t("bizCompare.runElapsed", { s: String(elapsedSec) })}
{t("bizCompare.runElapsed", { s: String(active ? elapsedSec : Math.round(durMs / 1000)) })}
</div>
) : null}
</td>
<td className="pt-list-time">{when}</td>
<td className="pt-list-time">
<span>{when}</span>
<code className="bs-cmp-runs__id" title={r.id}>{r.id.slice(0, 12)}</code>
</td>
<td>
<div className="bs-cmp-runs__sides" title={`${sideDeviceName(before)} → ${sideDeviceName(after)}`}>
<span>{sideDeviceName(before)}</span>
<span className="muted">{sideCollectTime(before)}</span>
<span className="bs-cmp-runs__arrow" aria-hidden>
→
</span>
<span>{sideDeviceName(after)}</span>
<span className="muted">{sideCollectTime(after)}</span>
<div><span className="bs-cmp-runs__side-tag">{t("bizCompare.sideBefore")}</span><strong>{sideDeviceName(before)}</strong><time>{sideCollectTime(before)}</time></div>
<div><span className="bs-cmp-runs__side-tag">{t("bizCompare.sideAfter")}</span><strong>{sideDeviceName(after)}</strong><time>{sideCollectTime(after)}</time></div>
</div>
</td>
<td>
@ -3278,7 +3402,8 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
<div
className="bs-cmp-run-prog__track"
role="progressbar"
aria-valuenow={pct}
aria-label={t("bizCompare.colProgress")}
aria-valuenow={Number(prog.sheet_total || 0) > 0 ? pct : undefined}
aria-valuemin={0}
aria-valuemax={100}
>
@ -3298,22 +3423,22 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
</div>
</div>
) : (
<div className="bs-cmp-runs__summary">
{st === "failed" || st === "cancelled"
? String((r as any).message || stLabel).slice(0, 96)
: t("bizCompare.runBatchSummary", {
fail: String(fail),
ok: String(ok),
added: String(Number(sum.added || 0)),
})}
</div>
st === "failed" || st === "cancelled" ? (
<div className="bs-cmp-runs__summary is-stopped" title={r.message}>{String(r.message || stLabel).slice(0, 160)}</div>
) : (
<div className="bs-cmp-run-verdict">
<span className={fail ? "is-fail" : ""}>{t("bizCompare.kindDiff")}<strong>{fail.toLocaleString()}</strong></span>
<span className="is-pass">{t("bizCompare.kindUnchanged")}<strong>{ok.toLocaleString()}</strong></span>
<span>{t("bizCompare.kindAdded")}<strong>{Number(sum.added || 0).toLocaleString()}</strong></span>
</div>
)
)}
</td>
<td>
<div className="pt-list-actions">
<Button
size="sm"
variant="primary"
variant={selected ? "primary" : "secondary"}
isDisabled={busy || active}
onPress={() => void loadRun(String(r.id))}
>
@ -3331,7 +3456,8 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
) : (
<Button
size="sm"
variant="danger"
variant="ghost"
className="bs-cmp-runs__delete"
isDisabled={busy}
onPress={() => void removeRun(String(r.id))}
>
@ -3351,6 +3477,9 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
) : (
<div
ref={boardRef}
role="tabpanel"
id="compare-result-panel"
aria-labelledby="compare-result-tab"
className={`bs-cmp-board${boardFs ? " is-fullscreen" : ""}`}
>
<div className="bs-cmp-board__toolbar">
@ -4214,36 +4343,27 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
</div>
)}
</Modal.Body>
<Modal.Footer>
<Modal.Footer className="bs-cmp-job-footer">
{jobDetailTab === "config" ? (
<>
<Button size="sm" variant="secondary" isDisabled={busy} onPress={() => void saveJobConfig()}>
<Button size="sm" variant="primary" isDisabled={busy} onPress={() => void saveJobConfig()}>
{t("bizCompare.saveJob")}
</Button>
<Button
size="sm"
variant="primary"
variant="secondary"
isDisabled={busy || jobHasActiveRun}
onPress={() => void runNow()}
>
{jobHasActiveRun ? t("bizCompare.runStatusRunning") : t("bizCompare.runNow")}
</Button>
{jobId ? (
<Button size="sm" variant="danger" isDisabled={busy} onPress={() => void removeJob(jobId)}>
<Button size="sm" variant="ghost" className="bs-cmp-runs__delete" isDisabled={busy} onPress={() => void removeJob(jobId)}>
{t("bizCompare.deleteJob")}
</Button>
) : null}
</>
) : jobDetailTab === "runs" ? (
<Button
size="sm"
variant="primary"
isDisabled={busy || jobHasActiveRun}
onPress={() => void runNow()}
>
{jobHasActiveRun ? t("bizCompare.runStatusRunning") : t("bizCompare.runNow")}
</Button>
) : (
) : jobDetailTab === "runs" ? null : (
<>
<Button
size="sm"

View file

@ -43,6 +43,16 @@ const diffs = [
let failDiffs = false;
let active = false;
let legacy = false;
let mixedHistory = false;
let failHistory = false;
let historyDelay = 0;
const history = [run,
{ ...run, id: "run-loading", status: "running", progress: { phase: "loading", sheet_index: 1, sheet_total: 3, sheet_title: "BGP 邻居", elapsed_ms: 18000 } },
{ ...run, id: "run-queued", status: "queued", progress: { phase: "queued", sheet_total: 0 } },
{ ...run, id: "run-failed", status: "failed", message: "读取原始批次失败,请检查采集状态后重试。" },
{ ...run, id: "run-cancelled", status: "cancelled", message: "操作员已终止本次对比。" },
];
const extraSheet = { ...sheet, sheet_id: "arp", title: "ARP 条目", metric_id: "arp" };
let pollCounts = { runs: 0, detail: 0 };
await page.route("**/v1/**", async (route) => {
const url = new URL(route.request().url());
@ -50,14 +60,16 @@ await page.route("**/v1/**", async (route) => {
let data = {};
if (p === "/v1/auth/me") data = { user: { id: "test", username: "test", role: "admin", is_active: true } };
else if (p.endsWith("/compare/jobs")) data = { items: [job, { ...job, id: "job-slow", name: "慢任务" }] };
else if (p.endsWith("/compare/templates")) data = { items: [{ id: "tpl", name: "割接验收模板", metrics: [sheet] }] };
else if (p.endsWith("/compare/templates")) data = { items: [{ id: "tpl", name: "割接验收模板", metrics: [sheet, extraSheet] }] };
else if (p.endsWith("/compare/metrics")) data = { items: [{ metric_id: "bgp_peer", fields: fields.map((name) => ({ name, display_name: name, dtype: "string", is_key: sheet.key_fields.includes(name) })) }] };
else if (p.endsWith("/tasks")) data = { items: [{ id: "t1", ne_name: "PE-BEFORE", ne_ip: "10.0.0.1" }, { id: "t2", ne_name: "PE-AFTER", ne_ip: "10.0.0.2" }] };
else if (p.endsWith("/batches")) data = { items: [{ id: p.includes("t1") ? "b" : "a", status: "success", row_count: 4 }] };
else if (p.endsWith("/tasks")) data = { items: [{ id: "t1", ne_name: "PE-BEFORE", ne_ip: "10.0.0.1", vendor: "ZTE" }, { id: "t2", ne_name: "PE-AFTER", ne_ip: "10.0.0.2", vendor: "ZTE" }] };
else if (p.endsWith("/batches")) data = { items: [{ id: p.includes("t1") ? "b" : "a", status: "success", row_count: 4, started_at: "2026-10-10T09:00:00Z" }] };
else if (p.endsWith("/runs") && p.includes("/jobs/")) {
pollCounts.runs++;
if (p.includes("job-slow")) await new Promise((resolve) => setTimeout(resolve, 900));
data = { items: [{ ...run, status: active ? "running" : "success" }] };
if (historyDelay) await new Promise((resolve) => setTimeout(resolve, historyDelay));
if (failHistory) { await route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ detail: "测试:历史读取暂时中断" }) }); return; }
data = { items: mixedHistory ? history : [{ ...run, status: active ? "running" : "success" }] };
} else if (p.endsWith("/diffs")) {
if (failDiffs) { await route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ detail: "测试:连接暂时中断" }) }); return; }
const kind = url.searchParams.get("kind") || "diff";
@ -74,8 +86,44 @@ await page.route("**/v1/**", async (route) => {
try {
await page.goto(`${base}/network/cutover/biz-compare`);
await page.getByRole("row").filter({ hasText: "割接前后 · 业务验收" }).getByText("已就绪", { exact: true }).waitFor();
assert.equal(await page.getByRole("row").filter({ hasText: "割接前后 · 业务验收" }).getByText("手工选批次", { exact: true }).count(), 1);
await page.screenshot({ path: path.join(output, "biz-compare-jobs.png"), fullPage: true });
await page.getByRole("row").filter({ hasText: "割接前后 · 业务验收" }).getByRole("button", { name: "详情", exact: true }).click();
await page.getByRole("button", { name: "比对结果", exact: true }).click();
await page.locator(".bs-cmp-runs__table tbody tr").first().waitFor();
await page.getByRole("tab", { name: "任务配置", exact: true }).click();
await page.getByLabel("操作前批次", { exact: true }).locator('option[value="b"]').waitFor({ state: "attached" });
assert.equal(await page.getByLabel("操作前批次", { exact: true }).inputValue(), "b");
assert.equal(await page.getByLabel("操作后批次", { exact: true }).inputValue(), "a");
assert.equal(await page.locator(".bs-cmp-source").first().getByRole("combobox").count(), 2);
assert.equal(await page.locator(".bs-cmp-source").last().getByRole("combobox").count(), 2);
assert.equal(await page.locator(".bs-cmp-config__section").count(), 4);
await page.screenshot({ path: path.join(output, "biz-compare-config.png"), fullPage: true });
await page.locator(".bs-cmp-scope__item").nth(1).click();
assert.ok(await page.locator(".bs-cmp-scope__item").first().isDisabled(), "last enabled sheet stays locked");
const savedRequest = page.waitForRequest((req) => req.method() === "PATCH" && req.url().endsWith("/compare/jobs/job-1"));
await page.getByRole("button", { name: "保存配置", exact: true }).click();
const body = (await savedRequest).postDataJSON();
assert.deepEqual(body.enabled_sheet_ids, ["peer"]);
assert.equal(body.before_batch_id, "b");
assert.equal(body.after_batch_id, "a");
await page.getByLabel("模式", { exact: true }).selectOption("auto");
assert.equal(await page.getByLabel("操作后批次", { exact: true }).count(), 0);
await page.getByText("保持操作前批次固定;操作后任务采集完成时,自动取最新批次进行对比。", { exact: true }).waitFor();
await page.getByLabel("模式", { exact: true }).selectOption("manual");
await page.setViewportSize({ width: 390, height: 1000 });
assert.ok(await page.locator(".bs-cmp-config").evaluate((el) => el.scrollWidth <= el.clientWidth + 1));
assert.ok(await page.locator(".bs-cmp-job-tabs").evaluate((el) => el.scrollWidth <= el.clientWidth + 1));
await page.screenshot({ path: path.join(output, "biz-compare-config-mobile.png"), fullPage: true });
await page.setViewportSize({ width: 1440, height: 1000 });
await page.getByRole("tab", { name: "任务配置", exact: true }).focus();
await page.keyboard.press("ArrowRight");
assert.equal(await page.getByRole("tab", { name: "对比批次", exact: true }).getAttribute("aria-selected"), "true");
await page.keyboard.press("End");
assert.equal(await page.getByRole("tab", { name: "比对结果", exact: true }).getAttribute("aria-selected"), "true");
await page.getByRole("tab", { name: "比对结果", exact: true }).click();
await page.locator(".bs-cmp-diff-table tbody tr.bs-cmp-row").first().waitFor();
assert.equal(await page.locator(".bs-cmp-diff-table tbody tr.bs-cmp-row").count(), 2);
assert.equal(await page.locator(".bs-cmp-board__config").textContent(), "执行配置已保存");
@ -106,7 +154,7 @@ try {
await page.getByRole("row").filter({ hasText: "慢任务" }).getByRole("button", { name: "详情", exact: true }).click();
await page.getByRole("button", { name: "关闭", exact: true }).click();
await page.getByRole("row").filter({ hasText: "割接前后 · 业务验收" }).getByRole("button", { name: "详情", exact: true }).click();
await page.getByRole("button", { name: "比对结果", exact: true }).click();
await page.getByRole("tab", { name: "比对结果", exact: true }).click();
await page.locator(".bs-cmp-row").first().waitFor();
await page.waitForTimeout(1100);
assert.equal(await page.locator(".bs-cmp-row").count(), 2, "slow old job must not change the current tab");
@ -114,10 +162,53 @@ try {
legacy = true;
await page.reload();
await page.getByRole("row").filter({ hasText: "割接前后 · 业务验收" }).getByRole("button", { name: "详情", exact: true }).click();
await page.getByRole("button", { name: "比对结果", exact: true }).click();
await page.getByRole("tab", { name: "比对结果", exact: true }).click();
await page.getByText("旧记录:未保存执行配置", { exact: true }).waitFor();
await page.screenshot({ path: path.join(output, "biz-compare-legacy.png"), fullPage: true });
mixedHistory = true;
await page.getByRole("tab", { name: "对比批次", exact: true }).click();
await page.locator(".bs-cmp-runs__table tbody tr").nth(4).waitFor();
assert.deepEqual(await page.locator(".bs-cmp-run-stats dd").allTextContents(), ["5", "2", "1", "2"]);
assert.equal(await page.locator('tr[data-status="queued"]').getByRole("progressbar").getAttribute("aria-valuenow"), null);
assert.ok(await page.locator('tr[data-status="running"]').getByRole("button", { name: "查看结果" }).isDisabled());
assert.equal(await page.locator('tr[data-status="success"]').getByText("已完成", { exact: true }).count(), 1);
assert.equal(await page.locator('tr[data-status="success"] .bs-cmp-run-verdict .is-fail strong').textContent(), "2");
await page.setViewportSize({ width: 1440, height: 1000 });
await page.screenshot({ path: path.join(output, "biz-compare-runs.png"), fullPage: true });
await page.setViewportSize({ width: 390, height: 1000 });
assert.ok(await page.locator(".bs-cmp-runs").evaluate((el) => el.scrollWidth <= el.clientWidth + 1));
await page.screenshot({ path: path.join(output, "biz-compare-runs-mobile.png"), fullPage: true });
await page.setViewportSize({ width: 768, height: 1000 });
historyDelay = 1000;
await page.getByRole("button", { name: "刷新记录", exact: true }).click();
assert.ok(await page.getByRole("button", { name: "刷新记录", exact: true }).isDisabled());
await page.waitForFunction(() => document.querySelector(".bs-cmp-runs")?.getAttribute("aria-busy") === "false");
historyDelay = 0;
failHistory = true;
await page.getByRole("button", { name: "刷新记录", exact: true }).click();
await page.locator(".bs-cmp-runs__error").waitFor();
assert.equal(await page.locator(".bs-cmp-runs__table tbody tr").count(), 5);
failHistory = false;
await page.getByRole("button", { name: "重试", exact: true }).click();
await page.locator(".bs-cmp-runs__error").waitFor({ state: "detached" });
mixedHistory = false;
await page.getByRole("button", { name: "关闭", exact: true }).click();
await page.getByRole("button", { name: "创建比对任务", exact: true }).click();
await page.getByRole("button", { name: "下一步", exact: true }).click();
await page.getByRole("button", { name: "下一步", exact: true }).click();
await page.getByLabel("操作前任务", { exact: true }).selectOption("t1");
await page.getByLabel("操作后批次", { exact: true }).locator('option[value="b"]').waitFor({ state: "attached" });
await page.getByLabel("操作后批次", { exact: true }).selectOption("b");
await page.getByLabel("操作后任务", { exact: true }).selectOption("t2");
assert.equal(await page.getByLabel("操作后批次", { exact: true }).inputValue(), "", "changing source clears the previous batch");
await page.getByLabel("操作前批次", { exact: true }).selectOption("b");
await page.getByLabel("操作后批次", { exact: true }).selectOption("a");
await page.getByRole("button", { name: "下一步", exact: true }).click();
await page.getByLabel("接口对应关系", { exact: true }).waitFor();
await page.getByRole("button", { name: "关闭", exact: true }).click();
active = true;
await page.reload();
await page.getByRole("row").filter({ hasText: "割接前后 · 业务验收" }).getByRole("button", { name: "详情", exact: true }).click();
@ -127,7 +218,11 @@ try {
assert.ok(pollCounts.runs <= 3 && pollCounts.detail <= 3, JSON.stringify(pollCounts));
assert.ok(pollCounts.runs >= 2 && pollCounts.detail >= 2, "active run should keep polling");
assert.deepEqual(errors, []);
console.log(JSON.stringify({ passed: ["result layout", "saved execution settings", "legacy settings warning", "error clears stale rows", "retry", "clear filters", "768px toolbar", "old job response ignored", "single poller", "no runtime errors"], pollCounts, output }));
console.log(JSON.stringify({ passed: ["job list labels", "four config sections", "source pairing", "last sheet locked", "configuration save payload", "automatic mode", "390px configuration", "keyboard tabs", "wizard source selection", "same-task batch selection", "source change clears batch", "history counts", "execution and verdict separation", "queued progress semantics", "history loading", "history error retry", "390px history", "result layout", "saved execution settings", "legacy settings warning", "error clears stale rows", "retry", "clear filters", "768px toolbar", "old job response ignored", "single poller", "no runtime errors"], pollCounts, output }));
} catch (error) {
await page.screenshot({ path: path.join(output, "biz-compare-test-failure.png"), fullPage: true });
console.error(JSON.stringify(await page.locator(".bs-cmp-source select").evaluateAll((els) => els.map((el) => ({ value: el.value, html: el.parentElement.outerHTML })))));
throw error;
} finally {
await browser.close();
}