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,624 @@
|
||||
/**
|
||||
* 菜单管理页(仅管理员)。
|
||||
*
|
||||
* 管理整棵侧边栏菜单(一级 / 二级 / 三级),含通过「应用管理」注入的 iframe 轻应用节点。
|
||||
* 编辑结果以「覆盖层」形式保存到后端 `/api/menu-overrides`,不改动代码里的静态菜单。
|
||||
*
|
||||
* 支持:
|
||||
* - 改名 / 停用 / 启用(编辑弹窗)
|
||||
* - 同级上下移动(↑ ↓ 箭头)
|
||||
* - 拖拽调整顺序 + 跨层级移动(横向拖动改变层级,严格 ≤ 3 级)
|
||||
* - 「移动到」父级选择器(拖拽之外的第二种调层级方式,键盘可达)
|
||||
* - 保存 / 重置全部
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
PointerSensor,
|
||||
closestCenter,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
type DragMoveEvent,
|
||||
type DragStartEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
arrayMove,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
GripVertical,
|
||||
Pencil,
|
||||
RotateCcw,
|
||||
Save,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import {
|
||||
MAX_MENU_DEPTH,
|
||||
diffOverrides,
|
||||
flattenMenu,
|
||||
isDescendant,
|
||||
mergeFlatNodes,
|
||||
wouldExceedDepth,
|
||||
type MenuOverride,
|
||||
type MergedMenuNode,
|
||||
} from "@/core/page-layout/menu-overrides";
|
||||
import { sidebarMenuItems, type MenuItem } from "@/core/page-layout/sidebar-menu";
|
||||
import { injectLightApps } from "@/core/page-layout/use-sidebar-menu";
|
||||
import { useLightApps } from "@/strategy-components/api/light-apps";
|
||||
import {
|
||||
useMenuOverrideActions,
|
||||
useMenuOverrides,
|
||||
} from "@/strategy-components/api/menu-overrides";
|
||||
|
||||
const INDENT = 22;
|
||||
|
||||
/** 编辑器内部的扁平行(数组顺序 = DFS 显示顺序,同级连续)。 */
|
||||
interface FlatRow {
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
label: string;
|
||||
disabled: boolean;
|
||||
source: MenuItem;
|
||||
defaultLabel: string;
|
||||
defaultParentId: string | null;
|
||||
defaultIndex: number;
|
||||
isLightApp: boolean;
|
||||
}
|
||||
|
||||
// ── 扁平行工具 ───────────────────────────────────────────────────────────
|
||||
|
||||
/** 按 parentId 分组、DFS 重排,使每个节点的子节点紧跟其后(同级保持相遇顺序)。 */
|
||||
function normalizeDfs(list: FlatRow[]): FlatRow[] {
|
||||
const childrenOf = new Map<string | null, FlatRow[]>();
|
||||
for (const r of list) {
|
||||
const key = r.parentId ?? null;
|
||||
const arr = childrenOf.get(key) ?? [];
|
||||
arr.push(r);
|
||||
childrenOf.set(key, arr);
|
||||
}
|
||||
const out: FlatRow[] = [];
|
||||
const walk = (pid: string | null) => {
|
||||
for (const node of childrenOf.get(pid) ?? []) {
|
||||
out.push(node);
|
||||
walk(node.id);
|
||||
}
|
||||
};
|
||||
walk(null);
|
||||
return out;
|
||||
}
|
||||
|
||||
function mergedToRows(merged: MergedMenuNode[]): FlatRow[] {
|
||||
const childrenOf = new Map<string | null, MergedMenuNode[]>();
|
||||
for (const n of merged) {
|
||||
const key = n.parentId ?? null;
|
||||
const arr = childrenOf.get(key) ?? [];
|
||||
arr.push(n);
|
||||
childrenOf.set(key, arr);
|
||||
}
|
||||
const rows: FlatRow[] = [];
|
||||
const walk = (pid: string | null) => {
|
||||
(childrenOf.get(pid) ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => a.order - b.order || a.id.localeCompare(b.id))
|
||||
.forEach((n) => {
|
||||
rows.push({
|
||||
id: n.id,
|
||||
parentId: n.parentId ?? null,
|
||||
label: n.label,
|
||||
disabled: n.disabled,
|
||||
source: n.source,
|
||||
defaultLabel: n.defaultLabel,
|
||||
defaultParentId: n.defaultParentId,
|
||||
defaultIndex: n.defaultIndex,
|
||||
isLightApp: n.isLightApp,
|
||||
});
|
||||
walk(n.id);
|
||||
});
|
||||
};
|
||||
walk(null);
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** 扁平行 → 合并节点(order = 同级内位置),供 diff / 深度校验复用。 */
|
||||
function rowsToMerged(rows: FlatRow[]): MergedMenuNode[] {
|
||||
const counter = new Map<string | null, number>();
|
||||
return rows.map((r) => {
|
||||
const key = r.parentId ?? null;
|
||||
const order = counter.get(key) ?? 0;
|
||||
counter.set(key, order + 1);
|
||||
return {
|
||||
id: r.id,
|
||||
defaultLabel: r.defaultLabel,
|
||||
defaultParentId: r.defaultParentId,
|
||||
defaultIndex: r.defaultIndex,
|
||||
source: r.source,
|
||||
isLightApp: r.isLightApp,
|
||||
label: r.label,
|
||||
parentId: r.parentId ?? null,
|
||||
order,
|
||||
disabled: r.disabled,
|
||||
hasOverride: false,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function depthByIdOf(rows: FlatRow[]): Map<string, number> {
|
||||
const parentOf = new Map(rows.map((r) => [r.id, r.parentId]));
|
||||
const cache = new Map<string, number>();
|
||||
const compute = (id: string): number => {
|
||||
if (cache.has(id)) return cache.get(id)!;
|
||||
const pid = parentOf.get(id) ?? null;
|
||||
const d = pid == null ? 1 : compute(pid) + 1;
|
||||
cache.set(id, d);
|
||||
return d;
|
||||
};
|
||||
for (const r of rows) compute(r.id);
|
||||
return cache;
|
||||
}
|
||||
|
||||
// ── 可排序行 ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface SortableRowProps {
|
||||
row: FlatRow;
|
||||
depth: number;
|
||||
canUp: boolean;
|
||||
canDown: boolean;
|
||||
onUp: () => void;
|
||||
onDown: () => void;
|
||||
onEdit: () => void;
|
||||
}
|
||||
|
||||
function SortableRow({ row, depth, canUp, canDown, onUp, onDown, onEdit }: SortableRowProps) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: row.id,
|
||||
});
|
||||
const Icon = row.source.icon;
|
||||
const levelLabel = ["一级", "二级", "三级"][depth - 1] ?? `${depth}级`;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={{
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
paddingLeft: (depth - 1) * INDENT,
|
||||
opacity: isDragging ? 0.4 : 1,
|
||||
}}
|
||||
className="flex items-center gap-2 rounded-md border border-border/60 bg-card px-2 py-1.5"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-grab touch-none text-muted-foreground hover:text-foreground"
|
||||
title="拖拽调整顺序 / 层级"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<GripVertical className="size-4" />
|
||||
</button>
|
||||
<Icon className={`size-4 ${row.disabled ? "text-muted-foreground/50" : "text-foreground/70"}`} />
|
||||
<span className={`truncate text-sm ${row.disabled ? "text-muted-foreground line-through" : ""}`}>
|
||||
{row.label}
|
||||
</span>
|
||||
<Badge variant="outline" className="shrink-0 text-[10px]">
|
||||
{levelLabel}
|
||||
</Badge>
|
||||
{row.isLightApp && (
|
||||
<Badge variant="secondary" className="shrink-0 text-[10px]">
|
||||
应用
|
||||
</Badge>
|
||||
)}
|
||||
{row.disabled && (
|
||||
<Badge variant="outline" className="shrink-0 border-amber-400 text-[10px] text-amber-600">
|
||||
已停用
|
||||
</Badge>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-0.5">
|
||||
<Button variant="ghost" size="icon" className="size-7" disabled={!canUp} onClick={onUp} title="上移">
|
||||
<ChevronUp className="size-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="size-7" disabled={!canDown} onClick={onDown} title="下移">
|
||||
<ChevronDown className="size-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="size-7" onClick={onEdit} title="编辑">
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 主页面 ───────────────────────────────────────────────────────────────
|
||||
|
||||
export default function MenuManagementPage() {
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
// 与侧边栏一致:只取「启用的 iframe 轻应用」作为合并基树,避免管理树与实际渲染脱节。
|
||||
const { data: iframeApps = [] } = useLightApps({ appType: "iframe", enabledOnly: true });
|
||||
const { data: overrides = [], isLoading } = useMenuOverrides();
|
||||
const { save } = useMenuOverrideActions();
|
||||
|
||||
const baseTree = useMemo(() => injectLightApps(sidebarMenuItems, iframeApps), [iframeApps]);
|
||||
|
||||
// 初始扁平行(套用已保存覆盖层,含停用项)。
|
||||
const initialRows = useMemo(
|
||||
() => mergedToRows(mergeFlatNodes(flattenMenu(baseTree), overrides)),
|
||||
[baseTree, overrides],
|
||||
);
|
||||
|
||||
const [rows, setRows] = useState<FlatRow[]>(initialRows);
|
||||
const [loadedKey, setLoadedKey] = useState("");
|
||||
const [editId, setEditId] = useState<string | null>(null);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [offsetX, setOffsetX] = useState(0);
|
||||
|
||||
// 数据首次/变化加载时,用服务端值重置工作副本(避免覆盖用户未保存的编辑:以来源 key 判定)。
|
||||
const sourceKey = useMemo(
|
||||
() => JSON.stringify({ a: iframeApps.map((x) => x.appId), o: overrides }),
|
||||
[iframeApps, overrides],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (sourceKey !== loadedKey) {
|
||||
setRows(initialRows);
|
||||
setLoadedKey(sourceKey);
|
||||
}
|
||||
}, [sourceKey, loadedKey, initialRows]);
|
||||
|
||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }));
|
||||
|
||||
const depthById = useMemo(() => depthByIdOf(rows), [rows]);
|
||||
const mergedNow = useMemo(() => rowsToMerged(rows), [rows]);
|
||||
const currentOverrides = useMemo(() => diffOverrides(mergedNow), [mergedNow]);
|
||||
|
||||
const dirty = useMemo(() => {
|
||||
const norm = (list: MenuOverride[]) =>
|
||||
JSON.stringify(
|
||||
list
|
||||
.map((o) => ({ ...o, label: o.label ?? null, parentId: o.parentId ?? null }))
|
||||
.sort((a, b) => a.nodeId.localeCompare(b.nodeId)),
|
||||
);
|
||||
return norm(currentOverrides) !== norm(overrides);
|
||||
}, [currentOverrides, overrides]);
|
||||
|
||||
const editingRow = rows.find((r) => r.id === editId) ?? null;
|
||||
|
||||
// ── 行操作 ──
|
||||
const siblingsOf = (parentId: string | null) => rows.filter((r) => (r.parentId ?? null) === (parentId ?? null));
|
||||
|
||||
const reorderBefore = (moveId: string, beforeId: string) => {
|
||||
setRows((prev) => {
|
||||
const from = prev.findIndex((r) => r.id === moveId);
|
||||
const to = prev.findIndex((r) => r.id === beforeId);
|
||||
if (from < 0 || to < 0) return prev;
|
||||
return normalizeDfs(arrayMove(prev, from, to));
|
||||
});
|
||||
};
|
||||
|
||||
const moveUp = (row: FlatRow) => {
|
||||
const sibs = siblingsOf(row.parentId);
|
||||
const i = sibs.findIndex((s) => s.id === row.id);
|
||||
if (i > 0) reorderBefore(row.id, sibs[i - 1].id);
|
||||
};
|
||||
const moveDown = (row: FlatRow) => {
|
||||
const sibs = siblingsOf(row.parentId);
|
||||
const i = sibs.findIndex((s) => s.id === row.id);
|
||||
if (i >= 0 && i < sibs.length - 1) reorderBefore(sibs[i + 1].id, row.id);
|
||||
};
|
||||
|
||||
const applyEdit = (label: string, disabled: boolean, newParentId: string | null) => {
|
||||
if (!editingRow) return;
|
||||
const id = editingRow.id;
|
||||
if ((newParentId ?? null) !== (editingRow.parentId ?? null)) {
|
||||
if (newParentId && (newParentId === id || isDescendant(id, newParentId, mergedNow))) {
|
||||
toast.error("不能移动到自身或其子菜单下");
|
||||
return;
|
||||
}
|
||||
if (wouldExceedDepth(id, newParentId ?? null, mergedNow)) {
|
||||
toast.error(`移动后将超过 ${MAX_MENU_DEPTH} 级,已阻止`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setRows((prev) => {
|
||||
let next = prev.map((r) =>
|
||||
r.id === id ? { ...r, label, disabled, parentId: newParentId ?? null } : r,
|
||||
);
|
||||
// 改了父级:把该行挪到数组末尾,规范化后成为新父级的末位子节点。
|
||||
if ((newParentId ?? null) !== (editingRow.parentId ?? null)) {
|
||||
const idx = next.findIndex((r) => r.id === id);
|
||||
const [moved] = next.splice(idx, 1);
|
||||
next.push(moved);
|
||||
}
|
||||
return normalizeDfs(next);
|
||||
});
|
||||
setEditId(null);
|
||||
};
|
||||
|
||||
// ── 拖拽 ──
|
||||
const flattenedDepths = depthById;
|
||||
|
||||
const computeProjection = (overId: string) => {
|
||||
const activeIndex = rows.findIndex((r) => r.id === activeId);
|
||||
const overIndex = rows.findIndex((r) => r.id === overId);
|
||||
if (activeIndex < 0 || overIndex < 0) return null;
|
||||
const moved = arrayMove(rows, activeIndex, overIndex);
|
||||
const prev = moved[overIndex - 1];
|
||||
const next = moved[overIndex + 1];
|
||||
const activeDepth = flattenedDepths.get(activeId!) ?? 1;
|
||||
const dragDepth = Math.round(offsetX / INDENT);
|
||||
const projected = activeDepth + dragDepth;
|
||||
|
||||
// 被移动子树的高度 → 其根可落的最大深度。
|
||||
const childrenMap = new Map<string | null, string[]>();
|
||||
for (const r of rows) {
|
||||
const k = r.parentId ?? null;
|
||||
const arr = childrenMap.get(k) ?? [];
|
||||
arr.push(r.id);
|
||||
childrenMap.set(k, arr);
|
||||
}
|
||||
const heightOf = (id: string): number => {
|
||||
const kids = childrenMap.get(id) ?? [];
|
||||
return kids.length ? 1 + Math.max(...kids.map(heightOf)) : 1;
|
||||
};
|
||||
const activeCap = MAX_MENU_DEPTH - (heightOf(activeId!) - 1);
|
||||
|
||||
const maxDepth = Math.min(prev ? (flattenedDepths.get(prev.id) ?? 1) + 1 : 1, activeCap);
|
||||
const minDepth = next ? flattenedDepths.get(next.id) ?? 1 : 1;
|
||||
let depth = Math.max(minDepth, Math.min(projected, maxDepth));
|
||||
depth = Math.max(1, depth);
|
||||
|
||||
const parentId = (() => {
|
||||
if (depth <= 1 || !prev) return null;
|
||||
const prevDepth = flattenedDepths.get(prev.id) ?? 1;
|
||||
if (depth === prevDepth) return prev.parentId ?? null;
|
||||
if (depth > prevDepth) return prev.id;
|
||||
const back = moved
|
||||
.slice(0, overIndex)
|
||||
.reverse()
|
||||
.find((r) => (flattenedDepths.get(r.id) ?? 1) === depth);
|
||||
return back ? back.parentId ?? null : null;
|
||||
})();
|
||||
|
||||
return { depth, parentId };
|
||||
};
|
||||
|
||||
const onDragStart = (e: DragStartEvent) => {
|
||||
setActiveId(String(e.active.id));
|
||||
setOffsetX(0);
|
||||
};
|
||||
const onDragMove = (e: DragMoveEvent) => {
|
||||
setOffsetX(e.delta.x);
|
||||
};
|
||||
const onDragEnd = (e: DragEndEvent) => {
|
||||
const aId = activeId;
|
||||
setActiveId(null);
|
||||
setOffsetX(0);
|
||||
const overId = e.over ? String(e.over.id) : null;
|
||||
if (!aId || !overId) return;
|
||||
const proj = computeProjection(overId);
|
||||
if (!proj) return;
|
||||
const parentId = proj.parentId;
|
||||
if (parentId && (parentId === aId || isDescendant(aId, parentId, mergedNow))) {
|
||||
return; // 落到自身子树,忽略
|
||||
}
|
||||
if (wouldExceedDepth(aId, parentId, mergedNow)) {
|
||||
toast.error(`移动后将超过 ${MAX_MENU_DEPTH} 级,已阻止`);
|
||||
return;
|
||||
}
|
||||
setRows((prev) => {
|
||||
const activeIndex = prev.findIndex((r) => r.id === aId);
|
||||
const overIndex = prev.findIndex((r) => r.id === overId);
|
||||
if (activeIndex < 0 || overIndex < 0) return prev;
|
||||
const clone = prev.map((r) => ({ ...r }));
|
||||
clone[activeIndex].parentId = parentId;
|
||||
return normalizeDfs(arrayMove(clone, activeIndex, overIndex));
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await save.mutateAsync(currentOverrides);
|
||||
toast.success("菜单配置已保存");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "保存失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (!confirm("确定要重置全部菜单为默认布局吗?保存后将清除所有自定义配置。")) return;
|
||||
setRows(mergedToRows(mergeFlatNodes(flattenMenu(baseTree), [])));
|
||||
};
|
||||
|
||||
if (!isAdmin) return <Navigate to="/page/workspace/chats/new" replace />;
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex h-full max-w-3xl flex-col gap-4 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">菜单管理</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
拖拽调整顺序与层级(横向拖动改变层级,最多 {MAX_MENU_DEPTH} 级);或用「编辑」改名、停用、移动到指定父级。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleReset}>
|
||||
<RotateCcw className="size-4" />
|
||||
重置全部
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={!dirty || save.isPending}>
|
||||
<Save className="size-4" />
|
||||
{save.isPending ? "保存中…" : "保存"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">加载中…</p>
|
||||
) : (
|
||||
<div className="flex-1 overflow-auto">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={onDragStart}
|
||||
onDragMove={onDragMove}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragCancel={() => {
|
||||
setActiveId(null);
|
||||
setOffsetX(0);
|
||||
}}
|
||||
>
|
||||
<SortableContext items={rows.map((r) => r.id)} strategy={verticalListSortingStrategy}>
|
||||
<div className="flex flex-col gap-1">
|
||||
{rows.map((row) => {
|
||||
const sibs = siblingsOf(row.parentId);
|
||||
const i = sibs.findIndex((s) => s.id === row.id);
|
||||
return (
|
||||
<SortableRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
depth={depthById.get(row.id) ?? 1}
|
||||
canUp={i > 0}
|
||||
canDown={i >= 0 && i < sibs.length - 1}
|
||||
onUp={() => moveUp(row)}
|
||||
onDown={() => moveDown(row)}
|
||||
onEdit={() => setEditId(row.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SortableContext>
|
||||
<DragOverlay>
|
||||
{activeId ? (
|
||||
<div className="rounded-md border bg-card px-3 py-1.5 text-sm shadow-lg">
|
||||
{rows.find((r) => r.id === activeId)?.label}
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editingRow && (
|
||||
<EditMenuDialog
|
||||
key={editingRow.id}
|
||||
row={editingRow}
|
||||
rows={rows}
|
||||
merged={mergedNow}
|
||||
depthById={depthById}
|
||||
onClose={() => setEditId(null)}
|
||||
onApply={applyEdit}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 编辑弹窗 ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface EditMenuDialogProps {
|
||||
row: FlatRow;
|
||||
rows: FlatRow[];
|
||||
merged: MergedMenuNode[];
|
||||
depthById: Map<string, number>;
|
||||
onClose: () => void;
|
||||
onApply: (label: string, disabled: boolean, parentId: string | null) => void;
|
||||
}
|
||||
|
||||
const ROOT_OPTION = "__root__";
|
||||
|
||||
function EditMenuDialog({ row, rows, merged, depthById, onClose, onApply }: EditMenuDialogProps) {
|
||||
const [label, setLabel] = useState(row.label);
|
||||
const [disabled, setDisabled] = useState(row.disabled);
|
||||
const [parentValue, setParentValue] = useState(row.parentId ?? ROOT_OPTION);
|
||||
|
||||
// 合法的「移动到」父级:非自身、非自身子树、移动后不超 3 级。
|
||||
const parentOptions = useMemo(() => {
|
||||
const opts: { value: string; label: string }[] = [{ value: ROOT_OPTION, label: "(设为一级菜单)" }];
|
||||
for (const r of rows) {
|
||||
if (r.id === row.id) continue;
|
||||
if (isDescendant(row.id, r.id, merged)) continue;
|
||||
if (wouldExceedDepth(row.id, r.id, merged)) continue;
|
||||
const depth = depthById.get(r.id) ?? 1;
|
||||
opts.push({ value: r.id, label: `${" ".repeat(depth - 1)}${r.label}` });
|
||||
}
|
||||
return opts;
|
||||
}, [row.id, rows, merged, depthById]);
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑菜单</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4 py-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium">名称</label>
|
||||
<Input value={label} onChange={(e) => setLabel(e.target.value)} placeholder="菜单显示名称" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">启用</p>
|
||||
<p className="text-xs text-muted-foreground">停用后该菜单(及其子菜单)对所有用户隐藏</p>
|
||||
</div>
|
||||
<Switch checked={!disabled} onCheckedChange={(v) => setDisabled(!v)} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium">移动到(父级)</label>
|
||||
<Select value={parentValue} onValueChange={setParentValue}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-72">
|
||||
{parentOptions.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">选择目标父级即可调整层级(拖拽之外的方式),最多 {MAX_MENU_DEPTH} 级。</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() =>
|
||||
onApply(label.trim() || row.label, disabled, parentValue === ROOT_OPTION ? null : parentValue)
|
||||
}
|
||||
>
|
||||
确定
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import PublicLeaderboardPage from "./PublicLeaderboardPage";
|
||||
import LightAppIframeView from "./LightAppIframeView";
|
||||
import LightAppListPage from "./LightAppListPage";
|
||||
import LightAppManagementPage from "./LightAppManagementPage";
|
||||
import MenuManagementPage from "./MenuManagementPage";
|
||||
import LightAppNewNotePage from "./LightAppNewNotePage";
|
||||
import LightAppNoteListPage from "./LightAppNoteListPage";
|
||||
import LightAppNoteQAPage from "./LightAppNoteQAPage";
|
||||
@@ -310,6 +311,16 @@ export default function StrategyRoutes() {
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/menu-management"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<MenuManagementPage />
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/scheduled-tasks"
|
||||
element={
|
||||
|
||||
Reference in new issue
Block a user