Refactor web UI: feature-first structure, tokenized styles, modern blue theme.

Structure
- pages/ -> features/<domain>/{Page, components, hooks, lib}; 95 files moved,
  333 imports rewritten, verified with tsc.
- services/api.ts -> services/api/<domain>.ts (250 exports preserved),
  types.ts -> types/<domain>.ts, i18n zh/en -> locales/<lang>/<namespace>.ts
  (2696 messages deep-equal before/after).
- ESLint rule: shared layers must not import features/.

Styles
- index.css (16k lines) split losslessly into styles/{base,layout,components,
  pages,legacy,theme}; compiled CSS was byte-identical before tokenizing.
- ~930 hard-coded colors replaced by --nx-* tokens (theme/tokens.css); WebCRT
  off-palette colors unified; fixed dark-on-dark text introduced by mapping.
- theme/theme.css: modern blue design language (rounded shapes, gradients,
  unified buttons/fields/tables/modals), form-grid alignment fix, flat nav list
  without tree indent, soft selected state instead of left accent bar.

Docs: WEB.md documents code structure and style rules.
Not touched: the large Biz*/Webcrt/Topology page components (to be split later).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
oliver 2026-10-12 03:17:51 +08:00
parent 3b45d6abe3
commit 0d894efe5e
196 changed files with 26947 additions and 28091 deletions

View file

@ -1,214 +0,0 @@
import type { Edge } from "@xyflow/react";
export type LinkMember = {
id: string;
a_port: string;
b_port: string;
source: string;
display_label?: string;
};
export type LinkEdgeData = {
source?: string;
source_port?: string;
target_port?: string;
stroke_color?: string;
stroke_width?: number;
line_style?: string;
discovered_at?: string | null;
/** UME userLabel when ports are unavailable (no EQ+PTP). */
display_label?: string;
/** Logical bundle of parallel physical links between the same NE pair. */
aggregated?: boolean;
member_count?: number;
members?: LinkMember[];
parallelIndex?: number;
parallelCount?: number;
};
export function pairKey(a: string, b: string): string {
return a < b ? `${a}::${b}` : `${b}::${a}`;
}
export function isAggregateEdgeId(id: string): boolean {
return String(id || "").startsWith("agg:");
}
export function aggregateIdForPair(a: string, b: string): string {
return `agg:${pairKey(a, b)}`;
}
function isLabelPlaceholderPort(port: string): boolean {
return String(port || "")
.trim()
.toLowerCase()
.startsWith("label:");
}
/** Clear A↔B port label (not ambiguous "local/remote"). */
export function formatPortPairLabel(aPort: string, bPort: string, displayLabel?: string): string {
const shown = String(displayLabel || "").trim();
if (shown) return shown;
const a = String(aPort || "").trim();
const b = String(bPort || "").trim();
if (isLabelPlaceholderPort(a) || isLabelPlaceholderPort(b)) return "";
if (a && b) return `${a} ↔ ${b}`;
return a || b || "";
}
export function formatBundleLabel(
count: number,
sample?: { a_port?: string; b_port?: string; display_label?: string },
opts?: { hidePorts?: boolean },
): string {
if (count <= 1) {
return opts?.hidePorts
? ""
: formatPortPairLabel(sample?.a_port || "", sample?.b_port || "", sample?.display_label);
}
if (opts?.hidePorts) return `×${count}`;
const sampleLabel = formatPortPairLabel(
sample?.a_port || "",
sample?.b_port || "",
sample?.display_label,
);
return sampleLabel ? `×${count} · ${sampleLabel}` : `×${count}`;
}
function memberFrom(edge: Edge): LinkMember {
const d = (edge.data || {}) as LinkEdgeData;
return {
id: edge.id,
a_port: String(d.source_port || "").trim(),
b_port: String(d.target_port || "").trim(),
source: String(d.source || "manual"),
display_label: String(d.display_label || "").trim(),
};
}
function statusRank(source: string): number {
if (source === "stale") return 2;
if (source === "manual") return 1;
return 0; // discovered / lldp preferred
}
/** Pick representative physical edge for an aggregate (prefer live discovered). */
function pickPrimary(list: Edge[]): Edge {
return [...list].sort((a, b) => {
const da = (a.data || {}) as LinkEdgeData;
const db = (b.data || {}) as LinkEdgeData;
return statusRank(String(da.source || "")) - statusRank(String(db.source || ""));
})[0];
}
/**
* Default: one logical edge per NE pair (bundle parallel links).
* Expanded: all physical edges, offset when count > 1.
*/
export function buildLinkDisplayEdges(
edges: Edge[],
expandPhysical: boolean,
hidePorts = false,
scaleBundleWidth = false,
): Edge[] {
const groups = new Map<string, Edge[]>();
for (const e of edges) {
const k = pairKey(e.source, e.target);
const list = groups.get(k);
if (list) list.push(e);
else groups.set(k, [e]);
}
const out: Edge[] = [];
for (const [, list] of groups) {
if (expandPhysical) {
const count = list.length;
list.forEach((e, i) => {
const prev = (e.data || {}) as LinkEdgeData;
const data: LinkEdgeData = {
...prev,
aggregated: false,
member_count: count,
members: list.map(memberFrom),
parallelIndex: i,
parallelCount: count,
};
const portLabel = formatPortPairLabel(
data.source_port || "",
data.target_port || "",
data.display_label,
);
out.push({
...e,
type: count > 1 ? "topoParallel" : e.type || "straight",
// Never fall back to a stale e.label — it may still contain ports.
label: hidePorts ? undefined : portLabel || undefined,
data,
});
});
continue;
}
if (list.length === 1) {
const e = list[0];
const prev = (e.data || {}) as LinkEdgeData;
const data: LinkEdgeData = {
...prev,
aggregated: false,
member_count: 1,
members: [memberFrom(e)],
};
const portLabel = formatPortPairLabel(
data.source_port || "",
data.target_port || "",
data.display_label,
);
out.push({
...e,
type: "straight",
label: hidePorts ? undefined : portLabel || undefined,
data,
});
continue;
}
const primary = pickPrimary(list);
const prev = (primary.data || {}) as LinkEdgeData;
const members = list.map(memberFrom);
const anyStale = members.some((m) => m.source === "stale");
const data: LinkEdgeData = {
...prev,
source: anyStale ? "stale" : prev.source,
aggregated: true,
member_count: list.length,
members,
};
// Optional: thicken logical bundles by member count (default keeps single-line width).
if (scaleBundleWidth) {
data.stroke_width = Math.max(Number(prev.stroke_width || 0), Math.min(8, 2 + list.length));
}
out.push({
...primary,
id: aggregateIdForPair(primary.source, primary.target),
type: "straight",
label: formatBundleLabel(
list.length,
{ a_port: data.source_port, b_port: data.target_port },
{ hidePorts },
) || undefined,
data,
});
}
return out;
}
export function physicalIdsForDisplayEdge(edge: Edge | null | undefined, allEdges: Edge[]): string[] {
if (!edge) return [];
const d = (edge.data || {}) as LinkEdgeData;
if (d.members?.length) return d.members.map((m) => m.id);
if (isAggregateEdgeId(edge.id)) {
const k = edge.id.slice(4);
return allEdges.filter((e) => pairKey(e.source, e.target) === k).map((e) => e.id);
}
return [edge.id];
}