From 37dd43923fb3f74b66227f598236d36c589f7462 Mon Sep 17 00:00:00 2001 From: oliver Date: Mon, 10 Aug 2026 00:39:06 +0800 Subject: [PATCH] Restore workbench clock, offline world map, and keep edge menus on-screen. Co-authored-by: Cursor --- web/src/i18n/en.ts | 1 + web/src/i18n/zh.ts | 1 + web/src/index.css | 116 +++++++++++++++++- web/src/pages/TopologyPage.tsx | 115 +++++++++++++---- web/src/pages/WorkbenchPage.tsx | 60 ++++++--- .../pages/topology/TopologyReactFlowView.tsx | 5 +- 6 files changed, 254 insertions(+), 44 deletions(-) diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index a2caea7..23f8c02 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -1431,6 +1431,7 @@ const en = { subRegionNotOnFlat: "The world map has no regions. Create sub-regions on World or a lower-level canvas.", worldMapNoDirectNes: "Cannot add NEs on the world map — add them on a sub-region canvas.", worldMapName: "World map", + worldMapOffline: "World map is temporarily offline", rootMapName: "Root map", worldHud: "World {{total}} · close-up {{cached}} · z{{zoom}} · {{lod}}", worldEmptyCoords: "World map has no coordinates yet", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 4d32ab0..d7820ae 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -1421,6 +1421,7 @@ const zh = { subRegionNotOnFlat: "世界地图不支持子区域,请在 World 或下级区域画布上创建。", worldMapNoDirectNes: "世界地图不能直接添加网元,请到某个子区域画布上添加。", worldMapName: "世界地图", + worldMapOffline: "世界地图功能已暂时下线", rootMapName: "根图", worldHud: "世界 {{total}} · 近景 {{cached}} · z{{zoom}} · {{lod}}", worldEmptyCoords: "世界地图还没有坐标", diff --git a/web/src/index.css b/web/src/index.css index a6b4481..55a6e2f 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -253,6 +253,101 @@ body { color: #64748b; } +.wb-hero__clock { + position: relative; + z-index: 1; + flex: 0 0 auto; + min-width: 0; +} + +.wb-hero__clock-panel { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 8px; + min-width: 220px; + padding: 12px 14px 10px; + border-radius: 10px; + border: 1px solid rgba(34, 197, 94, 0.22); + background: linear-gradient(180deg, rgba(6, 18, 12, 0.95) 0%, rgba(2, 10, 8, 0.98) 100%); + box-shadow: + inset 0 0 0 1px rgba(34, 197, 94, 0.08), + inset 0 10px 28px rgba(0, 0, 0, 0.55), + 0 0 18px rgba(34, 197, 94, 0.08); +} + +.wb-hero__clock-digits { + display: flex; + align-items: center; + justify-content: center; + gap: 3px; + font-family: ui-monospace, SFMono-Regular, "Cascadia Mono", Consolas, monospace; + font-variant-numeric: tabular-nums; + line-height: 1; +} + +.wb-hero__digit { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.05em; + min-width: 22px; + padding: 4px 0 2px; + border-radius: 4px; + background: rgba(0, 0, 0, 0.35); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); + font-size: clamp(26px, 2.8vw, 34px); + font-weight: 700; + letter-spacing: 0.02em; + color: #4ade80; + text-shadow: + 0 0 6px rgba(74, 222, 128, 0.55), + 0 0 14px rgba(34, 197, 94, 0.35); +} + +.wb-hero__colon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 0.45em; + margin: 0 1px; + font-size: clamp(24px, 2.6vw, 32px); + font-weight: 700; + color: rgba(74, 222, 128, 0.22); + transition: color 0.12s linear, text-shadow 0.12s linear; +} + +.wb-hero__colon.is-on { + color: #4ade80; + text-shadow: + 0 0 6px rgba(74, 222, 128, 0.55), + 0 0 14px rgba(34, 197, 94, 0.35); +} + +.wb-hero__clock-date { + display: block; + text-align: center; + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.08em; + color: rgba(74, 222, 128, 0.72); + text-shadow: 0 0 8px rgba(34, 197, 94, 0.25); + line-height: 1.2; +} + +.wb-hero__clock-sr { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + .wb-hero__kpis { position: relative; z-index: 1; @@ -713,6 +808,11 @@ body { flex-wrap: wrap; } + .wb-hero__clock-panel { + min-width: 0; + width: 100%; + } + .wb-hero__kpis { grid-template-columns: repeat(3, minmax(0, 1fr)); } @@ -6518,6 +6618,11 @@ pre { .topo-ctx--edge { min-width: 196px; + max-width: min(320px, calc(100vw - 16px)); + max-height: min(72vh, calc(100vh - 16px)); + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; } .topo-ctx__head { @@ -6559,9 +6664,13 @@ pre { } .topo-ctx__style-row > span { - flex: 0 0 auto; + flex: 1 1 auto; min-width: 2.5em; + max-width: 11.5em; color: #cbd5e1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .topo-ctx__style-row input[type="color"] { @@ -7747,9 +7856,10 @@ pre { margin: 0 0 8px; padding: 0 0 0 16px; font-size: 12px; - color: #334155; - max-height: 120px; + color: #cbd5e1; + max-height: 96px; overflow: auto; + overscroll-behavior: contain; } .topo-ctx__style-members { diff --git a/web/src/pages/TopologyPage.tsx b/web/src/pages/TopologyPage.tsx index f266680..cbda530 100644 --- a/web/src/pages/TopologyPage.tsx +++ b/web/src/pages/TopologyPage.tsx @@ -4,6 +4,7 @@ import { Suspense, useCallback, useEffect, + useLayoutEffect, useMemo, useRef, useState, @@ -220,6 +221,9 @@ function isWorldFlatViewName(name: string | undefined | null): boolean { return n === "世界地图" || n === "完整世界地图" || n === "World map"; } +/** Flat world-map canvas (LOD / scatter). Temporarily offline in the UI. */ +const WORLD_MAP_ENABLED = false; + function isManualRootMapName(name: string | undefined | null): boolean { const n = String(name || "").trim(); return n === "根图" || n === "Root map"; @@ -1115,6 +1119,7 @@ export function TopologyPage() { const [historyTick, setHistoryTick] = useState(0); const [selectedEdgeId, setSelectedEdgeId] = useState(null); const [ctxMenu, setCtxMenu] = useState(null); + const ctxMenuRef = useRef(null); const [hideIp, setHideIp] = useState(true); const [hideVendor, setHideVendor] = useState(true); const [hidePorts, setHidePorts] = useState(true); @@ -1293,8 +1298,12 @@ export function TopologyPage() { const graphQuery = useQuery({ queryKey: queryKeys.topologyGraph(mapId), queryFn: () => - fetchTopologyGraph(mapId, treeFlatMap ? { lod: "overview" } : undefined), - enabled: Boolean(mapId), + fetchTopologyGraph( + mapId, + WORLD_MAP_ENABLED && treeFlatMap ? { lod: "overview" } : undefined, + ), + // Offline: never fetch the flat world-map graph (LOD / scatter). + enabled: Boolean(mapId) && (WORLD_MAP_ENABLED || !treeFlatMap), staleTime: liveSync ? 0 : 30_000, refetchOnWindowFocus: liveSync && !treeFlatMap, refetchInterval: liveSync && mapId && !treeFlatMap ? 3000 : false, @@ -1302,9 +1311,10 @@ export function TopologyPage() { }); const isWorldFlatCanvas = - treeFlatMap || - isWorldFlatViewName(graphQuery.data?.view?.name) || - Boolean(graphQuery.data?.view?.filter?.world_flat); + WORLD_MAP_ENABLED && + (treeFlatMap || + isWorldFlatViewName(graphQuery.data?.view?.name) || + Boolean(graphQuery.data?.view?.filter?.world_flat)); isWorldFlatCanvasRef.current = isWorldFlatCanvas; mapIdRef.current = mapId; @@ -1360,7 +1370,7 @@ export function TopologyPage() { }, [graphQuery.data?.world_transform]); const refreshFlatViewport = useCallback(async () => { - if (!mapId || !isWorldFlatCanvas || dirtyRef.current) return; + if (!WORLD_MAP_ENABLED || !mapId || !isWorldFlatCanvas || dirtyRef.current) return; const inst = rfRef.current; if (!inst) return; const zoom = inst.getZoom(); @@ -1425,7 +1435,7 @@ export function TopologyPage() { }, [mapId, isWorldFlatCanvas, queryClient]); const scheduleFlatViewportRefresh = useCallback(() => { - if (!isWorldFlatCanvas) return; + if (!WORLD_MAP_ENABLED || !isWorldFlatCanvas) return; if (flatLodTimerRef.current) window.clearTimeout(flatLodTimerRef.current); flatLodTimerRef.current = window.setTimeout(() => { flatLodTimerRef.current = null; @@ -1578,13 +1588,18 @@ export function TopologyPage() { if (!activeRegion || !isUmeWorldContainer(activeRegion)) return null; const drill = (activeRegion.children || []).find((c) => isWorldDrillFolder(c)) || null; - const flatView = - (activeRegion.views || []).find((v) => isWorldFlatViewName(v.name)) || null; + const flatView = WORLD_MAP_ENABLED + ? (activeRegion.views || []).find((v) => isWorldFlatViewName(v.name)) || null + : null; return { drill, flatView }; }, [activeRegion]); // Resolve World drill view id for shortcuts; do not auto-open canvas. useEffect(() => { + if (!WORLD_MAP_ENABLED) { + setWorldViewId(""); + return; + } let cancelled = false; fetchTopologyWorld() .then((w) => { @@ -1625,7 +1640,23 @@ export function TopologyPage() { clearDirty(); }, [confirmDiscardIfDirty, clearDirty, regions]); + // Leave flat world map if still open after the feature was taken offline. + useEffect(() => { + if (WORLD_MAP_ENABLED || !mapId || !treeFlatMap) return; + const container = regions.find( + (r) => r.external_ref === "ume:world" || r.name === "UME World", + ); + setWorldFocusFolderId(""); + if (container) { + setSelectedFolderId(container.id); + setExpandedIds((p) => ({ ...p, [container.id]: true })); + } + setMapId(""); + clearDirty(); + }, [mapId, treeFlatMap, regions, clearDirty]); + const goWorld = useCallback(() => { + if (!WORLD_MAP_ENABLED) return; if (!confirmDiscardIfDirty()) return; if (!worldViewId) return; setWorldFocusFolderId(""); @@ -1727,6 +1758,10 @@ export function TopologyPage() { (viewId: string, folderId?: string) => { if (!confirmDiscardIfDirty()) return; const hit = findViewInRegion(regions, viewId); + if (!WORLD_MAP_ENABLED && isWorldFlatViewName(hit?.view?.name)) { + showError(t("topology.worldMapOffline")); + return; + } const regionId = folderId || hit?.region.id || selectedFolderId; if (regionId) { setSelectedFolderId(regionId); @@ -1735,7 +1770,7 @@ export function TopologyPage() { setWorldFocusFolderId(""); setMapId(viewId); }, - [confirmDiscardIfDirty, regions, selectedFolderId, expandFolderPath], + [confirmDiscardIfDirty, regions, selectedFolderId, expandFolderPath, showError, t], ); const goBackBrowse = useCallback(() => { @@ -2187,13 +2222,15 @@ export function TopologyPage() { const umeNav = isUmeWorldNavFolder(folder); // Region === canvas: hide the physical primary row (region click opens it), // but keep sibling custom canvases visible (e.g. MCP LPG-Full next to 根图). - // UME World container: only「世界地图」as a sibling view. + // UME World container: only「世界地图」as a sibling view (when enabled). const canvasRegion = isRegionCanvasFolder(folder, String(treeRoot?.id || "")); - const visibleViews = canvasRegion - ? (folder.views || []).filter((v) => String(v.kind) !== "physical") - : containerFolder - ? (folder.views || []).filter((v) => isWorldFlatViewName(v.name)) - : folder.views || []; + const visibleViews = ( + canvasRegion + ? (folder.views || []).filter((v) => String(v.kind) !== "physical") + : containerFolder + ? (folder.views || []).filter((v) => isWorldFlatViewName(v.name)) + : folder.views || [] + ).filter((v) => WORLD_MAP_ENABLED || !isWorldFlatViewName(v.name)); const hasKids = kids.length > 0 || visibleViews.length > 0; const open = Boolean(expandedIds[folder.id]); const viewActiveUnder = @@ -3301,7 +3338,11 @@ export function TopologyPage() { useEffect(() => { if (!ctxMenu) return; - const onScroll = () => closeCtxMenu(); + const onScroll = (e: Event) => { + const t = e.target; + if (t instanceof Node && ctxMenuRef.current?.contains(t)) return; + closeCtxMenu(); + }; const onPointerDown = (e: MouseEvent) => { const el = e.target as HTMLElement | null; if (el?.closest?.(".topo-ctx")) return; @@ -3315,6 +3356,25 @@ export function TopologyPage() { }; }, [ctxMenu, closeCtxMenu]); + useLayoutEffect(() => { + if (!ctxMenu || !ctxMenuRef.current) return; + const el = ctxMenuRef.current; + const pad = 8; + const rect = el.getBoundingClientRect(); + let x = ctxMenu.x; + let y = ctxMenu.y; + if (x + rect.width > window.innerWidth - pad) { + x = Math.max(pad, window.innerWidth - rect.width - pad); + } + if (y + rect.height > window.innerHeight - pad) { + y = Math.max(pad, window.innerHeight - rect.height - pad); + } + if (x !== ctxMenu.x || y !== ctxMenu.y) { + el.style.left = `${x}px`; + el.style.top = `${y}px`; + } + }, [ctxMenu]); + const removeNodeById = (nodeId: string) => { if (!mapId) return; pushHistory(); @@ -3765,21 +3825,28 @@ export function TopologyPage() { } const target = view || - views.find((v) => isWorldFlatViewName(v.view_name)) || - views.find((v) => String(v.kind) === "physical") || + (WORLD_MAP_ENABLED + ? views.find((v) => isWorldFlatViewName(v.view_name)) + : undefined) || + views.find((v) => !isWorldFlatViewName(v.view_name) && String(v.kind) === "physical") || + views.find((v) => !isWorldFlatViewName(v.view_name)) || views[0] || null; if (!target) { showError(t("topology.treeSearchNotOnMap")); return; } + if (!WORLD_MAP_ENABLED && isWorldFlatViewName(target.view_name)) { + showError(t("topology.worldMapOffline")); + return; + } if (!confirmDiscardIfDirty()) return; const regionId = target.folder_id || findViewInRegion(regions, target.view_id)?.region.id; if (regionId) { setSelectedFolderId(regionId); setExpandedIds((p) => ({ ...p, [regionId]: true })); } - if (isWorldFlatViewName(target.view_name) && worldCoords) { + if (WORLD_MAP_ENABLED && isWorldFlatViewName(target.view_name) && worldCoords) { pendingLocateWorldRef.current = { id: hit.id, ...worldCoords }; } else { pendingLocateWorldRef.current = null; @@ -4226,7 +4293,12 @@ export function TopologyPage() { {views.length > 0 ? (
- {views.map((v) => ( + {views + .filter( + (v) => + WORLD_MAP_ENABLED || !isWorldFlatViewName(v.view_name), + ) + .map((v) => (