import { Button, Input, Modal } from "@heroui/react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ListPager } from "../../components/ListPager"; import { AppModalShell } from "../../components/ui/AppModalShell"; import { FieldSelect } from "../../components/ui/FieldSelect"; import { useDebouncedValue } from "../../hooks/useDebouncedValue"; import { useToast } from "../../hooks/useToast"; import { useI18n } from "../../i18n"; import { bizCompareCreateJob, bizCompareCreateMapping, bizCompareCreateTemplate, bizCompareDeleteJob, bizCompareDeleteTemplate, bizCompareDownloadRun, bizCompareGetRun, bizCompareListJobs, bizCompareListMappings, bizCompareListMetrics, bizCompareListRunDiffs, bizCompareListRuns, bizCompareListTemplates, bizCompareRunJob, bizCompareUpdateJob, bizCompareUpdateMapping, bizCompareUpdateTemplate, bizCompareValidateMapping, bizStateListBatches, bizStateListTasks, formatErr, } from "../../services/api"; import { formatSystemTime } from "../../utils/time"; import { jobChipColor, NmStatusChip } from "./nmChips"; type PageTab = "templates" | "jobs"; type JobDetailTab = "config" | "result"; type KindFilter = "diff" | "all" | "added" | "removed" | "changed" | "unchanged"; 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 }; type MetricField = { name: string; display_name: string; dtype: string; is_key: boolean; is_interface: boolean; role: string; }; type MetricSchema = { metric_id: string; fields: MetricField[] }; type MetricSheet = { metric_id: string; key_fields: string[]; iface_fields: string[]; compare_fields: string[]; display_fields?: string[]; row_filters?: RowFilter[]; field_rules?: FieldRule[]; }; type FieldRule = { field: string; compare?: string; normalize?: string; ignore?: boolean; tolerance?: number; }; type RowFilter = { field?: string; op?: string; value?: string | string[]; any?: RowFilter[]; all?: RowFilter[]; }; type Template = { id: string; name: string; metrics?: MetricSheet[]; metric_ids?: string[]; metric_id: string; key_fields: string[]; iface_fields: string[]; compare_fields: string[]; note: string; }; type Mapping = { id: string; name: string; rows: { before_if: string; after_if: string }[] }; type Job = { id: string; name: string; template_id: string; mapping_id: string; before_task_id: string; after_task_id: string; before_batch_id: string; after_batch_id: string; mode: string; status: string; note?: string; }; type DiffRow = { kind: string; key: Record; before: Record | null; after: Record | null; mapped_before?: Record | null; changes: Record; }; type RunSheet = { metric_id: string; key_fields: string[]; iface_fields: string[]; compare_fields: string[]; display_fields?: string[]; field_rules?: FieldRule[]; mode?: string; summary?: Record; diffs?: DiffRow[]; }; function fmtTime(v?: string | null) { if (!v) return "—"; return formatSystemTime(v) || v; } function cellText(v: unknown): string { if (v === null || v === undefined) return ""; if (typeof v === "object") return JSON.stringify(v); return String(v); } function failFieldNames(d: DiffRow): string[] { if (d.kind === "added" || d.kind === "removed") return []; return Object.keys(d.changes || {}); } /** Non-key fields: always show before/after pair for cutover review. */ function PairCell(props: { beforeText: string; afterText: string; mismatch: boolean; reason?: string; beforeLabel: string; afterLabel: string; }) { const { beforeText, afterText, mismatch, reason, beforeLabel, afterLabel } = props; const pre = beforeText || "—"; const post = afterText || "—"; return (
{beforeLabel} {pre}
{afterLabel} {post}
{reason ?
{reason}
: null} ); } function toggleInList(list: string[], name: string, on: boolean): string[] { if (on) return list.includes(name) ? list : [...list, name]; return list.filter((x) => x !== name); } function taskLabel(row: TaskOpt) { return `${row.ne_name || row.ne_ip || row.id} (${row.vendor || "-"})`; } function templateSheets(tpl?: Template | null): MetricSheet[] { if (!tpl) return []; if (tpl.metrics?.length) return tpl.metrics; if (tpl.metric_id) { return [ { metric_id: tpl.metric_id, key_fields: [...(tpl.key_fields || [])], iface_fields: [...(tpl.iface_fields || [])], compare_fields: [...(tpl.compare_fields || [])], display_fields: [...(tpl.key_fields || []), ...(tpl.compare_fields || [])], row_filters: [], field_rules: [], }, ]; } return []; } function defaultSheetForMetric(schema: MetricSchema | undefined, metricId: string): MetricSheet { const fields = schema?.fields || []; const key_fields = fields.filter((f) => f.is_key).map((f) => f.name); const compare_fields = fields .filter((f) => !f.is_key && (f.role === "state" || f.role === "meta")) .map((f) => f.name); const sheet: MetricSheet = { metric_id: metricId, key_fields, iface_fields: fields.filter((f) => f.is_interface).map((f) => f.name), compare_fields, display_fields: [...key_fields, ...compare_fields], row_filters: [], field_rules: [], }; // Match backend ARP defaults when adding from UI if (metricId === "arp") { sheet.row_filters = [ { any: [ { field: "entry_type", op: "eq", value: "dynamic" }, { all: [ { field: "entry_type", op: "empty" }, { field: "age", op: "age_timer" }, ], }, ], }, ]; if (fields.some((f) => f.name === "mac")) { sheet.field_rules = [{ field: "mac", normalize: "mac" }]; } const ctx = ["vrf", "entry_type", "age"].filter( (n) => !key_fields.includes(n) && !compare_fields.includes(n) && fields.some((f) => f.name === n), ); sheet.display_fields = [...key_fields, ...compare_fields, ...ctx]; } return sheet; } function cloneSheet(s: MetricSheet): MetricSheet { const keys = [...(s.key_fields || [])]; const compare = [...(s.compare_fields || [])]; const display = s.display_fields && s.display_fields.length ? [...s.display_fields] : [...keys, ...compare]; return { metric_id: s.metric_id, key_fields: keys, iface_fields: [...(s.iface_fields || [])], compare_fields: compare, display_fields: display, row_filters: JSON.parse(JSON.stringify(s.row_filters || [])), field_rules: JSON.parse(JSON.stringify(s.field_rules || [])), }; } const TEMPLATE_EXPORT_FORMAT = "netx.biz_compare_template"; const TEMPLATE_EXPORT_VERSION = 1; function templateExportPayload(tpl: Template) { const metrics = templateSheets(tpl).map((s) => ({ metric_id: s.metric_id, key_fields: [...(s.key_fields || [])], iface_fields: [...(s.iface_fields || [])], compare_fields: [...(s.compare_fields || [])], display_fields: s.display_fields && s.display_fields.length ? [...s.display_fields] : [...(s.key_fields || []), ...(s.compare_fields || [])], row_filters: JSON.parse(JSON.stringify(s.row_filters || [])), field_rules: JSON.parse(JSON.stringify(s.field_rules || [])), })); return { format: TEMPLATE_EXPORT_FORMAT, version: TEMPLATE_EXPORT_VERSION, exported_at: new Date().toISOString(), name: tpl.name, note: tpl.note || "", metrics, }; } function parseTemplateImport(raw: unknown): { name: string; note: string; metrics: MetricSheet[]; } | null { if (!raw || typeof raw !== "object") return null; const o = raw as Record; // Accept our export shape or a bare {name, metrics} / TemplateIn body const name = String(o.name || "").trim(); const note = String(o.note || ""); let metricsRaw = o.metrics; if (!Array.isArray(metricsRaw) && o.metric_id) { metricsRaw = [ { metric_id: o.metric_id, key_fields: o.key_fields || [], iface_fields: o.iface_fields || [], compare_fields: o.compare_fields || [], display_fields: o.display_fields || [], row_filters: o.row_filters || [], field_rules: o.field_rules || [], }, ]; } if (!name || !Array.isArray(metricsRaw) || !metricsRaw.length) return null; const metrics: MetricSheet[] = []; for (const item of metricsRaw) { if (!item || typeof item !== "object") continue; const m = item as Record; const mid = String(m.metric_id || "").trim(); const keys = Array.isArray(m.key_fields) ? (m.key_fields as unknown[]).map((x) => String(x).trim()).filter(Boolean) : []; if (!mid || !keys.length) continue; metrics.push({ metric_id: mid, key_fields: keys, iface_fields: Array.isArray(m.iface_fields) ? (m.iface_fields as unknown[]).map((x) => String(x).trim()).filter(Boolean) : [], compare_fields: Array.isArray(m.compare_fields) ? (m.compare_fields as unknown[]).map((x) => String(x).trim()).filter(Boolean) : [], display_fields: Array.isArray(m.display_fields) ? (m.display_fields as unknown[]).map((x) => String(x).trim()).filter(Boolean) : undefined, row_filters: Array.isArray(m.row_filters) ? (m.row_filters as RowFilter[]) : [], field_rules: Array.isArray(m.field_rules) ? (m.field_rules as FieldRule[]) : [], }); } if (!metrics.length) return null; return { name, note, metrics }; } function downloadJsonFile(filename: string, data: unknown) { const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); } function ruleForField(sheet: MetricSheet, field: string): FieldRule { return (sheet.field_rules || []).find((r) => r.field === field) || { field }; } function upsertFieldRule(sheet: MetricSheet, field: string, patch: Partial): FieldRule[] { const rules = [...(sheet.field_rules || [])]; const idx = rules.findIndex((r) => r.field === field); const next: FieldRule = { ...(idx >= 0 ? rules[idx] : { field }), ...patch, field }; const norm = (next.normalize || "").toLowerCase(); const cmp = (next.compare || "").toLowerCase(); const empty = !next.ignore && (!cmp || cmp === "eq") && (!norm || norm === "none" || norm === "strip") && (next.tolerance === undefined || next.tolerance === null); if (empty) { return rules.filter((r) => r.field !== field); } if (idx >= 0) rules[idx] = next; else rules.push(next); return rules; } function isLeafFilter(f: RowFilter): boolean { return !f.any && !f.all && Boolean(f.field || f.op); } /** Expand sheet row_filters into OR-groups of AND leaf conditions for editing. */ function toOrGroups(filters: RowFilter[]): RowFilter[][] { const list = filters || []; if (!list.length) return []; if (list.length === 1 && list[0]?.any && Array.isArray(list[0].any)) { return list[0].any .map((g) => { if (g?.all && Array.isArray(g.all)) return g.all.filter(isLeafFilter); if (isLeafFilter(g)) return [g]; return [] as RowFilter[]; }) .filter((g) => g.length); } if (list.every(isLeafFilter)) return [list.map((f) => ({ ...f }))]; // Mixed / opaque: keep editable leaves only const leaves = list.filter(isLeafFilter); return leaves.length ? [leaves] : []; } /** Serialize OR-groups back to engine row_filters (AND of leaves, or single any-of). */ function fromOrGroups(groups: RowFilter[][]): RowFilter[] { const clean = groups .map((g) => g .map((f) => ({ field: f.field || "", op: f.op || "eq", value: f.value ?? "", })) .filter((f) => f.field), ) .filter((g) => g.length); if (!clean.length) return []; if (clean.length === 1) return clean[0]; return [ { any: clean.map((g) => (g.length === 1 ? g[0] : { all: g })), }, ]; } function emptyLeaf(field = ""): RowFilter { return { field, op: "eq", value: "" }; } function metricLabel(id: string) { return id; } export function BizComparePage() { const { t } = useI18n(); const { showOk, showError } = useToast(); const [pageTab, setPageTab] = useState("jobs"); const [busy, setBusy] = useState(false); const [tasks, setTasks] = useState([]); const [metrics, setMetrics] = useState([]); const [templates, setTemplates] = useState([]); const [mappings, setMappings] = useState([]); const [jobs, setJobs] = useState([]); const [listKw, setListKw] = useState(""); const debouncedListKw = useDebouncedValue(listKw, 250); // template editor const [tplOpen, setTplOpen] = useState(false); const [tplEditId, setTplEditId] = useState(""); const [tplName, setTplName] = useState(""); const [tplNote, setTplNote] = useState(""); const [tplSheets, setTplSheets] = useState([]); const [tplSheetIdx, setTplSheetIdx] = useState(0); // job create / detail const [jobCreateOpen, setJobCreateOpen] = useState(false); const [jobId, setJobId] = useState(""); const [jobDetailTab, setJobDetailTab] = useState("config"); const [name, setName] = useState(""); const [templateId, setTemplateId] = useState(""); const [mappingId, setMappingId] = useState(""); const [beforeTaskId, setBeforeTaskId] = useState(""); const [afterTaskId, setAfterTaskId] = useState(""); const [beforeBatches, setBeforeBatches] = useState([]); const [afterBatches, setAfterBatches] = useState([]); const [beforeBatchId, setBeforeBatchId] = useState(""); const [afterBatchId, setAfterBatchId] = useState(""); const [mode, setMode] = useState<"manual" | "auto">("manual"); const [mapName, setMapName] = useState("端口映射"); const [mapText, setMapText] = useState("before_if,after_if\n"); const [validateOut, setValidateOut] = useState(null); const [runs, setRuns] = useState([]); const [runDetail, setRunDetail] = useState(null); const [resultSheetId, setResultSheetId] = useState(""); // result filters (server-paged) const [kindFilter, setKindFilter] = useState("diff"); const [resultKw, setResultKw] = useState(""); const debouncedResultKw = useDebouncedValue(resultKw, 300); const [resultPage, setResultPage] = useState(1); const [resultPageSize, setResultPageSize] = useState(100); const [resultTotal, setResultTotal] = useState(0); const [pagedDiffs, setPagedDiffs] = useState([]); const [diffsLoading, setDiffsLoading] = useState(false); const boardRef = useRef(null); const [boardFs, setBoardFs] = useState(false); const tplImportRef = useRef(null); const refresh = useCallback(async () => { const [taskRes, tpl, maps, j, met] = await Promise.all([ bizStateListTasks(), bizCompareListTemplates(), bizCompareListMappings(), bizCompareListJobs(), bizCompareListMetrics(), ]); setTasks((taskRes.items || []) as TaskOpt[]); setTemplates((tpl.items || []) as Template[]); setMappings((maps.items || []) as Mapping[]); setJobs((j.items || []) as Job[]); setMetrics((met.items || []) as MetricSchema[]); }, []); useEffect(() => { void (async () => { try { await refresh(); } catch (e) { showError(formatErr(e)); } })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [refresh]); useEffect(() => { void (async () => { if (!beforeTaskId) { setBeforeBatches([]); return; } const b = await bizStateListBatches(beforeTaskId); setBeforeBatches((b.items || []) as BatchOpt[]); })(); }, [beforeTaskId]); useEffect(() => { void (async () => { if (!afterTaskId) { setAfterBatches([]); return; } const b = await bizStateListBatches(afterTaskId); setAfterBatches((b.items || []) as BatchOpt[]); })(); }, [afterTaskId]); const filteredJobs = useMemo(() => { const kw = debouncedListKw.trim().toLowerCase(); if (!kw) return jobs; return jobs.filter((j) => { const tpl = templates.find((x) => x.id === j.template_id); const mids = (tpl?.metric_ids || templateSheets(tpl).map((s) => s.metric_id)).join(" "); return `${j.name} ${j.mode} ${j.status} ${tpl?.name || ""} ${mids}`.toLowerCase().includes(kw); }); }, [jobs, templates, debouncedListKw]); const filteredTemplates = useMemo(() => { const kw = debouncedListKw.trim().toLowerCase(); if (!kw) return templates; return templates.filter((x) => { const mids = (x.metric_ids || templateSheets(x).map((s) => s.metric_id)).join(" "); return `${x.name} ${mids} ${x.note}`.toLowerCase().includes(kw); }); }, [templates, debouncedListKw]); const activeTplSheet = tplSheets[tplSheetIdx] || null; const activeTplFields = useMemo(() => { if (!activeTplSheet) return []; return metrics.find((m) => m.metric_id === activeTplSheet.metric_id)?.fields || []; }, [metrics, activeTplSheet]); const runSheets: RunSheet[] = useMemo(() => { const sheets = (runDetail?.sheets || []) as RunSheet[]; if (sheets.length) return sheets; if (runDetail?.diffs) { return [ { metric_id: String(runDetail.metric_id || "result"), key_fields: [], iface_fields: [], compare_fields: [], mode: "fields", summary: runDetail.summary, diffs: runDetail.diffs, }, ]; } return []; }, [runDetail]); useEffect(() => { if (!runSheets.length) { setResultSheetId(""); return; } if (!resultSheetId || !runSheets.some((s) => s.metric_id === resultSheetId)) { setResultSheetId(runSheets[0].metric_id); } }, [runSheets, resultSheetId]); const activeRunSheet = useMemo( () => runSheets.find((s) => s.metric_id === resultSheetId) || runSheets[0] || null, [runSheets, resultSheetId], ); // Reset page when sheet / filter / page size changes useEffect(() => { setResultPage(1); }, [resultSheetId, kindFilter, debouncedResultKw, resultPageSize, runDetail?.id]); useEffect(() => { const runId = String(runDetail?.id || ""); const mid = resultSheetId || activeRunSheet?.metric_id || ""; if (!runId || !mid || jobDetailTab !== "result") { setPagedDiffs([]); setResultTotal(0); return; } let cancelled = false; void (async () => { setDiffsLoading(true); try { const res = await bizCompareListRunDiffs({ runId, metricId: mid, kind: kindFilter, kw: debouncedResultKw.trim(), page: resultPage, pageSize: resultPageSize, }); if (cancelled) return; setPagedDiffs((res.items || []) as DiffRow[]); setResultTotal(Number(res.total || 0)); const pages = Math.max( 1, Math.ceil(Number(res.total || 0) / Number(res.page_size || resultPageSize)), ); if (resultPage > pages) setResultPage(pages); } catch (e) { if (!cancelled) { // Keep previous rows to avoid strip/table jump; only clear on hard empty run showError(formatErr(e)); } } finally { if (!cancelled) setDiffsLoading(false); } })(); return () => { cancelled = true; }; }, [ runDetail?.id, resultSheetId, activeRunSheet?.metric_id, kindFilter, debouncedResultKw, resultPage, resultPageSize, jobDetailTab, showError, ]); const resultColumns = useMemo(() => { const keys = activeRunSheet?.key_fields?.length ? activeRunSheet.key_fields : Object.keys((pagedDiffs[0]?.key as Record) || {}); const compare = (activeRunSheet?.compare_fields || []).filter((f) => !keys.includes(f)); const compareSet = new Set(compare); const keySet = new Set(keys); let display = (activeRunSheet?.display_fields || []).filter(Boolean); if (!display.length) { display = [...keys, ...compare]; } else { // keys first, then rest of display order const rest = display.filter((f) => !keySet.has(f)); display = [...keys, ...rest]; } const extras = display.filter((f) => !keySet.has(f)); return { keys, compare, extras, compareSet, presence: !(activeRunSheet?.compare_fields || []).length, }; }, [activeRunSheet, pagedDiffs]); const kindLabel = (kind: string) => { if (kind === "added") return t("bizCompare.kindAddedShort"); if (kind === "removed") return t("bizCompare.kindRemovedShort"); if (kind === "changed") return t("bizCompare.kindChangedShort"); if (kind === "unchanged") return t("bizCompare.kindUnchangedShort"); return kind; }; const verdictLabel = (kind: string) => { if (kind === "unchanged") return t("bizCompare.kindPass"); return t("bizCompare.kindFail"); }; const failFieldsLabel = (d: DiffRow) => { if (d.kind === "added" || d.kind === "removed") return t("bizCompare.failWholeRow"); const names = failFieldNames(d); return names.length ? names.join(" · ") : t("bizCompare.failFieldsEmpty"); }; const summary = runDetail?.summary || {}; const resultPages = Math.max(1, Math.ceil(resultTotal / Math.max(1, resultPageSize))); const sheetCards = useMemo(() => { const raw = (summary.sheet_cards || []) as Array<{ metric_id: string; mode?: string; added?: number; removed?: number; changed?: number; unchanged?: number; before_count?: number; after_count?: number; diff_count?: number; pass_rate?: number; }>; // Diffs first so ops can scan quickly when many sheets return [...raw].sort((a, b) => { const da = Number(a.diff_count || 0); const db = Number(b.diff_count || 0); if (da !== db) return db - da; return String(a.metric_id).localeCompare(String(b.metric_id)); }); }, [summary.sheet_cards]); const sheetFailCount = sheetCards.filter((c) => Number(c.diff_count || 0) > 0).length; const activeSheetCard = sheetCards.find((c) => c.metric_id === resultSheetId) || sheetCards[0]; useEffect(() => { const syncFs = () => { const el = boardRef.current; setBoardFs(Boolean(el && document.fullscreenElement === el)); }; document.addEventListener("fullscreenchange", syncFs); return () => document.removeEventListener("fullscreenchange", syncFs); }, []); const toggleBoardFullscreen = async () => { const el = boardRef.current; if (!el) return; try { if (document.fullscreenElement === el) await document.exitFullscreen(); else await el.requestFullscreen(); } catch (e) { showError(formatErr(e)); } }; const downloadRunTables = async () => { if (!runDetail?.id) return; setBusy(true); try { await bizCompareDownloadRun(String(runDetail.id)); showOk(t("bizCompare.exportOk")); } catch (e) { showError(formatErr(e)); } finally { setBusy(false); } }; const updateActiveSheet = (patch: Partial) => { setTplSheets((prev) => prev.map((s, i) => { if (i !== tplSheetIdx) return s; const next = { ...s, ...patch }; // Keys are identity — drop from compare if (patch.key_fields || patch.compare_fields) { const keySet = new Set(next.key_fields); next.compare_fields = next.compare_fields.filter((f) => !keySet.has(f)); } if (patch.display_fields && !patch.key_fields && !patch.compare_fields) { // Explicit display toggle: keys always forced on const disp = new Set(patch.display_fields); for (const k of next.key_fields) disp.add(k); next.display_fields = [ ...next.key_fields, ...[...disp].filter((f) => !next.key_fields.includes(f)), ]; } else if (patch.key_fields || patch.compare_fields) { const disp = new Set(next.display_fields || []); for (const k of next.key_fields) disp.add(k); if (patch.compare_fields) { const prevCompare = new Set(s.compare_fields || []); for (const f of next.compare_fields) { if (!prevCompare.has(f)) disp.add(f); } } next.display_fields = [ ...next.key_fields, ...[...disp].filter((f) => !next.key_fields.includes(f)), ]; } return next; }), ); }; const openNewTemplate = () => { setTplEditId(""); setTplName(""); setTplNote(""); setTplSheets([]); setTplSheetIdx(0); setTplOpen(true); }; const openEditTemplate = (tpl: Template) => { const sheets = templateSheets(tpl); setTplEditId(tpl.id); setTplName(tpl.name); setTplNote(tpl.note || ""); setTplSheets(sheets.length ? sheets.map(cloneSheet) : []); setTplSheetIdx(0); setTplOpen(true); }; const toggleTplMetric = (metricId: string, on: boolean) => { if (on) { const schema = metrics.find((m) => m.metric_id === metricId); setTplSheets((prev) => { if (prev.some((s) => s.metric_id === metricId)) return prev; const next = [...prev, defaultSheetForMetric(schema, metricId)]; setTplSheetIdx(next.length - 1); return next; }); return; } setTplSheets((prev) => { const idx = prev.findIndex((s) => s.metric_id === metricId); if (idx < 0) return prev; const next = prev.filter((_, i) => i !== idx); setTplSheetIdx((cur) => { if (!next.length) return 0; if (cur > idx) return cur - 1; if (cur >= next.length) return next.length - 1; return cur; }); return next; }); }; const removeTplMetric = (idx: number) => { setTplSheets((prev) => { const next = prev.filter((_, i) => i !== idx); setTplSheetIdx((cur) => { if (!next.length) return 0; if (cur > idx) return cur - 1; if (cur >= next.length) return next.length - 1; return cur; }); return next; }); }; const saveTemplate = async () => { if (!tplSheets.length) { showError(t("bizCompare.metricsRequired")); return; } for (const s of tplSheets) { if (!s.key_fields.length) { showError(`${metricLabel(s.metric_id)}: ${t("bizCompare.keyRequired")}`); return; } } setBusy(true); try { const body = { name: tplName || tplSheets.map((s) => s.metric_id).join("+"), note: tplNote, metrics: tplSheets.map((s) => ({ metric_id: s.metric_id, key_fields: s.key_fields, iface_fields: s.iface_fields, compare_fields: s.compare_fields, display_fields: s.display_fields || [...s.key_fields, ...s.compare_fields], row_filters: s.row_filters || [], field_rules: s.field_rules || [], })), }; if (tplEditId) await bizCompareUpdateTemplate(tplEditId, body); else await bizCompareCreateTemplate(body); showOk(t("bizCompare.templateSaved")); setTplOpen(false); await refresh(); } catch (e) { showError(formatErr(e)); } finally { setBusy(false); } }; const removeTemplate = async (id: string) => { if (!window.confirm(t("bizCompare.confirmDeleteTemplate"))) return; setBusy(true); try { await bizCompareDeleteTemplate(id); showOk(t("bizCompare.templateDeleted")); await refresh(); } catch (e) { showError(formatErr(e)); } finally { setBusy(false); } }; const exportTemplate = (tpl: Template) => { const payload = templateExportPayload(tpl); const safe = (tpl.name || "template").replace(/[^\w\u4e00-\u9fff.-]+/g, "_").slice(0, 64); downloadJsonFile(`netx-compare-template-${safe}.json`, payload); showOk(t("bizCompare.templateExported")); }; const importTemplateFile = async (file: File) => { setBusy(true); try { const text = await file.text(); let parsed: unknown; try { parsed = JSON.parse(text); } catch { showError(t("bizCompare.templateImportInvalid")); return; } const body = parseTemplateImport(parsed); if (!body) { showError(t("bizCompare.templateImportInvalid")); return; } await bizCompareCreateTemplate({ name: body.name, note: body.note, metrics: body.metrics.map((s) => ({ metric_id: s.metric_id, key_fields: s.key_fields, iface_fields: s.iface_fields, compare_fields: s.compare_fields, display_fields: s.display_fields || [...s.key_fields, ...s.compare_fields], row_filters: s.row_filters || [], field_rules: s.field_rules || [], })), }); showOk(t("bizCompare.templateImported")); await refresh(); setPageTab("templates"); } catch (e) { showError(formatErr(e)); } finally { setBusy(false); if (tplImportRef.current) tplImportRef.current.value = ""; } }; const removeJob = async (id: string) => { if (!window.confirm(t("bizCompare.confirmDeleteJob"))) return; setBusy(true); try { await bizCompareDeleteJob(id); if (jobId === id) closeJob(); showOk(t("bizCompare.jobDeleted")); await refresh(); } catch (e) { showError(formatErr(e)); } finally { setBusy(false); } }; const parseMapRows = () => { const rows: { before_if: string; after_if: string }[] = []; for (const line of mapText.split(/\r?\n/)) { const s = line.trim(); if (!s || s.startsWith("#") || s.toLowerCase().startsWith("old") || s.toLowerCase().startsWith("before")) { continue; } const parts = s.split(/[,|\t]+/).map((x) => x.trim()); if (parts.length >= 2 && parts[0] && parts[1]) { rows.push({ before_if: parts[0], after_if: parts[1] }); } } return rows; }; const loadMappingText = (id: string) => { const m = mappings.find((x) => x.id === id); if (!m) return; setMappingId(id); setMapName(m.name); setMapText(["before_if,after_if", ...m.rows.map((r) => `${r.before_if},${r.after_if}`)].join("\n")); }; const saveMapping = async () => { setBusy(true); try { const rows = parseMapRows(); if (mappingId) await bizCompareUpdateMapping(mappingId, { name: mapName, rows }); else { const m = await bizCompareCreateMapping({ name: mapName, rows }); setMappingId(String(m.id)); } showOk(t("bizCompare.mappingSaved")); await refresh(); } catch (e) { showError(formatErr(e)); } finally { setBusy(false); } }; const doValidate = async () => { if (!mappingId || !beforeBatchId || !afterBatchId) { showError(t("bizCompare.validateNeed")); return; } setBusy(true); try { const v = await bizCompareValidateMapping({ mapping_id: mappingId, before_batch_id: beforeBatchId, after_batch_id: afterBatchId, template_id: templateId, }); setValidateOut(v); } catch (e) { showError(formatErr(e)); } finally { setBusy(false); } }; const resetJobForm = (preset?: Partial) => { setName(preset?.name || t("bizCompare.defaultJobName")); setTemplateId(preset?.template_id || templates[0]?.id || ""); setMappingId(preset?.mapping_id || ""); setBeforeTaskId(preset?.before_task_id || ""); setAfterTaskId(preset?.after_task_id || ""); setBeforeBatchId(preset?.before_batch_id || ""); setAfterBatchId(preset?.after_batch_id || ""); setMode(preset?.mode === "auto" ? "auto" : "manual"); setValidateOut(null); if (preset?.mapping_id) loadMappingText(preset.mapping_id); else { setMapName(t("bizCompare.mapping")); setMapText("before_if,after_if\n"); } }; const openCreateJob = () => { resetJobForm(); setJobCreateOpen(true); }; const createJob = async () => { 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, }); showOk(t("bizCompare.created")); setJobCreateOpen(false); await refresh(); await openJob(String(j.id)); } catch (e) { showError(formatErr(e)); } finally { setBusy(false); } }; const openJob = async (id: string) => { setJobId(id); setJobDetailTab("config"); setRunDetail(null); setResultSheetId(""); setKindFilter("diff"); setResultKw(""); const job = jobs.find((x) => x.id === id); if (job) resetJobForm(job); try { const r = await bizCompareListRuns(id); setRuns(r.items || []); if ((r.items || []).length) { const latest = await bizCompareGetRun(String((r.items as any[])[0].id)); setRunDetail(latest); setJobDetailTab("result"); } } catch (e) { showError(formatErr(e)); } }; const closeJob = () => { setJobId(""); setRuns([]); setRunDetail(null); setResultSheetId(""); }; const saveJobConfig = async () => { if (!jobId) 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, }); showOk(t("bizCompare.jobSaved")); await refresh(); } catch (e) { showError(formatErr(e)); } finally { setBusy(false); } }; const runNow = async () => { if (!jobId) return; setBusy(true); try { await bizCompareUpdateJob(jobId, { before_batch_id: beforeBatchId, after_batch_id: mode === "manual" ? afterBatchId : "", mode, mapping_id: mappingId, template_id: templateId, }); const run = await bizCompareRunJob(jobId); setRunDetail(run); setJobDetailTab("result"); showOk(t("bizCompare.ran")); const r = await bizCompareListRuns(jobId); setRuns(r.items || []); await refresh(); } catch (e) { showError(formatErr(e)); } finally { setBusy(false); } }; const loadRun = async (runId: string) => { try { const d = await bizCompareGetRun(runId); setRunDetail(d); setKindFilter("diff"); setResultKw(""); setJobDetailTab("result"); } catch (e) { showError(formatErr(e)); } }; const renderJobForm = (compact = false) => (
setTemplateId(e.target.value)} fullWidth > {templates.map((tpl) => { const mids = tpl.metric_ids || templateSheets(tpl).map((s) => s.metric_id); return ( ); })} setMode(e.target.value as "manual" | "auto")} fullWidth > setBeforeTaskId(e.target.value)} fullWidth > {tasks.map((row) => ( ))} setBeforeBatchId(e.target.value)} fullWidth > {beforeBatches.map((b) => ( ))} setAfterTaskId(e.target.value)} fullWidth > {tasks.map((row) => ( ))} {mode === "manual" ? ( setAfterBatchId(e.target.value)} fullWidth > {afterBatches.map((b) => ( ))} ) : (

{t("bizCompare.autoHint")}

)} {!compact ? (

{t("bizCompare.mapping")}

{t("bizCompare.mappingOptionalHint")}

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