From edfa3f8756a5e466db701eeb16f99a112f35ebda Mon Sep 17 00:00:00 2001 From: oliver Date: Fri, 7 Aug 2026 01:03:37 +0800 Subject: [PATCH] Move topology display, align, and find into one panel that works in fullscreen. Co-authored-by: Cursor --- web/src/i18n/en.ts | 1 + web/src/i18n/zh.ts | 1 + web/src/index.css | 62 ++- web/src/pages/TopologyPage.tsx | 966 +++++++++++++++++---------------- 4 files changed, 575 insertions(+), 455 deletions(-) diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 9cc613c..0cb646c 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -1545,6 +1545,7 @@ const en = { removeEdge: "Delete edge", nodeMenu: "Node", paneMenu: "Canvas", + edgeMenu: "Edge", createNe: "New NE", createNeTitle: "New topology placeholder NE", createNeHint: diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index aaa341a..3afd460 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -1533,6 +1533,7 @@ const zh = { removeEdge: "删除链路", nodeMenu: "网元", paneMenu: "画布", + edgeMenu: "链路", createNe: "新建网元", createNeTitle: "新建拓扑占位网元", createNeHint: "来源为拓扑占位;完善登录信息后可在 WebCRT 连接,或在网元管理升级为手工。", diff --git a/web/src/index.css b/web/src/index.css index a097afa..24d84ef 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -1,4 +1,4 @@ -* { +* { box-sizing: border-box; } @@ -6892,6 +6892,19 @@ pre { max-height: 14px; } +.topo-toolbar__panel--view { + margin-left: 2px; + padding-left: 10px; + border-left: 1px solid rgba(148, 163, 184, 0.2); + gap: 8px; +} + +.topo-toolbar__panel--view .topo-toolbar__group--find { + margin-left: 0; + padding-left: 0; + border-left: 0; +} + .topo-fs-toolbar { position: absolute; top: 12px; @@ -6908,6 +6921,53 @@ pre { pointer-events: auto; } +.topo-view-tools { + position: absolute; + top: 12px; + right: 12px; + z-index: 20; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + padding: 6px 8px; + border-radius: 10px; + background: rgba(15, 23, 42, 0.92); + border: 1px solid rgba(148, 163, 184, 0.22); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); + pointer-events: auto; + max-width: min(520px, calc(100% - 24px)); +} + +.topo-view-tools .topo-toolbar__display { + position: relative; +} + +.topo-view-tools .topo-toolbar__display .topo-display-toggles { + right: 0; + left: auto; + max-height: min(70vh, 520px); + overflow: auto; +} + +.topo-view-tools .topo-toolbar__group--find { + margin-left: 0; + padding-left: 0; + border-left: 0; + position: relative; +} + +.topo-view-tools .topo-toolbar__find { + width: 140px; + min-width: 120px; +} + +.topo-canvas.is-fullscreen .topo-view-tools, +.topo-canvas:fullscreen .topo-view-tools { + top: 12px; + right: 12px; +} + .topo-fs-toolbar__btn { display: inline-flex; align-items: center; diff --git a/web/src/pages/TopologyPage.tsx b/web/src/pages/TopologyPage.tsx index 380ea58..8060fda 100644 --- a/web/src/pages/TopologyPage.tsx +++ b/web/src/pages/TopologyPage.tsx @@ -10,6 +10,7 @@ import { type CSSProperties, type ReactNode, } from "react"; +import { createPortal } from "react-dom"; import { useSearchParams } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { @@ -1083,6 +1084,7 @@ export function TopologyPage() { const searchHitTimerRef = useRef(null); const findBoxRef = useRef(null); const displayMenuRef = useRef(null); + const [viewToolsToolbarSlot, setViewToolsToolbarSlot] = useState(null); const findJustLocatedRef = useRef(false); const [nodes, setNodes, onNodesChange] = useNodesState>([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); @@ -3929,453 +3931,15 @@ export function TopologyPage() { ))} -
- {t("topology.display")} -
- - - - - - - - - - - -
-
- {t("topology.canvasBg")} - -
-
- {t("topology.canvasBgColor")} - { - const next = e.target.value; - setCanvasBg(next); - persistCanvasBg(next); - }} - /> - { - const raw = e.target.value.trim(); - if (!/^#[0-9a-fA-F]{0,6}$/.test(raw)) return; - setCanvasBg(raw); - if (isHexColor(raw)) persistCanvasBg(raw.toLowerCase()); - }} - onBlur={() => { - if (!isHexColor(canvasBg)) { - setCanvasBg(loadCanvasBg()); - } - }} - /> -
-
-
-
- {t("topology.textColors")} - -
- {( - [ - ["name", t("topology.labelColor")], - ["edgeLabel", t("topology.edgeLabelColor")], - ] as const - ).map(([key, label]) => ( -
- {label} - { - const next = { ...labelColors, [key]: e.target.value.toLowerCase() }; - setLabelColors(next); - persistLabelColors(next); - }} - /> - { - const raw = e.target.value.trim(); - if (!/^#[0-9a-fA-F]{0,6}$/.test(raw)) return; - const next = { ...labelColors, [key]: raw }; - setLabelColors(next); - if (isHexColor(raw)) persistLabelColors({ ...next, [key]: raw.toLowerCase() }); - }} - onBlur={() => { - if (!isHexColor(labelColors[key])) { - setLabelColors(loadLabelColors()); - } - }} - /> -
- ))} -
-
-
- {t("topology.vendorColors")} - -
- {VENDOR_TONE_KEYS.map((key) => { - const label = t(`topology.vendorTone.${key}`); - return ( -
- {label} - { - const next = { ...vendorColors, [key]: e.target.value.toLowerCase() }; - setVendorColors(next); - persistVendorColors(next); - }} - /> - { - const raw = e.target.value.trim(); - if (!/^#[0-9a-fA-F]{0,6}$/.test(raw)) return; - const next = { ...vendorColors, [key]: raw }; - setVendorColors(next); - if (isHexColor(raw)) { - persistVendorColors({ ...next, [key]: raw.toLowerCase() }); - } - }} - onBlur={() => { - if (!isHexColor(vendorColors[key])) { - setVendorColors(loadVendorColors()); - } - }} - /> -
- ); - })} -
-
-
- {t("topology.edgeDefaults")} - -
- {( - [ - ["manual", t("topology.edgeManual")], - ["discovered", t("topology.edgeDiscovered")], - ["stale", t("topology.edgeStale")], - ] as const - ).map(([kind, label]) => { - const d = edgeDefaults[kind]; - return ( -
- {label} - updateEdgeDefault(kind, { stroke_color: e.target.value })} - /> - - -
- ); - })} -
-
-
-
- - -
-
- setCanvasQuery(e.target.value)} - onFocus={() => { - if (canvasQuery.trim()) setFindOpen(true); - }} - onKeyDown={(e) => { - if (e.key === "ArrowDown") { - e.preventDefault(); - if (!canvasHits.length) return; - setFindOpen(true); - setFindActiveIdx((i) => (i + 1) % canvasHits.length); - return; - } - if (e.key === "ArrowUp") { - e.preventDefault(); - if (!canvasHits.length) return; - setFindOpen(true); - setFindActiveIdx((i) => (i - 1 + canvasHits.length) % canvasHits.length); - return; - } - if (e.key === "Escape") { - setFindOpen(false); - return; - } - if (e.key === "Enter") { - e.preventDefault(); - findOnCanvas(); - } - }} - placeholder={t("topology.findNodePh")} - disabled={!mapId || nodes.length === 0} - aria-label={t("topology.findNode")} - aria-autocomplete="list" - aria-expanded={findOpen} + {!fullscreen ? ( +
- {findOpen && canvasQuery.trim() ? ( -
- {canvasHits.length === 0 ? ( -
{t("topology.findNoMatch")}
- ) : ( - canvasHits.slice(0, 12).map((n, idx) => ( - - )) - )} - {canvasHits.length > 12 ? ( -
- {t("topology.findMore").replace("{{count}}", String(canvasHits.length - 12))} -
- ) : null} -
- ) : null} -
+ ) : null}
) : null} @@ -4729,6 +4293,442 @@ export function TopologyPage() { {truncateBannerText} ) : null} + {(() => { + const viewToolsBody = ( + <> +
+ {t("topology.display")} +
+ + + + + + + + + + + +
+
+ {t("topology.canvasBg")} + +
+
+ {t("topology.canvasBgColor")} + { + const next = e.target.value; + setCanvasBg(next); + persistCanvasBg(next); + }} + /> + { + const raw = e.target.value.trim(); + if (!/^#[0-9a-fA-F]{0,6}$/.test(raw)) return; + setCanvasBg(raw); + if (isHexColor(raw)) persistCanvasBg(raw.toLowerCase()); + }} + onBlur={() => { + if (!isHexColor(canvasBg)) { + setCanvasBg(loadCanvasBg()); + } + }} + /> +
+
+
+
+ {t("topology.textColors")} + +
+ {( + [ + ["name", t("topology.labelColor")], + ["edgeLabel", t("topology.edgeLabelColor")], + ] as const + ).map(([key, label]) => ( +
+ {label} + { + const next = { ...labelColors, [key]: e.target.value.toLowerCase() }; + setLabelColors(next); + persistLabelColors(next); + }} + /> + { + const raw = e.target.value.trim(); + if (!/^#[0-9a-fA-F]{0,6}$/.test(raw)) return; + const next = { ...labelColors, [key]: raw }; + setLabelColors(next); + if (isHexColor(raw)) persistLabelColors({ ...next, [key]: raw.toLowerCase() }); + }} + onBlur={() => { + if (!isHexColor(labelColors[key])) { + setLabelColors(loadLabelColors()); + } + }} + /> +
+ ))} +
+
+
+ {t("topology.vendorColors")} + +
+ {VENDOR_TONE_KEYS.map((key) => { + const label = t(`topology.vendorTone.${key}`); + return ( +
+ {label} + { + const next = { ...vendorColors, [key]: e.target.value.toLowerCase() }; + setVendorColors(next); + persistVendorColors(next); + }} + /> + { + const raw = e.target.value.trim(); + if (!/^#[0-9a-fA-F]{0,6}$/.test(raw)) return; + const next = { ...vendorColors, [key]: raw }; + setVendorColors(next); + if (isHexColor(raw)) { + persistVendorColors({ ...next, [key]: raw.toLowerCase() }); + } + }} + onBlur={() => { + if (!isHexColor(vendorColors[key])) { + setVendorColors(loadVendorColors()); + } + }} + /> +
+ ); + })} +
+
+
+ {t("topology.edgeDefaults")} + +
+ {( + [ + ["manual", t("topology.edgeManual")], + ["discovered", t("topology.edgeDiscovered")], + ["stale", t("topology.edgeStale")], + ] as const + ).map(([kind, label]) => { + const d = edgeDefaults[kind]; + return ( +
+ {label} + updateEdgeDefault(kind, { stroke_color: e.target.value })} + /> + + +
+ ); + })} +
+
+
+ +
+ setCanvasQuery(e.target.value)} + onFocus={() => { + if (canvasQuery.trim()) setFindOpen(true); + }} + onKeyDown={(e) => { + if (e.key === "ArrowDown") { + e.preventDefault(); + if (!canvasHits.length) return; + setFindOpen(true); + setFindActiveIdx((i) => (i + 1) % canvasHits.length); + return; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + if (!canvasHits.length) return; + setFindOpen(true); + setFindActiveIdx((i) => (i - 1 + canvasHits.length) % canvasHits.length); + return; + } + if (e.key === "Escape") { + setFindOpen(false); + return; + } + if (e.key === "Enter") { + e.preventDefault(); + findOnCanvas(); + } + }} + placeholder={t("topology.findNodePh")} + disabled={!mapId || nodes.length === 0} + aria-label={t("topology.findNode")} + aria-autocomplete="list" + aria-expanded={findOpen} + /> + {findOpen && canvasQuery.trim() ? ( +
+ {canvasHits.length === 0 ? ( +
{t("topology.findNoMatch")}
+ ) : ( + canvasHits.slice(0, 12).map((n, idx) => ( + + )) + )} + {canvasHits.length > 12 ? ( +
+ {t("topology.findMore").replace("{{count}}", String(canvasHits.length - 12))} +
+ ) : null} +
+ ) : null} +
+ + ); + if (!fullscreen) { + if (!viewToolsToolbarSlot) return null; + return createPortal(viewToolsBody, viewToolsToolbarSlot); + } + return ( +
+ {viewToolsBody} +
+ ); + })()} {fullscreen ? (
{( @@ -5171,18 +5171,32 @@ export function TopologyPage() {
) : null} - {ctxMenu ? ( -
    e.preventDefault()} - > + {ctxMenu + ? createPortal( +
      e.preventDefault()} + > {ctxMenu.kind === "selection" ? ( <>
    • {t("topology.selectionMenu")}
    • +
    • + +
    • ) : null} +
    • + +
    • ) : ctxMenu.kind === "node" ? ( <>
    • {t("topology.nodeMenu")}
    • +
    • + +
    • +
    • e.stopPropagation()}>
      {t("topology.edgeStyle")}
      @@ -5454,8 +5510,10 @@ export function TopologyPage() {
    • )} -
    - ) : null} +
, + canvasRef.current || document.body, + ) + : null} ); }