mirror of
https://github.com/hansjone/netx.git
synced 2026-10-18 23:23:21 +08:00
Restore workbench clock, offline world map, and keep edge menus on-screen.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
99d180c501
commit
37dd43923f
6 changed files with 254 additions and 44 deletions
|
|
@ -1431,6 +1431,7 @@ const en = {
|
||||||
subRegionNotOnFlat: "The world map has no regions. Create sub-regions on World or a lower-level canvas.",
|
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.",
|
worldMapNoDirectNes: "Cannot add NEs on the world map — add them on a sub-region canvas.",
|
||||||
worldMapName: "World map",
|
worldMapName: "World map",
|
||||||
|
worldMapOffline: "World map is temporarily offline",
|
||||||
rootMapName: "Root map",
|
rootMapName: "Root map",
|
||||||
worldHud: "World {{total}} · close-up {{cached}} · z{{zoom}} · {{lod}}",
|
worldHud: "World {{total}} · close-up {{cached}} · z{{zoom}} · {{lod}}",
|
||||||
worldEmptyCoords: "World map has no coordinates yet",
|
worldEmptyCoords: "World map has no coordinates yet",
|
||||||
|
|
|
||||||
|
|
@ -1421,6 +1421,7 @@ const zh = {
|
||||||
subRegionNotOnFlat: "世界地图不支持子区域,请在 World 或下级区域画布上创建。",
|
subRegionNotOnFlat: "世界地图不支持子区域,请在 World 或下级区域画布上创建。",
|
||||||
worldMapNoDirectNes: "世界地图不能直接添加网元,请到某个子区域画布上添加。",
|
worldMapNoDirectNes: "世界地图不能直接添加网元,请到某个子区域画布上添加。",
|
||||||
worldMapName: "世界地图",
|
worldMapName: "世界地图",
|
||||||
|
worldMapOffline: "世界地图功能已暂时下线",
|
||||||
rootMapName: "根图",
|
rootMapName: "根图",
|
||||||
worldHud: "世界 {{total}} · 近景 {{cached}} · z{{zoom}} · {{lod}}",
|
worldHud: "世界 {{total}} · 近景 {{cached}} · z{{zoom}} · {{lod}}",
|
||||||
worldEmptyCoords: "世界地图还没有坐标",
|
worldEmptyCoords: "世界地图还没有坐标",
|
||||||
|
|
|
||||||
|
|
@ -253,6 +253,101 @@ body {
|
||||||
color: #64748b;
|
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 {
|
.wb-hero__kpis {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
|
@ -713,6 +808,11 @@ body {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.wb-hero__clock-panel {
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.wb-hero__kpis {
|
.wb-hero__kpis {
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
@ -6518,6 +6618,11 @@ pre {
|
||||||
|
|
||||||
.topo-ctx--edge {
|
.topo-ctx--edge {
|
||||||
min-width: 196px;
|
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 {
|
.topo-ctx__head {
|
||||||
|
|
@ -6559,9 +6664,13 @@ pre {
|
||||||
}
|
}
|
||||||
|
|
||||||
.topo-ctx__style-row > span {
|
.topo-ctx__style-row > span {
|
||||||
flex: 0 0 auto;
|
flex: 1 1 auto;
|
||||||
min-width: 2.5em;
|
min-width: 2.5em;
|
||||||
|
max-width: 11.5em;
|
||||||
color: #cbd5e1;
|
color: #cbd5e1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topo-ctx__style-row input[type="color"] {
|
.topo-ctx__style-row input[type="color"] {
|
||||||
|
|
@ -7747,9 +7856,10 @@ pre {
|
||||||
margin: 0 0 8px;
|
margin: 0 0 8px;
|
||||||
padding: 0 0 0 16px;
|
padding: 0 0 0 16px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #334155;
|
color: #cbd5e1;
|
||||||
max-height: 120px;
|
max-height: 96px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topo-ctx__style-members {
|
.topo-ctx__style-members {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import {
|
||||||
Suspense,
|
Suspense,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
|
|
@ -220,6 +221,9 @@ function isWorldFlatViewName(name: string | undefined | null): boolean {
|
||||||
return n === "世界地图" || n === "完整世界地图" || n === "World map";
|
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 {
|
function isManualRootMapName(name: string | undefined | null): boolean {
|
||||||
const n = String(name || "").trim();
|
const n = String(name || "").trim();
|
||||||
return n === "根图" || n === "Root map";
|
return n === "根图" || n === "Root map";
|
||||||
|
|
@ -1115,6 +1119,7 @@ export function TopologyPage() {
|
||||||
const [historyTick, setHistoryTick] = useState(0);
|
const [historyTick, setHistoryTick] = useState(0);
|
||||||
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
|
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
|
||||||
const [ctxMenu, setCtxMenu] = useState<CtxMenu | null>(null);
|
const [ctxMenu, setCtxMenu] = useState<CtxMenu | null>(null);
|
||||||
|
const ctxMenuRef = useRef<HTMLUListElement | null>(null);
|
||||||
const [hideIp, setHideIp] = useState(true);
|
const [hideIp, setHideIp] = useState(true);
|
||||||
const [hideVendor, setHideVendor] = useState(true);
|
const [hideVendor, setHideVendor] = useState(true);
|
||||||
const [hidePorts, setHidePorts] = useState(true);
|
const [hidePorts, setHidePorts] = useState(true);
|
||||||
|
|
@ -1293,8 +1298,12 @@ export function TopologyPage() {
|
||||||
const graphQuery = useQuery({
|
const graphQuery = useQuery({
|
||||||
queryKey: queryKeys.topologyGraph(mapId),
|
queryKey: queryKeys.topologyGraph(mapId),
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
fetchTopologyGraph(mapId, treeFlatMap ? { lod: "overview" } : undefined),
|
fetchTopologyGraph(
|
||||||
enabled: Boolean(mapId),
|
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,
|
staleTime: liveSync ? 0 : 30_000,
|
||||||
refetchOnWindowFocus: liveSync && !treeFlatMap,
|
refetchOnWindowFocus: liveSync && !treeFlatMap,
|
||||||
refetchInterval: liveSync && mapId && !treeFlatMap ? 3000 : false,
|
refetchInterval: liveSync && mapId && !treeFlatMap ? 3000 : false,
|
||||||
|
|
@ -1302,9 +1311,10 @@ export function TopologyPage() {
|
||||||
});
|
});
|
||||||
|
|
||||||
const isWorldFlatCanvas =
|
const isWorldFlatCanvas =
|
||||||
treeFlatMap ||
|
WORLD_MAP_ENABLED &&
|
||||||
isWorldFlatViewName(graphQuery.data?.view?.name) ||
|
(treeFlatMap ||
|
||||||
Boolean(graphQuery.data?.view?.filter?.world_flat);
|
isWorldFlatViewName(graphQuery.data?.view?.name) ||
|
||||||
|
Boolean(graphQuery.data?.view?.filter?.world_flat));
|
||||||
|
|
||||||
isWorldFlatCanvasRef.current = isWorldFlatCanvas;
|
isWorldFlatCanvasRef.current = isWorldFlatCanvas;
|
||||||
mapIdRef.current = mapId;
|
mapIdRef.current = mapId;
|
||||||
|
|
@ -1360,7 +1370,7 @@ export function TopologyPage() {
|
||||||
}, [graphQuery.data?.world_transform]);
|
}, [graphQuery.data?.world_transform]);
|
||||||
|
|
||||||
const refreshFlatViewport = useCallback(async () => {
|
const refreshFlatViewport = useCallback(async () => {
|
||||||
if (!mapId || !isWorldFlatCanvas || dirtyRef.current) return;
|
if (!WORLD_MAP_ENABLED || !mapId || !isWorldFlatCanvas || dirtyRef.current) return;
|
||||||
const inst = rfRef.current;
|
const inst = rfRef.current;
|
||||||
if (!inst) return;
|
if (!inst) return;
|
||||||
const zoom = inst.getZoom();
|
const zoom = inst.getZoom();
|
||||||
|
|
@ -1425,7 +1435,7 @@ export function TopologyPage() {
|
||||||
}, [mapId, isWorldFlatCanvas, queryClient]);
|
}, [mapId, isWorldFlatCanvas, queryClient]);
|
||||||
|
|
||||||
const scheduleFlatViewportRefresh = useCallback(() => {
|
const scheduleFlatViewportRefresh = useCallback(() => {
|
||||||
if (!isWorldFlatCanvas) return;
|
if (!WORLD_MAP_ENABLED || !isWorldFlatCanvas) return;
|
||||||
if (flatLodTimerRef.current) window.clearTimeout(flatLodTimerRef.current);
|
if (flatLodTimerRef.current) window.clearTimeout(flatLodTimerRef.current);
|
||||||
flatLodTimerRef.current = window.setTimeout(() => {
|
flatLodTimerRef.current = window.setTimeout(() => {
|
||||||
flatLodTimerRef.current = null;
|
flatLodTimerRef.current = null;
|
||||||
|
|
@ -1578,13 +1588,18 @@ export function TopologyPage() {
|
||||||
if (!activeRegion || !isUmeWorldContainer(activeRegion)) return null;
|
if (!activeRegion || !isUmeWorldContainer(activeRegion)) return null;
|
||||||
const drill =
|
const drill =
|
||||||
(activeRegion.children || []).find((c) => isWorldDrillFolder(c)) || null;
|
(activeRegion.children || []).find((c) => isWorldDrillFolder(c)) || null;
|
||||||
const flatView =
|
const flatView = WORLD_MAP_ENABLED
|
||||||
(activeRegion.views || []).find((v) => isWorldFlatViewName(v.name)) || null;
|
? (activeRegion.views || []).find((v) => isWorldFlatViewName(v.name)) || null
|
||||||
|
: null;
|
||||||
return { drill, flatView };
|
return { drill, flatView };
|
||||||
}, [activeRegion]);
|
}, [activeRegion]);
|
||||||
|
|
||||||
// Resolve World drill view id for shortcuts; do not auto-open canvas.
|
// Resolve World drill view id for shortcuts; do not auto-open canvas.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!WORLD_MAP_ENABLED) {
|
||||||
|
setWorldViewId("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
fetchTopologyWorld()
|
fetchTopologyWorld()
|
||||||
.then((w) => {
|
.then((w) => {
|
||||||
|
|
@ -1625,7 +1640,23 @@ export function TopologyPage() {
|
||||||
clearDirty();
|
clearDirty();
|
||||||
}, [confirmDiscardIfDirty, clearDirty, regions]);
|
}, [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(() => {
|
const goWorld = useCallback(() => {
|
||||||
|
if (!WORLD_MAP_ENABLED) return;
|
||||||
if (!confirmDiscardIfDirty()) return;
|
if (!confirmDiscardIfDirty()) return;
|
||||||
if (!worldViewId) return;
|
if (!worldViewId) return;
|
||||||
setWorldFocusFolderId("");
|
setWorldFocusFolderId("");
|
||||||
|
|
@ -1727,6 +1758,10 @@ export function TopologyPage() {
|
||||||
(viewId: string, folderId?: string) => {
|
(viewId: string, folderId?: string) => {
|
||||||
if (!confirmDiscardIfDirty()) return;
|
if (!confirmDiscardIfDirty()) return;
|
||||||
const hit = findViewInRegion(regions, viewId);
|
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;
|
const regionId = folderId || hit?.region.id || selectedFolderId;
|
||||||
if (regionId) {
|
if (regionId) {
|
||||||
setSelectedFolderId(regionId);
|
setSelectedFolderId(regionId);
|
||||||
|
|
@ -1735,7 +1770,7 @@ export function TopologyPage() {
|
||||||
setWorldFocusFolderId("");
|
setWorldFocusFolderId("");
|
||||||
setMapId(viewId);
|
setMapId(viewId);
|
||||||
},
|
},
|
||||||
[confirmDiscardIfDirty, regions, selectedFolderId, expandFolderPath],
|
[confirmDiscardIfDirty, regions, selectedFolderId, expandFolderPath, showError, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const goBackBrowse = useCallback(() => {
|
const goBackBrowse = useCallback(() => {
|
||||||
|
|
@ -2187,13 +2222,15 @@ export function TopologyPage() {
|
||||||
const umeNav = isUmeWorldNavFolder(folder);
|
const umeNav = isUmeWorldNavFolder(folder);
|
||||||
// Region === canvas: hide the physical primary row (region click opens it),
|
// Region === canvas: hide the physical primary row (region click opens it),
|
||||||
// but keep sibling custom canvases visible (e.g. MCP LPG-Full next to 根图).
|
// 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 canvasRegion = isRegionCanvasFolder(folder, String(treeRoot?.id || ""));
|
||||||
const visibleViews = canvasRegion
|
const visibleViews = (
|
||||||
? (folder.views || []).filter((v) => String(v.kind) !== "physical")
|
canvasRegion
|
||||||
: containerFolder
|
? (folder.views || []).filter((v) => String(v.kind) !== "physical")
|
||||||
? (folder.views || []).filter((v) => isWorldFlatViewName(v.name))
|
: containerFolder
|
||||||
: folder.views || [];
|
? (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 hasKids = kids.length > 0 || visibleViews.length > 0;
|
||||||
const open = Boolean(expandedIds[folder.id]);
|
const open = Boolean(expandedIds[folder.id]);
|
||||||
const viewActiveUnder =
|
const viewActiveUnder =
|
||||||
|
|
@ -3301,7 +3338,11 @@ export function TopologyPage() {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ctxMenu) return;
|
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 onPointerDown = (e: MouseEvent) => {
|
||||||
const el = e.target as HTMLElement | null;
|
const el = e.target as HTMLElement | null;
|
||||||
if (el?.closest?.(".topo-ctx")) return;
|
if (el?.closest?.(".topo-ctx")) return;
|
||||||
|
|
@ -3315,6 +3356,25 @@ export function TopologyPage() {
|
||||||
};
|
};
|
||||||
}, [ctxMenu, closeCtxMenu]);
|
}, [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) => {
|
const removeNodeById = (nodeId: string) => {
|
||||||
if (!mapId) return;
|
if (!mapId) return;
|
||||||
pushHistory();
|
pushHistory();
|
||||||
|
|
@ -3765,21 +3825,28 @@ export function TopologyPage() {
|
||||||
}
|
}
|
||||||
const target =
|
const target =
|
||||||
view ||
|
view ||
|
||||||
views.find((v) => isWorldFlatViewName(v.view_name)) ||
|
(WORLD_MAP_ENABLED
|
||||||
views.find((v) => String(v.kind) === "physical") ||
|
? 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] ||
|
views[0] ||
|
||||||
null;
|
null;
|
||||||
if (!target) {
|
if (!target) {
|
||||||
showError(t("topology.treeSearchNotOnMap"));
|
showError(t("topology.treeSearchNotOnMap"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!WORLD_MAP_ENABLED && isWorldFlatViewName(target.view_name)) {
|
||||||
|
showError(t("topology.worldMapOffline"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!confirmDiscardIfDirty()) return;
|
if (!confirmDiscardIfDirty()) return;
|
||||||
const regionId = target.folder_id || findViewInRegion(regions, target.view_id)?.region.id;
|
const regionId = target.folder_id || findViewInRegion(regions, target.view_id)?.region.id;
|
||||||
if (regionId) {
|
if (regionId) {
|
||||||
setSelectedFolderId(regionId);
|
setSelectedFolderId(regionId);
|
||||||
setExpandedIds((p) => ({ ...p, [regionId]: true }));
|
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 };
|
pendingLocateWorldRef.current = { id: hit.id, ...worldCoords };
|
||||||
} else {
|
} else {
|
||||||
pendingLocateWorldRef.current = null;
|
pendingLocateWorldRef.current = null;
|
||||||
|
|
@ -4226,7 +4293,12 @@ export function TopologyPage() {
|
||||||
</button>
|
</button>
|
||||||
{views.length > 0 ? (
|
{views.length > 0 ? (
|
||||||
<div className="topo-tree-search__views">
|
<div className="topo-tree-search__views">
|
||||||
{views.map((v) => (
|
{views
|
||||||
|
.filter(
|
||||||
|
(v) =>
|
||||||
|
WORLD_MAP_ENABLED || !isWorldFlatViewName(v.view_name),
|
||||||
|
)
|
||||||
|
.map((v) => (
|
||||||
<button
|
<button
|
||||||
key={`${hit.id}-${v.view_id}`}
|
key={`${hit.id}-${v.view_id}`}
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -6034,6 +6106,7 @@ export function TopologyPage() {
|
||||||
{ctxMenu
|
{ctxMenu
|
||||||
? createPortal(
|
? createPortal(
|
||||||
<ul
|
<ul
|
||||||
|
ref={ctxMenuRef}
|
||||||
className={`topo-ctx${ctxMenu.kind === "edge" ? " topo-ctx--edge" : ""}`}
|
className={`topo-ctx${ctxMenu.kind === "edge" ? " topo-ctx--edge" : ""}`}
|
||||||
style={{ left: ctxMenu.x, top: ctxMenu.y }}
|
style={{ left: ctxMenu.x, top: ctxMenu.y }}
|
||||||
role="menu"
|
role="menu"
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useMemo } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useI18n } from "../i18n";
|
import { useI18n } from "../i18n";
|
||||||
import { WorkbenchCardIcon } from "../components/WorkbenchCardIcon";
|
import { WorkbenchCardIcon } from "../components/WorkbenchCardIcon";
|
||||||
|
|
@ -84,7 +84,7 @@ function Gauge({
|
||||||
|
|
||||||
export function WorkbenchPage() {
|
export function WorkbenchPage() {
|
||||||
const { t, locale } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
const { user, isAdmin, hasScope } = useAuth();
|
const { isAdmin, hasScope } = useAuth();
|
||||||
|
|
||||||
const metricsQuery = useQuery({
|
const metricsQuery = useQuery({
|
||||||
queryKey: ["runtimeMetrics"],
|
queryKey: ["runtimeMetrics"],
|
||||||
|
|
@ -148,28 +148,50 @@ export function WorkbenchPage() {
|
||||||
const taskCap = Math.max(totalTasks, activeTasks, 8);
|
const taskCap = Math.max(totalTasks, activeTasks, 8);
|
||||||
const taskPct = ratioPct(activeTasks, taskCap);
|
const taskPct = ratioPct(activeTasks, taskCap);
|
||||||
|
|
||||||
const todayLabel = useMemo(() => {
|
const [now, setNow] = useState(() => new Date());
|
||||||
try {
|
useEffect(() => {
|
||||||
return new Intl.DateTimeFormat(locale === "en" ? "en-US" : "zh-CN", {
|
const id = window.setInterval(() => setNow(new Date()), 1000);
|
||||||
year: "numeric",
|
return () => window.clearInterval(id);
|
||||||
month: "long",
|
}, []);
|
||||||
day: "numeric",
|
|
||||||
weekday: "short",
|
|
||||||
}).format(new Date());
|
|
||||||
} catch {
|
|
||||||
return new Date().toLocaleDateString();
|
|
||||||
}
|
|
||||||
}, [locale]);
|
|
||||||
|
|
||||||
const displayName = user?.username || "admin";
|
const clockLocale = locale === "en" ? "en-US" : "zh-CN";
|
||||||
|
const clockDate = useMemo(() => {
|
||||||
|
try {
|
||||||
|
return new Intl.DateTimeFormat(clockLocale, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
weekday: "short",
|
||||||
|
}).format(now);
|
||||||
|
} catch {
|
||||||
|
return now.toLocaleDateString();
|
||||||
|
}
|
||||||
|
}, [now, clockLocale]);
|
||||||
|
const hh = String(now.getHours()).padStart(2, "0");
|
||||||
|
const mm = String(now.getMinutes()).padStart(2, "0");
|
||||||
|
const ss = String(now.getSeconds()).padStart(2, "0");
|
||||||
|
const colonOn = now.getSeconds() % 2 === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="workbench">
|
<div className="workbench">
|
||||||
<section className="wb-hero" aria-label={t("workbench.title")}>
|
<section className="wb-hero" aria-label={t("workbench.title")}>
|
||||||
<div className="wb-hero__copy">
|
<div className="wb-hero__clock" aria-live="polite">
|
||||||
<p className="wb-hero__eyebrow">{t("workbench.welcomeEyebrow")}</p>
|
<time className="wb-hero__clock-panel" dateTime={now.toISOString()}>
|
||||||
<h1 className="wb-hero__title">{t("workbench.welcomeTitle", { name: displayName })}</h1>
|
<span className="wb-hero__clock-digits" aria-hidden="true">
|
||||||
<p className="wb-hero__date">{t("workbench.today", { date: todayLabel })}</p>
|
<span className="wb-hero__digit">{hh[0]}</span>
|
||||||
|
<span className="wb-hero__digit">{hh[1]}</span>
|
||||||
|
<span className={`wb-hero__colon${colonOn ? " is-on" : ""}`}>:</span>
|
||||||
|
<span className="wb-hero__digit">{mm[0]}</span>
|
||||||
|
<span className="wb-hero__digit">{mm[1]}</span>
|
||||||
|
<span className={`wb-hero__colon${colonOn ? " is-on" : ""}`}>:</span>
|
||||||
|
<span className="wb-hero__digit">{ss[0]}</span>
|
||||||
|
<span className="wb-hero__digit">{ss[1]}</span>
|
||||||
|
</span>
|
||||||
|
<span className="wb-hero__clock-sr">
|
||||||
|
{hh}:{mm}:{ss}
|
||||||
|
</span>
|
||||||
|
<span className="wb-hero__clock-date">{clockDate}</span>
|
||||||
|
</time>
|
||||||
</div>
|
</div>
|
||||||
<div className="wb-hero__status" aria-label={t("workbench.statusTitle")}>
|
<div className="wb-hero__status" aria-label={t("workbench.statusTitle")}>
|
||||||
<div className="wb-gauges wb-gauges--hero">
|
<div className="wb-gauges wb-gauges--hero">
|
||||||
|
|
|
||||||
|
|
@ -446,7 +446,10 @@ export default function TopologyReactFlowView(props: TopologyReactFlowViewProps)
|
||||||
onEdgeContextMenu={(e, edge) => {
|
onEdgeContextMenu={(e, edge) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
focusEdge(edge.id);
|
focusEdge(edge.id);
|
||||||
const pos = placeCtxMenu(e.clientX, e.clientY, { w: 260, h: 280 });
|
const pos = placeCtxMenu(e.clientX, e.clientY, {
|
||||||
|
w: 280,
|
||||||
|
h: Math.min(560, Math.max(280, window.innerHeight - 24)),
|
||||||
|
});
|
||||||
setCtxMenu({ kind: "edge", id: edge.id, ...pos });
|
setCtxMenu({ kind: "edge", id: edge.id, ...pos });
|
||||||
}}
|
}}
|
||||||
onSelectionContextMenu={(e) => {
|
onSelectionContextMenu={(e) => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue