feat: 菜单管理(侧边栏覆盖层)
管理员可对一/二/三级菜单(含注入的 iframe 轻应用节点)改名、停用/启用、 排序、跨层级移动。静态菜单仍是图标/路由来源,后端只存每节点覆盖项。 后端: /api/menu-overrides(全局共享;开放读、admin 原子替换;≤3 级校验), 新增 persistence/menu_overrides 持久化层(create_all 自动建表)。 前端: 合并逻辑 menu-overrides.ts、MenuManagementPage(@dnd-kit 拖拽+移动到 父级选择器+上下箭头+改名/停用)、左下角设置入口、Header/侧边栏自动生效。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
8d0759123a
commit
1c8b5b23a1
19 files changed
+1480
-8
No files matched your search
@@ -0,0 +1,119 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
applyMenuOverrides,
|
||||
buildTreeFromMerged,
|
||||
diffOverrides,
|
||||
flattenMenu,
|
||||
isDescendant,
|
||||
mergeFlatNodes,
|
||||
wouldExceedDepth,
|
||||
type MenuOverride,
|
||||
} from "./menu-overrides";
|
||||
import type { MenuItem } from "./sidebar-menu";
|
||||
|
||||
// 极简图标占位(合并逻辑不关心图标本身)。
|
||||
const I = (() => null) as unknown as MenuItem["icon"];
|
||||
|
||||
function sampleTree(): MenuItem[] {
|
||||
return [
|
||||
{
|
||||
id: "a",
|
||||
label: "A",
|
||||
icon: I,
|
||||
children: [
|
||||
{ id: "a1", label: "A1", icon: I, path: "/a1" },
|
||||
{
|
||||
id: "a2",
|
||||
label: "A2",
|
||||
icon: I,
|
||||
children: [{ id: "a2x", label: "A2X", icon: I, path: "/a2x" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ id: "b", label: "B", icon: I, path: "/b" },
|
||||
];
|
||||
}
|
||||
|
||||
const ids = (items: MenuItem[]): string[] =>
|
||||
items.flatMap((i) => [i.id, ...(i.children ? ids(i.children) : [])]);
|
||||
|
||||
test("无覆盖时原样返回", () => {
|
||||
const tree = sampleTree();
|
||||
assert.equal(applyMenuOverrides(tree, []), tree);
|
||||
});
|
||||
|
||||
test("改名只改 label,不动结构", () => {
|
||||
const out = applyMenuOverrides(sampleTree(), [
|
||||
{ nodeId: "a1", parentId: "a", sortOrder: 0, label: "改名了", disabled: false },
|
||||
]);
|
||||
const a = out.find((n) => n.id === "a")!;
|
||||
assert.equal(a.children![0].label, "改名了");
|
||||
assert.deepEqual(ids(out), ["a", "a1", "a2", "a2x", "b"]);
|
||||
});
|
||||
|
||||
test("停用一级菜单隐藏其整棵子树", () => {
|
||||
const out = applyMenuOverrides(sampleTree(), [
|
||||
{ nodeId: "a", parentId: null, sortOrder: 0, label: null, disabled: true },
|
||||
]);
|
||||
assert.deepEqual(ids(out), ["b"]); // a / a1 / a2 / a2x 全部隐藏
|
||||
});
|
||||
|
||||
test("管理页 includeDisabled=true 保留停用节点", () => {
|
||||
const merged = mergeFlatNodes(flattenMenu(sampleTree()), [
|
||||
{ nodeId: "a2", parentId: "a", sortOrder: 1, label: null, disabled: true },
|
||||
]);
|
||||
const all = buildTreeFromMerged(merged, true);
|
||||
assert.ok(ids(all).includes("a2"));
|
||||
const sidebar = buildTreeFromMerged(merged, false);
|
||||
assert.ok(!ids(sidebar).includes("a2"));
|
||||
assert.ok(!ids(sidebar).includes("a2x")); // 子树同隐藏
|
||||
});
|
||||
|
||||
test("改父级把子树一起搬走", () => {
|
||||
// 把 a2(带子 a2x)移到一级 B 之后 → a2 成为一级。
|
||||
const out = applyMenuOverrides(sampleTree(), [
|
||||
{ nodeId: "a2", parentId: null, sortOrder: 5, label: null, disabled: false },
|
||||
]);
|
||||
const top = out.map((n) => n.id);
|
||||
assert.ok(top.includes("a2"));
|
||||
const a2 = out.find((n) => n.id === "a2")!;
|
||||
assert.deepEqual(a2.children!.map((c) => c.id), ["a2x"]); // 子树跟随
|
||||
});
|
||||
|
||||
test("wouldExceedDepth 阻止四级", () => {
|
||||
const merged = mergeFlatNodes(flattenMenu(sampleTree()), []);
|
||||
// a2x 是三级;把 a(高度3)移到 a2x 下 → 6 级,必拒。
|
||||
assert.equal(wouldExceedDepth("a", "a2x", merged), true);
|
||||
// a1(叶子,高度1)移到 a2 下 → a2 是二级,结果三级,允许。
|
||||
assert.equal(wouldExceedDepth("a1", "a2", merged), false);
|
||||
});
|
||||
|
||||
test("isDescendant 识别子树", () => {
|
||||
const merged = mergeFlatNodes(flattenMenu(sampleTree()), []);
|
||||
assert.equal(isDescendant("a", "a2x", merged), true);
|
||||
assert.equal(isDescendant("a", "b", merged), false);
|
||||
});
|
||||
|
||||
test("diffOverrides 只输出有变化的节点", () => {
|
||||
const merged = mergeFlatNodes(flattenMenu(sampleTree()), []);
|
||||
// 原样 → 无 diff
|
||||
assert.deepEqual(diffOverrides(merged), []);
|
||||
// 改一个 label
|
||||
const renamed = merged.map((n) => (n.id === "b" ? { ...n, label: "B改" } : n));
|
||||
const diff = diffOverrides(renamed);
|
||||
assert.equal(diff.length, 1);
|
||||
assert.equal(diff[0]!.nodeId, "b");
|
||||
assert.equal(diff[0]!.label, "B改");
|
||||
});
|
||||
|
||||
test("套覆盖再 diff 可往返", () => {
|
||||
const overrides: MenuOverride[] = [
|
||||
{ nodeId: "a1", parentId: "a", sortOrder: 0, label: "X", disabled: false },
|
||||
];
|
||||
const merged = mergeFlatNodes(flattenMenu(sampleTree()), overrides);
|
||||
const diff = diffOverrides(merged);
|
||||
assert.equal(diff.length, 1);
|
||||
assert.equal(diff[0]!.label, "X");
|
||||
});
|
||||
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* 菜单覆盖层(overlay)合并逻辑。
|
||||
*
|
||||
* 静态菜单(sidebar-menu.ts)+ 注入的 iframe 轻应用 = 合并树,是结构/图标/路由的
|
||||
* 唯一来源;后端只按节点 id 存「覆盖项」(改名 label / 停用 disabled / 排序
|
||||
* sortOrder / 改父级 parentId)。渲染时把覆盖项套到合并树上重建。
|
||||
*
|
||||
* 图标与路由永不入库 —— 覆盖只动 label/可见性/位置/层级,所以新增的代码菜单、
|
||||
* 新注册的 iframe 应用都会自动出现,且立即可被配置。
|
||||
*
|
||||
* 严格 3 级:一级 → 二级 → 三级,移动导致超过 3 级的非法布局会被
|
||||
* `wouldExceedDepth` 拦截(后端 `_validate_depth` 二次校验)。
|
||||
*/
|
||||
import type { MenuItem } from "./sidebar-menu";
|
||||
|
||||
export const MAX_MENU_DEPTH = 3;
|
||||
export const ROOT_PARENT_ID = "__root__";
|
||||
|
||||
/** 单个节点的覆盖项(与后端 MenuOverride 对应;camelCase 边界)。 */
|
||||
export interface MenuOverride {
|
||||
nodeId: string;
|
||||
/** 生效的父节点 id;null = 一级菜单。 */
|
||||
parentId: string | null;
|
||||
sortOrder: number;
|
||||
/** 改名;null/undefined 保留代码定义的 label。 */
|
||||
label?: string | null;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
/** 拍平后的节点:保留原始定义 + 代码默认的父级/次序。 */
|
||||
export interface FlatMenuNode {
|
||||
id: string;
|
||||
/** 代码默认 label(未改名时显示)。 */
|
||||
defaultLabel: string;
|
||||
defaultParentId: string | null;
|
||||
defaultIndex: number;
|
||||
/** 原始静态节点(含 icon/path/adminOnly/userPath,重建时复用)。 */
|
||||
source: MenuItem;
|
||||
/** 是否为注入的 iframe 轻应用节点(light-app-<appId>)。 */
|
||||
isLightApp: boolean;
|
||||
}
|
||||
|
||||
/** 把合并树拍平成有序节点列表,记录每个节点的代码默认父级与同级次序。 */
|
||||
export function flattenMenu(items: MenuItem[]): FlatMenuNode[] {
|
||||
const out: FlatMenuNode[] = [];
|
||||
const walk = (nodes: MenuItem[], parentId: string | null) => {
|
||||
nodes.forEach((node, index) => {
|
||||
out.push({
|
||||
id: node.id,
|
||||
defaultLabel: node.label,
|
||||
defaultParentId: parentId,
|
||||
defaultIndex: index,
|
||||
source: node,
|
||||
isLightApp: node.id.startsWith("light-app-"),
|
||||
});
|
||||
if (node.children?.length) {
|
||||
walk(node.children, node.id);
|
||||
}
|
||||
});
|
||||
};
|
||||
walk(items, null);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并视图节点:拍平节点 + 生效后的 label / parentId / order / disabled。
|
||||
* 菜单管理页直接编辑这份结构(含停用项,用于灰显展示)。
|
||||
*/
|
||||
export interface MergedMenuNode extends FlatMenuNode {
|
||||
label: string;
|
||||
parentId: string | null;
|
||||
order: number;
|
||||
disabled: boolean;
|
||||
/** 该节点是否带显式覆盖项(用于「重置该项」判断)。 */
|
||||
hasOverride: boolean;
|
||||
}
|
||||
|
||||
/** 把覆盖项套到拍平节点上,得到生效后的合并节点(不重建树)。 */
|
||||
export function mergeFlatNodes(
|
||||
flat: FlatMenuNode[],
|
||||
overrides: MenuOverride[],
|
||||
): MergedMenuNode[] {
|
||||
const byId = new Map(overrides.map((o) => [o.nodeId, o]));
|
||||
return flat.map((node) => {
|
||||
const ov = byId.get(node.id);
|
||||
return {
|
||||
...node,
|
||||
label: ov?.label != null && ov.label !== "" ? ov.label : node.defaultLabel,
|
||||
parentId: ov ? ov.parentId : node.defaultParentId,
|
||||
order: ov ? ov.sortOrder : node.defaultIndex,
|
||||
disabled: ov?.disabled ?? false,
|
||||
hasOverride: ov != null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 把合并节点重建成菜单树。
|
||||
* @param includeDisabled 渲染侧边栏时为 false(连同停用节点的整棵子树一并隐藏);
|
||||
* 管理页为 true(全部展示,停用项灰显)。
|
||||
*/
|
||||
export function buildTreeFromMerged(
|
||||
merged: MergedMenuNode[],
|
||||
includeDisabled = false,
|
||||
): MenuItem[] {
|
||||
const parentOf = new Map(merged.map((n) => [n.id, n.parentId ?? null]));
|
||||
const disabledIds = new Set(merged.filter((n) => n.disabled).map((n) => n.id));
|
||||
|
||||
// 停用节点本身或任一祖先被停用 → 整棵子树隐藏(渲染侧边栏时)。
|
||||
const isHidden = (id: string): boolean => {
|
||||
let cursor: string | null = id;
|
||||
const seen = new Set<string>();
|
||||
while (cursor) {
|
||||
if (disabledIds.has(cursor)) return true;
|
||||
if (seen.has(cursor)) break;
|
||||
seen.add(cursor);
|
||||
cursor = parentOf.get(cursor) ?? null;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const visible = includeDisabled ? merged : merged.filter((n) => !isHidden(n.id));
|
||||
const visibleIds = new Set(visible.map((n) => n.id));
|
||||
|
||||
const childrenOf = new Map<string | null, MergedMenuNode[]>();
|
||||
for (const node of visible) {
|
||||
// parentId 指向不存在/已隐藏的节点 → 回落到一级(数据安全网,正常数据不会触发)。
|
||||
const key = node.parentId != null && visibleIds.has(node.parentId) ? node.parentId : null;
|
||||
const list = childrenOf.get(key) ?? [];
|
||||
list.push(node);
|
||||
childrenOf.set(key, list);
|
||||
}
|
||||
|
||||
const assemble = (parentId: string | null): MenuItem[] => {
|
||||
const list = (childrenOf.get(parentId) ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => a.order - b.order || a.id.localeCompare(b.id));
|
||||
return list.map((node) => {
|
||||
const children = assemble(node.id);
|
||||
const base: MenuItem = { ...node.source, label: node.label };
|
||||
if (children.length) {
|
||||
base.children = children;
|
||||
} else if (base.children) {
|
||||
// 原本有 children 但全被过滤/移走:保留空数组以维持「分组」语义。
|
||||
base.children = [];
|
||||
}
|
||||
return base;
|
||||
});
|
||||
};
|
||||
|
||||
return assemble(null);
|
||||
}
|
||||
|
||||
/** 应用覆盖层并返回供侧边栏渲染的菜单树(停用项已剔除)。 */
|
||||
export function applyMenuOverrides(items: MenuItem[], overrides: MenuOverride[]): MenuItem[] {
|
||||
if (!overrides.length) return items;
|
||||
const merged = mergeFlatNodes(flattenMenu(items), overrides);
|
||||
return buildTreeFromMerged(merged, false);
|
||||
}
|
||||
|
||||
// ── 管理页编辑辅助 ──────────────────────────────────────────────────────
|
||||
|
||||
/** 计算某节点在「父级映射」下的深度(1=一级)。检测到环时返回 Infinity。 */
|
||||
export function depthOf(nodeId: string, parentOf: Map<string, string | null>): number {
|
||||
let depth = 1;
|
||||
const seen = new Set<string>([nodeId]);
|
||||
let cursor = parentOf.get(nodeId) ?? null;
|
||||
while (cursor) {
|
||||
depth += 1;
|
||||
if (seen.has(cursor)) return Infinity;
|
||||
seen.add(cursor);
|
||||
cursor = parentOf.get(cursor) ?? null;
|
||||
}
|
||||
return depth;
|
||||
}
|
||||
|
||||
/** 某节点子树的最大相对高度(叶子=1)。用于判断移动后是否超 3 级。 */
|
||||
export function subtreeHeight(nodeId: string, childrenMap: Map<string | null, string[]>): number {
|
||||
const kids = childrenMap.get(nodeId) ?? [];
|
||||
if (!kids.length) return 1;
|
||||
return 1 + Math.max(...kids.map((k) => subtreeHeight(k, childrenMap)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 `nodeId` 移到 `targetParentId`(null=一级)后,是否会超过 3 级。
|
||||
* = 目标父级深度 + 被移动子树的高度 > MAX_MENU_DEPTH。
|
||||
*/
|
||||
export function wouldExceedDepth(
|
||||
nodeId: string,
|
||||
targetParentId: string | null,
|
||||
merged: MergedMenuNode[],
|
||||
): boolean {
|
||||
const parentOf = new Map(merged.map((n) => [n.id, n.parentId]));
|
||||
const childrenMap = new Map<string | null, string[]>();
|
||||
for (const n of merged) {
|
||||
const key = n.parentId ?? null;
|
||||
const list = childrenMap.get(key) ?? [];
|
||||
list.push(n.id);
|
||||
childrenMap.set(key, list);
|
||||
}
|
||||
const parentDepth = targetParentId == null ? 0 : depthOf(targetParentId, parentOf);
|
||||
if (parentDepth === Infinity) return true;
|
||||
const height = subtreeHeight(nodeId, childrenMap);
|
||||
return parentDepth + height > MAX_MENU_DEPTH;
|
||||
}
|
||||
|
||||
/** 目标父级是否落在被移动节点自身的子树内(禁止把节点移进自己的后代)。 */
|
||||
export function isDescendant(
|
||||
nodeId: string,
|
||||
candidateParentId: string | null,
|
||||
merged: MergedMenuNode[],
|
||||
): boolean {
|
||||
if (candidateParentId == null) return false;
|
||||
const parentOf = new Map(merged.map((n) => [n.id, n.parentId]));
|
||||
let cursor: string | null = candidateParentId;
|
||||
const seen = new Set<string>();
|
||||
while (cursor) {
|
||||
if (cursor === nodeId) return true;
|
||||
if (seen.has(cursor)) break;
|
||||
seen.add(cursor);
|
||||
cursor = parentOf.get(cursor) ?? null;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把当前合并节点与代码默认值求差,得到需要持久化的最小覆盖集。
|
||||
* 只有「改名 / 停用 / 父级变化 / 同级次序变化」的节点才入库。
|
||||
*/
|
||||
export function diffOverrides(merged: MergedMenuNode[]): MenuOverride[] {
|
||||
// 同级内按 order 排序后重排为紧凑索引,便于和 defaultIndex 比较。
|
||||
const siblings = new Map<string | null, MergedMenuNode[]>();
|
||||
for (const n of merged) {
|
||||
const key = n.parentId ?? null;
|
||||
const list = siblings.get(key) ?? [];
|
||||
list.push(n);
|
||||
siblings.set(key, list);
|
||||
}
|
||||
const compactIndex = new Map<string, number>();
|
||||
for (const list of siblings.values()) {
|
||||
list
|
||||
.slice()
|
||||
.sort((a, b) => a.order - b.order || a.id.localeCompare(b.id))
|
||||
.forEach((n, i) => compactIndex.set(n.id, i));
|
||||
}
|
||||
|
||||
const out: MenuOverride[] = [];
|
||||
for (const n of merged) {
|
||||
const idx = compactIndex.get(n.id) ?? n.order;
|
||||
const renamed = n.label !== n.defaultLabel;
|
||||
const reparented = (n.parentId ?? null) !== (n.defaultParentId ?? null);
|
||||
const reordered = idx !== n.defaultIndex;
|
||||
if (!renamed && !reparented && !n.disabled && !reordered) continue;
|
||||
out.push({
|
||||
nodeId: n.id,
|
||||
parentId: n.parentId ?? null,
|
||||
sortOrder: idx,
|
||||
label: renamed ? n.label : null,
|
||||
disabled: n.disabled,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -13,7 +13,9 @@ import { Layout } from "lucide-react";
|
||||
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useLightApps, type LightApp } from "@/strategy-components/api/light-apps";
|
||||
import { useMenuOverrides } from "@/strategy-components/api/menu-overrides";
|
||||
|
||||
import { applyMenuOverrides } from "./menu-overrides";
|
||||
import {
|
||||
filterMenuItemsByRole,
|
||||
getActiveThirdLevelItems,
|
||||
@@ -31,7 +33,7 @@ function lightAppLeaf(app: LightApp): MenuItem {
|
||||
}
|
||||
|
||||
/** 在菜单树(不可变克隆)中,把 iframe 应用按 mountLocation 注入对应父节点末尾。 */
|
||||
function injectLightApps(items: MenuItem[], apps: LightApp[]): MenuItem[] {
|
||||
export function injectLightApps(items: MenuItem[], apps: LightApp[]): MenuItem[] {
|
||||
if (apps.length === 0) return items;
|
||||
|
||||
const byMount = new Map<string, LightApp[]>();
|
||||
@@ -56,12 +58,23 @@ function injectLightApps(items: MenuItem[], apps: LightApp[]): MenuItem[] {
|
||||
return walk(items);
|
||||
}
|
||||
|
||||
/** 返回合并了 iframe 轻应用、并按 admin 角色过滤后的菜单。 */
|
||||
/**
|
||||
* 返回合并了 iframe 轻应用、套用了后端「菜单覆盖层」(改名/停用/排序/层级)、
|
||||
* 并按 admin 角色过滤后的菜单。
|
||||
*
|
||||
* 管线顺序:静态菜单 → 注入 iframe 轻应用 → 套覆盖层(停用项被剔除)→ 角色过滤。
|
||||
* 覆盖层作用在合并后的整棵树上,所以 iframe 轻应用节点同样可被改名/停用/移层级。
|
||||
*/
|
||||
export function useSidebarMenuItems(isAdmin: boolean): MenuItem[] {
|
||||
const { data: iframeApps = [] } = useLightApps({ appType: "iframe", enabledOnly: true });
|
||||
const { data: overrides = [] } = useMenuOverrides();
|
||||
return useMemo(
|
||||
() => filterMenuItemsByRole(injectLightApps(sidebarMenuItems, iframeApps), isAdmin),
|
||||
[iframeApps, isAdmin],
|
||||
() =>
|
||||
filterMenuItemsByRole(
|
||||
applyMenuOverrides(injectLightApps(sidebarMenuItems, iframeApps), overrides),
|
||||
isAdmin,
|
||||
),
|
||||
[iframeApps, overrides, isAdmin],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user