diff --git a/web/src/features/cutover/BizComparePage.tsx b/web/src/features/cutover/BizComparePage.tsx index 011e2a8..ad43a8c 100644 --- a/web/src/features/cutover/BizComparePage.tsx +++ b/web/src/features/cutover/BizComparePage.tsx @@ -1,5 +1,5 @@ import { Button, Input, Modal } from "@heroui/react"; -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import type { KeyboardEvent as ReactKeyboardEvent } from "react"; import { parseTemplateImport, parseIfaceNormText, formatIfaceNormText, parseValueMapText, formatValueMapText, @@ -639,16 +639,13 @@ const FILTER_OPS = [ /** Value-map editor for one field: text is parsed on blur so half-typed lines never clobber the rule. */ function ValueMapCell({ - field, value, scope, onScope, onCommit, rank, onRank, showRank, t, + field, value, scope, onScope, onCommit, t, }: { field: string; value: Record; scope: Side; onScope: (s: Side) => void; onCommit: (map: Record) => void; - rank: string[]; - onRank: (rank: string[]) => void; - showRank: boolean; t: (k: string, vars?: Record) => string; }) { const external = formatValueMapText(value); @@ -660,14 +657,6 @@ function ValueMapCell({ }, [external, scope]); return (
- {showRank ? ( - onRank(e.target.value.split(",").map((x) => x.trim()).filter(Boolean))} - /> - ) : null} updateActiveSheet({ builtin_vendor_canonical: e.target.checked ? undefined : false })} /> {t("bizCompare.builtinVendorCanonical")} @@ -3027,7 +3021,6 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage /> )) : ( <> -

{t("bizCompare.fieldsHintShort")}

@@ -3035,7 +3028,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage - + @@ -3056,8 +3049,11 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage const norm = rawNorm === "strip" ? "none" : rawNorm; const needsTol = ["numeric", "percent", "numeric_min", "numeric_max"].includes(cmpMode); const vmScope = valueMapScope[f.name] || "both"; + const vmCount = (["both", "before", "after"] as Side[]).reduce((n, sc) => n + Object.keys(valueMapFor(activeTplSheet, f.name, sc)).length, 0); + const vmExpanded = vmOpen === f.name; return ( - + + @@ -3111,11 +3107,11 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage /> + {vmExpanded ? ( + + + + ) : null} + ); })} {!activeTplFields.length ? ( diff --git a/web/src/i18n/locales/en/bizCompare.ts b/web/src/i18n/locales/en/bizCompare.ts index 03da9b7..a598c76 100644 --- a/web/src/i18n/locales/en/bizCompare.ts +++ b/web/src/i18n/locales/en/bizCompare.ts @@ -139,6 +139,8 @@ const bizCompare = { valueMap: "Value map", valueMapScope: "value map scope", valueMapPlaceholder: "One per line: source,canonical\nestab,established", + valueMapEdit: "edit value map", + valueMapCount: "{{n}} rules", rankLabel: "best→worst", rankPlaceholder: "up,down (earlier is better)", modeNotWorse: "Not worse", @@ -153,7 +155,8 @@ const bizCompare = { pairSorted: "Pair by sorted value (recommended)", pairOrdered: "Pair by collection order (legacy)", duplicatePairingHint: "When one key has several rows (ECMP, multipath), CLI ordering changes must not create false differences.", - builtinVendorCanonical: "Built-in per-vendor interface names (GigabitEthernet↔GE …)", + builtinVendorCanonical: "Built-in vendor interface names", + builtinVendorCanonicalHint: "Unify vendor interface names (GigabitEthernet↔GE …)", mappingNeedBatches: "Pick the before and after batches first", preflight: { "run": "Validate mapping", diff --git a/web/src/i18n/locales/zh/bizCompare.ts b/web/src/i18n/locales/zh/bizCompare.ts index 5fc9a07..6ef4834 100644 --- a/web/src/i18n/locales/zh/bizCompare.ts +++ b/web/src/i18n/locales/zh/bizCompare.ts @@ -138,6 +138,8 @@ const bizCompare = { valueMap: "值映射", valueMapScope: "值映射范围", valueMapPlaceholder: "每行:原值,规范值\nestab,established", + valueMapEdit: "编辑值映射", + valueMapCount: "{{n}} 条", rankLabel: "好→坏顺序", rankPlaceholder: "up,down(靠前更好)", modeNotWorse: "不劣于", @@ -152,7 +154,8 @@ const bizCompare = { pairSorted: "按取值排序后配对(推荐)", pairOrdered: "按采集顺序配对(旧行为)", duplicatePairingHint: "ECMP、多路径等同一个键出现多行时,CLI 输出顺序变化不应产生假差异。", - builtinVendorCanonical: "启用内置厂商接口名称统一(GigabitEthernet↔GE 等)", + builtinVendorCanonical: "内置厂商接口名称统一", + builtinVendorCanonicalHint: "GigabitEthernet↔GE 等厂商接口名自动统一", mappingNeedBatches: "请先选择前后批次", preflight: { "run": "校验映射", diff --git a/web/src/styles/pages/biz-compare.css b/web/src/styles/pages/biz-compare.css index 1dcf51f..6a9a184 100644 --- a/web/src/styles/pages/biz-compare.css +++ b/web/src/styles/pages/biz-compare.css @@ -905,7 +905,9 @@ .bs-template-editor .ct-editor__norm summary { cursor: pointer; color: #c4ddfa; } .bs-template-editor .ct-editor__norm-area { width: 100%; max-width: 100%; resize: vertical; font: 12px/1.6 ui-monospace, Consolas, monospace; } .bs-template-editor .ct-editor__main { grid-template-columns: 190px minmax(0, 1fr); min-width: 0; } -.bs-template-editor .ct-editor__nav { max-height: 560px; } +/* Nav takes the pane's height (contain: size keeps it from driving the row) and scrolls its own list. */ +.bs-template-editor .ct-editor__nav { max-height: none; contain: size; overflow: hidden; } +.bs-template-editor .ct-editor__nav-list { flex: 1 1 auto; min-height: 0; overflow-y: auto; } .bs-template-editor .ct-editor__nav-item { border-radius: 3px; font-size: 12px; } .bs-template-editor .ct-editor__nav-item.is-active { background: #1b2c43; border-color: #526f94; } .bs-template-editor .ct-editor__meta { font: 11px/1.6 ui-monospace, Consolas, monospace; overflow-wrap: anywhere; } @@ -914,7 +916,29 @@ .bs-template-editor .ct-field-table { min-width: 800px; } .bs-template-editor .ct-field-table td { font-size: 12px; } .bs-template-editor .ct-field-table th { font-size: 11px; } -.bs-template-editor .ct-field-table .input { max-width: 100px; } +.bs-template-editor .ct-field-table .input { max-width: 110px; height: 28px; padding: 2px 8px; font-size: 12px; } +.bs-template-editor .ct-field-table td { padding-top: 5px; padding-bottom: 5px; height: 38px; } +.bs-template-editor .ct-field-table .mt-select { width: 112px; height: 28px; padding: 2px 6px; } +.bs-template-editor .ct-field-table input[type="checkbox"] { width: 15px; height: 15px; cursor: pointer; } +.bs-template-editor .ct-field-table input[type="checkbox"]:disabled { cursor: default; opacity: 0.45; } +.bs-template-editor .ct-field-table tbody tr > td:first-child { box-shadow: inset 2px 0 0 transparent; } +.bs-template-editor .ct-field-table tbody tr.is-key > td:first-child { box-shadow: inset 2px 0 0 #6f8db3; } +.bs-template-editor .ct-field-table tbody tr.is-compare > td:first-child { box-shadow: inset 2px 0 0 var(--nx-accent); } +.bs-template-editor .ct-field-table tbody tr.is-compare > td { background: rgb(var(--nx-accent-rgb) / 0.05); } +.bs-template-editor .ct-field-table tbody tr.is-expanded > td { border-bottom-color: transparent; } +.bs-template-editor .ct-na { color: var(--nx-text-muted); opacity: 0.6; } +.bs-template-editor .ct-vm-toggle { min-width: 52px; height: 26px; padding: 0 10px; border: 1px dashed var(--nx-line); border-radius: 13px; background: transparent; color: var(--nx-text-muted); font-size: 12px; cursor: pointer; } +.bs-template-editor .ct-vm-toggle:hover { border-color: #526f94; color: #9cc7f7; } +.bs-template-editor .ct-vm-toggle.has-value { border-style: solid; border-color: #3d5878; background: #16263a; color: #9cc7f7; } +.bs-template-editor .ct-vm-toggle[aria-expanded="true"] { border-style: solid; border-color: var(--nx-accent); color: #9cc7f7; } +.bs-template-editor .ct-field-table__detail > td { background: var(--nx-surface-1); padding: 8px 12px 10px; text-align: left !important; white-space: normal !important; height: auto; } +.bs-template-editor .ct-field-table__detail .ct-valuemap { flex-direction: row; align-items: flex-start; gap: 8px; } +.bs-template-editor .ct-field-table__detail .ct-valuemap > .mt-select { flex: 0 0 112px; } +.bs-template-editor .ct-field-table__detail .ct-valuemap__text { flex: 1 1 auto; min-height: 64px; max-width: 520px; resize: vertical; font: 12px/1.6 ui-monospace, Consolas, monospace; } +.bs-template-editor .ct-sheet-options { display: grid; grid-template-columns: repeat(3, minmax(0, 220px)) auto; align-items: end; gap: 10px 14px; padding: 10px 12px; border: 1px solid var(--nx-line); border-radius: 4px; background: var(--nx-surface-1); } +.bs-template-editor .ct-sheet-options .ui-field__label { font-size: 11px; color: var(--nx-text-muted); } +.bs-template-editor .ct-sheet-options .ui-field__select { height: 30px; font-size: 12px; } +.bs-template-editor .ct-sheet-options__check { height: 30px; color: var(--nx-text-muted); white-space: nowrap; } .bs-template-editor .ct-filter-row { min-width: 0; } .bs-template-editor .ct-filter-row .mt-select { min-width: 0; max-width: 100%; } .bs-template-editor .ct-complex-filters pre { max-height: 360px; overflow: auto; background: var(--nx-surface-1); border: 1px solid var(--nx-line); border-radius: 4px; padding: 10px; font: 11px/1.6 ui-monospace, Consolas, monospace; } @@ -925,7 +949,8 @@ .bs-template-editor .ct-editor__main { grid-template-columns: minmax(0, 1fr); } .bs-template-editor .ct-editor__nav-list { flex-direction: row; overflow-x: auto; } .bs-template-editor .ct-editor__nav-item { flex: 0 0 160px; } - .bs-template-editor .ct-editor__nav { max-height: none; } + .bs-template-editor .ct-editor__nav { max-height: none; contain: none; } + .bs-template-editor .ct-sheet-options { grid-template-columns: minmax(0, 1fr); } } @media (max-width: 600px) { .bs-template-editor .ct-editor__pane { padding: 10px; } diff --git a/web/tests/bizCompareTemplates.browser.mjs b/web/tests/bizCompareTemplates.browser.mjs index 9556eef..7608d27 100644 --- a/web/tests/bizCompareTemplates.browser.mjs +++ b/web/tests/bizCompareTemplates.browser.mjs @@ -161,6 +161,7 @@ try { await stateRow.locator('input[type="checkbox"]').nth(2).check(); await editor().getByLabel("state 方式", { exact: true }).selectOption("not_worse"); await editor().getByLabel("state 好→坏顺序", { exact: true }).fill("up,down"); + await editor().getByRole("button", { name: "state 编辑值映射", exact: true }).click(); await editor().getByLabel("state 值映射", { exact: true }).fill("estab,established\n*down,down"); await editor().getByLabel("state 值映射", { exact: true }).blur(); await editor().getByLabel("state 值映射范围", { exact: true }).selectOption("before"); @@ -185,6 +186,8 @@ try { // ... and come back unchanged when the template is opened again. await openEditor(); await editor().getByRole("tab", { name: /^字段规则/ }).click(); + assert.equal(await editor().getByRole("button", { name: "state 编辑值映射", exact: true }).textContent(), "3 条"); + await editor().getByRole("button", { name: "state 编辑值映射", exact: true }).click(); assert.equal(await editor().getByLabel("state 值映射", { exact: true }).inputValue(), "estab,established\n*down,down"); assert.equal(await editor().getByLabel("未映射端口", { exact: true }).inputValue(), "ignore_port"); await page.keyboard.press("Escape");
{t("bizCompare.field")} {t("bizCompare.keyFields")} {t("bizCompare.ifaceFields")}{t("bizCompare.compareFields")}{t("bizCompare.compareFields")} {t("bizCompare.compareMode")} {t("bizCompare.tolerance")} {t("bizCompare.displayField")}
{f.name} + {!isCompare ? — : ( + )} + {isCompare && cmpMode === "not_worse" ? ( + updateActiveSheet({ field_rules: upsertFieldRule(activeTplSheet, f.name, { compare: "not_worse", rank: e.target.value.split(",").map((x) => x.trim()).filter(Boolean) }) })} + /> + ) : !isCompare || !needsTol ? — : ( { const raw = e.target.value.trim(); const tol = raw === "" ? undefined : Number(raw); @@ -3163,6 +3168,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage }); }} /> + )} + {!isCompare && !isKey ? — : ( + )} - setValueMapScope((prev) => ({ ...prev, [f.name]: sc }))} - value={valueMapFor(activeTplSheet, f.name, vmScope)} - onCommit={(map) => updateActiveSheet({ field_rules: setValueMapFor(activeTplSheet, f.name, vmScope, map) })} - showRank={cmpMode === "not_worse"} - rank={rule.rank || []} - onRank={(rank) => updateActiveSheet({ field_rules: upsertFieldRule(activeTplSheet, f.name, { compare: "not_worse", rank }) })} - /> +
+ setValueMapScope((prev) => ({ ...prev, [f.name]: sc }))} + value={valueMapFor(activeTplSheet, f.name, vmScope)} + onCommit={(map) => updateActiveSheet({ field_rules: setValueMapFor(activeTplSheet, f.name, vmScope, map) })} + /> +