From 3d2489041a788d80123f34d24a3f874a0845f72a Mon Sep 17 00:00:00 2001 From: oliver Date: Sun, 20 Sep 2026 16:29:50 +0800 Subject: [PATCH] Improve biz-compare UX with guided create, sheet toggles, and per-run delete. Also sticky template field headers and biz-state pause/start endpoints. Co-authored-by: Cursor --- netx_api/biz_state/compare_service.py | 32 ++ netx_api/biz_state/schema_ensure.py | 1 + netx_api/biz_state/service.py | 5 + netx_api/biz_state_router.py | 19 + netx_api/models/biz_state.py | 2 + tests/test_biz_state_compare.py | 14 + web/src/i18n/en.ts | 29 +- web/src/i18n/zh.ts | 33 +- web/src/index.css | 21 +- web/src/pages/network/BizComparePage.tsx | 603 +++++++++++++++++------ web/src/pages/network/BizStatePage.tsx | 54 +- web/src/services/api.ts | 11 + 12 files changed, 669 insertions(+), 155 deletions(-) diff --git a/netx_api/biz_state/compare_service.py b/netx_api/biz_state/compare_service.py index 79f7a03..65004da 100644 --- a/netx_api/biz_state/compare_service.py +++ b/netx_api/biz_state/compare_service.py @@ -1248,11 +1248,22 @@ def _job_out(j: BizCompareJob) -> dict[str, Any]: "after_batch_id": j.after_batch_id, "mode": j.mode, "status": j.status, + "enabled_sheet_ids": _str_list(getattr(j, "enabled_sheet_ids", None)), "note": j.note, "updated_at": j.updated_at.isoformat() + "Z" if j.updated_at else None, } +def _filter_enabled_sheets( + sheets_cfg: list[dict[str, Any]], enabled_sheet_ids: list[str] | None +) -> list[dict[str, Any]]: + """Empty enabled list → all sheets; else keep matching sheet_id only.""" + allowed = set(_str_list(enabled_sheet_ids)) + if not allowed: + return sheets_cfg + return [s for s in sheets_cfg if sheet_key(s) in allowed] + + def list_jobs(db: Session) -> list[dict[str, Any]]: rows = db.query(BizCompareJob).order_by(BizCompareJob.updated_at.desc()).all() return [_job_out(j) for j in rows] @@ -1267,6 +1278,7 @@ def create_job(db: Session, body: dict[str, Any]) -> dict[str, Any]: else: if not db.get(BizCompareTemplate, template_id): raise HTTPException(status_code=404, detail="template_not_found") + enabled = _str_list(body.get("enabled_sheet_ids")) j = BizCompareJob( id=uuid4().hex, name=str(body.get("name") or "compare")[:256], @@ -1278,6 +1290,7 @@ def create_job(db: Session, body: dict[str, Any]) -> dict[str, Any]: after_batch_id=str(body.get("after_batch_id") or ""), mode=str(body.get("mode") or "manual")[:16], status="ready", + enabled_sheet_ids=enabled, note=str(body.get("note") or "")[:512], created_at=_utcnow(), updated_at=_utcnow(), @@ -1307,6 +1320,8 @@ def update_job(db: Session, job_id: str, body: dict[str, Any]) -> dict[str, Any] ): if key in body and body.get(key) is not None: setattr(j, key, str(body.get(key) or "")) + if "enabled_sheet_ids" in body: + j.enabled_sheet_ids = _str_list(body.get("enabled_sheet_ids")) j.updated_at = _utcnow() db.commit() return _job_out(j) @@ -1328,6 +1343,20 @@ def delete_job(db: Session, job_id: str) -> None: db.commit() +def delete_run(db: Session, run_id: str) -> dict[str, Any]: + """Delete one compare run and its diffs; leave the job intact.""" + r = db.get(BizCompareRun, run_id) + if not r: + raise HTTPException(status_code=404, detail="run_not_found") + job_id = str(r.job_id or "") + db.query(BizCompareDiff).filter(BizCompareDiff.run_id == run_id).delete( + synchronize_session=False + ) + db.delete(r) + db.commit() + return {"ok": True, "job_id": job_id, "run_id": run_id} + + def _resolve_after_batch(db: Session, job: BizCompareJob) -> str: if job.mode != "auto": return str(job.after_batch_id or "") @@ -1423,6 +1452,9 @@ def run_compare(db: Session, job_id: str, *, force_after_batch_id: str = "") -> sheets_cfg = template_metrics(tpl) if not sheets_cfg: raise HTTPException(status_code=400, detail="template_has_no_metrics") + sheets_cfg = _filter_enabled_sheets(sheets_cfg, getattr(j, "enabled_sheet_ids", None)) + if not sheets_cfg: + raise HTTPException(status_code=400, detail="no_enabled_sheets") pmap = _port_map_dict(db, j.mapping_id) norm_rules = template_iface_normalize(tpl) diff --git a/netx_api/biz_state/schema_ensure.py b/netx_api/biz_state/schema_ensure.py index a984229..7ba1aff 100644 --- a/netx_api/biz_state/schema_ensure.py +++ b/netx_api/biz_state/schema_ensure.py @@ -22,6 +22,7 @@ def apply_biz_state_schema(conn: Connection) -> None: "CREATE INDEX IF NOT EXISTS ix_biz_state_batch_task_id ON biz_state_batch (task_id)", "CREATE INDEX IF NOT EXISTS ix_biz_state_batch_command_batch_id ON biz_state_batch_command (batch_id)", "CREATE INDEX IF NOT EXISTS ix_biz_state_lldp_neighbor_batch_id ON biz_state_lldp_neighbor (batch_id)", + "ALTER TABLE biz_compare_job ADD COLUMN IF NOT EXISTS enabled_sheet_ids JSON DEFAULT '[]'", "CREATE INDEX IF NOT EXISTS ix_biz_compare_job_status ON biz_compare_job (status)", "CREATE INDEX IF NOT EXISTS ix_biz_compare_run_job_id ON biz_compare_run (job_id)", "CREATE INDEX IF NOT EXISTS ix_biz_state_vrf_route_batch_id ON biz_state_vrf_route_summary (batch_id)", diff --git a/netx_api/biz_state/service.py b/netx_api/biz_state/service.py index f6355af..df12922 100644 --- a/netx_api/biz_state/service.py +++ b/netx_api/biz_state/service.py @@ -235,6 +235,11 @@ def _replace_items(db: Session, task_id: str, items_in: list[dict[str, Any]]) -> ) +def set_task_status(db: Session, task_id: str, status: str) -> dict[str, Any]: + """Set lifecycle status: draft | running | paused | stopped.""" + return update_task(db, task_id, {"status": status}) + + def update_task(db: Session, task_id: str, body: dict[str, Any]) -> dict[str, Any]: task = db.get(BizStateTask, task_id) if not task: diff --git a/netx_api/biz_state_router.py b/netx_api/biz_state_router.py index aaf310a..9f330f9 100644 --- a/netx_api/biz_state_router.py +++ b/netx_api/biz_state_router.py @@ -174,6 +174,18 @@ def api_patch_task( return svc.update_task(db, task_id, body.model_dump(exclude_unset=True)) +@router.post("/tasks/{task_id}/pause") +def api_pause_task(task_id: str, db: Session = Depends(get_db)) -> dict[str, Any]: + """Pause periodic schedule; manual collect remains allowed.""" + return svc.set_task_status(db, task_id, "paused") + + +@router.post("/tasks/{task_id}/start") +def api_start_task(task_id: str, db: Session = Depends(get_db)) -> dict[str, Any]: + """Enable periodic schedule (requires bindings for non–cutover-HF tasks).""" + return svc.set_task_status(db, task_id, "running") + + @router.delete("/tasks/{task_id}") def api_delete_task(task_id: str, db: Session = Depends(get_db)) -> dict[str, Any]: svc.delete_task(db, task_id) @@ -374,6 +386,8 @@ class CompareJobIn(BaseModel): before_batch_id: str = "" after_batch_id: str = "" mode: str = "manual" + # Empty = all template sheets; non-empty = only these sheet_id values + enabled_sheet_ids: list[str] = Field(default_factory=list) note: str = "" @@ -478,6 +492,11 @@ def api_get_run(run_id: str, db: Session = Depends(get_db)) -> dict[str, Any]: return cmp_svc.get_run(db, run_id) +@router.delete("/compare/runs/{run_id}") +def api_delete_run(run_id: str, db: Session = Depends(get_db)) -> dict[str, Any]: + return cmp_svc.delete_run(db, run_id) + + @router.get("/compare/runs/{run_id}/diffs") def api_list_run_diffs( run_id: str, diff --git a/netx_api/models/biz_state.py b/netx_api/models/biz_state.py index c52950e..520d454 100644 --- a/netx_api/models/biz_state.py +++ b/netx_api/models/biz_state.py @@ -293,6 +293,8 @@ class BizCompareJob(Base): # manual: fixed after_batch; auto: after_batch_id empty → use latest after task batch mode: Mapped[str] = mapped_column(String(16), default="manual", index=True) status: Mapped[str] = mapped_column(String(32), default="draft", index=True) # draft|ready|auto + # Empty = all template sheets; non-empty = only these sheet_id values + enabled_sheet_ids: Mapped[list] = mapped_column(_JsonType, default=list) note: Mapped[str] = mapped_column(String(512), default="") created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive) updated_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive) diff --git a/tests/test_biz_state_compare.py b/tests/test_biz_state_compare.py index 50a4cb9..8f8c23d 100644 --- a/tests/test_biz_state_compare.py +++ b/tests/test_biz_state_compare.py @@ -444,6 +444,20 @@ class CompareSheetDefaultsTests(unittest.TestCase): isis4 = next(s for s in parsed if sheet_key(s) == "isis_adjacency.ipv4") self.assertTrue(str(isis4.get("title") or "").strip()) + def test_filter_enabled_sheets_empty_means_all(self) -> None: + from netx_api.biz_state.compare_service import _filter_enabled_sheets, sheet_key + + sheets = [ + {"sheet_id": "isis_adjacency.ipv4", "metric_id": "isis_adjacency"}, + {"sheet_id": "isis_adjacency.ipv6", "metric_id": "isis_adjacency"}, + {"sheet_id": "arp", "metric_id": "arp"}, + ] + self.assertEqual(len(_filter_enabled_sheets(sheets, None)), 3) + self.assertEqual(len(_filter_enabled_sheets(sheets, [])), 3) + only = _filter_enabled_sheets(sheets, ["isis_adjacency.ipv4", "arp"]) + self.assertEqual([sheet_key(s) for s in only], ["isis_adjacency.ipv4", "arp"]) + self.assertEqual(_filter_enabled_sheets(sheets, ["missing"]), []) + def test_arp_default_sheet_has_row_filters(self) -> None: from netx_api.biz_state.compare_service import _default_sheet_for_metric diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 44c01e5..5a2fde5 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -189,6 +189,7 @@ const en = { colConnect: "Connect", start: "Start schedule", pause: "Pause", + statusPaused: "Paused", scheduleEnabled: "Enable schedule", scheduleOn: "Scheduled", scheduleOff: "Manual", @@ -198,7 +199,7 @@ const en = { confirmDelete: "Delete this task and all batches?", deleted: "Deleted", started: "Schedule enabled", - paused: "Schedule off (manual only)", + paused: "Schedule paused (manual only)", collecting: "Collecting", detail: "Details", interval: "Interval", @@ -293,6 +294,10 @@ const en = { templateDeleted: "Template deleted", confirmDeleteTemplate: "Delete this compare template?", confirmDeleteJob: "Delete this compare job and all runs?", + confirmDeleteRun: "Delete this compare run? Job config is kept.", + deleteJob: "Delete job", + deleteRun: "Delete run", + runDeleted: "Compare run deleted", emptyTemplates: "No templates yet. Create one or import a JSON file.", exportTemplate: "Export", importTemplate: "Import", @@ -406,6 +411,21 @@ const en = { sameAsBefore: "Same as before / select…", autoHint: "auto: new after-task batches trigger compare", createCompare: "Create job", + createSteps: "Create steps", + stepBasics: "Basics", + stepSheets: "Sheets", + stepBatches: "Batches", + stepMapping: "Port mapping", + next: "Next", + back: "Back", + enabledSheets: "Compare sheets", + enabledSheetsHint: "Toggle sheets; all on by default. Off sheets are skipped.", + noTemplateSheets: "This template has no sheets", + needJobName: "Enter a job name", + needTemplate: "Select a compare template", + needSheets: "Enable at least one sheet", + needBeforeBatch: "Select before task and batch", + needAfterBatch: "Select after batch", runNow: "Run now", saveJob: "Save config", jobSaved: "Job config saved", @@ -414,13 +434,14 @@ const en = { detail: "Details", edit: "Edit", delete: "Delete", - tabConfig: "Config / batches", + tabConfig: "Job config", tabResult: "Result", runs: "Run history", noRuns: "No runs yet — run a compare first", result: "Result", - pickBatchRun: "Select compare batch", - pickRun: "Select batch…", + pickBatchRun: "Select compare run", + pickRun: "Select run…", + runCount: "{{n}} runs", resultEmpty: "No matching diff rows", resultFilterPh: "Filter key / values…", kindAll: "All", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 589fad7..6ad8896 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -189,6 +189,7 @@ const zh = { colConnect: "连通", start: "启动周期", pause: "暂停", + statusPaused: "已暂停", scheduleEnabled: "启用周期调度", scheduleOn: "周期", scheduleOff: "手动", @@ -198,7 +199,7 @@ const zh = { confirmDelete: "删除该业务监控任务及所有批次?", deleted: "已删除", started: "已启用周期采集", - paused: "已关闭周期(仅手动采集)", + paused: "已暂停周期(仅手动采集)", collecting: "采集中", detail: "详情", interval: "采集周期", @@ -292,7 +293,11 @@ const zh = { templateSaved: "模板已保存", templateDeleted: "模板已删除", confirmDeleteTemplate: "删除该比对模板?", - confirmDeleteJob: "删除该比对任务及全部运行记录?", + confirmDeleteJob: "删除该比对任务及全部比对记录?", + confirmDeleteRun: "删除本次比对结果?任务配置将保留。", + deleteJob: "删除任务", + deleteRun: "删除本次", + runDeleted: "比对记录已删除", emptyTemplates: "暂无模板。可新建,或从 JSON 文件导入。", exportTemplate: "导出", importTemplate: "导入", @@ -405,6 +410,21 @@ const zh = { sameAsBefore: "同操作前 / 选择…", autoHint: "auto:操作后任务新批次到达会自动跑比对", createCompare: "创建比对任务", + createSteps: "创建步骤", + stepBasics: "基本信息", + stepSheets: "对比项", + stepBatches: "批次", + stepMapping: "端口映射", + next: "下一步", + back: "上一步", + enabledSheets: "对比项", + enabledSheetsHint: "点击开关;默认全部开启。关闭的项不参与本次比对。", + noTemplateSheets: "该模板暂无对比表", + needJobName: "请填写任务名称", + needTemplate: "请选择比对模板", + needSheets: "请至少开启一项对比", + needBeforeBatch: "请选择操作前任务与批次", + needAfterBatch: "请选择操作后批次", runNow: "立即比对", saveJob: "保存配置", jobSaved: "任务配置已保存", @@ -413,13 +433,14 @@ const zh = { detail: "详情", edit: "编辑", delete: "删除", - tabConfig: "配置 / 批次", + tabConfig: "任务配置", tabResult: "比对结果", - runs: "历史 Run", + runs: "历史比对", noRuns: "尚无比对记录,请先执行比对", result: "比对结果", - pickBatchRun: "选择比对批次", - pickRun: "选择批次…", + pickBatchRun: "选择比对记录", + pickRun: "选择比对记录…", + runCount: "{{n}} 次", resultEmpty: "无匹配失败/结果行", resultFilterPh: "筛选身份 / 字段值…", kindAll: "全部", diff --git a/web/src/index.css b/web/src/index.css index 3deef06..81861c7 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -10821,6 +10821,17 @@ html.login-page--paused .login-page__flare { max-height: min(55vh, 520px); overflow: auto; } +.ct-field-table { + border-collapse: separate; + border-spacing: 0; +} +.ct-field-table thead th { + position: sticky; + top: 0; + z-index: 2; + background: rgba(15, 23, 42, 0.98) !important; + box-shadow: 0 1px 0 rgba(148, 163, 184, 0.22); +} .ct-field-table th, .ct-field-table td { vertical-align: middle; @@ -11314,13 +11325,19 @@ html.login-page--paused .login-page__flare { } .bs-cmp-board__run-select { - flex: 0 1 280px; + flex: 0 1 360px; min-width: 160px; - max-width: 320px; + max-width: 420px; height: 32px; margin: 0; } +.bs-cmp-board__run-count { + flex: 0 0 auto; + font-size: 12px; + white-space: nowrap; +} + .bs-cmp-board__actions { margin-left: auto; flex: 0 0 auto; diff --git a/web/src/pages/network/BizComparePage.tsx b/web/src/pages/network/BizComparePage.tsx index ebd76aa..107476b 100644 --- a/web/src/pages/network/BizComparePage.tsx +++ b/web/src/pages/network/BizComparePage.tsx @@ -11,6 +11,7 @@ import { bizCompareCreateMapping, bizCompareCreateTemplate, bizCompareDeleteJob, + bizCompareDeleteRun, bizCompareDeleteTemplate, bizCompareDownloadRun, bizCompareGetRun, @@ -35,6 +36,8 @@ import { jobChipColor, NmStatusChip } from "./nmChips"; type PageTab = "templates" | "jobs"; type JobDetailTab = "config" | "result"; type KindFilter = "diff" | "all" | "added" | "removed" | "changed" | "unchanged"; +type CreateJobStep = 0 | 1 | 2 | 3; +const CREATE_JOB_STEPS = 4; type TaskOpt = { id: string; ne_name: string; ne_ip: string; vendor: string }; type BatchOpt = { id: string; status: string; row_count: number; started_at?: string | null }; @@ -101,6 +104,7 @@ type Job = { after_batch_id: string; mode: string; status: string; + enabled_sheet_ids?: string[]; note?: string; }; @@ -774,10 +778,12 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage // job create / detail const [jobCreateOpen, setJobCreateOpen] = useState(false); + const [createStep, setCreateStep] = useState(0); const [jobId, setJobId] = useState(""); const [jobDetailTab, setJobDetailTab] = useState("config"); const [name, setName] = useState(""); const [templateId, setTemplateId] = useState(""); + const [enabledSheetIds, setEnabledSheetIds] = useState([]); const [mappingId, setMappingId] = useState(""); const [beforeTaskId, setBeforeTaskId] = useState(""); const [afterTaskId, setAfterTaskId] = useState(""); @@ -873,6 +879,66 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage }); }, [templates, debouncedListKw]); + const selectedJobTemplate = useMemo( + () => templates.find((x) => x.id === templateId) || null, + [templates, templateId], + ); + + const jobTemplateSheets = useMemo( + () => (selectedJobTemplate ? templateSheets(selectedJobTemplate) : []), + [selectedJobTemplate], + ); + + const jobSheetAllIds = useMemo( + () => jobTemplateSheets.map((s) => sheetIdentity(s)).filter(Boolean), + [jobTemplateSheets], + ); + + const isJobSheetOn = useCallback( + (sid: string) => { + if (!enabledSheetIds.length) return true; + return enabledSheetIds.includes(sid); + }, + [enabledSheetIds], + ); + + const enabledJobSheetCount = useMemo(() => { + if (!jobSheetAllIds.length) return 0; + if (!enabledSheetIds.length) return jobSheetAllIds.length; + return jobSheetAllIds.filter((id) => enabledSheetIds.includes(id)).length; + }, [jobSheetAllIds, enabledSheetIds]); + + const toggleJobSheet = useCallback( + (sid: string) => { + const all = jobSheetAllIds; + if (!all.length) return; + const currentlyOn = !enabledSheetIds.length + ? [...all] + : enabledSheetIds.filter((id) => all.includes(id)); + const next = currentlyOn.includes(sid) + ? currentlyOn.filter((id) => id !== sid) + : [...currentlyOn, sid]; + // Empty list means "all on" (new template sheets auto-included) + setEnabledSheetIds(next.length === all.length ? [] : next); + }, + [jobSheetAllIds, enabledSheetIds], + ); + + const setJobTemplateAndSheets = useCallback( + (nextTplId: string, presetIds?: string[] | null) => { + setTemplateId(nextTplId); + const tpl = templates.find((x) => x.id === nextTplId); + const all = tpl ? templateSheets(tpl).map((s) => sheetIdentity(s)).filter(Boolean) : []; + if (presetIds && presetIds.length) { + const kept = presetIds.filter((id) => all.includes(id)); + setEnabledSheetIds(kept.length === all.length ? [] : kept); + } else { + setEnabledSheetIds([]); + } + }, + [templates], + ); + const activeTplSheet = tplSheets[tplSheetIdx] || null; const activeTplFields = useMemo(() => { if (!activeTplSheet) return []; @@ -1444,8 +1510,18 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage }; const resetJobForm = (preset?: Partial) => { + const tplId = preset?.template_id || templates[0]?.id || ""; setName(preset?.name || t("bizCompare.defaultJobName")); - setTemplateId(preset?.template_id || templates[0]?.id || ""); + setTemplateId(tplId); + const tpl = templates.find((x) => x.id === tplId); + const all = tpl ? templateSheets(tpl).map((s) => sheetIdentity(s)).filter(Boolean) : []; + const presetIds = (preset?.enabled_sheet_ids || []).map(String).filter(Boolean); + if (presetIds.length) { + const kept = presetIds.filter((id) => all.includes(id)); + setEnabledSheetIds(kept.length === all.length ? [] : kept); + } else { + setEnabledSheetIds([]); + } setMappingId(preset?.mapping_id || ""); setBeforeTaskId(preset?.before_task_id || ""); setAfterTaskId(preset?.after_task_id || ""); @@ -1460,26 +1536,85 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage } }; + const jobConfigBody = () => ({ + name, + template_id: templateId, + mapping_id: mappingId, + before_task_id: beforeTaskId, + after_task_id: afterTaskId || beforeTaskId, + before_batch_id: beforeBatchId, + after_batch_id: mode === "manual" ? afterBatchId : "", + mode, + enabled_sheet_ids: enabledSheetIds, + }); + + const closeCreateJob = () => { + setJobCreateOpen(false); + setCreateStep(0); + }; + const openCreateJob = () => { resetJobForm(); + setCreateStep(0); setJobCreateOpen(true); }; + const canAdvanceCreateStep = (step: CreateJobStep): boolean => { + if (step === 0) return Boolean(name.trim() && templateId); + if (step === 1) return enabledJobSheetCount > 0; + if (step === 2) { + if (!beforeTaskId || !beforeBatchId) return false; + if (mode === "manual" && !afterBatchId) return false; + return true; + } + return true; + }; + + const createStepBlockReason = (step: CreateJobStep): string | null => { + if (step === 0) { + if (!name.trim()) return t("bizCompare.needJobName"); + if (!templateId) return t("bizCompare.needTemplate"); + return null; + } + if (step === 1) { + if (!enabledJobSheetCount) return t("bizCompare.needSheets"); + return null; + } + if (step === 2) { + if (!beforeTaskId || !beforeBatchId) return t("bizCompare.needBeforeBatch"); + if (mode === "manual" && !afterBatchId) return t("bizCompare.needAfterBatch"); + return null; + } + return null; + }; + + const onCreateNext = () => { + const reason = createStepBlockReason(createStep); + if (reason) { + showError(reason); + return; + } + setCreateStep((s) => Math.min(CREATE_JOB_STEPS - 1, (s + 1) as CreateJobStep) as CreateJobStep); + }; + + const onCreateBack = () => { + setCreateStep((s) => Math.max(0, s - 1) as CreateJobStep); + }; + const createJob = async () => { + const reason = + createStepBlockReason(0) || + createStepBlockReason(1) || + createStepBlockReason(2); + if (reason) { + showError(reason); + return; + } setBusy(true); try { - const j = await bizCompareCreateJob({ - name, - template_id: templateId, - mapping_id: mappingId, - before_task_id: beforeTaskId, - after_task_id: afterTaskId || beforeTaskId, - before_batch_id: beforeBatchId, - after_batch_id: mode === "manual" ? afterBatchId : "", - mode, - }); + const j = await bizCompareCreateJob(jobConfigBody()); showOk(t("bizCompare.created")); - setJobCreateOpen(false); + closeCreateJob(); await refresh(); await openJob(String(j.id)); } catch (e) { @@ -1520,18 +1655,13 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage const saveJobConfig = async () => { if (!jobId) return; + if (!enabledJobSheetCount) { + showError(t("bizCompare.needSheets")); + return; + } setBusy(true); try { - await bizCompareUpdateJob(jobId, { - name, - template_id: templateId, - mapping_id: mappingId, - before_task_id: beforeTaskId, - after_task_id: afterTaskId || beforeTaskId, - before_batch_id: beforeBatchId, - after_batch_id: mode === "manual" ? afterBatchId : "", - mode, - }); + await bizCompareUpdateJob(jobId, jobConfigBody()); showOk(t("bizCompare.jobSaved")); await refresh(); } catch (e) { @@ -1543,15 +1673,13 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage const runNow = async () => { if (!jobId) return; + if (!enabledJobSheetCount) { + showError(t("bizCompare.needSheets")); + return; + } setBusy(true); try { - await bizCompareUpdateJob(jobId, { - before_batch_id: beforeBatchId, - after_batch_id: mode === "manual" ? afterBatchId : "", - mode, - mapping_id: mappingId, - template_id: templateId, - }); + await bizCompareUpdateJob(jobId, jobConfigBody()); const run = await bizCompareRunJob(jobId); setRunDetail(run); setJobDetailTab("result"); @@ -1578,36 +1706,120 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage } }; - const renderJobForm = (compact = false) => ( -
- - setTemplateId(e.target.value)} - fullWidth - > - {templates.map((tpl) => { - const mids = tpl.metric_ids || templateSheets(tpl).map((s) => s.metric_id); + const removeRun = async (runId: string) => { + if (!runId) return; + if (!window.confirm(t("bizCompare.confirmDeleteRun"))) return; + setBusy(true); + try { + const wasCurrent = String(runDetail?.id || "") === runId; + await bizCompareDeleteRun(runId); + let nextRuns: typeof runs = []; + if (jobId) { + const r = await bizCompareListRuns(jobId); + nextRuns = r.items || []; + } else { + nextRuns = (runs || []).filter((r) => String(r.id) !== runId); + } + setRuns(nextRuns); + if (wasCurrent) { + if (nextRuns.length) { + await loadRun(String(nextRuns[0].id)); + } else { + setRunDetail(null); + setResultSheetId(""); + } + } + showOk(t("bizCompare.runDeleted")); + } catch (e) { + showError(formatErr(e)); + } finally { + setBusy(false); + } + }; + + const renderSheetChips = () => ( +
+
+ {t("bizCompare.enabledSheets")} + + {enabledJobSheetCount}/{jobSheetAllIds.length || 0} + +
+

{t("bizCompare.enabledSheetsHint")}

+
+ {jobTemplateSheets.map((s) => { + const sid = sheetIdentity(s); + const on = isJobSheetOn(sid); return ( - + ); })} - - setMode(e.target.value as "manual" | "auto")} - fullWidth - > - - - +
+ {!jobTemplateSheets.length ? ( +

{t("bizCompare.noTemplateSheets")}

+ ) : null} + {jobTemplateSheets.length && !enabledJobSheetCount ? ( +

{t("bizCompare.needSheets")}

+ ) : null} +
+ ); + + const renderMappingBlock = () => ( +
+

{t("bizCompare.mapping")}

+

{t("bizCompare.mappingOptionalHint")}

+
+ { + const id = e.target.value; + if (id) loadMappingText(id); + else { + setMappingId(""); + setMapText(""); + } + }} + > + + {mappings.map((m) => ( + + ))} + + setMapName(e.target.value)} /> + + +
+