mirror of
https://github.com/hansjone/netx.git
synced 2026-10-12 03:00:46 +08:00
Refine business compare configuration and history UI
This commit is contained in:
parent
4d5101ec3a
commit
051a10ad95
16 changed files with 1107 additions and 236 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: "尚无比对记录,请先执行比对",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue