diff --git a/netx_api/topology_views_tree.py b/netx_api/topology_views_tree.py index 76a5b14..e0a9481 100644 --- a/netx_api/topology_views_tree.py +++ b/netx_api/topology_views_tree.py @@ -549,22 +549,25 @@ def update_folder(db: Session, folder_id: str, body: TopologyFolderUpdate) -> To def delete_folder(db: Session, folder_id: str, *, force: bool = False) -> dict[str, Any]: """Delete a region and cascade-delete nested regions + maps. - System L2「根图」(manual, no UME external_ref) may be removed only as part of - cascading from its parent「根」. + Not user-deletable (structure / auto-managed): + - kind=root + - manual system「根图」(is_system, no UME external_ref) + - UME World container and World drill (ume:world / ume:world:drill) - UME World and UME-synced SBN folders (``external_ref`` set) are user-deletable — - they are synced inventory, not structural locks; a later UME apply can recreate them. + User-deletable: manual sub-regions and UME-synced SBN folders under World. + World map view is never a folder — it is auto created/suppressed by reconcile. """ row = _get_folder_or_404(db, folder_id) if str(row.kind or "") == "root": raise HTTPException(status_code=400, detail="cannot_delete_system_folder") - from .ume_topology_world import is_ume_world_container + from .ume_topology_world import WORLD_DRILL_REF, WORLD_FOLDER_REF, is_ume_world_container ext = str(getattr(row, "external_ref", None) or "").strip() - ume_synced = bool(ext) # ume:world / ume:world:drill / SBN id - # Protect manual system folders (根图); allow UME World + synced children. - if bool(row.is_system) and not is_ume_world_container(row) and not ume_synced: + if is_ume_world_container(row) or ext in (WORLD_FOLDER_REF, WORLD_DRILL_REF): + raise HTTPException(status_code=400, detail="cannot_delete_system_folder") + # Manual「根图」and other non-UME system folders stay protected except via parent cascade. + if bool(row.is_system) and not ext: raise HTTPException(status_code=400, detail="cannot_delete_system_folder") _ = force from .topology_region_canvas import remove_region_canvas_placements @@ -884,6 +887,10 @@ def update_view(db: Session, view_id: str, body: TopologyViewUpdate) -> Topology def delete_view(db: Session, view_id: str, *, force: bool = False) -> dict[str, Any]: row = _get_view_or_404(db, view_id) + from .ume_topology_world import is_world_flat_view + + if is_world_flat_view(row): + raise HTTPException(status_code=400, detail="cannot_delete_world_map_view") is_physical = normalize_view_kind(row.kind) == VIEW_KIND_PHYSICAL if is_physical and not force: raise HTTPException(status_code=400, detail="cannot_delete_physical_view") diff --git a/netx_api/ume_topology_world.py b/netx_api/ume_topology_world.py index a4544b9..34ee5f0 100644 --- a/netx_api/ume_topology_world.py +++ b/netx_api/ume_topology_world.py @@ -21,16 +21,19 @@ WORLD_FOLDER_REF = "ume:world" WORLD_DRILL_REF = "ume:world:drill" WORLD_VIEW_NAME = "World" WORLD_FLAT_VIEW_NAME = "世界地图" -# Pre-rename label kept so existing TopoView rows still match. +# Pre-rename / locale labels kept so existing TopoView rows still match. WORLD_FLAT_VIEW_NAME_LEGACY = "完整世界地图" +WORLD_FLAT_VIEW_NAME_EN = "World map" +WORLD_FLAT_VIEW_NAMES = frozenset( + {WORLD_FLAT_VIEW_NAME, WORLD_FLAT_VIEW_NAME_LEGACY, WORLD_FLAT_VIEW_NAME_EN} +) # Drill-down root: children of MD (root SBNs + rare direct MEs). WORLD_LEVEL_FILTER = {"ume_level": True, "parent": "md"} WORLD_FLAT_FILTER = {"world_flat": True} def is_world_flat_view_name(name: str | None) -> bool: - n = str(name or "").strip() - return n == WORLD_FLAT_VIEW_NAME or n == WORLD_FLAT_VIEW_NAME_LEGACY + return str(name or "").strip() in WORLD_FLAT_VIEW_NAMES def _filt(view: TopoView | None) -> dict[str, Any]: diff --git a/tests/test_ume_hierarchy.py b/tests/test_ume_hierarchy.py index f3cca4e..bdff75a 100644 --- a/tests/test_ume_hierarchy.py +++ b/tests/test_ume_hierarchy.py @@ -277,6 +277,7 @@ class UmeHierarchyTests(unittest.TestCase): self.assertEqual(WORLD_FLAT_VIEW_NAME, "世界地图") self.assertTrue(is_world_flat_view_name(WORLD_FLAT_VIEW_NAME)) self.assertTrue(is_world_flat_view_name(WORLD_FLAT_VIEW_NAME_LEGACY)) + self.assertTrue(is_world_flat_view_name("World map")) self.assertFalse(is_world_flat_view_name("World")) def test_world_map_forbids_add_nes(self): @@ -436,10 +437,19 @@ class UmeHierarchyTests(unittest.TestCase): out = delete_folder(self.db, target.id) self.assertTrue(out.get("ok")) self.assertIsNone(self.db.get(TopoFolder, target.id)) - # Manual system folder without UME ref stays protected. + # Structural UME folders stay protected. with self.assertRaises(HTTPException) as ctx: - delete_folder(self.db, "nope-missing") - self.assertEqual(ctx.exception.status_code, 404) + delete_folder(self.db, drill.id) + self.assertEqual(ctx.exception.status_code, 400) + self.assertEqual(ctx.exception.detail, "cannot_delete_system_folder") + from netx_api.topology_views_tree import delete_view + from netx_api.ume_topology_world import get_world_flat_view + + flat = get_world_flat_view(self.db) + self.assertIsNotNone(flat) + with self.assertRaises(HTTPException) as ctx2: + delete_view(self.db, flat.id) + self.assertEqual(ctx2.exception.detail, "cannot_delete_world_map_view") def test_flat_slots_do_not_overlap(self): self._seed_tree() diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index ba1f42d..aec04a9 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -1442,6 +1442,7 @@ const en = { newSubRegionPrompt: "Sub-region name", 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", worldHud: "World {{total}} · close-up {{cached}} · z{{zoom}} · {{lod}}", worldEmptyCoords: "World map has no coordinates yet", worldEmptyCoordsHint: diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 694e190..6e917c9 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -1432,6 +1432,7 @@ const zh = { newSubRegionPrompt: "子区域名称", subRegionNotOnFlat: "世界地图不支持子区域,请在 World 或下级区域画布上创建。", worldMapNoDirectNes: "世界地图不能直接添加网元,请到某个子区域画布上添加。", + worldMapName: "世界地图", worldHud: "世界 {{total}} · 近景 {{cached}} · z{{zoom}} · {{lod}}", worldEmptyCoords: "世界地图还没有坐标", worldEmptyCoordsHint: "码头已有 {{dock}} 个网元,但尚未灌入 Fabric / 世界坐标。可一键应用(不拉 UME)。", diff --git a/web/src/pages/TopologyPage.tsx b/web/src/pages/TopologyPage.tsx index 18350ef..f953c07 100644 --- a/web/src/pages/TopologyPage.tsx +++ b/web/src/pages/TopologyPage.tsx @@ -180,7 +180,26 @@ function isWorldDrillFolder(folder: TopologyTreeFolderItem | null | undefined): function isWorldFlatViewName(name: string | undefined | null): boolean { const n = String(name || "").trim(); - return n === "世界地图" || n === "完整世界地图"; + return n === "世界地图" || n === "完整世界地图" || n === "World map"; +} + +function displayViewName(name: string | undefined | null, t: (key: string) => string): string { + if (isWorldFlatViewName(name)) return t("topology.worldMapName"); + return String(name || "").trim(); +} + +/** UME structural folders (not user-deletable): container + World drill. */ +function isUmeStructuralFolder(folder: TopologyTreeFolderItem | null | undefined): boolean { + if (!folder) return false; + if (isUmeWorldContainer(folder) || isWorldDrillFolder(folder)) return true; + const ext = String(folder.external_ref || "").trim(); + return ext === "ume:world" || ext === "ume:world:drill"; +} + +/** UME-synced SBN under World — deletable like a manual sub-region. */ +function isUmeSyncedSubRegion(folder: TopologyTreeFolderItem | null | undefined): boolean { + if (!folder || isUmeStructuralFolder(folder)) return false; + return Boolean(String(folder.external_ref || "").trim()); } /** Visual LOD for 1:1 world coords (~1e5 span). fitView overview is ~0.01. */ @@ -2285,7 +2304,7 @@ export function TopologyPage() { /> - {v.name} + {displayViewName(v.name, t)} {viewActive && dirty ? " *" : ""} {!umeNav ? ( @@ -2395,15 +2414,16 @@ export function TopologyPage() { - {!umeNav || containerFolder || Boolean(String(folder.external_ref || "").trim()) ? ( + {!isUmeStructuralFolder(folder) && + (!folder.is_system || isUmeSyncedSubRegion(folder)) ? (
- {!containerFolder ? ( + {!folder.is_system ? (