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
@@ -80,6 +80,7 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
|
||||
- **Routing**: React Router v7; top-level routes in `src/pages/PageRoutes.tsx`, workspace sub-routes in `src/pages/WorkspaceRoutes.tsx`
|
||||
- **Pages**: `ChatPage` (thread chat), `AgentChatPage` (custom agent chat), `AgentsPage`/`NewAgentPage` (agent management), `ScheduledTasksPage`/`ScheduledTaskRunDetailPage` (scheduled tasks), `LightAppManagementPage`/`LightAppListPage`/`LightAppIframeView` (轻应用中心 — register/list/embed mini-apps)
|
||||
- **轻应用中心 (Light App Center)**: a sibling of 任务管理 in the sidebar with two pages — 应用管理 (admin registers `window_open` or `iframe` mini-apps with a route-param builder: login params resolved from `localStorage.userInfo`, theme params literal) and 应用列表 (card grid of `window_open` apps, `window.open` launch). `iframe` apps are mounted under a chosen parent menu (XK/PG/TY/综合管理) and **dynamically injected into the sidebar** by `core/page-layout/use-sidebar-menu.ts` (`useSidebarMenuItems`), opening in the `LightAppIframeView` host. Client + React Query hooks in `strategy-components/api/light-apps.ts`; URL builder/constants in `strategy-components/lib/light-app.ts`. Backend: `/api/light-apps` (global/shared, admin-gated writes).
|
||||
- **菜单管理 (Menu Management, admin-only)**: an **overlay** over the static sidebar menu — `MenuManagementPage` (`pages/MenuManagementPage.tsx`, route `/page/strategy/admin/menu-management`, opened from the admin-only 「菜单管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`). Admins rename / 停用·启用 / reorder (↑↓ or @dnd-kit drag) / re-parent (horizontal drag **or** the edit dialog's 「移动到」parent selector) any node across 一级/二级/三级, **including injected iframe light-app nodes** (`light-app-<appId>`). The static tree (`core/page-layout/sidebar-menu.ts`) stays the source of icons/routes; only per-node deltas (`label`/`disabled`/`parentId`/`sortOrder`) persist to the backend. Merge logic in `core/page-layout/menu-overrides.ts` (`applyMenuOverrides`, applied in `use-sidebar-menu.ts` after light-app injection, before role filter; disabling a node hides its whole subtree) — strict **3-level cap** (`wouldExceedDepth`). Client `strategy-components/api/menu-overrides.ts`. Backend: `/api/menu-overrides` (global/shared; open read, admin atomic replace-all).
|
||||
- **State**: TanStack Query for server state, context providers (`SubtasksProvider`, `ArtifactsProvider`, `PromptInputProvider`) scoped to `WorkspaceRoutes`
|
||||
- **LLM streaming**: `@langchain/langgraph-sdk` streams messages from the backend via SSE
|
||||
- **UI**: Radix UI primitives + TailwindCSS v4 + shadcn-style components in `src/components/ui/`
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ChevronsUpDown,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
ListTree,
|
||||
Settings,
|
||||
User,
|
||||
Wrench,
|
||||
@@ -198,6 +199,14 @@ function SecondaryNavSidebar() {
|
||||
<User className="size-4" />
|
||||
个人信息
|
||||
</DropdownMenuItem>
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/strategy/admin/menu-management")}
|
||||
>
|
||||
<ListTree className="size-4" />
|
||||
菜单管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
@@ -464,6 +473,12 @@ function LegacyAccordionSidebar({
|
||||
<User className="size-4" />
|
||||
个人信息
|
||||
</DropdownMenuItem>
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/menu-management")}>
|
||||
<ListTree className="size-4" />
|
||||
菜单管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -455,9 +455,15 @@ export function AIWritingPanel({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 技能检索 pending:用户点击瞬间到 awaiting 之间,始终显示 SkillStepsCard 占位
|
||||
steps.length === 0 时 SkillStepsCard 内部会渲染骨架行,不让用户干等空白 */}
|
||||
{state.skillSearchPending && (
|
||||
{/* 技能检索过程实时展示:
|
||||
① skillSearchPending —— PAUSE-1 用户点"重新搜索/按输入追加"瞬间到 awaiting
|
||||
之间的占位(steps 为空时 SkillStepsCard 内部渲染骨架行,不让用户干等空白);
|
||||
② 首次素材收集(researching 且已有 skill_search_step)—— 让"调用了哪个技能 /
|
||||
检索到多少条 / 检索完成"像智能体问答一样边搜边显示,而不是搜完才在素材确认
|
||||
卡里一次性看到。素材确认(awaiting_material_confirm)时由 InterventionCard
|
||||
接管展示同一份 steps,这里 researching 条件自动失效,不会重复。 */}
|
||||
{(state.skillSearchPending
|
||||
|| (state.status === 'researching' && state.skillSearchSteps.length > 0)) && (
|
||||
<div className="px-4 pb-2">
|
||||
<SkillStepsCard steps={state.skillSearchSteps} inProgress />
|
||||
</div>
|
||||
@@ -467,8 +473,11 @@ export function AIWritingPanel({
|
||||
state.status !== 'writing_draft' &&
|
||||
!(state.status === 'writing_outline' && (state.outlineStreamingText || state.outlineThinkingText)) &&
|
||||
!(state.status === 'reviewing' && state.streamingReviewItems.length > 0) &&
|
||||
/* 技能检索占位卡 / 选择打字机 已经在流式展示,不再叠加 skeleton */
|
||||
/* 技能检索占位卡 / 选择打字机 / 已流式的技能步骤 已经在展示,不再叠加 skeleton。
|
||||
(研究阶段一旦有 skill_search_step,就改由上面的 SkillStepsCard 实时呈现进度,
|
||||
研究词生成阶段 steps 还为空时仍走 skeleton 透传 researchProgress 文案。) */
|
||||
!state.skillSearchPending &&
|
||||
!(state.status === 'researching' && state.skillSearchSteps.length > 0) &&
|
||||
!(state.status === 'researching' && (state.skillSelectStreamingText || state.skillSelectThinkingText)) &&
|
||||
/* 意图解析流式过程中(LLM 还在吐 JSON)不要显示 skeleton */
|
||||
!state.intentStreamingText && !state.intentThinkingText && (
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* 菜单覆盖层(菜单管理)API 客户端 + React Query hooks。
|
||||
*
|
||||
* 后端:`/api/menu-overrides`(见 app/gateway/routers/menu_overrides.py)。
|
||||
* 全局共享、无用户隔离;读对所有登录用户开放(侧边栏要用),写仅 admin(整体替换)。
|
||||
* snake_case ⇄ camelCase 在此边界收敛。
|
||||
*/
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import type { MenuOverride } from "@/core/page-layout/menu-overrides";
|
||||
|
||||
const BASE = () => `${getBackendBaseURL()}/api/menu-overrides`;
|
||||
|
||||
interface ServerOverride {
|
||||
node_id: string;
|
||||
parent_id: string | null;
|
||||
sort_order: number;
|
||||
label: string | null;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
function toOverride(s: ServerOverride): MenuOverride {
|
||||
return {
|
||||
nodeId: s.node_id,
|
||||
parentId: s.parent_id ?? null,
|
||||
sortOrder: s.sort_order ?? 0,
|
||||
label: s.label ?? null,
|
||||
disabled: s.disabled ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
function toBody(o: MenuOverride): ServerOverride {
|
||||
return {
|
||||
node_id: o.nodeId,
|
||||
parent_id: o.parentId ?? null,
|
||||
sort_order: o.sortOrder,
|
||||
label: o.label ?? null,
|
||||
disabled: o.disabled,
|
||||
};
|
||||
}
|
||||
|
||||
async function readJson<T>(res: Response, ctx: string): Promise<T> {
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null as unknown);
|
||||
const detail =
|
||||
data && typeof data === "object" && "detail" in data
|
||||
? String((data as { detail: unknown }).detail)
|
||||
: `${ctx} failed (${res.status})`;
|
||||
throw new Error(detail);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
export async function listMenuOverrides(): Promise<MenuOverride[]> {
|
||||
const res = await apiFetch(BASE(), { method: "GET" });
|
||||
const data = await readJson<{ overrides: ServerOverride[] }>(res, "list menu overrides");
|
||||
return (data.overrides ?? []).map(toOverride);
|
||||
}
|
||||
|
||||
export async function replaceMenuOverrides(overrides: MenuOverride[]): Promise<MenuOverride[]> {
|
||||
const res = await apiFetch(BASE(), {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ overrides: overrides.map(toBody) }),
|
||||
});
|
||||
const data = await readJson<{ overrides: ServerOverride[] }>(res, "save menu overrides");
|
||||
return (data.overrides ?? []).map(toOverride);
|
||||
}
|
||||
|
||||
// ── React Query hooks(侧边栏 + 管理页共用缓存) ────────────────────────────
|
||||
|
||||
const MENU_OVERRIDES_KEY = "menu-overrides";
|
||||
|
||||
export function useMenuOverrides() {
|
||||
return useQuery({
|
||||
queryKey: [MENU_OVERRIDES_KEY],
|
||||
queryFn: listMenuOverrides,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMenuOverrideActions() {
|
||||
const queryClient = useQueryClient();
|
||||
return {
|
||||
save: useMutation({
|
||||
mutationFn: replaceMenuOverrides,
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: [MENU_OVERRIDES_KEY] }),
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -252,6 +252,7 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA
|
||||
| **HTML Page Favorites** (`/api/html-page-favorites`) | Bookmarked generated HTML pages, owner-scoped. `GET /` - list; `POST /` `{page_id, title?, description?, tags?}` - bookmark a `scheduled_html_pages` row (extracts a reusable style/layout summary via GLM5 at favorite time); a page can only be favorited once per user (`DuplicateFavoriteError` ? HTTP 409). `GET/DELETE /{id}` - read/delete (the create-task reference picker deletes favorites here). A favorite can be selected as a style reference when creating a new `html_page` task. |
|
||||
| **Public Scheduled Tasks** (`/api/public/scheduled-tasks`) | **Unauthenticated** mirror for embedding. Adds `GET /runs/{rid}/html` and `GET /{task_id}/latest-html` for the `html_page` preview; the run result keeps `result.html` (only file paths are redacted). |
|
||||
| **Light Apps** (`/api/light-apps`) | Light-app registry (????? / ????), **global/shared** (no user scoping; `created_by`/`updated_by` audit-only). Two kinds: `window_open` (launched in a new tab from the ???? card grid) and `iframe` (embedded under a parent sidebar menu via `mount_location` and dynamically injected into the sidebar). Both carry `route_params` ? a list of `{key, paramType: login\|theme, source, customValue, themeValue}` descriptors the **frontend** resolves at launch time (login params read from `localStorage.userInfo`; theme params use a literal value) and appends to `url` as query string. `GET /` - list (filters `app_type`/`mount_location`/`enabled_only`/`search`); `GET /{app_id}` - read one (both open to any authenticated user); `POST /`, `PUT /{app_id}`, `DELETE /{app_id}` - **admin only**; iframe apps must carry a `mount_location` (400 otherwise); the unique `name` collides as HTTP 409. Backed by `deerflow.persistence.light_apps` (table `light_apps`, auto-created by `create_all`); store wired in `deps.py` as `app.state.light_app_store`. Tests: `tests/test_light_apps.py`. |
|
||||
| **Menu Overrides** (`/api/menu-overrides`) | Sidebar-menu overrides (菜单管理), **global/shared** (no user scoping; `updated_by` audit-only). The static menu tree (icons/routes/`adminOnly`) lives in the **frontend** code (`core/page-layout/sidebar-menu.ts`); iframe light-apps are injected at render time. This router persists a per-node **overlay** the frontend merges onto that tree: `parent_id` (re-parent, moves the whole subtree; NULL = top level), `sort_order` (reorder among siblings), `label` (rename; NULL keeps code label), `disabled` (停用 — hides the node **and its subtree**). A node with no row renders exactly as code defines it, so new code menus / newly-registered iframe apps appear automatically and are immediately configurable. `GET /` - list all overrides (open to any authenticated user); `PUT /` - **admin only**, atomically **replaces the whole set** (`{overrides: [...]}`); rejects re-parenting that would exceed **3 levels** or form a cycle (`_validate_depth`, HTTP 400). Backed by `deerflow.persistence.menu_overrides` (table `menu_overrides`, auto-created by `create_all`); store wired in `deps.py` as `app.state.menu_override_store`. Frontend: merge logic `core/page-layout/menu-overrides.ts` (`applyMenuOverrides` in `use-sidebar-menu.ts`), client `strategy-components/api/menu-overrides.ts`, admin page `pages/MenuManagementPage.tsx` (@dnd-kit drag-reorder/re-parent + 「移动到」parent selector + ↑↓ arrows + 改名/停用), entry in the bottom-left settings dropdown (`components/page-sidebar.tsx`, admin-only). Tests: `tests/test_menu_overrides.py`, `frontend-web/src/core/page-layout/menu-overrides.test.ts`. |
|
||||
| **Knowledge** (`/api/knowledge`) | Product knowledge base, **global/shared** (no user isolation; writes record `created_by`/`updated_by` for audit). `POST /ingest/thread/{thread_id}` - sediment one thread into a note (reads messages from the checkpointer, `threads:read` owner-checked); `POST /ingest/threads/batch` - batch-sediment the caller's recent threads (`skip_existing` via `.manifest.json` dedup). **Defaults to `background=true`**: the slow per-thread extraction loop is deferred to a FastAPI background task and the endpoint returns `{queued:true, processed:N}` immediately (each thread's transcript is truncated to `max_input_chars` before the LLM call). Running the loop inline for a large batch outlives the nginx gateway timeout (504), which the frontend's `parseResponse` surfaces as a generic "Request failed"; `background=false` keeps the blocking path (full per-thread `created/skipped/failed` tally) for small batches/scripts. Shared loop: `_run_batch_ingest`. **Optional model selection**: both ingest endpoints accept `model_name` (overrides `knowledge.extract_model_name` for that capture; threaded through `KnowledgeService.capture_thread` ? `extract_thread_llm_multi`). **Model-error surfacing**: when the chosen model produces nothing usable (timeout / API error / non-JSON) capture still **falls back to rule-based extraction and lands a draft** (`llm_fallback:true`); the single-thread (non-background) response and the batch response (`llm_fallback` count) carry this so the UI tells the user "???????". The "??????" chat button stays background/fire-and-forget (passes `model_name`, no per-save error toast); the batch-import dialog is synchronous and shows the fallback count. `POST /ingest/file` - **import an uploaded file** (multipart: `file` + optional `title`/`tags`/`status`/`model_name`) into wiki note(s): the file is converted to Markdown (md/txt read directly, pdf/word/ppt/excel via `convert_file_to_markdown`), distilled by the same wiki-capture LLM pipeline as thread capture (`source_type="file"`), and falls back to a verbatim **draft** note when the model returns nothing usable (`llm_fallback:true`); `POST /ingest/search` - sediment search/tool results as a reference note; `POST /notes` - manual note; `GET /notes` (keyword/tag/source_type/status + pagination); `GET/PUT/DELETE /notes/{id}` (DELETE is soft = archive, `?hard=true` removes the vault file); `POST /search` - `mode=keyword\|vector\|hybrid` (vector?keyword fallback when no embedding endpoint); `GET /notes/{id}/versions` - edit history; `GET /duplicates` + `POST /merge` - dedup; `POST /reindex` - rebuild vector index; `GET /graph` - product graph data (notes + entities, graph-blacklist filtered); `GET/POST /graph/blacklist` + `DELETE /graph/blacklist/{id}` - graph node blacklist (labels = entity names / note titles, case-insensitive match; blacklisted nodes and their edges are dropped from `/graph` and the wiki-export artifacts; table `knowledge_graph_blacklist`, auto-created); `POST /export/graph` + `GET /export/files/{graph.json\|graph.html\|graph.graphml\|cypher.txt}` - obsidian-wiki `wiki-export` artifacts (self-contained `graph.html` for iframe); `POST /resolve` `{targets:[?]}` + `GET /notes/{id}/wikilinks` - resolve `[[wikilink]]` targets to a note id / vault page / missing (powers clickable wikilinks); `GET /vault-page?path=` - read a raw vault meta/hub Markdown page (no DB note). `GET/POST /folders` + `PUT/DELETE /folders/{id}` - user-managed directories (notes carry a `folder` path; `GET /notes?folder=` filters a dir + sub-dirs); `GET/POST /extract-templates` + `GET/PUT/DELETE /extract-templates/{id}` - editable extraction-prompt templates (thread/document ingest accept `template_id`; capture/PUT-note accept `folder`). Backed by the `deerflow.knowledge` module (see below). |
|
||||
|
||||
Proxied through nginx: `/api/langgraph/*` ? LangGraph, all other `/api/*` ? Gateway.
|
||||
|
||||
@@ -45,6 +45,7 @@ from app.gateway.routers import (
|
||||
llm_metrics,
|
||||
mcp,
|
||||
memory,
|
||||
menu_overrides,
|
||||
models,
|
||||
multi_agent,
|
||||
notifications,
|
||||
@@ -1339,6 +1340,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
||||
# Light-app registry (?????) at /api/light-apps
|
||||
app.include_router(light_apps.router)
|
||||
|
||||
# Sidebar-menu overrides (菜单管理) at /api/menu-overrides
|
||||
app.include_router(menu_overrides.router)
|
||||
|
||||
# Standalone parallel multi-agent panel: fan-out streaming + saved tasks
|
||||
app.include_router(parallel_agents.router)
|
||||
app.include_router(parallel_agent_tasks.router)
|
||||
|
||||
@@ -239,6 +239,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
|
||||
app.state.light_app_store = make_light_app_store(sf)
|
||||
|
||||
from deerflow.persistence.menu_overrides import make_menu_override_store
|
||||
|
||||
app.state.menu_override_store = make_menu_override_store(sf)
|
||||
|
||||
from deerflow.persistence.parallel_agent_tasks import make_parallel_agent_task_store
|
||||
|
||||
app.state.parallel_agent_task_store = make_parallel_agent_task_store(sf)
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Sidebar-menu override API (菜单管理).
|
||||
|
||||
The static sidebar menu tree (icons / routes / adminOnly) is defined in the
|
||||
frontend code; iframe light-apps are injected into it at render time. This
|
||||
router persists a **global / shared** set of per-node overrides that the
|
||||
frontend merges onto that tree: rename (``label``), 停用/启用 (``disabled``),
|
||||
reorder (``sort_order``) and re-parent (``parent_id``, moving a node's whole
|
||||
subtree). A node with no override renders exactly as the code defines it.
|
||||
|
||||
Reads are open to any authenticated user (the sidebar needs them); the whole
|
||||
override set is replaced atomically and only by an admin.
|
||||
|
||||
Routes (prefix ``/api/menu-overrides``):
|
||||
GET "" list all overrides
|
||||
PUT "" replace the entire override set (admin)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.gateway.deps import get_current_user, get_optional_user_from_request
|
||||
from deerflow.persistence.menu_overrides import MenuOverrideStore
|
||||
|
||||
router = APIRouter(prefix="/api/menu-overrides", tags=["menu-overrides"])
|
||||
|
||||
# Hard cap mirroring the sidebar's 一级 → 二级 → 三级 rendering depth.
|
||||
_MAX_DEPTH = 3
|
||||
|
||||
|
||||
# ── schemas ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class MenuOverride(BaseModel):
|
||||
node_id: str = Field(..., min_length=1, max_length=128)
|
||||
parent_id: str | None = Field(default=None, max_length=128)
|
||||
sort_order: int = 0
|
||||
label: str | None = Field(default=None, max_length=255)
|
||||
disabled: bool = False
|
||||
|
||||
|
||||
class MenuOverrideResponse(MenuOverride):
|
||||
updated_by: str | None = None
|
||||
updated_at: datetime | str | None = None
|
||||
|
||||
|
||||
class MenuOverrideListResponse(BaseModel):
|
||||
overrides: list[MenuOverrideResponse]
|
||||
|
||||
|
||||
class MenuOverrideReplaceRequest(BaseModel):
|
||||
overrides: list[MenuOverride] = Field(default_factory=list)
|
||||
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _get_store(request: Request) -> MenuOverrideStore:
|
||||
store = getattr(request.app.state, "menu_override_store", None)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="Menu override store not available")
|
||||
return store
|
||||
|
||||
|
||||
async def _require_admin(request: Request) -> None:
|
||||
"""Reject non-admins. Lets the call through when auth is disabled."""
|
||||
user = await get_optional_user_from_request(request)
|
||||
if user is None:
|
||||
return
|
||||
if getattr(user, "system_role", None) != "admin":
|
||||
raise HTTPException(status_code=403, detail="菜单管理仅限管理员")
|
||||
|
||||
|
||||
def _validate_depth(overrides: list[MenuOverride]) -> None:
|
||||
"""Reject cycles and any node whose effective depth exceeds 3 levels.
|
||||
|
||||
Only nodes that appear in the override set carry an explicit ``parent_id``;
|
||||
a node absent from the set keeps its code-defined parent (which is at most
|
||||
3 levels deep by construction). So validating the override graph alone is
|
||||
sufficient to stop an admin from pushing a 4th level via re-parenting.
|
||||
"""
|
||||
parent_of = {o.node_id: o.parent_id for o in overrides}
|
||||
|
||||
for node_id in parent_of:
|
||||
depth = 1
|
||||
seen = {node_id}
|
||||
cursor = parent_of[node_id]
|
||||
while cursor:
|
||||
depth += 1
|
||||
if depth > _MAX_DEPTH:
|
||||
raise HTTPException(status_code=400, detail=f"菜单「{node_id}」层级超过 {_MAX_DEPTH} 级")
|
||||
if cursor in seen:
|
||||
raise HTTPException(status_code=400, detail=f"菜单「{node_id}」存在循环引用")
|
||||
seen.add(cursor)
|
||||
# A parent that is not itself in the override set is a code node at a
|
||||
# known depth ≤ 3, so the chain terminates there.
|
||||
cursor = parent_of.get(cursor)
|
||||
|
||||
|
||||
# ── routes ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("", response_model=MenuOverrideListResponse)
|
||||
async def list_menu_overrides(request: Request) -> MenuOverrideListResponse:
|
||||
store = _get_store(request)
|
||||
rows = await store.list_overrides()
|
||||
return MenuOverrideListResponse(overrides=[MenuOverrideResponse(**r) for r in rows])
|
||||
|
||||
|
||||
@router.put("", response_model=MenuOverrideListResponse)
|
||||
async def replace_menu_overrides(request: Request, body: MenuOverrideReplaceRequest) -> MenuOverrideListResponse:
|
||||
await _require_admin(request)
|
||||
_validate_depth(body.overrides)
|
||||
store = _get_store(request)
|
||||
user_id = await get_current_user(request)
|
||||
rows = await store.replace_all(
|
||||
[o.model_dump() for o in body.overrides],
|
||||
updated_by=user_id,
|
||||
)
|
||||
return MenuOverrideListResponse(overrides=[MenuOverrideResponse(**r) for r in rows])
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
"""Sidebar-menu override persistence (菜单管理)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from deerflow.persistence.menu_overrides.base import MenuOverrideStore
|
||||
from deerflow.persistence.menu_overrides.memory import MemoryMenuOverrideStore
|
||||
from deerflow.persistence.menu_overrides.model import MenuOverrideRow
|
||||
from deerflow.persistence.menu_overrides.sql import MenuOverrideRepository
|
||||
|
||||
__all__ = [
|
||||
"MemoryMenuOverrideStore",
|
||||
"MenuOverrideRepository",
|
||||
"MenuOverrideRow",
|
||||
"MenuOverrideStore",
|
||||
"make_menu_override_store",
|
||||
]
|
||||
|
||||
|
||||
def make_menu_override_store(session_factory):
|
||||
if session_factory is None:
|
||||
return MemoryMenuOverrideStore()
|
||||
return MenuOverrideRepository(session_factory)
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
"""Abstract sidebar-menu override persistence API.
|
||||
|
||||
Global / shared store: no ``user_id`` scoping. Reads are open to any caller; the
|
||||
router gates writes behind admin. ``updated_by`` is audit-only.
|
||||
|
||||
The whole override set is treated as one document: the editor computes the full
|
||||
desired layout and replaces every row atomically via :meth:`replace_all`, so the
|
||||
stored set never drifts out of sync with what the admin sees.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
|
||||
class MenuOverrideStore(ABC):
|
||||
@abstractmethod
|
||||
async def list_overrides(self) -> list[dict[str, Any]]:
|
||||
"""Return every stored override row (order unspecified)."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def replace_all(self, rows: list[dict[str, Any]], *, updated_by: str | None = None) -> list[dict[str, Any]]:
|
||||
"""Atomically replace the entire override set with ``rows``.
|
||||
|
||||
Each row is ``{node_id, parent_id, sort_order, label, disabled}``.
|
||||
Returns the persisted rows.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
"""In-memory sidebar-menu override store (used when no DB is configured / tests)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from deerflow.persistence.menu_overrides.base import MenuOverrideStore
|
||||
|
||||
|
||||
def _normalize(data: dict[str, Any], updated_by: str | None) -> dict[str, Any]:
|
||||
return {
|
||||
"node_id": str(data["node_id"]),
|
||||
"parent_id": (str(data["parent_id"]) if data.get("parent_id") not in (None, "") else None),
|
||||
"sort_order": int(data.get("sort_order", 0)),
|
||||
"label": (data.get("label") or None),
|
||||
"disabled": bool(data.get("disabled", False)),
|
||||
"updated_by": updated_by,
|
||||
"updated_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
class MemoryMenuOverrideStore(MenuOverrideStore):
|
||||
def __init__(self) -> None:
|
||||
self._rows: dict[str, dict[str, Any]] = {}
|
||||
|
||||
async def list_overrides(self) -> list[dict[str, Any]]:
|
||||
return [dict(r) for r in sorted(self._rows.values(), key=lambda r: r.get("sort_order", 0))]
|
||||
|
||||
async def replace_all(self, rows: list[dict[str, Any]], *, updated_by: str | None = None) -> list[dict[str, Any]]:
|
||||
self._rows = {}
|
||||
for r in rows:
|
||||
node_id = r.get("node_id")
|
||||
if not node_id:
|
||||
continue
|
||||
self._rows[str(node_id)] = _normalize(r, updated_by)
|
||||
return [dict(r) for r in self._rows.values()]
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
"""ORM model for sidebar-menu overrides (菜单管理).
|
||||
|
||||
Global / shared — there is no ``user_id`` scoping; every account sees the same
|
||||
menu layout. The static menu tree (icons / routes / adminOnly) lives in the
|
||||
frontend code; this table only stores per-node **overrides** keyed by the static
|
||||
node id (or an injected iframe light-app node id ``light-app-<appId>``):
|
||||
|
||||
- ``parent_id`` — the effective parent node id (NULL = top level). Re-parenting
|
||||
a node moves its whole subtree.
|
||||
- ``sort_order`` — position among siblings under the effective parent.
|
||||
- ``label`` — rename; NULL keeps the code-defined label.
|
||||
- ``disabled`` — hide the node (and its subtree) from the sidebar.
|
||||
|
||||
A node with no row here renders exactly as the code defines it. ``updated_by``
|
||||
is audit-only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import Boolean, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from deerflow.persistence.base import Base
|
||||
from deerflow.persistence.types import BeijingDateTime
|
||||
|
||||
|
||||
class MenuOverrideRow(Base):
|
||||
__tablename__ = "menu_overrides"
|
||||
|
||||
node_id: Mapped[str] = mapped_column(String(128), primary_key=True)
|
||||
# Effective parent node id; NULL = top-level menu.
|
||||
parent_id: Mapped[str | None] = mapped_column(String(128), index=True)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
# Rename override; NULL keeps the code-defined label.
|
||||
label: Mapped[str | None] = mapped_column(String(255))
|
||||
disabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
updated_by: Mapped[str | None] = mapped_column(String(64))
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
BeijingDateTime(), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC)
|
||||
)
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
"""SQLAlchemy-backed sidebar-menu override store (global / shared)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from deerflow.persistence.menu_overrides.base import MenuOverrideStore
|
||||
from deerflow.persistence.menu_overrides.model import MenuOverrideRow
|
||||
|
||||
|
||||
def _to_dict(row: MenuOverrideRow) -> dict[str, Any]:
|
||||
data = row.to_dict()
|
||||
value = data.get("updated_at")
|
||||
if isinstance(value, datetime):
|
||||
data["updated_at"] = value.isoformat()
|
||||
return data
|
||||
|
||||
|
||||
def _row_from(data: dict[str, Any], updated_by: str | None) -> MenuOverrideRow:
|
||||
return MenuOverrideRow(
|
||||
node_id=str(data["node_id"]),
|
||||
parent_id=(str(data["parent_id"]) if data.get("parent_id") not in (None, "") else None),
|
||||
sort_order=int(data.get("sort_order", 0)),
|
||||
label=(data.get("label") or None),
|
||||
disabled=bool(data.get("disabled", False)),
|
||||
updated_by=updated_by,
|
||||
)
|
||||
|
||||
|
||||
class MenuOverrideRepository(MenuOverrideStore):
|
||||
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||
self._sf = session_factory
|
||||
|
||||
async def list_overrides(self) -> list[dict[str, Any]]:
|
||||
stmt = select(MenuOverrideRow).order_by(MenuOverrideRow.sort_order.asc())
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(stmt)
|
||||
return [_to_dict(row) for row in result.scalars()]
|
||||
|
||||
async def replace_all(self, rows: list[dict[str, Any]], *, updated_by: str | None = None) -> list[dict[str, Any]]:
|
||||
# De-dupe by node_id (last wins) so a malformed payload can't violate the PK.
|
||||
by_id: dict[str, dict[str, Any]] = {}
|
||||
for r in rows:
|
||||
node_id = r.get("node_id")
|
||||
if node_id:
|
||||
by_id[str(node_id)] = r
|
||||
async with self._sf() as session:
|
||||
await session.execute(delete(MenuOverrideRow))
|
||||
new_rows = [_row_from(r, updated_by) for r in by_id.values()]
|
||||
session.add_all(new_rows)
|
||||
await session.commit()
|
||||
for row in new_rows:
|
||||
await session.refresh(row)
|
||||
return [_to_dict(row) for row in new_rows]
|
||||
+2
@@ -40,6 +40,7 @@ from deerflow.knowledge.models import (
|
||||
)
|
||||
from deerflow.persistence.light_apps.model import LightAppRow
|
||||
from deerflow.persistence.llm_metrics.model import LlmCallMetricRow
|
||||
from deerflow.persistence.menu_overrides.model import MenuOverrideRow
|
||||
from deerflow.persistence.models.run_event import RunEventRow
|
||||
from deerflow.persistence.notifications.model import NotificationRow
|
||||
from deerflow.persistence.parallel_agent_tasks.model import ParallelAgentTaskRow
|
||||
@@ -92,6 +93,7 @@ __all__ = [
|
||||
"KnowledgeTagRow",
|
||||
"LightAppRow",
|
||||
"LlmCallMetricRow",
|
||||
"MenuOverrideRow",
|
||||
"NotificationRow",
|
||||
"ParallelAgentTaskRow",
|
||||
"PositionRow",
|
||||
|
||||
Reference in new issue
Block a user