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

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

View file

@ -37,10 +37,12 @@ LLDP 数据存于 `biz_state_lldp_neighbor`,原搜索只读 `biz_state_metric_
- 两个 HeroUI Input 使用了 `isDisabled`,实际需要 `disabled`;过滤值与容差输入的禁用状态已修正。
- 列表缓存缺少并发合并,慢请求可以覆盖强制刷新结果或在失效后重新填充缓存;现在共享请求、保护失效状态并抑制过期的后台通知。
- 清理中英文文案中的重复键,解决相关类型检查错误。
- 操作后任务选择“同操作前”时原来没有加载批次列表;现在按实际使用的任务读取并共享缓存。切换采集任务时清除原批次选择,避免把上一个任务的批次带入配置。
- 原通用下拉框嵌套了两个 label,标签不能正确关联控件;现在使用明确的 `htmlFor/id`,并关联提示文本。
## 性能改动与实际收益范围
- 合并两套轮询,下一轮在本轮请求完成后启动。模拟浏览器测试中,运行任务在 5.2 秒内请求历史列表和详情各两次,没有重复轮询,也不会因慢请求持续叠加。
- 合并两套轮询,下一轮在本轮请求完成后启动。模拟浏览器测试中,运行任务在 5.2 秒内历史列表和详情各请求 2–3 次(包含可能尚未完成的初始加载),没有重复轮询,也不会因慢请求持续叠加。
- 已保存的失败、新增、变化等结果在数据库中筛选并分页,避免每次翻页重新加载和对比原始批次。回归测试明确验证这一查询路径不调用 `compare_rows`。
- 同一前后采集任务的批次请求共用短期缓存,列表后台刷新合并同一请求;6 项缓存测试覆盖并发、强制刷新、失败重试与失效竞态。
- 模板查找使用 Map,避免列表搜索与绘制逐项线性查找模板。
@ -72,10 +74,19 @@ LLDP 数据存于 `biz_state_lldp_neighbor`,原搜索只读 `biz_state_metric_
## UI 改动
任务列表、创建向导、任务配置、对比批次和结果区统一使用深色平面、细边框、小圆角、低饱和蓝色和等宽数字。任务配置按作业参数、前后数据源、检查范围、端口映射四个区块组织;数据源左右配对,显示设备地址和采集数量。保存配置作为配置页主操作,删除操作降低视觉权重。
批次页增加最近 50 条记录的统计、刷新、加载、错误重试;刷新失败保留已载入记录。已完成只表示执行结束,业务失败/成功/新增数量单独显示。排队与运行使用不同文案,未知进度使用不确定进度条;时间、批次标识和前后采集信息便于核查。页签支持方向键及 Home/End,窄屏配置改为单列,批次表在独立容器中横向滚动。
结果区分为当前检查项/全表通过率、结果类型、搜索与辅助操作三层。增加明确的分页范围、清空筛选、加载状态、错误重试和搜索无结果提示。筛选按钮增加按下状态,字段筛选增加展开状态与键盘焦点提示。后续增加了执行配置保存状态及旧记录限制提示。
窄屏检查项导航改为紧凑的横向排列,避免一个检查项占据整行的大块区域。浏览器检查覆盖 1440px、768px、390px;筛选栏没有横向溢出。结果表保留必要的横向滚动。
- [任务列表](assets/biz-compare-jobs.png)
- [桌面任务配置](assets/biz-compare-config.png)
- [390px 任务配置](assets/biz-compare-config-mobile.png)
- [桌面对比批次](assets/biz-compare-runs.png)
- [390px 对比批次](assets/biz-compare-runs-mobile.png)
- [桌面结果区](assets/biz-compare-desktop.png)
- [768px 结果区](assets/biz-compare-narrow.png)
- [加载失败与重试](assets/biz-compare-error.png)
@ -85,10 +96,10 @@ LLDP 数据存于 `biz_state_lldp_neighbor`,原搜索只读 `biz_state_metric_
- Python:87 项通过,1 项百万行基准默认跳过;单独启用百万行基准后通过。新增测试覆盖搜索、历史配置快照、有界抽样与游标导出。
- 缓存:6 项 Node 测试通过。
- 浏览器:10 个场景通过,包括配置保存状态、旧记录提示、结果布局、错误清除旧明细、重试、清空筛选、窄屏布局、旧任务响应保护、单套轮询、无运行时异常。
- 浏览器:27 个场景通过,包括任务列表文案、配置保存数据、同任务批次加载与切换清理、自动模式、检查项开关、创建向导、键盘页签、批次统计/状态/刷新/错误重试、配置保存状态、旧记录提示、结果布局、筛选、窄屏布局、旧任务响应保护、单套轮询、无运行时异常。
- Vite 生产打包成功。
- 本次业务对比页面的类型错误已经修复。全项目 `tsc -b` 仍被 `BizStatePage.tsx`、`BizMigrationPage.tsx` 中原有的类型/未使用变量错误阻塞。
- 页面 ESLint 原有 34 个错误,主要是 `any` 与现有 React Hook 写法;本次未增加错误。结果请求依赖警告已修正。缓存模块没有 lint 错误。
- 页面 ESLint 原有 34 个错误,主要是 `any` 与现有 React Hook 写法;批次列表增加类型后减少了 `any`,本次未增加错误。结果请求依赖警告已修正。通用下拉框和缓存模块没有 lint 错误。
- PostgreSQL 路径验证了 SQL 编译与规则降级分支,没有在真实 PostgreSQL 数据库上执行查询或测量执行计划。
可重复执行的检查:

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();
}