Compare template editor: compact field table, nav matches pane height.

- Value map collapses to a "+ / N 条" toggle that expands a detail row,
  instead of a scope select + textarea on every field row.
- Unused mode/tolerance/normalize cells show "—" instead of disabled inputs;
  the not_worse rank input moves into the tolerance column.
- Key/compare rows get a left accent; sheet options sit in one row with
  hints moved to tooltips.
- Sheet nav stretches to the pane height and scrolls its own list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
oliver 2026-10-14 19:38:54 +08:00
parent 51cfb41237
commit e8f77440ca
5 changed files with 96 additions and 42 deletions

View file

@ -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<string, string>;
scope: Side;
onScope: (s: Side) => void;
onCommit: (map: Record<string, string>) => void;
rank: string[];
onRank: (rank: string[]) => void;
showRank: boolean;
t: (k: string, vars?: Record<string, string | number>) => string;
}) {
const external = formatValueMapText(value);
@ -660,14 +657,6 @@ function ValueMapCell({
}, [external, scope]);
return (
<div className="ct-valuemap">
{showRank ? (
<Input
aria-label={`${field} ${t("bizCompare.rankLabel")}`}
placeholder={t("bizCompare.rankPlaceholder")}
value={rank.join(",")}
onChange={(e) => onRank(e.target.value.split(",").map((x) => x.trim()).filter(Boolean))}
/>
) : null}
<select
className="mt-select"
aria-label={`${field} ${t("bizCompare.valueMapScope")}`}
@ -682,8 +671,9 @@ function ValueMapCell({
className={`bs-cmp-mapping__editor ct-valuemap__text${error ? " is-invalid" : ""}`}
aria-label={`${field} ${t("bizCompare.valueMap")}`}
placeholder={t("bizCompare.valueMapPlaceholder")}
rows={2}
rows={3}
spellCheck={false}
autoFocus
value={text}
onChange={(e) => setText(e.target.value)}
onBlur={() => {
@ -899,6 +889,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
const [tplSheets, setTplSheets] = useState<MetricSheet[]>([]);
const [tplSheetIdx, setTplSheetIdx] = useState(0);
const [valueMapScope, setValueMapScope] = useState<Record<string, Side>>({});
const [vmOpen, setVmOpen] = useState<string | null>(null);
const [tplPaneTab, setTplPaneTab] = useState<"fields" | "filters">("fields");
const [showMetricPicker, setShowMetricPicker] = useState(true);
@ -1618,6 +1609,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
setTplSheetIdx(0);
setTplPaneTab("fields");
setValueMapScope({});
setVmOpen(null);
setShowMetricPicker(true);
setTplOpen(true);
};
@ -1633,6 +1625,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
setTplSheetIdx(0);
setTplPaneTab("fields");
setValueMapScope({});
setVmOpen(null);
setShowMetricPicker(!sheets.length);
setTplOpen(true);
};
@ -2909,6 +2902,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
className={`ct-editor__nav-item${tplSheetIdx === i ? " is-active" : ""}`}
onClick={() => {
setTplSheetIdx(i);
setVmOpen(null);
setTplPaneTab("fields");
}}
>
@ -2998,21 +2992,21 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
</button>
</div>
<FieldSelect label={t("bizCompare.portPolicy")} value={activeTplSheet.ignore_port_changes == null ? "auto" : activeTplSheet.ignore_port_changes ? "ignore" : "strict"} onChange={(e) => updateActiveSheet({ignore_port_changes: e.target.value === "auto" ? null : e.target.value === "ignore"})} hint={t("bizCompare.portPolicyHint")}>
<option value="auto">{t("bizCompare.portPolicyAuto")}</option><option value="strict">{t("bizCompare.portPolicyStrict")}</option><option value="ignore">{t("bizCompare.portPolicyIgnore")}</option>
</FieldSelect>
<div className="ct-sheet-options">
<FieldSelect label={t("bizCompare.unmappedPortPolicy")} value={activeTplSheet.unmapped_port_policy || "by_name"} hint={t("bizCompare.unmappedPortPolicyHint")}
<FieldSelect label={t("bizCompare.portPolicy")} title={t("bizCompare.portPolicyHint")} value={activeTplSheet.ignore_port_changes == null ? "auto" : activeTplSheet.ignore_port_changes ? "ignore" : "strict"} onChange={(e) => updateActiveSheet({ignore_port_changes: e.target.value === "auto" ? null : e.target.value === "ignore"})}>
<option value="auto">{t("bizCompare.portPolicyAuto")}</option><option value="strict">{t("bizCompare.portPolicyStrict")}</option><option value="ignore">{t("bizCompare.portPolicyIgnore")}</option>
</FieldSelect>
<FieldSelect label={t("bizCompare.unmappedPortPolicy")} title={t("bizCompare.unmappedPortPolicyHint")} value={activeTplSheet.unmapped_port_policy || "by_name"}
onChange={(e) => updateActiveSheet({ unmapped_port_policy: e.target.value as "by_name" | "ignore_port" })}>
<option value="by_name">{t("bizCompare.unmappedByName")}</option>
<option value="ignore_port">{t("bizCompare.unmappedIgnorePort")}</option>
</FieldSelect>
<FieldSelect label={t("bizCompare.duplicatePairing")} value={activeTplSheet.duplicate_pairing || "sorted"} hint={t("bizCompare.duplicatePairingHint")}
<FieldSelect label={t("bizCompare.duplicatePairing")} title={t("bizCompare.duplicatePairingHint")} value={activeTplSheet.duplicate_pairing || "sorted"}
onChange={(e) => updateActiveSheet({ duplicate_pairing: e.target.value as "sorted" | "ordered" })}>
<option value="sorted">{t("bizCompare.pairSorted")}</option>
<option value="ordered">{t("bizCompare.pairOrdered")}</option>
</FieldSelect>
<label className="ct-sheet-options__check">
<label className="ct-sheet-options__check" title={t("bizCompare.builtinVendorCanonicalHint")}>
<input type="checkbox" checked={activeTplSheet.builtin_vendor_canonical !== false}
onChange={(e) => updateActiveSheet({ builtin_vendor_canonical: e.target.checked ? undefined : false })} />
<span>{t("bizCompare.builtinVendorCanonical")}</span>
@ -3027,7 +3021,6 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
/>
)) : (
<>
<p className="muted ct-field-hint">{t("bizCompare.fieldsHintShort")}</p>
<div className="pt-list-table-wrap ct-field-table-wrap">
<table className="data-table pt-list-table ct-field-table">
<thead>
@ -3035,7 +3028,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
<th>{t("bizCompare.field")}</th>
<th title={t("bizCompare.keyFields")}>{t("bizCompare.keyFields")}</th>
<th title={t("bizCompare.ifaceFields")}>{t("bizCompare.ifaceFields")}</th>
<th title={t("bizCompare.compareFields")}>{t("bizCompare.compareFields")}</th>
<th title={t("bizCompare.fieldsHintShort")}>{t("bizCompare.compareFields")}</th>
<th>{t("bizCompare.compareMode")}</th>
<th>{t("bizCompare.tolerance")}</th>
<th>{t("bizCompare.displayField")}</th>
@ -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 (
<tr key={f.name}>
<Fragment key={f.name}>
<tr className={`${isKey ? "is-key" : isCompare ? "is-compare" : ""}${vmExpanded ? " is-expanded" : ""}`}>
<td>
<code className="ct-field-name">{f.name}</code>
</td>
@ -3111,11 +3107,11 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
/>
</td>
<td>
{!isCompare ? <span className="ct-na">—</span> : (
<select
className="mt-select"
value={cmpMode === "ignore" ? "eq" : cmpMode}
aria-label={`${f.name} ${t("bizCompare.compareMode")}`}
disabled={!isCompare}
onChange={(e) => {
const mode = e.target.value;
updateActiveSheet({
@ -3134,8 +3130,18 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
<option value="numeric_min">{t("bizCompare.modeNumericMin")}</option>
<option value="numeric_max">{t("bizCompare.modeNumericMax")}</option>
</select>
)}
</td>
<td>
{isCompare && cmpMode === "not_worse" ? (
<Input
aria-label={`${f.name} ${t("bizCompare.rankLabel")}`}
placeholder={t("bizCompare.rankPlaceholder")}
title={t("bizCompare.rankLabel")}
value={(rule.rank || []).join(",")}
onChange={(e) => updateActiveSheet({ field_rules: upsertFieldRule(activeTplSheet, f.name, { compare: "not_worse", rank: e.target.value.split(",").map((x) => x.trim()).filter(Boolean) }) })}
/>
) : !isCompare || !needsTol ? <span className="ct-na">—</span> : (
<Input
type="number"
min={0}
@ -3149,7 +3155,6 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
: ""
}
placeholder={["percent", "numeric_min", "numeric_max"].includes(cmpMode) ? "%" : ""}
disabled={!isCompare || !needsTol}
onChange={(e) => {
const raw = e.target.value.trim();
const tol = raw === "" ? undefined : Number(raw);
@ -3163,6 +3168,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
});
}}
/>
)}
</td>
<td>
<input
@ -3182,11 +3188,11 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
/>
</td>
<td>
{!isCompare && !isKey ? <span className="ct-na">—</span> : (
<select
className="mt-select"
value={norm}
aria-label={`${f.name} ${t("bizCompare.normalizeField")}`}
disabled={!isCompare && !isKey}
onChange={(e) =>
updateActiveSheet({
field_rules: upsertFieldRule(activeTplSheet, f.name, {
@ -3203,21 +3209,35 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
{t("bizCompare.normalizeEmpty")}
</option>
</select>
)}
</td>
<td>
<ValueMapCell
field={f.name}
t={t}
scope={vmScope}
onScope={(sc) => 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 }) })}
/>
<button
type="button"
className={`ct-vm-toggle${vmCount ? " has-value" : ""}`}
aria-expanded={vmExpanded}
aria-label={`${f.name} ${t("bizCompare.valueMapEdit")}`}
onClick={() => setVmOpen(vmExpanded ? null : f.name)}
>
{vmCount ? t("bizCompare.valueMapCount", { n: String(vmCount) }) : "+"}
</button>
</td>
</tr>
{vmExpanded ? (
<tr className="ct-field-table__detail">
<td colSpan={9}>
<ValueMapCell
field={f.name}
t={t}
scope={vmScope}
onScope={(sc) => setValueMapScope((prev) => ({ ...prev, [f.name]: sc }))}
value={valueMapFor(activeTplSheet, f.name, vmScope)}
onCommit={(map) => updateActiveSheet({ field_rules: setValueMapFor(activeTplSheet, f.name, vmScope, map) })}
/>
</td>
</tr>
) : null}
</Fragment>
);
})}
{!activeTplFields.length ? (

View file

@ -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",

View file

@ -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": "校验映射",

View file

@ -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; }

View file

@ -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");