mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 00:43:17 +08:00
Protect root maps and auto world map; only sub-regions are deletable.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
8fa4b2e4ea
commit
1b592490a5
6 changed files with 70 additions and 34 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -1432,6 +1432,7 @@ const zh = {
|
|||
newSubRegionPrompt: "子区域名称",
|
||||
subRegionNotOnFlat: "世界地图不支持子区域,请在 World 或下级区域画布上创建。",
|
||||
worldMapNoDirectNes: "世界地图不能直接添加网元,请到某个子区域画布上添加。",
|
||||
worldMapName: "世界地图",
|
||||
worldHud: "世界 {{total}} · 近景 {{cached}} · z{{zoom}} · {{lod}}",
|
||||
worldEmptyCoords: "世界地图还没有坐标",
|
||||
worldEmptyCoordsHint: "码头已有 {{dock}} 个网元,但尚未灌入 Fabric / 世界坐标。可一键应用(不拉 UME)。",
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
/>
|
||||
</span>
|
||||
<span className="topo-map-list__title">
|
||||
{v.name}
|
||||
{displayViewName(v.name, t)}
|
||||
{viewActive && dirty ? " *" : ""}
|
||||
</span>
|
||||
{!umeNav ? (
|
||||
|
|
@ -2395,15 +2414,16 @@ export function TopologyPage() {
|
|||
</span>
|
||||
</span>
|
||||
</button>
|
||||
{!umeNav || containerFolder || Boolean(String(folder.external_ref || "").trim()) ? (
|
||||
{!isUmeStructuralFolder(folder) &&
|
||||
(!folder.is_system || isUmeSyncedSubRegion(folder)) ? (
|
||||
<div className="topo-map-list__actions">
|
||||
{!containerFolder ? (
|
||||
{!folder.is_system ? (
|
||||
<button
|
||||
type="button"
|
||||
className="topo-map-list__icon"
|
||||
title={t("topology.renameRegion")}
|
||||
aria-label={t("topology.renameRegion")}
|
||||
disabled={!!folder.is_system || renameRegionMut.isPending}
|
||||
disabled={renameRegionMut.isPending}
|
||||
onClick={() => promptRenameRegion(folder.id, folder.name)}
|
||||
>
|
||||
<PencilIcon />
|
||||
|
|
@ -2414,18 +2434,12 @@ export function TopologyPage() {
|
|||
className="topo-map-list__icon"
|
||||
title={t("topology.deleteRegion")}
|
||||
aria-label={t("topology.deleteRegion")}
|
||||
disabled={
|
||||
deleteFolderMut.isPending ||
|
||||
(!!folder.is_system &&
|
||||
!containerFolder &&
|
||||
!String(folder.external_ref || "").trim())
|
||||
}
|
||||
disabled={deleteFolderMut.isPending}
|
||||
onClick={() => {
|
||||
const msg = (
|
||||
containerFolder
|
||||
? t("topology.deleteUmeWorldConfirm")
|
||||
: t("topology.deleteRegionConfirm")
|
||||
).replace("{{name}}", folder.name);
|
||||
const msg = t("topology.deleteRegionConfirm").replace(
|
||||
"{{name}}",
|
||||
folder.name,
|
||||
);
|
||||
if (window.confirm(msg)) {
|
||||
deleteFolderMut.mutate(folder.id);
|
||||
}
|
||||
|
|
@ -3991,9 +4005,9 @@ export function TopologyPage() {
|
|||
}, [graphTruncated, truncateReason, activeView?.name, graphQuery.data?.view?.filter?.world_flat, t]);
|
||||
const activeLeafName = useMemo(() => {
|
||||
if (!mapId) return "";
|
||||
if (activeView?.name) return activeView.name;
|
||||
return graphQuery.data?.view?.name || "";
|
||||
}, [mapId, activeView, graphQuery.data?.view?.name]);
|
||||
if (activeView?.name) return displayViewName(activeView.name, t);
|
||||
return displayViewName(graphQuery.data?.view?.name, t);
|
||||
}, [mapId, activeView, graphQuery.data?.view?.name, t]);
|
||||
|
||||
const titleText = useMemo(() => {
|
||||
if (canvasMode) return activeLeafName || t("topology.selectMap");
|
||||
|
|
@ -4798,7 +4812,7 @@ export function TopologyPage() {
|
|||
<LayerGlyph role={isPhysical ? "core" : "aggregation"} size={22} />
|
||||
</span>
|
||||
<span className="topo-region-hex__title">
|
||||
<span className="topo-region-hex__name">{v.name}</span>
|
||||
<span className="topo-region-hex__name">{displayViewName(v.name, t)}</span>
|
||||
<span className="topo-region-hex__meta">{v.node_count || 0}N</span>
|
||||
</span>
|
||||
</button>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue