mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 02:00:46 +08:00
Unify manual topology roots with UME World: auto root map, and fix empty UME canvas.
Top-level folders are nav-only with a unique system root map; UI drops custom/extra L2 creates. Batched dock-to-Fabric apply avoids PG lock OOM so World NEs can render, with subtree region counts restored. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
edfa3f8756
commit
48b10656d2
9 changed files with 867 additions and 366 deletions
|
|
@ -1394,7 +1394,11 @@ const en = {
|
|||
maps: "Maps",
|
||||
tree: "Topology tree",
|
||||
treeLoading: "Loading tree…",
|
||||
treeHint: "Regions hold maps you create; open a map to edit the canvas.",
|
||||
graphLoading: "Loading topology…",
|
||||
graphRefreshing: "Refreshing…",
|
||||
canvasEmpty: "No NEs on this map yet",
|
||||
canvasEmptyHint: "Use Add NE, or pick a region that already has devices.",
|
||||
treeHint: "Top level is a Root (nav only); its unique Root Map is the canvas. Open the root map or a sub-region to edit devices.",
|
||||
treeSearch: "Search NEs across maps",
|
||||
treeSearchPh: "Name / IP",
|
||||
treeSearchEmpty: "No matching NEs",
|
||||
|
|
@ -1402,47 +1406,47 @@ const en = {
|
|||
treeSearchNotOnMap: "This NE is not placed on any topology map yet",
|
||||
treeSearchViewCount: "{{count}} map(s)",
|
||||
treeSearchTruncated: "Showing first 30 of {{total}}",
|
||||
rootName: "Regions",
|
||||
selectRegion: "Select a region",
|
||||
rootName: "Roots",
|
||||
selectRegion: "Select a root or region",
|
||||
openMap: "Open map",
|
||||
openRegion: "Open region",
|
||||
openRegion: "Open",
|
||||
kindPhysical: "Physical",
|
||||
kindCustom: "Custom",
|
||||
regionNodeHint: "{{count}}P",
|
||||
layerNodeHint: "{{count}} NE(s)",
|
||||
layerNodeEmpty: "Click to create & open",
|
||||
rootCanvasHint: "Pick a region from the left tree.",
|
||||
regionCanvasHint: "Open a map to edit devices, or create a custom map.",
|
||||
regionBrowseHint: "Region folder: maps you add. Click to open.",
|
||||
rootCanvasHint: "Pick a root from the left tree.",
|
||||
regionCanvasHint: "Open the root map to edit devices; create sub-regions on that canvas.",
|
||||
regionBrowseHint: "Root folder: unique root map below. Click to open.",
|
||||
browserTitle: "Topology folder",
|
||||
browserRegionSub: "{{count}} map(s)",
|
||||
browserRegionsSub: "{{count}} region(s)",
|
||||
browserEmptyMaps: "No maps in this region yet.",
|
||||
browserRegionsSub: "{{count}} item(s)",
|
||||
browserEmptyMaps: "No root map under this root yet.",
|
||||
addNe: "Add NE",
|
||||
backUp: "Back to region",
|
||||
newRegion: "New region",
|
||||
newRegionName: "New region",
|
||||
newRegionPrompt: "Region name",
|
||||
backUp: "Back",
|
||||
newRegion: "New root",
|
||||
newRegionName: "New root",
|
||||
newRegionPrompt: "Root name",
|
||||
newSubRegion: "New sub-region",
|
||||
newSubRegionName: "New sub-region",
|
||||
newSubRegionPrompt: "Sub-region name",
|
||||
subRegionNotOnFlat: "The flat 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.",
|
||||
subRegionNeedParent: "Open a region canvas first, then create a sub-region.",
|
||||
regionCreated: "Region created",
|
||||
regionDeleted: "Region deleted",
|
||||
deleteRegion: "Delete region",
|
||||
deleteRegionConfirm: "Delete region \"{{name}}\"? Its maps will also be deleted.",
|
||||
renameRegion: "Rename region",
|
||||
renameRegionPrompt: "Region name",
|
||||
regionRenamed: "Region renamed",
|
||||
collapseRegion: "Collapse region",
|
||||
expandRegion: "Expand region",
|
||||
subRegionNeedParent: "Open a root map or region canvas first, then create a sub-region.",
|
||||
regionCreated: "Created",
|
||||
regionDeleted: "Deleted",
|
||||
deleteRegion: "Delete",
|
||||
deleteRegionConfirm: "Delete \"{{name}}\"? Its maps will also be deleted.",
|
||||
renameRegion: "Rename",
|
||||
renameRegionPrompt: "Name",
|
||||
regionRenamed: "Renamed",
|
||||
collapseRegion: "Collapse",
|
||||
expandRegion: "Expand",
|
||||
newCustom: "Custom map",
|
||||
newCustomName: "Custom topology",
|
||||
newCustomPrompt: "Custom map name",
|
||||
folderHint: "Regions group maps. After creating a region, add a physical or custom map yourself.",
|
||||
folderHintShort: "Region folder",
|
||||
folderHint: "Creating a root auto-creates its unique root map. Add sub-regions on that canvas.",
|
||||
folderHintShort: "Root folder",
|
||||
outsidePeers: "{{count}} outside peer(s) linked but not on this map",
|
||||
projectNeighbors: "Project neighbors",
|
||||
projectedNeighbors: "Projected {{count}} neighbor(s) onto the map",
|
||||
|
|
@ -1467,7 +1471,7 @@ const en = {
|
|||
renamed: "Topology map renamed",
|
||||
deleteMap: "Delete map",
|
||||
deleteMapConfirm: "Delete map \"{{name}}\"? Nodes and edges will be removed.",
|
||||
emptyMaps: "No regions yet. Create a region first.",
|
||||
emptyMaps: "No roots yet. Create a root first.",
|
||||
palette: "NE library",
|
||||
paletteHint: "Select multiple to add in batch, or drag one onto the canvas",
|
||||
paletteDragHint: "Drag onto canvas to add",
|
||||
|
|
|
|||
|
|
@ -1385,7 +1385,11 @@ const zh = {
|
|||
maps: "拓扑图",
|
||||
tree: "拓扑树",
|
||||
treeLoading: "加载拓扑树…",
|
||||
treeHint: "区域下为画布列表;点图打开设备画布(区域本身不自动建图)。",
|
||||
graphLoading: "正在加载拓扑图…",
|
||||
graphRefreshing: "刷新中…",
|
||||
canvasEmpty: "这张图上还没有网元",
|
||||
canvasEmptyHint: "可用「添加网元」上图,或从左侧换到有设备的区域。",
|
||||
treeHint: "顶级为「根」(导航);其下唯一「根图」为画布。点根图或子区域打开设备画布。",
|
||||
treeSearch: "全拓扑搜索网元",
|
||||
treeSearchPh: "名称 / IP",
|
||||
treeSearchEmpty: "没有匹配的网元",
|
||||
|
|
@ -1393,47 +1397,47 @@ const zh = {
|
|||
treeSearchNotOnMap: "该网元尚未出现在任何拓扑图上",
|
||||
treeSearchViewCount: "{{count}} 张图",
|
||||
treeSearchTruncated: "仅显示前 30 条(共 {{total}})",
|
||||
rootName: "区域",
|
||||
selectRegion: "请选择区域",
|
||||
rootName: "根",
|
||||
selectRegion: "请选择根或区域",
|
||||
openMap: "打开本图",
|
||||
openRegion: "打开区域",
|
||||
openRegion: "打开",
|
||||
kindPhysical: "物理拓扑",
|
||||
kindCustom: "自定义图",
|
||||
regionNodeHint: "{{count}}P",
|
||||
layerNodeHint: "{{count}} 个网元",
|
||||
layerNodeEmpty: "点击创建并打开",
|
||||
rootCanvasHint: "从左侧选择区域进入目录。",
|
||||
regionCanvasHint: "点击图打开设备画布,或新建自定义图。",
|
||||
regionBrowseHint: "区域目录:自行添加的画布。点击打开。",
|
||||
rootCanvasHint: "从左侧选择根进入目录。",
|
||||
regionCanvasHint: "点击根图打开设备画布;可在根图上新建子区域。",
|
||||
regionBrowseHint: "根目录:下方为唯一根图。点击打开。",
|
||||
browserTitle: "拓扑目录",
|
||||
browserRegionSub: "共 {{count}} 张拓扑图",
|
||||
browserRegionsSub: "共 {{count}} 个区域",
|
||||
browserEmptyMaps: "此区域还没有拓扑图。",
|
||||
browserRegionsSub: "共 {{count}} 个",
|
||||
browserEmptyMaps: "此根下还没有根图。",
|
||||
addNe: "添加网元",
|
||||
backUp: "返回区域",
|
||||
newRegion: "新建区域",
|
||||
newRegionName: "新区域",
|
||||
newRegionPrompt: "区域名称",
|
||||
backUp: "返回",
|
||||
newRegion: "新建根",
|
||||
newRegionName: "新根",
|
||||
newRegionPrompt: "根名称",
|
||||
newSubRegion: "新建子区域",
|
||||
newSubRegionName: "新子区域",
|
||||
newSubRegionPrompt: "子区域名称",
|
||||
subRegionNotOnFlat: "完整世界地图不支持子区域,请在 World 或下级区域画布上创建。",
|
||||
worldMapNoDirectNes: "世界地图不能直接添加网元,请到某个子区域画布上添加。",
|
||||
subRegionNeedParent: "请先打开一个区域画布,再新建子区域。",
|
||||
regionCreated: "区域已创建",
|
||||
regionDeleted: "区域已删除",
|
||||
deleteRegion: "删除区域",
|
||||
deleteRegionConfirm: "删除区域「{{name}}」?其下画布将一并删除。",
|
||||
renameRegion: "重命名区域",
|
||||
renameRegionPrompt: "区域名称",
|
||||
regionRenamed: "区域已重命名",
|
||||
collapseRegion: "折叠区域",
|
||||
expandRegion: "展开区域",
|
||||
subRegionNeedParent: "请先打开根图或区域画布,再新建子区域。",
|
||||
regionCreated: "已创建",
|
||||
regionDeleted: "已删除",
|
||||
deleteRegion: "删除",
|
||||
deleteRegionConfirm: "删除「{{name}}」?其下画布将一并删除。",
|
||||
renameRegion: "重命名",
|
||||
renameRegionPrompt: "名称",
|
||||
regionRenamed: "已重命名",
|
||||
collapseRegion: "折叠",
|
||||
expandRegion: "展开",
|
||||
newCustom: "自定义图",
|
||||
newCustomName: "自定义拓扑",
|
||||
newCustomPrompt: "自定义拓扑图名称",
|
||||
folderHint: "区域用于分组。新建区域后请自行添加物理拓扑或自定义图。",
|
||||
folderHintShort: "区域目录",
|
||||
folderHint: "新建根时自动创建唯一「根图」。子区域请在根图画布上创建。",
|
||||
folderHintShort: "根目录",
|
||||
outsidePeers: "图外对端 {{count}} 个(有链路未上图)",
|
||||
projectNeighbors: "投影邻居上图",
|
||||
projectedNeighbors: "已投影 {{count}} 个邻居上图",
|
||||
|
|
@ -1457,7 +1461,7 @@ const zh = {
|
|||
renamed: "拓扑图已重命名",
|
||||
deleteMap: "删除图",
|
||||
deleteMapConfirm: "删除拓扑图「{{name}}」?节点与链路会一并删除。",
|
||||
emptyMaps: "还没有区域,先点「新建区域」。",
|
||||
emptyMaps: "还没有根,先点「新建根」。",
|
||||
palette: "网元库",
|
||||
paletteHint: "勾选后批量添加;也可拖到画布添加单台",
|
||||
paletteDragHint: "拖到画布添加",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
* {
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
|
|
@ -7003,12 +7003,121 @@ pre {
|
|||
color: var(--nm-muted, #5b6b7c);
|
||||
}
|
||||
|
||||
.topo-canvas__status {
|
||||
gap: 12px;
|
||||
justify-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.topo-canvas__status p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.topo-loading-spinner {
|
||||
display: inline-block;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid rgba(148, 163, 184, 0.28);
|
||||
border-top-color: #60a5fa;
|
||||
border-radius: 50%;
|
||||
animation: topo-spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
.topo-loading-spinner--lg {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-width: 3px;
|
||||
}
|
||||
|
||||
@keyframes topo-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.topo-canvas__overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 15;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
justify-items: center;
|
||||
gap: 8px;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.topo-canvas__overlay--loading {
|
||||
background: rgba(11, 18, 32, 0.55);
|
||||
backdrop-filter: blur(2px);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.topo-canvas__overlay--empty {
|
||||
background: transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.topo-canvas__overlay-title {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.topo-canvas__overlay--empty .topo-canvas__overlay-title {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.topo-canvas__overlay-hint {
|
||||
margin: 0;
|
||||
max-width: 320px;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.topo-canvas__refresh {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 50%;
|
||||
z-index: 21;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transform: translateX(-50%);
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||
background: rgba(15, 23, 42, 0.92);
|
||||
color: #cbd5e1;
|
||||
font-size: 12px;
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.28);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.topo-browser__empty--loading {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.topo-browser__empty--loading p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.topo-sidebar .panel__hint {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--nm-muted);
|
||||
}
|
||||
|
||||
.topo-tree-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.topo-sidebar .input {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
|
|
|
|||
|
|
@ -40,7 +40,6 @@ import {
|
|||
createFabricManualEdge,
|
||||
createTopologyFolder,
|
||||
createTopologyPlaceholder,
|
||||
createTopologyView,
|
||||
deleteFabricEdges,
|
||||
purgePlaceholderFabricNodes,
|
||||
deleteTopologyFolder,
|
||||
|
|
@ -81,7 +80,6 @@ import type {
|
|||
TopologyViewEdgeItem,
|
||||
TopologyViewGraph,
|
||||
TopologyViewNodeItem,
|
||||
TopologyViewKind,
|
||||
TopologyViewRole,
|
||||
} from "../types";
|
||||
import { alignNodes, layoutGraph, type LayoutKind } from "./topology/layoutGraph";
|
||||
|
|
@ -182,14 +180,18 @@ function isWorldFlatViewName(name: string | undefined | null): boolean {
|
|||
}
|
||||
|
||||
/**
|
||||
* Region row IS the canvas (SBN / manual region / nested sub-region / World drill).
|
||||
* UME World container is nav-only (hex browse for L2 + world map).
|
||||
* Region row IS the canvas for nested / UME SBN / World drill.
|
||||
* Top-level under Network root (and UME World container) are hex-nav only — same as UME World.
|
||||
*/
|
||||
function isRegionCanvasFolder(
|
||||
folder: TopologyTreeFolderItem | null | undefined,
|
||||
rootId?: string,
|
||||
): boolean {
|
||||
if (!folder) return false;
|
||||
if (isUmeWorldContainer(folder)) return false;
|
||||
const rid = String(rootId || "").trim();
|
||||
const parent = String(folder.parent_id || "").trim();
|
||||
if (rid && parent === rid) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -1183,6 +1185,8 @@ export function TopologyPage() {
|
|||
const treeQuery = useQuery({
|
||||
queryKey: queryKeys.topologyTree,
|
||||
queryFn: fetchTopologyTree,
|
||||
staleTime: liveSync ? 0 : 30_000,
|
||||
refetchOnWindowFocus: liveSync,
|
||||
refetchInterval: liveSync ? 5000 : false,
|
||||
refetchIntervalInBackground: false,
|
||||
});
|
||||
|
|
@ -1202,6 +1206,8 @@ export function TopologyPage() {
|
|||
queryKey: queryKeys.topologyGraph(mapId),
|
||||
queryFn: () => fetchTopologyGraph(mapId),
|
||||
enabled: Boolean(mapId),
|
||||
staleTime: liveSync ? 0 : 30_000,
|
||||
refetchOnWindowFocus: liveSync,
|
||||
refetchInterval: liveSync && mapId ? 3000 : false,
|
||||
refetchIntervalInBackground: false,
|
||||
});
|
||||
|
|
@ -1284,6 +1290,8 @@ export function TopologyPage() {
|
|||
}
|
||||
}, [mapId, selectedFolderId, treeRoot, regions]);
|
||||
|
||||
const rootFolderId = String(treeRoot?.id || "").trim();
|
||||
|
||||
const canvasMode = Boolean(mapId);
|
||||
|
||||
const activeRegion = useMemo(() => {
|
||||
|
|
@ -1299,22 +1307,26 @@ export function TopologyPage() {
|
|||
const browseEntries = useMemo(
|
||||
(): TopologyTreeViewItem[] => {
|
||||
// Region === canvas: no map subdirectory listing.
|
||||
// UME World container: hex shows L2 + flat modules (handled separately).
|
||||
if (!activeRegion || isRegionCanvasFolder(activeRegion) || isUmeWorldContainer(activeRegion)) {
|
||||
// UME World / top-level containers: hex shows modules (handled separately).
|
||||
if (
|
||||
!activeRegion ||
|
||||
isRegionCanvasFolder(activeRegion, rootFolderId) ||
|
||||
isUmeWorldContainer(activeRegion)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return activeRegion.views || [];
|
||||
},
|
||||
[activeRegion],
|
||||
[activeRegion, rootFolderId],
|
||||
);
|
||||
|
||||
const hexBrowseRegion = useMemo(() => {
|
||||
// Root hex, or UME World container hex (L2 + world map modules).
|
||||
// Root hex, or nav container hex (UME World / top-level manual).
|
||||
if (!activeRegion) return null;
|
||||
if (isUmeWorldContainer(activeRegion)) return activeRegion;
|
||||
if (isRegionCanvasFolder(activeRegion)) return null;
|
||||
if (isRegionCanvasFolder(activeRegion, rootFolderId)) return null;
|
||||
return activeRegion;
|
||||
}, [activeRegion]);
|
||||
}, [activeRegion, rootFolderId]);
|
||||
|
||||
const umeWorldHexModules = useMemo(() => {
|
||||
if (!activeRegion || !isUmeWorldContainer(activeRegion)) return null;
|
||||
|
|
@ -1425,7 +1437,7 @@ export function TopologyPage() {
|
|||
expandFolderPath(folderId);
|
||||
const folder = findFolderInTree(regions, folderId);
|
||||
// Region === canvas: open its primary map (no map subdirectory / hex list).
|
||||
if (isRegionCanvasFolder(folder)) {
|
||||
if (isRegionCanvasFolder(folder, String(treeRoot?.id || ""))) {
|
||||
setWorldFocusFolderId("");
|
||||
const view = primaryViewOfFolder(folder);
|
||||
if (view) {
|
||||
|
|
@ -1449,6 +1461,7 @@ export function TopologyPage() {
|
|||
regions,
|
||||
primaryViewOfFolder,
|
||||
expandFolderPath,
|
||||
treeRoot?.id,
|
||||
],
|
||||
);
|
||||
|
||||
|
|
@ -1494,16 +1507,22 @@ export function TopologyPage() {
|
|||
return;
|
||||
}
|
||||
}
|
||||
// Leaving other region canvases → root hex.
|
||||
if (isRegionCanvasFolder(folder) || isUmeWorldNavFolder(folder)) {
|
||||
setSelectedFolderId("");
|
||||
// Leaving other region canvases → parent nav container, else root hex.
|
||||
if (isRegionCanvasFolder(folder, String(treeRoot?.id || "")) || isUmeWorldNavFolder(folder)) {
|
||||
const parentId = String(folder?.parent_id || "").trim();
|
||||
const parent = parentId ? findFolderInTree(regions, parentId) : null;
|
||||
if (parent && !isRegionCanvasFolder(parent, String(treeRoot?.id || ""))) {
|
||||
setSelectedFolderId(parent.id);
|
||||
} else {
|
||||
setSelectedFolderId("");
|
||||
}
|
||||
}
|
||||
setWorldFocusFolderId("");
|
||||
setMapId("");
|
||||
clearDirty();
|
||||
setNodes([]);
|
||||
setEdges([]);
|
||||
}, [confirmDiscardIfDirty, clearDirty, setNodes, setEdges, regions, selectedFolderId]);
|
||||
}, [confirmDiscardIfDirty, clearDirty, setNodes, setEdges, regions, selectedFolderId, treeRoot?.id]);
|
||||
|
||||
// Deep link from classify search: /topology?view=&ne=
|
||||
useEffect(() => {
|
||||
|
|
@ -1756,10 +1775,9 @@ export function TopologyPage() {
|
|||
});
|
||||
}
|
||||
} else {
|
||||
// Top-level = nav container (UME World pattern): stay on hex browse, do not open canvas.
|
||||
setSelectedFolderId(folder.id);
|
||||
const view = hit?.views?.[0];
|
||||
if (view) setMapId(view.id);
|
||||
else setMapId("");
|
||||
setMapId("");
|
||||
}
|
||||
showOk(t("topology.regionCreated"));
|
||||
},
|
||||
|
|
@ -1787,21 +1805,6 @@ export function TopologyPage() {
|
|||
[renameRegionMut, t],
|
||||
);
|
||||
|
||||
const createMapMut = useMutation({
|
||||
mutationFn: (input: { folder_id: string; kind: TopologyViewKind; name: string }) =>
|
||||
createTopologyView(input),
|
||||
onSuccess: async (row) => {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyTree });
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyMaps });
|
||||
clearDirty();
|
||||
if (row.folder_id) setSelectedFolderId(row.folder_id);
|
||||
setMapId(row.id);
|
||||
showOk(t("topology.newMap"));
|
||||
window.setTimeout(() => promptRenameMap(row.id, row.name), 0);
|
||||
},
|
||||
onError: (err) => showError(String(err)),
|
||||
});
|
||||
|
||||
const deleteMapMut = useMutation({
|
||||
mutationFn: (id: string) => deleteTopologyMap(id),
|
||||
onSuccess: async (_out, id) => {
|
||||
|
|
@ -1859,19 +1862,6 @@ export function TopologyPage() {
|
|||
setCtxMenu(null);
|
||||
}, [activeView?.name, selectedFolderId, worldViewId, mapId, regions, createRegionMut, t, showError]);
|
||||
|
||||
const promptNewCustom = useCallback(
|
||||
(folderId: string) => {
|
||||
const name = window.prompt(t("topology.newCustomPrompt"), t("topology.newCustomName"));
|
||||
if (!name?.trim()) return;
|
||||
createMapMut.mutate({
|
||||
folder_id: folderId,
|
||||
kind: "custom",
|
||||
name: name.trim(),
|
||||
});
|
||||
},
|
||||
[createMapMut, t],
|
||||
);
|
||||
|
||||
const renderWorldNavFolder = useCallback(
|
||||
(folder: TopologyTreeFolderItem, depth: number): ReactNode => {
|
||||
const kids = folder.children || [];
|
||||
|
|
@ -1881,7 +1871,7 @@ export function TopologyPage() {
|
|||
const umeNav = isUmeWorldNavFolder(folder);
|
||||
// Region === canvas: hide physical/custom view rows; only child regions.
|
||||
// UME World container: only「完整世界地图」as a sibling view.
|
||||
const canvasRegion = isRegionCanvasFolder(folder);
|
||||
const canvasRegion = isRegionCanvasFolder(folder, String(treeRoot?.id || ""));
|
||||
const visibleViews = canvasRegion
|
||||
? []
|
||||
: containerFolder
|
||||
|
|
@ -2120,6 +2110,7 @@ export function TopologyPage() {
|
|||
deleteFolderMut,
|
||||
deleteMapMut,
|
||||
t,
|
||||
treeRoot?.id,
|
||||
],
|
||||
);
|
||||
|
||||
|
|
@ -3492,6 +3483,15 @@ export function TopologyPage() {
|
|||
const outsidePeers = graphQuery.data?.outside_peers || [];
|
||||
const graphTruncated = Boolean(graphQuery.data?.truncated);
|
||||
const truncateReason = String(graphQuery.data?.truncate_reason || "").trim();
|
||||
const canvasGraphLoading = Boolean(mapId) && (graphQuery.isPending || !graphQuery.data);
|
||||
const canvasGraphEmpty =
|
||||
Boolean(mapId) &&
|
||||
graphQuery.isSuccess &&
|
||||
!graphQuery.isFetching &&
|
||||
(graphQuery.data?.nodes?.length ?? 0) === 0 &&
|
||||
nodes.length === 0;
|
||||
const canvasGraphRefreshing =
|
||||
Boolean(mapId) && liveSync && graphQuery.isFetching && Boolean(graphQuery.data) && !canvasGraphLoading;
|
||||
const truncateBannerText = useMemo(() => {
|
||||
if (!graphTruncated) return "";
|
||||
// World map soft-caps viewport nodes; the banner is noise there.
|
||||
|
|
@ -3716,7 +3716,16 @@ export function TopologyPage() {
|
|||
) : null}
|
||||
</div>
|
||||
{!treeRoot ? (
|
||||
<p className="panel__hint">{treeQuery.isLoading ? t("topology.treeLoading") : t("topology.emptyMaps")}</p>
|
||||
<p className="panel__hint topo-tree-status" role="status">
|
||||
{treeQuery.isLoading ? (
|
||||
<>
|
||||
<span className="topo-loading-spinner" aria-hidden="true" />
|
||||
{t("topology.treeLoading")}
|
||||
</>
|
||||
) : (
|
||||
t("topology.emptyMaps")
|
||||
)}
|
||||
</p>
|
||||
) : regions.length === 0 ? (
|
||||
<p className="panel__hint">{t("topology.emptyMaps")}</p>
|
||||
) : (
|
||||
|
|
@ -3760,7 +3769,7 @@ export function TopologyPage() {
|
|||
<>
|
||||
<span className="topo-breadcrumb__sep">/</span>
|
||||
{activeView &&
|
||||
isRegionCanvasFolder(activeRegion) &&
|
||||
isRegionCanvasFolder(activeRegion, rootFolderId) &&
|
||||
primaryViewOfFolder(activeRegion)?.id === activeView.id ? (
|
||||
<span className="topo-breadcrumb__current">
|
||||
{regionDisplayName(activeRegion)}
|
||||
|
|
@ -3780,7 +3789,7 @@ export function TopologyPage() {
|
|||
{activeView &&
|
||||
!(
|
||||
activeRegion &&
|
||||
isRegionCanvasFolder(activeRegion) &&
|
||||
isRegionCanvasFolder(activeRegion, rootFolderId) &&
|
||||
primaryViewOfFolder(activeRegion)?.id === activeView.id
|
||||
) ? (
|
||||
<>
|
||||
|
|
@ -4069,7 +4078,12 @@ export function TopologyPage() {
|
|||
</button>
|
||||
</div>
|
||||
{!hexBrowseRegion ? (
|
||||
regions.length === 0 ? (
|
||||
treeQuery.isLoading ? (
|
||||
<div className="topo-browser__empty topo-browser__empty--loading" role="status">
|
||||
<span className="topo-loading-spinner" aria-hidden="true" />
|
||||
<p>{t("topology.treeLoading")}</p>
|
||||
</div>
|
||||
) : regions.length === 0 ? (
|
||||
<div className="topo-browser__empty">
|
||||
<span className="topo-browser__empty-icon" aria-hidden="true">
|
||||
<RegionGlyph size={36} />
|
||||
|
|
@ -4207,22 +4221,36 @@ export function TopologyPage() {
|
|||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : browseEntries.length === 0 ? (
|
||||
<div className="topo-browser__grid topo-browser__grid--regions">
|
||||
<button
|
||||
type="button"
|
||||
className="topo-region-hex topo-region-hex--add"
|
||||
onClick={() => promptNewCustom(hexBrowseRegion.id)}
|
||||
title={t("topology.newCustom")}
|
||||
>
|
||||
<span className="topo-region-hex__plus" aria-hidden="true">
|
||||
+
|
||||
</span>
|
||||
<span className="topo-region-hex__name">{t("topology.newCustom")}</span>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="topo-browser__grid topo-browser__grid--regions">
|
||||
{(hexBrowseRegion.children || []).map((region, idx) => (
|
||||
<button
|
||||
key={region.id}
|
||||
type="button"
|
||||
className={`topo-region-hex topo-region-hex--tone-${idx % 5}${
|
||||
selectedFolderId === region.id ? " is-selected" : ""
|
||||
}${hotBrowseKey === `region:${region.id}` ? " is-hot" : ""}`}
|
||||
onMouseEnter={() => setHotBrowseKey(`region:${region.id}`)}
|
||||
onMouseLeave={() =>
|
||||
setHotBrowseKey((k) => (k === `region:${region.id}` ? "" : k))
|
||||
}
|
||||
onClick={() => goRegion(region.id)}
|
||||
title={t("topology.openRegion")}
|
||||
>
|
||||
<span className="topo-region-hex__icon" aria-hidden="true">
|
||||
<RegionGlyph size={22} />
|
||||
</span>
|
||||
<span className="topo-region-hex__title">
|
||||
<span className="topo-region-hex__name">{regionDisplayName(region)}</span>
|
||||
<span className="topo-region-hex__meta">
|
||||
{t("topology.regionNodeHint").replace(
|
||||
"{{count}}",
|
||||
String(region.views?.length || region.children?.length || 0),
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{browseEntries.map((v, idx) => {
|
||||
const isPhysical = String(v.kind) === "physical";
|
||||
const tone = isPhysical ? "physical" : String((idx + 1) % 5);
|
||||
|
|
@ -4237,7 +4265,7 @@ export function TopologyPage() {
|
|||
onMouseLeave={() =>
|
||||
setHotBrowseKey((k) => (k === `view:${v.id}` ? "" : k))
|
||||
}
|
||||
onClick={() => goCanvas(v.id, activeRegion.id)}
|
||||
onClick={() => goCanvas(v.id, hexBrowseRegion.id)}
|
||||
title={t("topology.openMap")}
|
||||
>
|
||||
<span className="topo-region-hex__icon" aria-hidden="true">
|
||||
|
|
@ -4250,17 +4278,23 @@ export function TopologyPage() {
|
|||
</button>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
className="topo-region-hex topo-region-hex--add"
|
||||
onClick={() => promptNewCustom(hexBrowseRegion.id)}
|
||||
title={t("topology.newCustom")}
|
||||
>
|
||||
<span className="topo-region-hex__plus" aria-hidden="true">
|
||||
+
|
||||
</span>
|
||||
<span className="topo-region-hex__name">{t("topology.newCustom")}</span>
|
||||
</button>
|
||||
{/* Top-level「根」is nav-only with unique「根图」— no extra L2 / custom map. */}
|
||||
{String(hexBrowseRegion.parent_id || "") !== rootFolderId ? (
|
||||
<button
|
||||
type="button"
|
||||
className="topo-region-hex topo-region-hex--add"
|
||||
onClick={() => {
|
||||
setSelectedFolderId(hexBrowseRegion.id);
|
||||
promptNewSubRegion();
|
||||
}}
|
||||
title={t("topology.newSubRegion")}
|
||||
>
|
||||
<span className="topo-region-hex__plus" aria-hidden="true">
|
||||
+
|
||||
</span>
|
||||
<span className="topo-region-hex__name">{t("topology.newSubRegion")}</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -4905,8 +4939,29 @@ export function TopologyPage() {
|
|||
</ReactFlow>
|
||||
</TopoDisplayContext.Provider>
|
||||
) : (
|
||||
<div className="topo-canvas__empty">{t("topology.treeLoading")}</div>
|
||||
<div className="topo-canvas__empty topo-canvas__status" role="status">
|
||||
<span className="topo-loading-spinner" aria-hidden="true" />
|
||||
<p>{t("topology.treeLoading")}</p>
|
||||
</div>
|
||||
)}
|
||||
{treeRoot && canvasGraphLoading ? (
|
||||
<div className="topo-canvas__overlay topo-canvas__overlay--loading" role="status">
|
||||
<span className="topo-loading-spinner topo-loading-spinner--lg" aria-hidden="true" />
|
||||
<p className="topo-canvas__overlay-title">{t("topology.graphLoading")}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{treeRoot && canvasGraphEmpty ? (
|
||||
<div className="topo-canvas__overlay topo-canvas__overlay--empty" role="status">
|
||||
<p className="topo-canvas__overlay-title">{t("topology.canvasEmpty")}</p>
|
||||
<p className="topo-canvas__overlay-hint">{t("topology.canvasEmptyHint")}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{treeRoot && canvasGraphRefreshing ? (
|
||||
<div className="topo-canvas__refresh" role="status" aria-live="polite">
|
||||
<span className="topo-loading-spinner" aria-hidden="true" />
|
||||
{t("topology.graphRefreshing")}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue