From 1c8b5b23a190a3dca0ebbf081f02cab7e02d7c84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Tue, 16 Jun 2026 23:32:33 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=8F=9C=E5=8D=95=E7=AE=A1=E7=90=86?= =?UTF-8?q?=EF=BC=88=E4=BE=A7=E8=BE=B9=E6=A0=8F=E8=A6=86=E7=9B=96=E5=B1=82?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 管理员可对一/二/三级菜单(含注入的 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) --- CLAUDE.md | 1 + frontend-web/src/components/page-sidebar.tsx | 15 + .../core/page-layout/menu-overrides.test.ts | 119 ++++ .../src/core/page-layout/menu-overrides.ts | 263 ++++++++ .../src/core/page-layout/use-sidebar-menu.ts | 21 +- .../components/ai-writing/AIWritingPanel.tsx | 17 +- frontend-web/src/pages/MenuManagementPage.tsx | 624 ++++++++++++++++++ frontend-web/src/pages/StrategyRoutes.tsx | 11 + .../strategy-components/api/menu-overrides.ts | 94 +++ offline-backend-20260512/backend/CLAUDE.md | 1 + .../backend/app/gateway/app.py | 4 + .../backend/app/gateway/deps.py | 4 + .../app/gateway/routers/menu_overrides.py | 123 ++++ .../persistence/menu_overrides/__init__.py | 22 + .../persistence/menu_overrides/base.py | 30 + .../persistence/menu_overrides/memory.py | 37 ++ .../persistence/menu_overrides/model.py | 42 ++ .../persistence/menu_overrides/sql.py | 58 ++ .../deerflow/persistence/models/__init__.py | 2 + 19 files changed, 1480 insertions(+), 8 deletions(-) create mode 100644 frontend-web/src/core/page-layout/menu-overrides.test.ts create mode 100644 frontend-web/src/core/page-layout/menu-overrides.ts create mode 100644 frontend-web/src/pages/MenuManagementPage.tsx create mode 100644 frontend-web/src/strategy-components/api/menu-overrides.ts create mode 100644 offline-backend-20260512/backend/app/gateway/routers/menu_overrides.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/menu_overrides/__init__.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/menu_overrides/base.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/menu_overrides/memory.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/menu_overrides/model.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/menu_overrides/sql.py diff --git a/CLAUDE.md b/CLAUDE.md index 54243962..56533a1d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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-`). 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/` diff --git a/frontend-web/src/components/page-sidebar.tsx b/frontend-web/src/components/page-sidebar.tsx index 576c205e..6d77b6fa 100644 --- a/frontend-web/src/components/page-sidebar.tsx +++ b/frontend-web/src/components/page-sidebar.tsx @@ -7,6 +7,7 @@ import { ChevronsUpDown, Folder, FolderOpen, + ListTree, Settings, User, Wrench, @@ -198,6 +199,14 @@ function SecondaryNavSidebar() { 个人信息 + {isAdmin && ( + navigate("/page/strategy/admin/menu-management")} + > + + 菜单管理 + + )} ) : null} @@ -464,6 +473,12 @@ function LegacyAccordionSidebar({ 个人信息 + {isAdmin && ( + navigate("/page/strategy/admin/menu-management")}> + + 菜单管理 + + )} ) : ( diff --git a/frontend-web/src/core/page-layout/menu-overrides.test.ts b/frontend-web/src/core/page-layout/menu-overrides.test.ts new file mode 100644 index 00000000..0db6edcf --- /dev/null +++ b/frontend-web/src/core/page-layout/menu-overrides.test.ts @@ -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"); +}); diff --git a/frontend-web/src/core/page-layout/menu-overrides.ts b/frontend-web/src/core/page-layout/menu-overrides.ts new file mode 100644 index 00000000..4c7bf822 --- /dev/null +++ b/frontend-web/src/core/page-layout/menu-overrides.ts @@ -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-)。 */ + 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(); + 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(); + 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): number { + let depth = 1; + const seen = new Set([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): 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(); + 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(); + 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(); + 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(); + 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; +} diff --git a/frontend-web/src/core/page-layout/use-sidebar-menu.ts b/frontend-web/src/core/page-layout/use-sidebar-menu.ts index 006c767e..b30321a0 100644 --- a/frontend-web/src/core/page-layout/use-sidebar-menu.ts +++ b/frontend-web/src/core/page-layout/use-sidebar-menu.ts @@ -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(); @@ -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], ); } diff --git a/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx b/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx index 7663204d..d2ada26a 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx @@ -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)) && (
@@ -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 && ( diff --git a/frontend-web/src/pages/MenuManagementPage.tsx b/frontend-web/src/pages/MenuManagementPage.tsx new file mode 100644 index 00000000..cfc46123 --- /dev/null +++ b/frontend-web/src/pages/MenuManagementPage.tsx @@ -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(); + 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(); + 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(); + 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 { + const parentOf = new Map(rows.map((r) => [r.id, r.parentId])); + const cache = new Map(); + 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 ( +
+ + + + {row.label} + + + {levelLabel} + + {row.isLightApp && ( + + 应用 + + )} + {row.disabled && ( + + 已停用 + + )} +
+ + + +
+
+ ); +} + +// ── 主页面 ─────────────────────────────────────────────────────────────── + +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(initialRows); + const [loadedKey, setLoadedKey] = useState(""); + const [editId, setEditId] = useState(null); + const [activeId, setActiveId] = useState(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(); + 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 ; + + return ( +
+
+
+

菜单管理

+

+ 拖拽调整顺序与层级(横向拖动改变层级,最多 {MAX_MENU_DEPTH} 级);或用「编辑」改名、停用、移动到指定父级。 +

+
+
+ + +
+
+ + {isLoading ? ( +

加载中…

+ ) : ( +
+ { + setActiveId(null); + setOffsetX(0); + }} + > + r.id)} strategy={verticalListSortingStrategy}> +
+ {rows.map((row) => { + const sibs = siblingsOf(row.parentId); + const i = sibs.findIndex((s) => s.id === row.id); + return ( + 0} + canDown={i >= 0 && i < sibs.length - 1} + onUp={() => moveUp(row)} + onDown={() => moveDown(row)} + onEdit={() => setEditId(row.id)} + /> + ); + })} +
+
+ + {activeId ? ( +
+ {rows.find((r) => r.id === activeId)?.label} +
+ ) : null} +
+
+
+ )} + + {editingRow && ( + setEditId(null)} + onApply={applyEdit} + /> + )} +
+ ); +} + +// ── 编辑弹窗 ───────────────────────────────────────────────────────────── + +interface EditMenuDialogProps { + row: FlatRow; + rows: FlatRow[]; + merged: MergedMenuNode[]; + depthById: Map; + 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 ( + !o && onClose()}> + + + 编辑菜单 + +
+
+ + setLabel(e.target.value)} placeholder="菜单显示名称" /> +
+ +
+
+

启用

+

停用后该菜单(及其子菜单)对所有用户隐藏

+
+ setDisabled(!v)} /> +
+ +
+ + +

选择目标父级即可调整层级(拖拽之外的方式),最多 {MAX_MENU_DEPTH} 级。

+
+
+ + + + +
+
+ ); +} diff --git a/frontend-web/src/pages/StrategyRoutes.tsx b/frontend-web/src/pages/StrategyRoutes.tsx index 2e17bcdf..2aa89a01 100644 --- a/frontend-web/src/pages/StrategyRoutes.tsx +++ b/frontend-web/src/pages/StrategyRoutes.tsx @@ -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() { ) } /> + + ) : ( + + ) + } + /> `${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(res: Response, ctx: string): Promise { + 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 { + 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 { + 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] }), + }), + }; +} diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index f2af2199..f9c5cc9f 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -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. diff --git a/offline-backend-20260512/backend/app/gateway/app.py b/offline-backend-20260512/backend/app/gateway/app.py index bae7f335..b31de5ee 100644 --- a/offline-backend-20260512/backend/app/gateway/app.py +++ b/offline-backend-20260512/backend/app/gateway/app.py @@ -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) diff --git a/offline-backend-20260512/backend/app/gateway/deps.py b/offline-backend-20260512/backend/app/gateway/deps.py index e403a7cc..008bbea5 100644 --- a/offline-backend-20260512/backend/app/gateway/deps.py +++ b/offline-backend-20260512/backend/app/gateway/deps.py @@ -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) diff --git a/offline-backend-20260512/backend/app/gateway/routers/menu_overrides.py b/offline-backend-20260512/backend/app/gateway/routers/menu_overrides.py new file mode 100644 index 00000000..9a254e64 --- /dev/null +++ b/offline-backend-20260512/backend/app/gateway/routers/menu_overrides.py @@ -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]) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/menu_overrides/__init__.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/menu_overrides/__init__.py new file mode 100644 index 00000000..c1b7f130 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/menu_overrides/__init__.py @@ -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) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/menu_overrides/base.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/menu_overrides/base.py new file mode 100644 index 00000000..270d2141 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/menu_overrides/base.py @@ -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 diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/menu_overrides/memory.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/menu_overrides/memory.py new file mode 100644 index 00000000..a08c27b2 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/menu_overrides/memory.py @@ -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()] diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/menu_overrides/model.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/menu_overrides/model.py new file mode 100644 index 00000000..3622c051 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/menu_overrides/model.py @@ -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-``): + +- ``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) + ) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/menu_overrides/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/menu_overrides/sql.py new file mode 100644 index 00000000..665012f7 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/menu_overrides/sql.py @@ -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] diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py index 3870e1f5..aaa66560 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py @@ -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",