Merge branch 'dhk-1' into 'master'

feat: 业务链条配置管理导航与顶栏用户名统一

See merge request knowledge-base-bj/deerflow-server!133
This commit is contained in:
董宏凯 committed 2026-06-17 12:25:17 +00:00
commit d82973c8c8
63 files changed
+5346 -1704

No files matched your search

+3 -1
View File
@@ -80,11 +80,13 @@ 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).
- **敏感词管理 (Sensitive Word Management)**: configures the **展示层脱敏映射** (原始词 → 替换词). **Gated on the username `lqq`** (not the admin role) — `getAccountDisplayName().toLowerCase() === "lqq"` (username = email `@` prefix, else `user_id`, same as the profile page); the dropdown entry, the route guard, and the page-level guard all use this check. `src/lib/desensitize.ts` ships a frozen seed map `SEED_DESENSITIZE_MAP` (the deployment seed) plus a live mutable `DESENSITIZE_MAP` (initialized from the seed so `desensitize()` works before any fetch). `DesensitizeWordsLoader` (mounted in `App.tsx` `AppProviders`) fetches `/api/sensitive-words` on startup and, if non-empty, calls `replaceDesensitizeMap(...)` to make the backend config the source of truth (empty/failed → keep seed). Page `SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, opened from the 「敏感词管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`, present in **both** full + collapsed sidebar modes) lists/adds/edits/启停/deletes words and saves the **whole set atomically** (replace-all); a 「导入种子词」 button merges any missing `SEED_DESENSITIZE_MAP` entries into the editor. Client + hooks in `strategy-components/api/sensitive-words.ts` (`wordsToMap` folds enabled rows into the runtime map). Backend: `/api/sensitive-words` (global/shared; open read, write allowed for admin **or** the `lqq` account), mirroring 菜单管理's store shape. **侧边栏 + 导航栏 menu labels are desensitized** at the data source: `useSidebarMenuItems`/`useActiveThirdLevel` (`core/page-layout/use-sidebar-menu.ts`) map every node's `label` through a desensitizer built **directly from the `/api/sensitive-words` query** (`makeDesensitizer(wordsToMap(words))`, falling back to the global seed `desensitize` while empty) — this sidesteps the `replaceDesensitizeMap` effect-timing race. Only `label` is rewritten; `path`/`id`/`icon` are untouched so routing/highlight are unaffected. All nav surfaces (`page-sidebar.tsx`, `page-sidebar-dark.tsx`, `page-sidebar-v1.tsx`, `strategy-components/Header.tsx`, `PageRoutes.tsx`) consume this hook and are covered; `MenuManagementPage` uses the raw tree so admins still edit real labels.
- **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/`
- **Shims**: `src/shims/` provides `next/link` and `next/navigation` polyfills (original code was Next.js)
- **Login URL params** (`LoginPage.tsx`, frontend-only — the backend never sees these): besides the auth params (`ticket`/`userId`/`env`/`theme`/`yUserId`/`token`/`access_token`/`authToken`/`password`), the page supports a **task deep-link** via `?taskId=&goPath=`. After login it calls the consumer API `GET {VITE_AIPI_CONSUMER_URL}/taskAnalyseSearch/cop-task-detail?taskId=` (header `Authorization: Bearer admin`, auto-injected by `utils/request.ts`), stashes the full `data` (`id/overview/taskDirection/taskName/taskContent`) into `sessionStorage["login.copTaskDetail"]`, then routes by `goPath`: base codes `3qfx`/`rwfx`/`xdfx` → that business's configured **single agent** Q&A (resolved via `/api/business-mapping`, opening message in `sessionStorage.pendingAgentMessage`); the `-A` suffix (`3qfx-A`/…) → **multi-agent roundtable** with `roundtablePrefill.businessCode` so the page auto-selects the configured 业务链条. goPath→businessCode: `3qfx`→`3Q`, `rwfx`→`6BF`, `xdfx`→`7BF`. **Both targets render embedded under the 任务管理 layout** — nested routes under `task-management/<business-path>/{roundtable,agent/:agent_id/chats/:thread_id}` in `StrategyRoutes.tsx` (`<business-path>` = `plan-task-sentiment` / `plan-task/plan-task-fx` / `plan-task/plan-task-action`, matching the COH中任务 menu paths in `sidebar-menu.ts`) so the global sidebar keeps 任务管理 → COH中任务 → the matching business highlighted. The agent chat is rendered via `TaskEmbeddedAgentChat`, which passes `AgentChatPage`'s `chatBasePath` prop so the post-send URL rewrite + new-chat button stay in the task-management context (instead of the standalone `/agents/...` route). Any failure (no consumer URL / fetch error / unmapped goPath / no configured agent) silently falls back to the default home. Helpers: `core/auth/task-deeplink.ts`.
- **Login URL params** (`LoginPage.tsx`, frontend-only — the backend never sees these): besides the auth params (`ticket`/`userId`/`env`/`theme`/`yUserId`/`token`/`access_token`/`authToken`/`password`), the page supports a **task deep-link** via `?taskId=&goPath=`. After login it calls the consumer API `GET {VITE_AIPI_CONSUMER_URL}/taskAnalyseSearch/cop-task-detail?taskId=` (header `Authorization: Bearer admin`, auto-injected by `utils/request.ts`), stashes the full `data` (`id/overview/taskDirection/taskName/taskContent`) into `sessionStorage["login.copTaskDetail"]`, then routes by `goPath`: base codes `3qfx`/`rwfx`/`xdfx` → that business's configured **single agent** Q&A (resolved via `/api/business-mapping`, opening message in `sessionStorage.pendingAgentMessage`); the `-A` suffix (`3qfx-A`/…) → **multi-agent roundtable** with `roundtablePrefill.businessCode` so the page auto-selects the configured 业务链条. goPath→businessCode: `3qfx`→`3Q`, `rwfx`→`6BF`, `xdfx`→`7BF`. The roundtable target also threads the URL `taskId` into `roundtablePrefill.taskId` (`resolveTaskDeeplinkTarget(goPath, detail, taskId)`), which turns on two taskId-scoped behaviors: (1) **per-task draft isolation** — drafts save/list/restore by `task_id` (one taskId = many records, shared across users; backend `GET /api/roundtable-drafts/by-task/{id}` + `/latest`); the history dropdown filters to the task and 新建 keeps the binding; (2) **Step-3 auto structured output** — after the `roundtable-summary` agent writes the Markdown report it automatically runs a second round emitting a `flow-json` flowchart (`useStep3Summary` `autoFlow`+`chainLevels`), whose hierarchy follows the business chain's stage goals (任务 → 目的 → 行为体 → …; coordinatorPrompt-specified hierarchy wins) and whose nodes never carry 阶段编号/阶段名 (the prior bug). **Both targets render embedded under the 任务管理 layout** — nested routes under `task-management/<business-path>/{roundtable,agent/:agent_id/chats/:thread_id}` in `StrategyRoutes.tsx` (`<business-path>` = `plan-task-sentiment` / `plan-task/plan-task-fx` / `plan-task/plan-task-action`, matching the COH中任务 menu paths in `sidebar-menu.ts`) so the global sidebar keeps 任务管理 → COH中任务 → the matching business highlighted. The agent chat is rendered via `TaskEmbeddedAgentChat`, which passes `AgentChatPage`'s `chatBasePath` prop so the post-send URL rewrite + new-chat button stay in the task-management context (instead of the standalone `/agents/...` route). Any failure (no consumer URL / fetch error / unmapped goPath / no configured agent) silently falls back to the default home. Helpers: `core/auth/task-deeplink.ts`.
### Configuration
+2
View File
@@ -2,6 +2,7 @@ import { useMemo } from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import { DesensitizeWordsLoader } from "@/components/desensitize-words-loader";
import { FontScaleApplier } from "@/components/font-scale";
import { QueryClientProvider } from "@/components/query-client-provider";
import { AuthSyncWatcher } from "@/core/auth/sync";
@@ -76,6 +77,7 @@ function AppProviders({ children }: { children: React.ReactNode }) {
<ThemeProvider>
<ChatProvider>
<AuthSyncWatcher />
<DesensitizeWordsLoader />
<FontScaleApplier />
{children}
</ChatProvider>
@@ -0,0 +1,27 @@
import { useEffect } from "react";
import { replaceDesensitizeMap } from "@/lib/desensitize";
import { useSensitiveWords, wordsToMap } from "@/strategy-components/api/sensitive-words";
/**
* 启动时把后台「敏感词管理」配置加载进运行时脱敏映射表。
*
* - 后台返回**非空**时:用其中「启用」的词条整体替换运行时映射
* ({@link replaceDesensitizeMap}),后台即唯一真源,被删除的词不再生效。
* - 后台为空(全新部署还没配置)或拉取失败:什么都不做,保持使用
* `src/lib/desensitize.ts` 内置的种子映射。
*
* 渲染 `null`;挂在 `AppProviders` 里(QueryClientProvider 之内)。与「敏感词管理」
* 页共享同一份 React Query 缓存,保存后会自动刷新并回灌运行时映射。
*/
export function DesensitizeWordsLoader() {
const { data } = useSensitiveWords();
useEffect(() => {
if (data && data.length > 0) {
replaceDesensitizeMap(wordsToMap(data));
}
}, [data]);
return null;
}
@@ -6,6 +6,8 @@ import { getStoredAuth } from "@/core/auth";
import { isWujieEnv } from "@/core/embed/wujie";
import {
defaultExpandedIds,
menuItemContainsPath,
menuItemMatchesPath,
type MenuItem,
} from "@/core/page-layout/sidebar-menu";
import { useSidebarMenuItems } from "@/core/page-layout/use-sidebar-menu";
@@ -53,13 +55,7 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar
}, 180);
};
const hasActiveChild = (item: MenuItem): boolean => {
if (item.path && (pathname === item.path || pathname.startsWith(item.path + "/"))) {
return true;
}
return item.children?.some(hasActiveChild) ?? false;
};
const hasActiveChild = (item: MenuItem): boolean => menuItemContainsPath(item, pathname);
// lazy init:仅在首次 render 计算一次 active id;之后由下面的 useEffect 跟随 pathname 同步
const [activeTopId, setActiveTopId] = useState<string>(
@@ -152,9 +148,9 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar
const isSidebarItemActive = (item: MenuItem) =>
inWujieEnv
? item.id === activeTopId
: item.path
? pathname === item.path || pathname.startsWith(item.path + "/")
: hasActiveChild(item);
: item.path || item.aliasPaths?.length
? menuItemMatchesPath(item, pathname)
: menuItemContainsPath(item, pathname);
const handleSidebarItemClick = (item: MenuItem) => {
if (inWujieEnv) {
handleTopItemClick(item);
@@ -176,7 +172,9 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar
{items.map((item) => {
const hasChildren = !!item.children?.length;
const isExpanded = expandedIds.has(item.id);
const isSelected = item.path ? pathname === item.path : hasActiveChild(item);
const isSelected = item.path || item.aliasPaths?.length
? menuItemMatchesPath(item, pathname)
: hasActiveChild(item);
return (
<div
+46 -14
View File
@@ -7,7 +7,9 @@ import {
ChevronsUpDown,
Folder,
FolderOpen,
ListTree,
Settings,
ShieldAlert,
User,
Wrench,
} from "lucide-react";
@@ -21,13 +23,20 @@ import {
import { NotificationDialog } from "@/components/workspace/notification-dialog";
import { SettingsDialog } from "@/components/workspace/settings";
import { useTheme } from "@/contexts/ThemeContext";
import { getStoredAuth } from "@/core/auth";
import { getAccountDisplayName, getStoredAuth } from "@/core/auth";
/** 敏感词管理仅对用户名为 lqq 的账号可见(用户名 = 设置→个人信息页展示名)。 */
const SENSITIVE_WORDS_USERNAME = "lqq";
const canManageSensitiveWords = () =>
getAccountDisplayName().toLowerCase() === SENSITIVE_WORDS_USERNAME;
import { isWujieEnv } from "@/core/embed/wujie";
import { useI18n } from "@/core/i18n/hooks";
import { useUnreadCount } from "@/core/notifications";
import {
defaultExpandedIds,
getActiveThirdLevelItems,
menuItemContainsPath,
menuItemMatchesPath,
type MenuItem,
} from "@/core/page-layout/sidebar-menu";
import { useSidebarMenuItems } from "@/core/page-layout/use-sidebar-menu";
@@ -41,12 +50,8 @@ type PageSidebarProps = {
type SettingsSection = "account" | "appearance" | "memory" | "tools" | "about";
const hasActiveChild = (item: MenuItem, pathname: string): boolean => {
if (item.path && (pathname === item.path || pathname.startsWith(item.path + "/"))) {
return true;
}
return item.children?.some((child) => hasActiveChild(child, pathname)) ?? false;
};
const hasActiveChild = (item: MenuItem, pathname: string): boolean =>
menuItemContainsPath(item, pathname);
const findFirstRoutePath = (items?: MenuItem[]): string | null => {
if (!items?.length) {
@@ -116,9 +121,9 @@ function SecondaryNavSidebar() {
const secondaryItems = activeTopItem?.children ?? [];
const isItemActive = (item: MenuItem) =>
item.path
? pathname === item.path || pathname.startsWith(item.path + "/")
: hasActiveChild(item, pathname);
item.path || item.aliasPaths?.length
? menuItemMatchesPath(item, pathname)
: menuItemContainsPath(item, pathname);
const handleItemClick = (item: MenuItem) => {
const path = item.path ?? findFirstRoutePath(item.children);
@@ -200,6 +205,22 @@ function SecondaryNavSidebar() {
<User className="size-4" />
个人信息
</DropdownMenuItem>
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/strategy/admin/menu-management")}
>
<ListTree className="size-4" />
菜单管理
</DropdownMenuItem>
)}
{canManageSensitiveWords() && (
<DropdownMenuItem
onClick={() => navigate("/page/strategy/admin/sensitive-words")}
>
<ShieldAlert className="size-4" />
敏感词管理
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
) : null}
@@ -211,9 +232,8 @@ function SecondaryNavSidebar() {
<nav className="light-sidebar-subnav" aria-label="三级菜单">
{thirdItems.map((child) => {
const ChildIcon = child.icon;
const childActive = child.path
? pathname === child.path || pathname.startsWith(child.path + "/")
: hasActiveChild(child, pathname);
const childActive = menuItemMatchesPath(child, pathname)
|| (child.children?.length ? menuItemContainsPath(child, pathname) : false);
return (
<button
key={child.id}
@@ -301,7 +321,7 @@ function LegacyAccordionSidebar({
const renderLeaf = (item: MenuItem, depth = 0) => {
const Icon = item.icon;
const isActive = !!item.path && pathname === item.path;
const isActive = menuItemMatchesPath(item, pathname);
return (
<button
@@ -467,6 +487,18 @@ function LegacyAccordionSidebar({
<User className="size-4" />
个人信息
</DropdownMenuItem>
{isAdmin && (
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/menu-management")}>
<ListTree className="size-4" />
菜单管理
</DropdownMenuItem>
)}
{canManageSensitiveWords() && (
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/sensitive-words")}>
<ShieldAlert className="size-4" />
敏感词管理
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
) : (
@@ -0,0 +1,59 @@
import * as React from "react";
export interface AutoGrowTextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
/** 内容增长高度上限(px),超过后框内滚动。默认 200。 */
maxHeight?: number;
}
/**
* 自适应高度的多行输入框。
*
* 内容增多时自动扩展高度(输入框处于底部布局时即「向上扩展」),到 `maxHeight` 后框内滚动。
* 体验对齐通用问答页(`/page/strategy/qa/general`)的 tdesign `autosize` 输入框,但保留原生
* `<textarea>`,从而沿用各调用处自带的 Tailwind 样式 / 边框 / 占位色,无需 TDesign 包裹层。
*
* 用法:当成普通受控 `<textarea>` 用(传 `value` + `onChange`)。高度在布局阶段随 `value` 重算,
* 也在 `onChange` 时即时重算(兼顾非受控/输入法场景)。最小高度交给调用方的 `min-h-*` className
* 或 `rows` 控制——本组件不强加最小高度。换行/发送(Enter/Shift+Enter)逻辑由调用方的
* `onKeyDown` 决定。
*/
export const AutoGrowTextarea = React.forwardRef<
HTMLTextAreaElement,
AutoGrowTextareaProps
>(function AutoGrowTextarea(
{ maxHeight = 200, value, onChange, onInput, style, rows = 1, ...rest },
ref,
) {
const innerRef = React.useRef<HTMLTextAreaElement | null>(null);
React.useImperativeHandle(ref, () => innerRef.current as HTMLTextAreaElement);
const resize = React.useCallback(() => {
const el = innerRef.current;
if (!el) return;
el.style.height = "auto";
const next = Math.min(el.scrollHeight, maxHeight);
el.style.height = `${next}px`;
el.style.overflowY = el.scrollHeight > maxHeight ? "auto" : "hidden";
}, [maxHeight]);
// 受控值变化(含外部清空、粘贴大段文本)后在布局阶段重算,避免闪烁。
React.useLayoutEffect(() => {
resize();
}, [resize, value]);
return (
<textarea
ref={innerRef}
rows={rows}
value={value}
onChange={onChange}
onInput={(e) => {
onInput?.(e);
resize();
}}
style={{ resize: "none", ...style }}
{...rest}
/>
);
});
@@ -0,0 +1,35 @@
/**
* 智能体图标底色 —— 按 id 稳定散列到一组主题色,复刻参考图的彩色方块。
*
* 浅色模式沿用原高彩度彩色(不改);深蓝主题改用《深蓝 UI 规范》§1.1.7/§1.1.8 的蓝-青系
* (docs/ui规范-深蓝,克制、不刺眼),仅深蓝主题下生效,互不影响。
*
* 全量版通用问答左栏(GeneralQaSidebar)与 zzJC/zzXD 模块左栏(AgentModuleSidebar)共用,
* 保证彩色卡片配色完全一致。
*/
const AGENT_COLORS_LIGHT = [
{ from: "#fb7185", to: "#ef4444" },
{ from: "#60a5fa", to: "#3b82f6" },
{ from: "#fbbf24", to: "#f59e0b" },
{ from: "#34d399", to: "#10b981" },
{ from: "#a78bfa", to: "#8b5cf6" },
] as const;
const AGENT_COLORS_DARK = [
{ from: "#4096FF", to: "#1677FF" }, // 蓝(链接蓝 → 主色)
{ from: "#36CFC9", to: "#13C2C2" }, // 青(辅色)
{ from: "#6388FF", to: "#0958D9" }, // 紫蓝(装饰紫 → 主色深一档)
{ from: "#5CDBD3", to: "#08979C" }, // 浅青 → 深青
{ from: "#69B1FF", to: "#003EB3" }, // 浅蓝 → 主色按下
] as const;
/** 按 agent id 稳定散列出一对渐变色(from → to)。`isDark` 切换深蓝/浅色调色板。 */
export function agentColor(id: string, isDark: boolean) {
let hash = 0;
for (let i = 0; i < id.length; i++) {
hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
}
const palette = isDark ? AGENT_COLORS_DARK : AGENT_COLORS_LIGHT;
return palette[hash % palette.length]!;
}
@@ -5,6 +5,8 @@ import Link from "next/link";
import { usePathname } from "next/navigation";
import { useMemo, useState } from "react";
import { agentColor } from "@/components/workspace/agent-icon-color";
import { useTheme } from "@/contexts/ThemeContext";
import { useAgents } from "@/core/agents";
import type { Agent } from "@/core/agents";
import { useAgentModuleConfig } from "@/core/system-settings/hooks";
@@ -125,6 +127,9 @@ export function AgentModuleSidebar({
}) {
const routeBase = useRouteBase();
const pathname = usePathname();
// 智能体图标配色分主题:深蓝主题用规范蓝-青系,其它(浅色)保持原高彩度色,互不影响。
const { theme } = useTheme();
const isDark = theme === "dark";
const { agents } = useAgents();
const { data: threads = [] } = useThreads();
const { data: moduleConfig } = useAgentModuleConfig(moduleKey);
@@ -165,7 +170,8 @@ export function AgentModuleSidebar({
});
return (
<aside className="flex h-full w-(--sidebar-width) shrink-0 flex-col overflow-hidden border-r border-border/70 bg-card/60 backdrop-blur-sm">
// 背景按需求固定白色(不走 sidebar token),与全量版 GeneralQaSidebar 的卡片视觉看齐。
<aside className="flex h-full w-(--sidebar-width) shrink-0 flex-col overflow-hidden border-r border-border/70 bg-white text-foreground">
{onOpenSettings && (
<div className="flex shrink-0 items-center justify-between border-b border-border/60 px-3 py-2">
<span className="text-xs font-semibold text-foreground/80">智能体</span>
@@ -174,22 +180,22 @@ export function AgentModuleSidebar({
onClick={onOpenSettings}
title="模块设置"
aria-label="模块设置"
className="text-muted-foreground hover:text-foreground rounded-md p-1 transition-colors hover:bg-sidebar-accent/60"
className="text-muted-foreground hover:text-foreground rounded-md p-1 transition-colors hover:bg-accent/60"
>
<Settings className="size-4" />
</button>
</div>
)}
{/* 上半:按类型分组的智能体 */}
{/* 上半:按类型分组的智能体(彩色卡片,与全量版看齐) */}
<div className="flex min-h-0 flex-[3] flex-col overflow-y-auto px-2 py-3">
<h2 className="text-muted-foreground/70 px-2 mb-1.5 text-[10px] font-semibold tracking-[0.12em] uppercase select-none">
<h2 className="mb-1 px-3 py-1 text-[11px] font-semibold tracking-[0.04em] text-muted-foreground/80 select-none">
智能体类型
</h2>
{groups.length === 0 ? (
<p className="px-2 py-1.5 text-xs text-muted-foreground/70">暂无智能体</p>
<p className="px-3 py-1.5 text-xs text-muted-foreground/70">暂无智能体</p>
) : (
<div className="flex flex-col gap-0.5">
<div className="flex flex-col gap-1.5">
{groups.map((group) => {
const isCollapsed = collapsed.has(group.key);
return (
@@ -197,11 +203,11 @@ export function AgentModuleSidebar({
<button
type="button"
onClick={() => toggleGroup(group.key)}
className="flex w-full items-center justify-between gap-1 rounded-lg px-2 py-1.5 text-xs font-semibold text-foreground/80 hover:bg-sidebar-accent/60"
className="flex w-full items-center justify-between px-3 py-1 text-[11px] font-semibold tracking-[0.04em] text-muted-foreground/80 transition-colors hover:text-muted-foreground select-none"
>
<span className="flex items-center gap-1.5 truncate">
<span className="truncate">{group.label}</span>
<span className="text-muted-foreground/60 text-[10px] font-normal tabular-nums">
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-normal text-muted-foreground tabular-nums">
{group.agents.length}
</span>
</span>
@@ -213,22 +219,48 @@ export function AgentModuleSidebar({
/>
</button>
{!isCollapsed && (
<div className="mt-0.5 flex flex-col gap-0.5 pl-1">
<div className="mt-1 flex flex-col gap-1">
{group.agents.map((agent) => {
const href = `${routeBase}/agents/${agent.id}/chats/new`;
const isActive = agent.id === activeAgentId;
const { from, to } = agentColor(agent.id, isDark);
return (
<Link
key={`${group.key}:${agent.id}`}
href={href}
className={cn(
"flex items-center gap-2 rounded-lg px-2.5 py-1.5 text-[13px] text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/60",
isActive &&
"bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary",
"group relative flex w-full items-start gap-2.5 rounded-xl border px-2.5 py-2 transition-colors",
isActive
? "border-primary/40 bg-primary/5"
: "border-transparent hover:border-border hover:bg-accent/50",
)}
>
<Bot className="size-3.5 shrink-0 text-muted-foreground/70" />
<span className="truncate">{desensitize(agent.name)}</span>
<span
className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-lg text-white shadow-sm"
style={{
backgroundImage: `linear-gradient(135deg, ${from}, ${to})`,
}}
>
<Bot className="size-[18px]" />
</span>
<span className="min-w-0 flex-1">
<span
className={cn(
"block truncate text-[13px] font-semibold",
isActive ? "text-primary" : "text-foreground",
)}
>
{desensitize(agent.name)}
</span>
<span className="mt-0.5 block truncate text-[11px] leading-snug text-muted-foreground">
{agent.description
? desensitize(agent.description)
: "智能体助手"}
</span>
</span>
{isActive && (
<span className="absolute top-1/2 right-1 h-6 w-1 -translate-y-1/2 rounded-full bg-primary" />
)}
</Link>
);
})}
@@ -241,13 +273,13 @@ export function AgentModuleSidebar({
)}
</div>
{/* 下半:聊天记录(仅本模块智能体会话) */}
{/* 下半:聊天记录(仅本模块智能体会话),简单列表、配色与全量版统一 */}
<div className="flex min-h-0 flex-[2] flex-col overflow-y-auto border-t border-border/60 px-2 py-3">
<h2 className="text-muted-foreground/70 px-2 mb-1.5 text-[10px] font-semibold tracking-[0.12em] uppercase select-none">
<h2 className="mb-1 px-3 py-1 text-[11px] font-semibold tracking-[0.04em] text-muted-foreground/80 select-none">
聊天记录
</h2>
{moduleThreads.length === 0 ? (
<p className="px-2 py-1.5 text-xs text-muted-foreground/70">暂无聊天记录</p>
<p className="px-3 py-1.5 text-xs text-muted-foreground/70">暂无聊天记录</p>
) : (
<div className="flex flex-col gap-0.5">
{moduleThreads.map((thread) => {
@@ -258,15 +290,24 @@ export function AgentModuleSidebar({
key={thread.thread_id}
href={href}
className={cn(
"flex items-center gap-2 rounded-lg px-2.5 py-1.5 text-[13px] text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/60",
isActive &&
"bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary",
"relative flex h-9 min-w-0 items-center gap-2 rounded-lg px-2.5 text-[13px] transition-colors",
isActive
? "border border-primary/40 bg-primary/5 font-medium text-primary"
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
)}
>
<MessageSquare className="size-3.5 shrink-0 text-muted-foreground/70" />
<MessageSquare
className={cn(
"size-3.5 shrink-0",
isActive ? "text-primary" : "text-muted-foreground/70",
)}
/>
<span className="truncate">
{desensitize(titleOfThread(thread, "未命名对话"))}
</span>
{isActive && (
<span className="absolute top-1/2 right-1 h-5 w-1 -translate-y-1/2 rounded-full bg-primary" />
)}
</Link>
);
})}
@@ -86,6 +86,7 @@ import { desensitize } from "@/lib/desensitize";
import { isIMEComposing } from "@/lib/ime";
import { cn } from "@/lib/utils";
import { agentColor } from "./agent-icon-color";
import { SidebarCollapseButton } from "./workspace-header";
const NEW_CHAT_PATH = "/page/workspace/chats/new";
@@ -96,33 +97,8 @@ const MORE_CHATS_HREF = "/page/workspace/chats";
/** 常用智能体最多展示个数。 */
const MAX_FREQUENT = 4;
// 智能体图标统一用 Bot 机器人图标;底色按 id 稳定散列到一组主题色,复刻参考图的彩色方块。
// 浅色模式沿用原高彩度彩色(不改);深蓝主题改用《深蓝 UI 规范》§1.1.7/§1.1.8 的蓝-青系
// (docs/ui规范-深蓝,克制、不刺眼),仅深蓝主题下生效,互不影响。
const AGENT_COLORS_LIGHT = [
{ from: "#fb7185", to: "#ef4444" },
{ from: "#60a5fa", to: "#3b82f6" },
{ from: "#fbbf24", to: "#f59e0b" },
{ from: "#34d399", to: "#10b981" },
{ from: "#a78bfa", to: "#8b5cf6" },
] as const;
const AGENT_COLORS_DARK = [
{ from: "#4096FF", to: "#1677FF" }, // 蓝(链接蓝 → 主色)
{ from: "#36CFC9", to: "#13C2C2" }, // 青(辅色)
{ from: "#6388FF", to: "#0958D9" }, // 紫蓝(装饰紫 → 主色深一档)
{ from: "#5CDBD3", to: "#08979C" }, // 浅青 → 深青
{ from: "#69B1FF", to: "#003EB3" }, // 浅蓝 → 主色按下
] as const;
function agentColor(id: string, isDark: boolean) {
let hash = 0;
for (let i = 0; i < id.length; i++) {
hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
}
const palette = isDark ? AGENT_COLORS_DARK : AGENT_COLORS_LIGHT;
return palette[hash % palette.length]!;
}
// 智能体图标配色(按 id 稳定散列、分主题)抽到共享模块 ./agent-icon-color,
// 与 zzJC/zzXD 模块左栏(AgentModuleSidebar)共用,保证彩色卡片配色完全一致。
/** 折叠区块标题(带 chevron 与可选计数徽标)。 */
function SectionLabel({
@@ -6,7 +6,7 @@ import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { clearStoredAuth, getStoredAuth } from "@/core/auth";
import { clearStoredAuth, getAccountDisplayName, getStoredAuth } from "@/core/auth";
import { SettingsSection } from "./settings-section";
@@ -37,8 +37,7 @@ export function AccountSettingsPage() {
);
}
const usernameFromEmail = (auth.email ?? "").split("@")[0] ?? "";
const displayName = usernameFromEmail || auth.user_id;
const displayName = getAccountDisplayName(auth);
const handleLogout = () => {
clearStoredAuth();
+103 -94
View File
@@ -1,94 +1,103 @@
export interface LoginByUsernameResponse {
access_token: string;
token_type: string;
expires_in: number;
user_id: string;
email: string;
system_role: string;
needs_setup: boolean;
created: boolean;
}
const AUTH_STORAGE_KEY = "deerflow.auth";
function isBrowser(): boolean {
return typeof window !== "undefined";
}
export function getStoredAuth(): LoginByUsernameResponse | null {
if (!isBrowser()) {
return null;
}
const raw = window.localStorage.getItem(AUTH_STORAGE_KEY);
if (!raw) {
return null;
}
try {
return JSON.parse(raw) as LoginByUsernameResponse;
} catch {
return null;
}
}
export function setStoredAuth(auth: LoginByUsernameResponse) {
if (!isBrowser()) {
return;
}
window.localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify(auth));
}
export function clearStoredAuth() {
if (!isBrowser()) {
return;
}
window.localStorage.removeItem(AUTH_STORAGE_KEY);
}
/**
* 把登录用户名写入 localStorage.userInfo.userName(顶栏用户区据此显示)。
* 仅在尚未设置时写入,避免覆盖外部登录(yUserId)从 consumer 拿到的用户名。
*/
export function setStoredUserName(name: string | null | undefined) {
if (!isBrowser()) {
return;
}
const trimmed = name?.trim();
if (!trimmed) {
return;
}
let info: Record<string, unknown> = {};
try {
const raw = window.localStorage.getItem("userInfo");
if (raw) {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object") {
info = parsed as Record<string, unknown>;
}
}
} catch {
info = {};
}
if (typeof info.userName === "string" && info.userName.trim()) {
return;
}
info.userName = trimmed;
window.localStorage.setItem("userInfo", JSON.stringify(info));
}
export function getAccessToken(): string | undefined {
const auth = getStoredAuth();
const token = auth?.access_token?.trim();
return token ? token : undefined;
}
export function getAuthorizationHeaderValue(): string | undefined {
const auth = getStoredAuth();
const token = auth?.access_token?.trim();
if (!token) {
return undefined;
}
const rawType = auth?.token_type?.trim();
const tokenType = rawType ? rawType : "Bearer";
return `${tokenType} ${token}`;
}
export interface LoginByUsernameResponse {
access_token: string;
token_type: string;
expires_in: number;
user_id: string;
email: string;
system_role: string;
needs_setup: boolean;
created: boolean;
}
const AUTH_STORAGE_KEY = "deerflow.auth";
function isBrowser(): boolean {
return typeof window !== "undefined";
}
export function getStoredAuth(): LoginByUsernameResponse | null {
if (!isBrowser()) {
return null;
}
const raw = window.localStorage.getItem(AUTH_STORAGE_KEY);
if (!raw) {
return null;
}
try {
return JSON.parse(raw) as LoginByUsernameResponse;
} catch {
return null;
}
}
export function setStoredAuth(auth: LoginByUsernameResponse) {
if (!isBrowser()) {
return;
}
window.localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify(auth));
}
export function clearStoredAuth() {
if (!isBrowser()) {
return;
}
window.localStorage.removeItem(AUTH_STORAGE_KEY);
}
/** 与设置 → 个人信息页「用户名」一致:邮箱 @ 前缀,否则 user_id。 */
export function getAccountDisplayName(
auth: LoginByUsernameResponse | null = getStoredAuth(),
): string {
if (!auth) return "用户";
const usernameFromEmail = (auth.email ?? "").split("@")[0] ?? "";
return usernameFromEmail || auth.user_id || "用户";
}
/**
* 把登录用户名写入 localStorage.userInfo.userName(轻应用登录参数等场景使用)。
* 仅在尚未设置时写入,避免覆盖外部登录(yUserId)从 consumer 拿到的用户名。
*/
export function setStoredUserName(name: string | null | undefined) {
if (!isBrowser()) {
return;
}
const trimmed = name?.trim();
if (!trimmed) {
return;
}
let info: Record<string, unknown> = {};
try {
const raw = window.localStorage.getItem("userInfo");
if (raw) {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object") {
info = parsed as Record<string, unknown>;
}
}
} catch {
info = {};
}
if (typeof info.userName === "string" && info.userName.trim()) {
return;
}
info.userName = trimmed;
window.localStorage.setItem("userInfo", JSON.stringify(info));
}
export function getAccessToken(): string | undefined {
const auth = getStoredAuth();
const token = auth?.access_token?.trim();
return token ? token : undefined;
}
export function getAuthorizationHeaderValue(): string | undefined {
const auth = getStoredAuth();
const token = auth?.access_token?.trim();
if (!token) {
return undefined;
}
const rawType = auth?.token_type?.trim();
const tokenType = rawType ? rawType : "Bearer";
return `${tokenType} ${token}`;
}
+8 -2
View File
@@ -128,10 +128,14 @@ function buildOpeningText(detail: CopTaskDetail): string {
/**
* 据 goPath + 任务详情解析跳转目标。
* - 多智能体会商:返回带 ``roundtablePrefill``(businessCode 让会商页自动选用配置的业务链条)的 state。
* - 多智能体会商:返回带 ``roundtablePrefill``(businessCode 让会商页自动选用配置的业务链条;
* taskId 让会商页按任务隔离存取草稿/聊天记录)的 state。
* - 单智能体问答:解析「配置管理」里该业务配置的智能体;把开场信息写入
* ``sessionStorage.pendingAgentMessage``(AgentChatPage 自动发出)。
* 无法识别 goPath / 未配置智能体时返回 null(调用方回退到默认首页)。
*
* @param taskId 深链 URL 的原始 ``?taskId=``(外部任务 id,用于按任务隔离草稿;与
* consumer 返回的 detail.id 区分——后者不保证等于外部任务 id)。
*/
export async function resolveTaskDeeplinkTarget(
goPath: string,
@@ -147,7 +151,8 @@ export async function resolveTaskDeeplinkTarget(
clearTaskContext();
if (parsed.roundtable) {
// 多智能体会商:嵌进对应业务的「任务管理」布局;businessCode 让会商页自动选用配置的业务链条。
// 多智能体会商:嵌进对应业务的「任务管理」布局;businessCode 让会商页自动选用配置的业务链条;
// taskId 让会商页按任务隔离存取草稿(一个 taskId 多条记录),并触发第三步自动结构化输出。
return {
path: `${taskBase}/roundtable`,
state: {
@@ -155,6 +160,7 @@ export async function resolveTaskDeeplinkTarget(
taskTitle: detail.taskName,
taskGoal: detail.taskContent,
businessCode: parsed.businessCode,
taskId: taskId || undefined,
autoStart: true,
},
},
@@ -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;
}
+366 -353
View File
@@ -1,353 +1,366 @@
/**
* 全局侧边栏菜单数据与权限过滤逻辑。
*
* 浅色 ``PageSidebar`` 和深色 ``PageSidebarDark`` 共用这份数据 —— 菜单结构与
* adminOnly 权限策略对两种皮肤完全一致;UI 渲染(icon 显示/折叠样式/hover flyout)
* 在各自组件里实现。
*
* 修改菜单只需改这一处,两个 sidebar 自动同步。
*/
import type { ElementType } from "react";
import {
Activity,
BookOpen,
Bot,
Brain,
Briefcase,
CalendarClock,
CircleHelp,
ClipboardList,
Database,
FileBox,
FileText,
Gauge,
Grid3X3,
HelpCircle,
History,
Layers,
Layout,
Library,
List,
ListChecks,
ListTodo,
Network,
Radar,
Settings,
Sparkles,
Target,
Trophy,
Users,
} from "lucide-react";
export type MenuItem = {
id: string;
label: string;
icon: ElementType;
path?: string;
/** 非 admin 用户使用的路由(菜单对普通用户可见时生效,与简洁模式公开排行榜一致) */
userPath?: string;
adminOnly?: boolean;
children?: MenuItem[];
};
export const sidebarMenuItems: MenuItem[] = [
{
id: "qa-center",
label: "问答管理",
icon: HelpCircle,
children: [
{ id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/workspace/chats/new" },
{
id: "multi-agent-roundtable",
label: "多智能体会商",
icon: Users,
path: "/page/strategy/qa/multi-agent-roundtable",
},
// { id: "expert-qa", label: "专家问答", icon: Brain, path: "/page/strategy/qa/expert-qa" },
{
id: "qa-zzjc",
label: "JC支持",
icon: Bot,
path: "/page/strategy/qa/zzjc",
},
{ id: "qa-zzxd", label: "XD应用", icon: Layout, path: "/page/strategy/qa/zzxd" },
{ id: "qa-research", label: "课题研究", icon: FileText, path: "/page/canvas/ai-writing" },
],
},
{
id: "task-management",
label: "任务管理",
icon: Target,
children: [
{
id: "xk-center-task",
label: "XK中任务",
icon: ClipboardList,
children: [
// {
// id: "xk-task-jc",
// label: "任务JC",
// icon: ListTodo,
// path: "/page/strategy/task-management/xk-task-jc",
// },
// {
// id: "xk-task-dispatch",
// label: "任务调度",
// icon: ListTodo,
// path: "/page/strategy/task-management/xk-task-dispatch",
// },
],
},
{
id: "coh-center-task",
label: "COH中任务",
icon: Activity,
children: [
// {
// id: "coh-task-3qfx",
// label: "3QFX",
// icon: ListTodo,
// path: "/page/strategy/task-management/plan-task-sentiment",
// },
// {
// id: "coh-task-fx",
// label: "任务FX",
// icon: ListTodo,
// path: "/page/strategy/task-management/plan-task/plan-task-fx",
// },
// {
// id: "coh-task-xdfx",
// label: "XDFX",
// icon: ListTodo,
// path: "/page/strategy/task-management/plan-task/plan-task-action",
// },
],
},
{
id: "pg-center-task",
label: "PG中任务",
icon: Gauge,
children: [
// {
// id: "pg-task-pg",
// label: "任务PG",
// icon: ListTodo,
// path: "/page/strategy/task-management/effect-task-pg",
// },
// {
// id: "pg-task-xdpg",
// label: "XDPG",
// icon: ListTodo,
// path: "/page/strategy/task-management/effect-action-pg",
// },
// {
// id: "pg-task-info-pg",
// label: "信息PG",
// icon: ListTodo,
// path: "/page/strategy/task-management/effect-info-pg",
// },
],
},
{
id: "ty-center-task",
label: "TY中任务",
icon: Radar,
children: [
// {
// id: "ty-task-xdty",
// label: "XDTY任务",
// icon: ListTodo,
// path: "/page/strategy/task-management/mnty-task-mechanism",
// },
// {
// id: "ty-task-plan",
// label: "方案TY任务",
// icon: ListTodo,
// path: "/page/strategy/task-management/mnty-task-plan",
// },
// {
// id: "ty-task-xxty",
// label: "XXTY任务",
// icon: ListTodo,
// path: "/page/strategy/task-management/mnty-task-zsty",
// },
],
},
],
},
{
id: "light-app-center",
label: "应用中心",
icon: Grid3X3,
children: [
{
id: "light-app-management",
label: "应用管理",
icon: Layout,
path: "/page/strategy/light-app/management",
adminOnly: true,
},
{
id: "light-app-apps",
label: "应用列表",
icon: List,
path: "/page/strategy/light-app/apps",
},
],
},
{
id: "comprehensive-management",
label: "综合管理",
icon: Settings,
children: [
{
id: "mgmt-user-leaderboard",
label: "用户排行榜",
icon: Trophy,
path: "/page/strategy/admin/user-leaderboard",
userPath: "/page/strategy/user-leaderboard",
},
{ id: "mgmt-agents", label: "智能体管理", icon: Bot, path: "/page/strategy/agents" },
{
id: "mgmt-business-chain-mapping",
label: "配置管理",
icon: Network,
path: "/page/strategy/business-chain-mapping",
},
{
id: "mgmt-business-chains",
label: "业务链条配置",
icon: Layers,
path: "/page/strategy/business-chains",
},
{ id: "mgmt-skills", label: "技能管理", icon: Sparkles, path: "/page/strategy/skills" },
{ id: "mgmt-memory", label: "记忆管理", icon: Brain, path: "/page/strategy/memory" },
{
id: "mgmt-tasks",
label: "定时任务",
icon: ListChecks,
path: "/page/strategy/scheduled/tasks",
},
{
id: "mgmt-llm-metrics",
label: "大模型调用统计",
icon: Gauge,
path: "/page/strategy/settings/llm-metrics",
adminOnly: true,
},
{
id: "mgmt-users",
label: "用户管理",
icon: Users,
path: "/page/strategy/admin/users",
adminOnly: true,
},
{
id: "mgmt-positions",
label: "岗位管理",
icon: Briefcase,
path: "/page/strategy/admin/positions",
adminOnly: true,
},
{
id: "mgmt-admin-scheduled-tasks",
label: "定时任务管理",
icon: CalendarClock,
path: "/page/strategy/admin/scheduled-tasks",
adminOnly: true,
},
{
id: "mgmt-user-activity",
label: "用户活动审计",
icon: History,
path: "/page/strategy/admin/user-activity",
adminOnly: true,
},
],
},
{
id: "resource-management",
label: "资源管理",
icon: Database,
children: [
// {
// id: "resource-network-xwt",
// label: "网络xwt资源管理",
// icon: Network,
// path: "/page/strategy/resource-management/resource-network-actor",
// },
{
id: "resource-knowledge-native",
label: "问答梳理",
icon: BookOpen,
path: "/page/workspace/knowledge",
},
{
id: "resource-knowledge-base",
label: "知识库资源管理",
icon: Library,
path: "/page/strategy/resource-management/resource-knowledge-base",
},
// {
// id: "resource-evaluation-index",
// label: "作战资源管理",
// icon: FileBox,
// path: "/page/strategy/resource-management/resource-evaluation-index",
// },
],
},
];
/** 默认展开的菜单 id 集合(浅色版可折叠交互的初始状态) */
export const defaultExpandedIds: ReadonlySet<string> = new Set([
"qa-center",
"light-app-center",
"light-app-note-management",
"task-management",
"xk-center-task",
"comprehensive-management",
]);
/** 该菜单项自身或任意后代的 path 是否匹配当前路由(支持子路径,如 /knowledge/:id) */
export function menuItemContainsPath(item: MenuItem, pathname: string): boolean {
if (item.path && (pathname === item.path || pathname.startsWith(item.path + "/"))) {
return true;
}
return item.children?.some((child) => menuItemContainsPath(child, pathname)) ?? false;
}
/**
* 在「一级 → 二级 → 三级」菜单中,取当前路由所在的「激活二级项」的三级子项。
* 浅色侧边栏据此在二级右侧展开三级列;深色顶栏据此渲染三级横条。
* 全量模式:三级仅一项时不返回(无选择必要,侧边栏保持窄栏 / 不渲染横条)。
*/
export function getActiveThirdLevelItems(
items: MenuItem[],
pathname: string,
): MenuItem[] {
const activeTop =
items.find((item) => menuItemContainsPath(item, pathname)) ?? items[0];
const secondary = activeTop?.children ?? [];
const activeSecond = secondary.find(
(item) => (item.children?.length ?? 0) > 0 && menuItemContainsPath(item, pathname),
);
const third = activeSecond?.children ?? [];
return third.length <= 1 ? [] : third;
}
/** 按 admin 角色过滤菜单:非 admin 不展示 ``adminOnly: true`` 的项;``userPath`` 替换 ``path``。 */
export function filterMenuItemsByRole(items: MenuItem[], isAdmin: boolean): MenuItem[] {
return items
.filter((item) => !item.adminOnly || isAdmin)
.map((item) => {
const children = item.children
? filterMenuItemsByRole(item.children, isAdmin)
: undefined;
const base = children ? { ...item, children } : item;
if (!isAdmin && base.userPath) {
const { userPath: _userPath, ...rest } = base;
return { ...rest, path: base.userPath };
}
return base;
});
}
/**
* 全局侧边栏菜单数据与权限过滤逻辑。
*
* 浅色 ``PageSidebar`` 和深色 ``PageSidebarDark`` 共用这份数据 —— 菜单结构与
* adminOnly 权限策略对两种皮肤完全一致;UI 渲染(icon 显示/折叠样式/hover flyout)
* 在各自组件里实现。
*
* 修改菜单只需改这一处,两个 sidebar 自动同步。
*/
import type { ElementType } from "react";
import {
Activity,
BookOpen,
Bot,
Brain,
Briefcase,
CalendarClock,
CircleHelp,
ClipboardList,
Database,
FileBox,
FileText,
Gauge,
Grid3X3,
HelpCircle,
History,
Layers,
Layout,
Library,
List,
ListChecks,
ListTodo,
Network,
Radar,
Settings,
Sparkles,
Target,
Trophy,
Users,
} from "lucide-react";
export type MenuItem = {
id: string;
label: string;
icon: ElementType;
path?: string;
/** 额外视为「当前页属于本菜单项」的路由(不在侧栏展示,仅用于高亮)。 */
aliasPaths?: string[];
/** 非 admin 用户使用的路由(菜单对普通用户可见时生效,与简洁模式公开排行榜一致) */
userPath?: string;
adminOnly?: boolean;
children?: MenuItem[];
};
export const sidebarMenuItems: MenuItem[] = [
{
id: "qa-center",
label: "问答管理",
icon: HelpCircle,
children: [
{ id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/workspace/chats/new" },
{
id: "multi-agent-roundtable",
label: "多智能体会商",
icon: Users,
path: "/page/strategy/qa/multi-agent-roundtable",
},
// { id: "expert-qa", label: "专家问答", icon: Brain, path: "/page/strategy/qa/expert-qa" },
{
id: "qa-zzjc",
label: "JC支持",
icon: Bot,
path: "/page/strategy/qa/zzjc",
},
{ id: "qa-zzxd", label: "XD应用", icon: Layout, path: "/page/strategy/qa/zzxd" },
{ id: "qa-research", label: "课题研究", icon: FileText, path: "/page/canvas/ai-writing" },
],
},
{
id: "task-management",
label: "任务管理",
icon: Target,
children: [
{
id: "xk-center-task",
label: "XK中任务",
icon: ClipboardList,
children: [
// {
// id: "xk-task-jc",
// label: "任务JC",
// icon: ListTodo,
// path: "/page/strategy/task-management/xk-task-jc",
// },
// {
// id: "xk-task-dispatch",
// label: "任务调度",
// icon: ListTodo,
// path: "/page/strategy/task-management/xk-task-dispatch",
// },
],
},
{
id: "coh-center-task",
label: "COH中任务",
icon: Activity,
children: [
// {
// id: "coh-task-3qfx",
// label: "3QFX",
// icon: ListTodo,
// path: "/page/strategy/task-management/plan-task-sentiment",
// },
// {
// id: "coh-task-fx",
// label: "任务FX",
// icon: ListTodo,
// path: "/page/strategy/task-management/plan-task/plan-task-fx",
// },
// {
// id: "coh-task-xdfx",
// label: "XDFX",
// icon: ListTodo,
// path: "/page/strategy/task-management/plan-task/plan-task-action",
// },
],
},
{
id: "pg-center-task",
label: "PG中任务",
icon: Gauge,
children: [
// {
// id: "pg-task-pg",
// label: "任务PG",
// icon: ListTodo,
// path: "/page/strategy/task-management/effect-task-pg",
// },
// {
// id: "pg-task-xdpg",
// label: "XDPG",
// icon: ListTodo,
// path: "/page/strategy/task-management/effect-action-pg",
// },
// {
// id: "pg-task-info-pg",
// label: "信息PG",
// icon: ListTodo,
// path: "/page/strategy/task-management/effect-info-pg",
// },
],
},
{
id: "ty-center-task",
label: "TY中任务",
icon: Radar,
children: [
// {
// id: "ty-task-xdty",
// label: "XDTY任务",
// icon: ListTodo,
// path: "/page/strategy/task-management/mnty-task-mechanism",
// },
// {
// id: "ty-task-plan",
// label: "方案TY任务",
// icon: ListTodo,
// path: "/page/strategy/task-management/mnty-task-plan",
// },
// {
// id: "ty-task-xxty",
// label: "XXTY任务",
// icon: ListTodo,
// path: "/page/strategy/task-management/mnty-task-zsty",
// },
],
},
],
},
{
id: "light-app-center",
label: "应用中心",
icon: Grid3X3,
children: [
{
id: "light-app-management",
label: "应用管理",
icon: Layout,
path: "/page/strategy/light-app/management",
adminOnly: true,
},
{
id: "light-app-apps",
label: "应用列表",
icon: List,
path: "/page/strategy/light-app/apps",
},
],
},
{
id: "comprehensive-management",
label: "综合管理",
icon: Settings,
children: [
{
id: "mgmt-user-leaderboard",
label: "用户排行榜",
icon: Trophy,
path: "/page/strategy/admin/user-leaderboard",
userPath: "/page/strategy/user-leaderboard",
},
{ id: "mgmt-agents", label: "智能体管理", icon: Bot, path: "/page/strategy/agents" },
// {
// id: "mgmt-business-chain-mapping",
// label: "配置管理",
// icon: Network,
// path: "/page/strategy/business-chain-mapping",
// },
{
id: "mgmt-business-chains",
label: "业务链条配置",
icon: Layers,
path: "/page/strategy/business-chains",
aliasPaths: ["/page/strategy/business-chain-mapping"],
},
{ id: "mgmt-skills", label: "技能管理", icon: Sparkles, path: "/page/strategy/skills" },
{ id: "mgmt-memory", label: "记忆管理", icon: Brain, path: "/page/strategy/memory" },
{
id: "mgmt-tasks",
label: "定时任务",
icon: ListChecks,
path: "/page/strategy/scheduled/tasks",
},
{
id: "mgmt-llm-metrics",
label: "大模型调用统计",
icon: Gauge,
path: "/page/strategy/settings/llm-metrics",
adminOnly: true,
},
{
id: "mgmt-users",
label: "用户管理",
icon: Users,
path: "/page/strategy/admin/users",
adminOnly: true,
},
{
id: "mgmt-positions",
label: "岗位管理",
icon: Briefcase,
path: "/page/strategy/admin/positions",
adminOnly: true,
},
{
id: "mgmt-admin-scheduled-tasks",
label: "定时任务管理",
icon: CalendarClock,
path: "/page/strategy/admin/scheduled-tasks",
adminOnly: true,
},
{
id: "mgmt-user-activity",
label: "用户活动审计",
icon: History,
path: "/page/strategy/admin/user-activity",
adminOnly: true,
},
],
},
{
id: "resource-management",
label: "资源管理",
icon: Database,
children: [
// {
// id: "resource-network-xwt",
// label: "网络xwt资源管理",
// icon: Network,
// path: "/page/strategy/resource-management/resource-network-actor",
// },
{
id: "resource-knowledge-native",
label: "问答梳理",
icon: BookOpen,
path: "/page/workspace/knowledge",
},
{
id: "resource-knowledge-base",
label: "知识库资源管理",
icon: Library,
path: "/page/strategy/resource-management/resource-knowledge-base",
},
// {
// id: "resource-evaluation-index",
// label: "作战资源管理",
// icon: FileBox,
// path: "/page/strategy/resource-management/resource-evaluation-index",
// },
],
},
];
/** 默认展开的菜单 id 集合(浅色版可折叠交互的初始状态) */
export const defaultExpandedIds: ReadonlySet<string> = new Set([
"qa-center",
"light-app-center",
"light-app-note-management",
"task-management",
"xk-center-task",
"comprehensive-management",
]);
/** 单条路由是否与 pathname 精确匹配或为其子路径。 */
function routeMatchesPathname(pathname: string, route?: string): boolean {
if (!route) return false;
return pathname === route || pathname.startsWith(route + "/");
}
/** 菜单项自身 path / aliasPaths 是否匹配当前路由。 */
export function menuItemMatchesPath(item: MenuItem, pathname: string): boolean {
if (routeMatchesPathname(pathname, item.path)) return true;
return item.aliasPaths?.some((p) => routeMatchesPathname(pathname, p)) ?? false;
}
/** 该菜单项自身或任意后代的 path 是否匹配当前路由(支持子路径,如 /knowledge/:id) */
export function menuItemContainsPath(item: MenuItem, pathname: string): boolean {
if (menuItemMatchesPath(item, pathname)) return true;
return item.children?.some((child) => menuItemContainsPath(child, pathname)) ?? false;
}
/**
* 在「一级 → 二级 → 三级」菜单中,取当前路由所在的「激活二级项」的三级子项。
* 浅色侧边栏据此在二级右侧展开三级列;深色顶栏据此渲染三级横条。
* 全量模式:三级仅一项时不返回(无选择必要,侧边栏保持窄栏 / 不渲染横条)。
*/
export function getActiveThirdLevelItems(
items: MenuItem[],
pathname: string,
): MenuItem[] {
const activeTop =
items.find((item) => menuItemContainsPath(item, pathname)) ?? items[0];
const secondary = activeTop?.children ?? [];
const activeSecond = secondary.find(
(item) => (item.children?.length ?? 0) > 0 && menuItemContainsPath(item, pathname),
);
const third = activeSecond?.children ?? [];
return third.length <= 1 ? [] : third;
}
/** 按 admin 角色过滤菜单:非 admin 不展示 ``adminOnly: true`` 的项;``userPath`` 替换 ``path``。 */
export function filterMenuItemsByRole(items: MenuItem[], isAdmin: boolean): MenuItem[] {
return items
.filter((item) => !item.adminOnly || isAdmin)
.map((item) => {
const children = item.children
? filterMenuItemsByRole(item.children, isAdmin)
: undefined;
const base = children ? { ...item, children } : item;
if (!isAdmin && base.userPath) {
const { userPath: _userPath, ...rest } = base;
return { ...rest, path: base.userPath };
}
return base;
});
}
@@ -12,8 +12,12 @@ import { useLocation } from "react-router-dom";
import { Layout } from "lucide-react";
import { getStoredAuth } from "@/core/auth";
import { desensitize, makeDesensitizer } from "@/lib/desensitize";
import { useLightApps, type LightApp } from "@/strategy-components/api/light-apps";
import { useMenuOverrides } from "@/strategy-components/api/menu-overrides";
import { useSensitiveWords, wordsToMap } from "@/strategy-components/api/sensitive-words";
import { applyMenuOverrides } from "./menu-overrides";
import {
filterMenuItemsByRole,
getActiveThirdLevelItems,
@@ -31,7 +35,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 +60,43 @@ function injectLightApps(items: MenuItem[], apps: LightApp[]): MenuItem[] {
return walk(items);
}
/** 返回合并了 iframe 轻应用、并按 admin 角色过滤后的菜单。 */
/** 递归对菜单标签做脱敏(仅改 `label`,path/id/icon 不动,保证高亮与路由不受影响)。 */
function desensitizeLabels(items: MenuItem[], fn: (text: string) => string): MenuItem[] {
return items.map((node) => ({
...node,
label: fn(node.label),
children: node.children ? desensitizeLabels(node.children, fn) : node.children,
}));
}
/**
* 返回合并了 iframe 轻应用、套用了后端「菜单覆盖层」(改名/停用/排序/层级)、
* 按 admin 角色过滤、并对标签做**展示层脱敏**后的菜单。
*
* 管线顺序:静态菜单 → 注入 iframe 轻应用 → 套覆盖层(停用项被剔除)→ 角色过滤 → 标签脱敏。
* 覆盖层作用在合并后的整棵树上,所以 iframe 轻应用节点同样可被改名/停用/移层级。
*
* 脱敏直接用 `/api/sensitive-words` 查询结果构造(后台非空时用其映射,否则回退到内置种子的
* 全局 {@link desensitize}),避免依赖 {@link replaceDesensitizeMap} 的 effect 回灌时序。
*/
export function useSidebarMenuItems(isAdmin: boolean): MenuItem[] {
const { data: iframeApps = [] } = useLightApps({ appType: "iframe", enabledOnly: true });
const { data: overrides = [] } = useMenuOverrides();
const { data: words = [] } = useSensitiveWords();
const desensitizeLabel = useMemo(
() => (words.length > 0 ? makeDesensitizer(wordsToMap(words)) : desensitize),
[words],
);
return useMemo(
() => filterMenuItemsByRole(injectLightApps(sidebarMenuItems, iframeApps), isAdmin),
[iframeApps, isAdmin],
() =>
desensitizeLabels(
filterMenuItemsByRole(
applyMenuOverrides(injectLightApps(sidebarMenuItems, iframeApps), overrides),
isAdmin,
),
desensitizeLabel,
),
[iframeApps, overrides, isAdmin, desensitizeLabel],
);
}
+48 -3
View File
@@ -1,9 +1,16 @@
/**
* 脱敏词映射表
* 脱敏词「种子」映射表(前端内置)。
* key: 原始词(支持正则特殊字符,会被转义)
* value: 脱敏后替换词
*
* 这份常量是**部署种子**:新机器首次启动、后台还没有任何敏感词时,运行时直接用它;
* 管理员也可在「敏感词管理」页一键「导入种子词」把它写入后台。线上以后台配置为准
* (见 {@link replaceDesensitizeMap}),后台为空时回退到这份种子。
*
* 注意:请勿在运行时修改本对象——它要保持与「出厂种子」一致。运行时可变映射见
* {@link DESENSITIZE_MAP}。
*/
export const DESENSITIZE_MAP: Record<string, string> = {
export const SEED_DESENSITIZE_MAP: Readonly<Record<string, string>> = Object.freeze({
方案: "FA",
设计: "SJ",
任务: "RW",
@@ -44,8 +51,15 @@ export const DESENSITIZE_MAP: Record<string, string> = {
军: "J",
美军: "MJ",
台军: "TJ",
});
};
/**
* 运行时实际生效的脱敏词映射表(可变)。
*
* 初始为 {@link SEED_DESENSITIZE_MAP} 的浅拷贝(保证后台数据加载前 `desensitize`
* 立即可用);应用启动后由 {@link replaceDesensitizeMap} 用后台配置整体替换。
*/
export const DESENSITIZE_MAP: Record<string, string> = { ...SEED_DESENSITIZE_MAP };
/**
* 将 DESENSITIZE_MAP 编译为单个正则,避免多次遍历字符串。
@@ -75,6 +89,22 @@ export function desensitize(text: string): string {
return text.replace(_regex, (matched) => DESENSITIZE_MAP[matched] ?? matched);
}
/**
* 用**指定映射**(而非全局 {@link DESENSITIZE_MAP})构造一个脱敏函数。
*
* 适用于「数据加载完就要立即用其结果脱敏、不能依赖全局 map 的异步回灌时序」的场景
* (如侧边栏/导航栏菜单:直接用 `/api/sensitive-words` 查询结果计算标签,避免与
* {@link replaceDesensitizeMap} 的 effect 时序竞态)。正则在构造时编译一次,可缓存复用。
*
* @param map 原始词 → 替换词
* @returns `(text) => 脱敏后文本`;map 为空时返回恒等函数
*/
export function makeDesensitizer(map: Record<string, string>): (text: string) => string {
const regex = buildRegex(map);
if (!regex) return (text) => text;
return (text) => text.replace(regex, (matched) => map[matched] ?? matched);
}
/**
* 深度脱敏:递归遍历对象 / 数组,对其中所有字符串值做 {@link desensitize},
* 其余类型原样保留。返回**新值**(不修改入参),用于对结构化数据(如大屏 report-json
@@ -107,3 +137,18 @@ export function addDesensitizeEntries(entries: Record<string, string>): void {
Object.assign(DESENSITIZE_MAP, entries);
_regex = buildRegex(DESENSITIZE_MAP);
}
/**
* 用后台配置**整体替换**运行时脱敏词映射表,替换后自动重编译正则。
*
* 与 {@link addDesensitizeEntries} 不同,本函数会先清空现有词条,因此后台里被
* 删除的词不会因为还残留在种子里而继续生效。应用启动时拉取
* `/api/sensitive-words` 后调用;若后台为空则不调用,保持使用种子。
*
* @param entries 后台返回的「启用」词条,格式同 {@link DESENSITIZE_MAP}
*/
export function replaceDesensitizeMap(entries: Record<string, string>): void {
for (const key of Object.keys(DESENSITIZE_MAP)) delete DESENSITIZE_MAP[key];
Object.assign(DESENSITIZE_MAP, entries);
_regex = buildRegex(DESENSITIZE_MAP);
}
@@ -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 && (
@@ -1,7 +1,22 @@
"use client";
import { BookOpen, FileText, LayoutGrid, Loader2, Plus, Settings2 } from "lucide-react";
import { useEffect, useState } from "react";
import {
BarChart3,
BookOpen,
FileText,
Globe2,
LayoutGrid,
Loader2,
Newspaper,
Plus,
Search,
Settings2,
SlidersHorizontal,
Sparkles,
TrendingUp,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
@@ -22,23 +37,62 @@ import type { ResearchTemplate } from "../../api/ai-writing-research-templates";
import { useWritingForm } from "../../contexts/WritingFormContext";
import { useAIWriting } from "../../contexts/AIWritingContext";
/**
* 课题研究左栏(需求 0616 #5)。
* - 上半「研究模板」= 研究模板实体(``useResearchTemplates``);点击一键预填整张写作表单
* (文章类型 / 大纲 / 搜索来源 / 字数 / 受众 / 严格模式 / 主题)并展开配置。顶部「配置」
* 入口进入模板配置页维护这些模板。
* - 下半「笔记本」= 我的空间(``useStudioNotebooks``);点击在右侧内嵌打开该笔记本页。
*/
type TemplateVisual = {
Icon: LucideIcon;
iconClass: string;
accentClass: string;
};
const TEMPLATE_VISUALS: TemplateVisual[] = [
{
Icon: Globe2,
iconClass: "bg-blue-50 text-blue-600",
accentClass: "bg-blue-500",
},
{
Icon: Newspaper,
iconClass: "bg-amber-50 text-amber-600",
accentClass: "bg-amber-500",
},
{
Icon: Sparkles,
iconClass: "bg-violet-50 text-violet-600",
accentClass: "bg-violet-500",
},
{
Icon: TrendingUp,
iconClass: "bg-rose-50 text-rose-600",
accentClass: "bg-rose-500",
},
{
Icon: BookOpen,
iconClass: "bg-emerald-50 text-emerald-600",
accentClass: "bg-emerald-500",
},
{
Icon: BarChart3,
iconClass: "bg-cyan-50 text-cyan-600",
accentClass: "bg-cyan-500",
},
];
function getTemplateVisual(tpl: ResearchTemplate, index: number): TemplateVisual {
const text = `${tpl.name} ${tpl.description ?? ""} ${tpl.article_type_key ?? ""}`;
if (/网摘|网络|热点|摘/.test(text)) return TEMPLATE_VISUALS[0];
if (/周报|周刊|日报|月报/.test(text)) return TEMPLATE_VISUALS[1];
if (/要讯|资讯|快讯|简报/.test(text)) return TEMPLATE_VISUALS[2];
if (/行业|分析|市场|竞争/.test(text)) return TEMPLATE_VISUALS[3];
if (/政策|解读|法规/.test(text)) return TEMPLATE_VISUALS[4];
return TEMPLATE_VISUALS[index % TEMPLATE_VISUALS.length];
}
export function ResearchSidebar({
viewingNotebookId,
onOpenNotebook,
onOpenAllSpaces,
}: {
/** 当前在右侧查看的笔记本 id(高亮用)。 */
viewingNotebookId?: number | null;
/** 点击笔记本 → 在右侧内嵌打开该笔记本页(替换右侧内容)。 */
onOpenNotebook: (notebookId: number) => void;
/** 点击「所有空间」→ 在右侧内嵌打开空间列表页。 */
onOpenAllSpaces: () => void;
}) {
const navigate = useNavigate();
@@ -62,7 +116,7 @@ export function ResearchSidebar({
} = useWritingForm();
const queryClient = useQueryClient();
// 新建空间弹窗(与 workspace-nav-studio 同款)。
const [templateSearch, setTemplateSearch] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [name, setName] = useState("");
const [creating, setCreating] = useState(false);
@@ -72,6 +126,16 @@ export function ResearchSidebar({
void refresh();
}, [refresh]);
const filteredTemplates = useMemo(() => {
const keyword = templateSearch.trim().toLowerCase();
if (!keyword) return templates;
return templates.filter((tpl) =>
[tpl.name, tpl.description, tpl.article_type_key]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(keyword)),
);
}, [templates, templateSearch]);
const handleCreate = async () => {
const trimmed = name.trim();
if (!trimmed) return;
@@ -92,8 +156,6 @@ export function ResearchSidebar({
}
};
// 点击研究模板 → ①回填整张写作表单(保持写作态左栏显示与运行配置一致)
// ②直接以模板预设进入 AI 写作流程(startWriting)。
const handleTemplateClick = (tpl: ResearchTemplate) => {
const intent = tpl.topic?.trim() ?? "";
const articleType = tpl.article_type_key || "analysis";
@@ -102,7 +164,6 @@ export function ResearchSidebar({
const wordCount = tpl.word_count > 0 ? tpl.word_count : 800;
const writingMode = tpl.strict_mode ? "strict" : "loose";
// 回填表单上下文(写作态左栏据此展示)。
if (intent) setIntent(intent);
if (tpl.article_type_key) setArticleType(tpl.article_type_key);
if (outline) {
@@ -115,7 +176,6 @@ export function ResearchSidebar({
setStrictMode(tpl.strict_mode);
setFormExpanded(true);
// 无主题/意图无法发起写作 → 仅回填表单,由用户补充后手动开始。
if (!intent) return;
void startWriting({
userIntent: intent,
@@ -130,116 +190,191 @@ export function ResearchSidebar({
};
return (
<aside className="flex h-full w-64 shrink-0 flex-col overflow-hidden border-r border-border/70 bg-card/60 backdrop-blur-sm">
{/* 上半:研究模板 */}
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-2 py-3">
<div className="px-2 mb-1.5 flex items-center justify-between">
<h2 className="text-muted-foreground/70 text-[10px] font-semibold tracking-[0.12em] uppercase select-none">
研究模板
</h2>
<button
type="button"
onClick={() => navigate("/page/canvas/research-templates")}
title="配置研究模板"
aria-label="配置研究模板"
className="text-muted-foreground/70 hover:text-foreground rounded p-0.5 transition-colors"
>
<Settings2 className="size-3.5" />
</button>
</div>
{templates.length === 0 ? (
<p className="px-2 py-1.5 text-xs text-muted-foreground/70">
暂无研究模板,点右上角配置
</p>
) : (
<div className="flex flex-col gap-1">
{templates.map((tpl) => (
<button
key={tpl.id}
type="button"
onClick={() => handleTemplateClick(tpl)}
title={tpl.description ?? tpl.name}
className="flex items-start gap-2 rounded-lg px-2.5 py-1.5 text-left text-muted-foreground transition-colors hover:bg-sidebar-accent/60 hover:text-foreground"
>
<FileText className="mt-0.5 size-3.5 shrink-0 text-muted-foreground/70" />
<span className="min-w-0">
<span className="block truncate text-[13px] font-medium">{tpl.name}</span>
{tpl.description && (
<span className="text-muted-foreground/70 line-clamp-1 block text-[11px]">
{tpl.description}
</span>
)}
</span>
</button>
))}
<aside className="flex h-full w-[320px] shrink-0 flex-col overflow-hidden border-r border-slate-100 bg-white text-slate-700 shadow-[1px_0_18px_rgba(15,23,42,0.03)]">
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto overflow-x-hidden px-3 py-4">
<section className="mb-5">
<div className="mb-3 flex items-center justify-between gap-3 px-1">
<button
type="button"
onClick={() => navigate("/page/canvas/research-templates")}
title="配置研究模板"
aria-label="配置研究模板"
className="group flex items-center gap-2 rounded-lg text-[16px] font-bold text-slate-500 transition-colors hover:text-slate-800"
>
<SlidersHorizontal className="size-4 text-slate-400" />
<span>研究模板</span>
<Settings2 className="size-3.5 text-slate-300 opacity-0 transition-opacity group-hover:opacity-100" />
</button>
<div className="relative w-[150px]">
<Search className="pointer-events-none absolute left-3 top-1/2 size-3.5 -translate-y-1/2 text-slate-400" />
<input
type="text"
value={templateSearch}
onChange={(event) => setTemplateSearch(event.target.value)}
placeholder="搜索写作模板..."
className="h-8 w-full rounded-lg border border-slate-200 bg-slate-50/80 pl-8 pr-3 text-[12px] text-slate-600 outline-none transition focus:border-blue-200 focus:bg-white focus:ring-2 focus:ring-blue-100"
/>
</div>
</div>
)}
</div>
{/* 下半:笔记本(我的空间) */}
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto border-t border-border/60 px-2 py-3">
<h2 className="text-muted-foreground/70 px-2 mb-1.5 text-[10px] font-semibold tracking-[0.12em] uppercase select-none">
笔记本
</h2>
<div className="flex flex-col gap-0.5">
{/* 新建空间 */}
<button
type="button"
onClick={() => {
setName("");
setCreateError("");
setCreateOpen(true);
}}
className="flex items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-[13px] text-muted-foreground transition-colors hover:bg-sidebar-accent/60 hover:text-foreground"
>
<Plus className="size-3.5 shrink-0 text-muted-foreground/70" />
<span>新建空间</span>
</button>
{notebooksLoading ? (
<p className="px-2 py-1.5 text-xs text-muted-foreground/70">加载中…</p>
) : notebooksError ? (
<p className="px-2 py-1.5 text-xs text-amber-600">
笔记本服务暂不可用(检查空间服务地址 / 账号)
{templates.length === 0 ? (
<p className="rounded-xl border border-dashed border-slate-200 px-3 py-5 text-center text-xs text-slate-400">
暂无研究模板,点击标题配置
</p>
) : filteredTemplates.length === 0 ? (
<p className="rounded-xl border border-dashed border-slate-200 px-3 py-5 text-center text-xs text-slate-400">
暂无匹配模板
</p>
) : notebooks.length === 0 ? (
<p className="px-2 py-1.5 text-xs text-muted-foreground/70">暂无笔记本</p>
) : (
notebooks.map((nb) => {
const active = viewingNotebookId === nb.id;
return (
<button
key={nb.id}
type="button"
onClick={() => onOpenNotebook(nb.id)}
title={nb.description || nb.name}
className={cn(
"flex items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-[13px] transition-colors",
active
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-sidebar-accent/60 hover:text-foreground",
)}
>
<BookOpen className="size-3.5 shrink-0 text-muted-foreground/70" />
<span className="truncate">{nb.name}</span>
</button>
);
})
<div className="space-y-3">
{filteredTemplates.map((tpl, index) => {
const visual = getTemplateVisual(tpl, index);
const Icon = visual.Icon;
return (
<button
key={tpl.id}
type="button"
onClick={() => handleTemplateClick(tpl)}
title={tpl.description ?? tpl.name}
className="group relative flex w-full items-start gap-3 overflow-hidden rounded-[5px] bg-white px-4 py-4 text-left shadow-[0_8px_24px_rgba(15,23,42,0.04)] transition-all hover:-translate-y-0.5 hover:shadow-[0_14px_30px_rgba(30,64,175,0.08)] active:translate-y-0"
>
<span
className={cn(
"flex size-12 shrink-0 items-center justify-center rounded-xl transition-colors",
visual.iconClass,
)}
>
<Icon className="size-6 stroke-[2.2]" />
</span>
<span className="min-w-0 flex-1 pt-0.5">
<span className="block truncate text-[16px] font-bold leading-5 text-slate-900 transition-colors group-hover:text-blue-700">
{tpl.name}
</span>
{tpl.description && (
<span
className="mt-1.5 overflow-hidden text-[13px] leading-6 text-slate-500"
style={{
display: "-webkit-box",
WebkitBoxOrient: "vertical",
WebkitLineClamp: 2,
}}
>
{tpl.description}
</span>
)}
</span>
<span
className={cn(
"absolute bottom-4 left-0 top-4 w-1 rounded-r-full opacity-0 transition-opacity group-hover:opacity-100",
visual.accentClass,
)}
/>
</button>
);
})}
</div>
)}
</section>
{/* 所有空间 */}
<button
type="button"
onClick={onOpenAllSpaces}
className="flex items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-[13px] text-muted-foreground transition-colors hover:bg-sidebar-accent/60 hover:text-foreground"
>
<LayoutGrid className="size-3.5 shrink-0 text-muted-foreground/70" />
<span>所有空间</span>
</button>
</div>
<section className="flex min-h-0 flex-1 flex-col border-t border-slate-100 pt-5">
<div className="mb-3 flex items-center justify-between px-2">
<h2 className="text-[15px] font-bold tracking-wide text-slate-400">
笔记本
</h2>
<button
type="button"
onClick={() => {
setName("");
setCreateError("");
setCreateOpen(true);
}}
title="新建写作空间"
aria-label="新建写作空间"
className="rounded-md p-1 text-slate-400 transition-colors hover:bg-rose-50 hover:text-rose-600"
>
<Plus className="size-4 stroke-[2.5]" />
</button>
</div>
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto px-1 pb-3">
{notebooksLoading ? (
<p className="px-3 py-2 text-xs text-slate-400">加载中...</p>
) : notebooksError ? (
<p className="rounded-xl bg-amber-50 px-3 py-2 text-xs leading-5 text-amber-700">
笔记本服务暂不可用,请检查空间服务地址或账号
</p>
) : notebooks.length === 0 ? (
<p className="px-3 py-2 text-xs text-slate-400">暂无笔记本</p>
) : (
notebooks.map((nb) => {
const active = viewingNotebookId === nb.id;
const sourceCount = Math.max(0, nb.source_count ?? 0);
return (
<button
key={nb.id}
type="button"
onClick={() => onOpenNotebook(nb.id)}
title={nb.description || nb.name}
className={cn(
"group relative flex min-h-14 w-full items-center gap-3 rounded-xl border bg-white px-3 py-2 text-left transition-all",
active
? "border-rose-100 bg-rose-50/35 text-slate-900 shadow-[0_6px_18px_rgba(244,63,94,0.06)]"
: "border-slate-100/80 text-slate-600 hover:border-slate-200 hover:bg-slate-50/70",
)}
>
{active && (
<span className="absolute bottom-4 left-0 top-4 w-1 rounded-r-full bg-rose-500" />
)}
<span
className={cn(
"flex size-8 shrink-0 items-center justify-center rounded-lg",
active ? "bg-rose-50 text-rose-600" : "bg-slate-100 text-slate-500",
)}
>
<BookOpen className="size-4 stroke-[2.2]" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[15px] font-medium leading-5">
{nb.name}
</span>
{sourceCount > 0 && (
<span className="mt-1 flex items-center gap-1 text-[11px] text-slate-400">
<FileText className="size-3" />
已有 {sourceCount} 篇排版
</span>
)}
</span>
{sourceCount > 0 && (
<span
className={cn(
"flex size-7 shrink-0 items-center justify-center rounded-full border text-[12px] font-semibold",
active
? "border-rose-100 bg-rose-50 text-rose-600"
: "border-slate-100 bg-slate-50 text-slate-400",
)}
>
{sourceCount}
</span>
)}
</button>
);
})
)}
</div>
<div className="border-t border-slate-100 px-1 pt-3">
<button
type="button"
onClick={onOpenAllSpaces}
className="flex w-full items-center gap-3 rounded-xl px-3 py-3 text-left text-[14px] font-bold text-slate-700 transition-colors hover:bg-slate-50 hover:text-slate-950"
>
<LayoutGrid className="size-4 text-rose-500 stroke-[2.3]" />
<span>所有空间</span>
</button>
</div>
</section>
</div>
{/* 新建空间弹窗 */}
<Dialog open={createOpen} onOpenChange={(v) => !creating && setCreateOpen(v)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
@@ -9,6 +9,7 @@ import { motion } from "motion/react";
import { Select } from "tdesign-react";
import {
AlertCircle,
ArrowLeft,
HelpCircle,
Info,
Link2,
@@ -21,6 +22,7 @@ import {
import { toast } from "sonner";
import { useTheme } from "@/contexts/ThemeContext";
import { useRouteNavigate } from "@/core/route-base";
import { listAgents } from "@/core/agents/api";
import type { Agent } from "@/core/agents/types";
import { listChains } from "@/roundtable-planning/api/chains";
@@ -34,6 +36,7 @@ import { cn } from "@/lib/utils";
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
export default function BusinessChainMappingPage() {
const navigate = useRouteNavigate();
const { theme } = useTheme();
const isDark = theme === "dark";
// 整片脱敏:业务链条映射页展示文本(链条名/描述/席位名/标题)。输入框 Hook 自动跳过。
@@ -233,6 +236,20 @@ export default function BusinessChainMappingPage() {
</span>
</div>
</div>
<button
type="button"
onClick={() => navigate("/page/strategy/business-chains")}
className={cn(
"relative z-10 inline-flex shrink-0 items-center gap-1.5 rounded-[5px] border px-3.5 py-2 text-xs font-semibold transition-all active:scale-95",
isDark
? "border-white/10 bg-white/5 text-slate-300 hover:bg-white/10 hover:text-slate-100"
: "border-slate-200 bg-white text-slate-600 hover:bg-slate-50 hover:text-slate-900",
)}
>
<ArrowLeft className="size-3.5" />
返回业务链条
</button>
</motion.div>
{/* ── 表格区 ── */}
@@ -3,13 +3,16 @@
*
* 深蓝主题适配见 `./light-app-management.css`(《深蓝色 UI 规范》tokens.css)。
*/
import { useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import {
AlertCircle,
AlignLeft,
ArrowDown,
ArrowUp,
ChevronDown,
ChevronUp,
Copy,
GripVertical,
ExternalLink,
FolderTree,
Globe,
@@ -239,6 +242,68 @@ export default function LightAppManagementPage() {
[apps, categoryFilter, mountFilter],
);
// ── 排序调整:仅当「iframe 嵌套 + 指定某个挂载菜单」时开启 ──
// 同一菜单下的多个 iframe 子项可拖拽 / 上下移调整顺序;调整即写回各应用的 sortOrder,
// 侧栏(use-sidebar-menu 按列表顺序注入,后端按 sort_order 升序返回)随之同步。
const reorderable = categoryFilter === "iframe" && !!mountFilter && viewMode === "table";
// 乐观顺序覆盖(保存期间稳定显示,刷新回来与服务端一致);切换筛选/搜索即清空,回落服务端顺序。
const [orderIds, setOrderIds] = useState<string[] | null>(null);
const [reordering, setReordering] = useState(false);
const dragIndexRef = useRef<number | null>(null);
useEffect(() => {
setOrderIds(null);
}, [categoryFilter, mountFilter, search]);
const displayApps = useMemo(() => {
if (!reorderable || !orderIds) return tableApps;
const pos = new Map(orderIds.map((id, i) => [id, i] as const));
return [...tableApps].sort(
(a, b) => (pos.get(a.appId) ?? 1e9) - (pos.get(b.appId) ?? 1e9),
);
}, [reorderable, orderIds, tableApps]);
// 把整组按新顺序重排 sortOrder(组内 0-based;只 PUT 顺序变了的项),乐观先行。
const persistOrder = async (ordered: LightApp[]) => {
setOrderIds(ordered.map((a) => a.appId));
const changed = ordered.filter((a, i) => a.sortOrder !== i);
if (changed.length === 0) return;
setReordering(true);
try {
await Promise.all(
ordered.map((a, i) =>
a.sortOrder === i
? Promise.resolve()
: actions.update.mutateAsync({ appId: a.appId, patch: { sortOrder: i } }),
),
);
} catch (err) {
toast.error((err as Error).message || "排序保存失败");
setOrderIds(null);
} finally {
setReordering(false);
}
};
const moveApp = (index: number, dir: -1 | 1) => {
if (reordering) return;
const target = index + dir;
if (target < 0 || target >= displayApps.length) return;
const next = [...displayApps];
[next[index], next[target]] = [next[target]!, next[index]!];
void persistOrder(next);
};
const handleRowDrop = (toIndex: number) => {
const from = dragIndexRef.current;
dragIndexRef.current = null;
if (from === null || from === toIndex || reordering) return;
const next = [...displayApps];
const [moved] = next.splice(from, 1);
if (!moved) return;
next.splice(toIndex, 0, moved);
void persistOrder(next);
};
return (
<div className="light-app-management-page h-full overflow-y-auto bg-neutral-50 dark:bg-[#0C1836] dark:text-[#C5CFE0]">
<div className="mx-auto flex min-h-full w-full max-w-7xl flex-col space-y-7 px-8 py-8">
@@ -349,9 +414,18 @@ export default function LightAppManagementPage() {
</div>
<div className="space-y-4">
<div className="lam-count text-xs font-medium text-neutral-500">
搜寻到 <strong className="font-bold text-[#F5222D]">{tableApps.length}</strong>{" "}
个注册完成的子模块轻应用
<div className="lam-count flex flex-wrap items-center gap-x-3 gap-y-1 text-xs font-medium text-neutral-500">
<span>
搜寻到 <strong className="font-bold text-[#F5222D]">{tableApps.length}</strong>{" "}
个注册完成的子模块轻应用
</span>
{reorderable && (
<span className="inline-flex items-center gap-1 text-[11px] text-sky-600">
<GripVertical className="h-3.5 w-3.5" />
拖拽行或用上 / 下按钮调整「{mountLabel(mountFilter)}」下的菜单顺序,调整即生效
{reordering && <span className="text-neutral-400">· 保存中…</span>}
</span>
)}
</div>
{viewMode === "table" ? (
@@ -359,6 +433,7 @@ export default function LightAppManagementPage() {
<table className="lam-table w-full table-fixed border-collapse text-left">
<thead>
<tr className="select-none border-b border-neutral-100/80 bg-neutral-50/40 text-[12.5px] font-semibold text-neutral-500">
{reorderable && <th className="w-[44px] px-2 py-4" aria-label="拖拽排序" />}
<th className="w-[15%] px-6 py-4">名称</th>
<th className="w-[14%] px-4 py-4">类型</th>
<th className="w-[27%] px-4 py-4">地址</th>
@@ -369,8 +444,32 @@ export default function LightAppManagementPage() {
</tr>
</thead>
<tbody className="divide-y divide-neutral-100">
{tableApps.map((app) => (
<tr key={app.appId} className="group align-middle transition-colors hover:bg-neutral-50/40">
{displayApps.map((app, index) => (
<tr
key={app.appId}
className={`group align-middle transition-colors hover:bg-neutral-50/40 ${
reorderable ? "lam-reorder-row" : ""
}`}
draggable={reorderable}
onDragStart={
reorderable
? (e) => {
dragIndexRef.current = index;
e.dataTransfer.effectAllowed = "move";
}
: undefined
}
onDragOver={reorderable ? (e) => e.preventDefault() : undefined}
onDrop={reorderable ? () => handleRowDrop(index) : undefined}
>
{reorderable && (
<td className="px-2 py-[18px] text-center">
<GripVertical
className="lam-drag-handle mx-auto h-4 w-4 cursor-grab text-neutral-300"
aria-hidden
/>
</td>
)}
<td className="px-6 py-[18px]">
<span className="lam-row-name text-[14px] font-bold text-neutral-800 transition-colors group-hover:text-[#F5222D]">
{app.name}
@@ -505,6 +604,29 @@ export default function LightAppManagementPage() {
<td className="px-4 py-[18px] pr-6 text-center">
<div className="flex items-center justify-center space-x-1">
{reorderable && (
<>
<button
type="button"
disabled={index === 0 || reordering}
onClick={() => moveApp(index, -1)}
className="lam-icon-btn cursor-pointer rounded-[5px] p-1 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-[#F5222D] disabled:cursor-not-allowed disabled:opacity-30"
title="上移"
>
<ArrowUp className="h-3.5 w-3.5" />
</button>
<button
type="button"
disabled={index === displayApps.length - 1 || reordering}
onClick={() => moveApp(index, 1)}
className="lam-icon-btn cursor-pointer rounded-[5px] p-1 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-[#F5222D] disabled:cursor-not-allowed disabled:opacity-30"
title="下移"
>
<ArrowDown className="h-3.5 w-3.5" />
</button>
<span className="mx-0.5 h-4 w-px shrink-0 bg-neutral-200" />
</>
)}
<button
onClick={() => openEdit(app)}
className="lam-icon-btn cursor-pointer rounded-[5px] p-1.5 text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-emerald-600"
@@ -524,9 +646,9 @@ export default function LightAppManagementPage() {
</tr>
))}
{tableApps.length === 0 && (
{displayApps.length === 0 && (
<tr>
<td colSpan={7} className="py-12 text-center">
<td colSpan={reorderable ? 8 : 7} className="py-12 text-center">
<div className="flex flex-col items-center justify-center space-y-3">
<div className="lam-empty-icon flex h-12 w-12 items-center justify-center rounded-full bg-neutral-100 text-neutral-300">
<Info className="h-6 w-6" />
@@ -0,0 +1,895 @@
/**
* 菜单管理页(仅管理员)。
*
* 管理整棵侧边栏菜单(一级 / 二级 / 三级),含通过「应用管理」注入的 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,
ChevronRight,
ChevronsLeft,
ChevronsRight,
ChevronUp,
GripVertical,
Info,
Menu,
Pencil,
RotateCcw,
Save,
Search,
Sparkles,
} from "lucide-react";
import { toast } from "sonner";
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;
canIndent: boolean;
canOutdent: boolean;
dragEnabled: boolean;
onUp: () => void;
onDown: () => void;
onIndent: () => void;
onOutdent: () => void;
onToggle: () => void;
onEdit: () => void;
}
/** 一级 / 二级 / 三级 的层级色标。 */
const LEVEL_BADGE = [
"bg-blue-50 text-blue-600 border border-blue-100",
"bg-purple-50 text-purple-600 border border-purple-100",
"bg-indigo-50 text-indigo-600 border border-indigo-100",
];
function SortableRow({
row,
depth,
canUp,
canDown,
canIndent,
canOutdent,
dragEnabled,
onUp,
onDown,
onIndent,
onOutdent,
onToggle,
onEdit,
}: SortableRowProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: row.id,
});
const Icon = row.source.icon;
const levelLabel = ["一级", "二级", "三级"][depth - 1] ?? `${depth}级`;
const typeLabel = row.isLightApp ? "应用" : "内置";
return (
<div
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
marginLeft: (depth - 1) * INDENT,
opacity: isDragging ? 0.4 : 1,
}}
className={`group relative flex flex-col gap-3 rounded-2xl border p-3.5 transition-all md:flex-row md:items-center md:justify-between ${
row.disabled
? "border-slate-100 bg-slate-50/40"
: "border-slate-200/70 bg-white shadow-[0_1px_4px_-1px_rgba(0,0,0,0.04)] hover:border-slate-300 hover:shadow-[0_4px_16px_-4px_rgba(0,0,0,0.08)]"
}`}
>
{/* 左侧:拖拽柄 + 图标 + 名称/路径 */}
<div className="flex min-w-0 items-center gap-3">
<button
type="button"
className={`shrink-0 rounded p-1 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600 ${
dragEnabled ? "cursor-grab touch-none active:cursor-grabbing" : "cursor-not-allowed opacity-40"
}`}
title={dragEnabled ? "拖动调整顺序与层级" : "筛选状态下不可拖动"}
{...(dragEnabled ? attributes : {})}
{...(dragEnabled ? listeners : {})}
>
<GripVertical className="size-4" />
</button>
{depth > 1 && (
<ChevronRight className="hidden size-3.5 shrink-0 stroke-[2.5] text-slate-300 sm:block" />
)}
<button
type="button"
onClick={onEdit}
className={`shrink-0 rounded-xl p-2.5 transition-all ${
row.disabled
? "bg-slate-100 text-slate-400"
: "bg-red-50 text-red-600 hover:scale-105 hover:bg-red-100"
}`}
title="点击修改菜单详情"
>
<Icon className="size-[18px]" />
</button>
<div className="flex min-w-0 flex-wrap items-center gap-2">
<button
type="button"
onClick={onEdit}
className={`truncate text-sm font-semibold transition-colors hover:text-red-600 ${
row.disabled ? "text-slate-400 line-through" : "text-slate-800"
}`}
>
{row.label}
</button>
<span
className={`rounded-full px-2 py-0.5 text-[10px] font-medium ${LEVEL_BADGE[depth - 1] ?? LEVEL_BADGE[2]}`}
>
{levelLabel}
</span>
<span className="rounded border border-slate-200/60 bg-slate-100 px-1.5 py-0.5 font-mono text-[10px] font-medium text-slate-500">
{typeLabel}
</span>
</div>
</div>
{/* 右侧:层级/排序按钮组 + 状态 + 编辑 */}
<div className="flex items-center justify-between gap-2.5 border-t border-slate-100 pt-2.5 md:justify-end md:border-t-0 md:pt-0">
<div className="flex items-center rounded-xl border border-slate-200/70 bg-slate-50/80 p-0.5 text-slate-500">
<button
type="button"
onClick={onOutdent}
disabled={!canOutdent}
className="rounded-lg p-1.5 transition-colors hover:bg-white hover:text-slate-800 disabled:opacity-20 disabled:hover:bg-transparent"
title="升一级"
>
<ChevronsLeft className="size-3.5" />
</button>
<button
type="button"
onClick={onIndent}
disabled={!canIndent}
className="rounded-lg p-1.5 transition-colors hover:bg-white hover:text-slate-800 disabled:opacity-20 disabled:hover:bg-transparent"
title="降一级"
>
<ChevronsRight className="size-3.5" />
</button>
<div className="mx-1 h-4 w-px bg-slate-200" />
<button
type="button"
onClick={onUp}
disabled={!canUp}
className="rounded-lg p-1.5 transition-colors hover:bg-white hover:text-slate-800 disabled:opacity-20 disabled:hover:bg-transparent"
title="上移"
>
<ChevronUp className="size-3.5" />
</button>
<button
type="button"
onClick={onDown}
disabled={!canDown}
className="rounded-lg p-1.5 transition-colors hover:bg-white hover:text-slate-800 disabled:opacity-20 disabled:hover:bg-transparent"
title="下移"
>
<ChevronDown className="size-3.5" />
</button>
</div>
<button
type="button"
onClick={onToggle}
className={`inline-flex select-none items-center gap-1.5 rounded-xl px-3 py-1.5 text-xs font-semibold transition-all ${
row.disabled
? "bg-slate-100 text-slate-400 hover:bg-slate-200 hover:text-slate-600"
: "border border-emerald-200/50 bg-emerald-50 text-emerald-600 hover:bg-emerald-100"
}`}
title="点击切换启用 / 停用"
>
<span className={`size-1.5 shrink-0 rounded-full ${row.disabled ? "bg-slate-400" : "bg-emerald-500"}`} />
{row.disabled ? "停用中" : "启用中"}
</button>
<button
type="button"
onClick={onEdit}
className="rounded-lg p-1.5 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700"
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);
const [query, setQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<"all" | "active" | "inactive">("all");
// 数据首次/变化加载时,用服务端值重置工作副本(避免覆盖用户未保存的编辑:以来源 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 isFiltering = query.trim() !== "" || statusFilter !== "all";
const visibleRows = useMemo(() => {
const q = query.trim().toLowerCase();
return rows.filter((r) => {
const matchQ = !q || r.label.toLowerCase().includes(q);
const matchS =
statusFilter === "all" ||
(statusFilter === "active" && !r.disabled) ||
(statusFilter === "inactive" && r.disabled);
return matchQ && matchS;
});
}, [rows, query, statusFilter]);
// ── 行操作 ──
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 indentRow = (row: FlatRow) => {
const sibs = siblingsOf(row.parentId);
const i = sibs.findIndex((s) => s.id === row.id);
if (i <= 0) return;
const newParent = sibs[i - 1].id;
if (wouldExceedDepth(row.id, newParent, mergedNow)) {
toast.error(`移动后将超过 ${MAX_MENU_DEPTH} 级,已阻止`);
return;
}
setRows((prev) => {
const next = prev.map((r) => (r.id === row.id ? { ...r, parentId: newParent } : r));
const idx = next.findIndex((r) => r.id === row.id);
const [moved] = next.splice(idx, 1);
next.push(moved);
return normalizeDfs(next);
});
};
/** 升一级(移位):移动到「父级的父级」下,紧跟原父级之后。 */
const outdentRow = (row: FlatRow) => {
if (row.parentId == null) return;
const parentRow = rows.find((r) => r.id === row.parentId);
if (!parentRow) return;
const newParent = parentRow.parentId ?? null;
setRows((prev) => {
const next = prev.map((r) => (r.id === row.id ? { ...r, parentId: newParent } : r));
const idx = next.findIndex((r) => r.id === row.id);
const [moved] = next.splice(idx, 1);
const pIdx = next.findIndex((r) => r.id === parentRow.id);
next.splice(pIdx + 1, 0, moved);
return normalizeDfs(next);
});
};
const toggleRow = (row: FlatRow) =>
setRows((prev) => prev.map((r) => (r.id === row.id ? { ...r, disabled: !r.disabled } : r)));
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="h-full overflow-auto bg-slate-50/60 px-4 py-6 sm:px-6 lg:px-8">
<div className="mx-auto max-w-4xl">
{/* 顶部信息卡 */}
<div className="relative mb-6 overflow-hidden rounded-3xl border border-slate-200/60 bg-white p-6 shadow-sm md:p-8">
<div className="pointer-events-none absolute right-0 top-0 select-none p-8 opacity-[0.03]">
<Menu className="size-44 text-slate-900" />
</div>
<div className="relative">
<div className="mb-4 flex flex-col justify-between gap-4 md:flex-row md:items-center">
<div>
<span className="mb-3 inline-flex select-none items-center gap-1.5 rounded-full border border-red-100 bg-red-50 px-3 py-1 text-xs font-semibold text-red-600">
<Sparkles className="size-3" />
系统集成版
</span>
<h1 className="flex items-center gap-3 text-2xl font-bold tracking-tight text-slate-800 md:text-3xl">
菜单管理
<span className="rounded-lg border border-slate-200/60 bg-slate-100 px-2.5 py-0.5 font-mono text-xs font-medium text-slate-500">
{rows.length} 个项目
</span>
</h1>
</div>
<div className="flex items-center gap-2.5">
<button
type="button"
onClick={handleReset}
className="inline-flex cursor-pointer items-center gap-2 rounded-xl border border-slate-200 bg-white px-3.5 py-2 text-sm font-semibold text-slate-600 shadow-sm transition-all hover:border-slate-300 hover:bg-slate-50 hover:text-slate-800"
title="恢复为默认菜单布局"
>
<RotateCcw className="size-[15px]" />
重置全部
</button>
<button
type="button"
onClick={handleSave}
disabled={!dirty || save.isPending}
className="inline-flex cursor-pointer items-center gap-2 rounded-xl border border-red-600 bg-red-600 px-5 py-2 text-sm font-semibold text-white shadow-sm transition-all hover:bg-red-700 active:scale-95 disabled:cursor-not-allowed disabled:opacity-50"
>
{save.isPending ? (
<>
<div className="size-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
保存中…
</>
) : (
<>
<Save className="size-[15px]" />
保存修改
</>
)}
</button>
</div>
</div>
<p className="flex max-w-2xl items-start gap-2.5 rounded-2xl border border-slate-200/50 bg-slate-50/70 p-3.5 text-sm leading-relaxed text-slate-600">
<Info className="mt-0.5 size-4 shrink-0 text-slate-400" />
<span>
<strong>拖拽调整</strong> 菜单顺序与层级(按住拖拽地标 <span className="font-mono">::</span>{" "}
上下排序,横向拖动改变层级);也可点击卡片上的<strong> 缩进 / 移位按钮 </strong>
精确调整层级(最多 {MAX_MENU_DEPTH} 级)、启停与编辑。
</span>
</p>
</div>
</div>
{/* 搜索 + 状态筛选栏 */}
<div className="mb-6 flex flex-col items-center justify-between gap-4 rounded-2xl border border-slate-200/60 bg-white p-4 shadow-sm md:flex-row md:p-5">
<div className="relative w-full md:w-80">
<Search className="absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-slate-400" />
<input
type="text"
placeholder="搜索菜单名称…"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="w-full rounded-xl border border-slate-200 bg-slate-50/50 py-2 pl-10 pr-4 text-sm transition-all hover:border-slate-300 focus:border-red-500 focus:outline-none focus:ring-4 focus:ring-red-500/5"
/>
</div>
<div className="inline-flex items-center rounded-xl border border-slate-200/40 bg-slate-100/80 p-1 text-xs font-medium">
{(
[
["all", "全部"],
["active", "已启用"],
["inactive", "已停用"],
] as const
).map(([key, label]) => (
<button
key={key}
type="button"
onClick={() => setStatusFilter(key)}
className={`cursor-pointer rounded-lg px-3.5 py-1.5 transition-all ${
statusFilter === key
? "bg-white font-semibold text-slate-800 shadow-sm"
: "text-slate-500 hover:text-slate-800"
}`}
>
{label}
</button>
))}
</div>
</div>
{/* 菜单列表卡 */}
<div className="min-h-[400px] rounded-3xl border border-slate-200/60 bg-white p-4 shadow-sm md:p-6">
{isLoading ? (
<p className="py-20 text-center text-sm text-slate-400">加载中…</p>
) : visibleRows.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="mb-4 rounded-full bg-slate-100 p-4 text-slate-400">
<Menu className="size-8" />
</div>
<h4 className="mb-1 text-base font-semibold text-slate-700">无符合条件的菜单项</h4>
<p className="max-w-xs text-sm text-slate-400">尝试清空搜索或筛选条件以查看全部菜单。</p>
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={onDragStart}
onDragMove={onDragMove}
onDragEnd={onDragEnd}
onDragCancel={() => {
setActiveId(null);
setOffsetX(0);
}}
>
<SortableContext
items={visibleRows.map((r) => r.id)}
strategy={verticalListSortingStrategy}
>
<div className="flex flex-col gap-3">
{visibleRows.map((row) => {
const sibs = siblingsOf(row.parentId);
const i = sibs.findIndex((s) => s.id === row.id);
const canIndent = i > 0 && !wouldExceedDepth(row.id, sibs[i - 1].id, mergedNow);
return (
<SortableRow
key={row.id}
row={row}
depth={depthById.get(row.id) ?? 1}
canUp={i > 0}
canDown={i >= 0 && i < sibs.length - 1}
canIndent={canIndent}
canOutdent={row.parentId != null}
dragEnabled={!isFiltering}
onUp={() => moveUp(row)}
onDown={() => moveDown(row)}
onIndent={() => indentRow(row)}
onOutdent={() => outdentRow(row)}
onToggle={() => toggleRow(row)}
onEdit={() => setEditId(row.id)}
/>
);
})}
</div>
</SortableContext>
<DragOverlay>
{activeId ? (
<div className="rounded-2xl border border-red-200 bg-white px-3.5 py-2 text-sm font-semibold text-slate-800 shadow-lg">
{rows.find((r) => r.id === activeId)?.label}
</div>
) : null}
</DragOverlay>
</DndContext>
)}
</div>
</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>
);
}
@@ -0,0 +1,273 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Navigate } from "react-router-dom";
import { Download, Info, Plus, Save, Search, ShieldAlert, Sparkles, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Switch } from "@/components/ui/switch";
import { getAccountDisplayName } from "@/core/auth";
import { SEED_DESENSITIZE_MAP } from "@/lib/desensitize";
import {
type SensitiveWord,
useSensitiveWordActions,
useSensitiveWords,
} from "@/strategy-components/api/sensitive-words";
interface EditRow {
/** 稳定的行 key(编辑原始词时不丢焦点) */
key: number;
word: string;
replacement: string;
enabled: boolean;
}
let _seq = 1;
const nextKey = () => _seq++;
function toRows(words: SensitiveWord[]): EditRow[] {
return words.map((w) => ({ key: nextKey(), word: w.word, replacement: w.replacement, enabled: w.enabled }));
}
/** 归一化用于「脏」判断与重复检测(按原始词去空白后比较)。 */
function serialize(rows: { word: string; replacement: string; enabled: boolean }[]): string {
return JSON.stringify(
rows
.map((r) => ({ w: r.word.trim(), r: r.replacement, e: r.enabled }))
.filter((r) => r.w !== "")
.sort((a, b) => a.w.localeCompare(b.w)),
);
}
export default function SensitiveWordsPage() {
// 仅用户名为 lqq 的账号可访问(用户名 = 设置→个人信息页展示名)。
const canManage = getAccountDisplayName().toLowerCase() === "lqq";
const { data: serverWords = [], isLoading } = useSensitiveWords();
const { save } = useSensitiveWordActions();
const [rows, setRows] = useState<EditRow[]>([]);
const [loadedKey, setLoadedKey] = useState<string | null>(null);
const [query, setQuery] = useState("");
// 服务端数据首次/变化时回填工作副本(以序列化值判定,避免覆盖未保存编辑)。
const sourceKey = useMemo(() => serialize(serverWords), [serverWords]);
useEffect(() => {
if (sourceKey !== loadedKey) {
setRows(toRows(serverWords));
setLoadedKey(sourceKey);
}
}, [sourceKey, loadedKey, serverWords]);
const dirty = useMemo(() => serialize(rows) !== serialize(serverWords), [rows, serverWords]);
const q = query.trim().toLowerCase();
const visibleRows = useMemo(
() =>
!q
? rows
: rows.filter(
(r) => r.word.toLowerCase().includes(q) || r.replacement.toLowerCase().includes(q),
),
[rows, q],
);
const enabledCount = rows.filter((r) => r.enabled && r.word.trim()).length;
// ── 行操作 ──
const patchRow = (key: number, patch: Partial<EditRow>) =>
setRows((prev) => prev.map((r) => (r.key === key ? { ...r, ...patch } : r)));
const removeRow = (key: number) => setRows((prev) => prev.filter((r) => r.key !== key));
const addRow = () => setRows((prev) => [{ key: nextKey(), word: "", replacement: "", enabled: true }, ...prev]);
/** 用前端内置种子补齐当前没有的词(已存在的词不动)。 */
const importSeed = () => {
const have = new Set(rows.map((r) => r.word.trim()).filter(Boolean));
const added: EditRow[] = [];
for (const [word, replacement] of Object.entries(SEED_DESENSITIZE_MAP)) {
if (!have.has(word)) added.push({ key: nextKey(), word, replacement, enabled: true });
}
if (added.length === 0) {
toast.info("种子词已全部存在,无需导入");
return;
}
setRows((prev) => [...added, ...prev]);
toast.success(`已导入 ${added.length} 个种子词(保存后生效)`);
};
const handleSave = async () => {
// 校验:去空白、丢弃空原始词、检测重复。
const cleaned = rows
.map((r) => ({ ...r, word: r.word.trim() }))
.filter((r) => r.word !== "");
const seen = new Set<string>();
for (const r of cleaned) {
if (seen.has(r.word)) {
toast.error(`原始词「${r.word}」重复,请合并后再保存`);
return;
}
seen.add(r.word);
}
const payload: SensitiveWord[] = cleaned.map((r, i) => ({
word: r.word,
replacement: r.replacement,
enabled: r.enabled,
sortOrder: i,
}));
try {
await save.mutateAsync(payload);
toast.success("敏感词已保存");
} catch (err) {
toast.error(err instanceof Error ? err.message : "保存失败");
}
};
if (!canManage) return <Navigate to="/page/workspace/chats/new" replace />;
return (
<div className="h-full overflow-auto bg-slate-50/60 px-4 py-6 sm:px-6 lg:px-8">
<div className="mx-auto max-w-4xl">
{/* 顶部信息卡 */}
<div className="relative mb-6 overflow-hidden rounded-3xl border border-slate-200/60 bg-white p-6 shadow-sm md:p-8">
<div className="pointer-events-none absolute right-0 top-0 select-none p-8 opacity-[0.03]">
<ShieldAlert className="size-44 text-slate-900" />
</div>
<div className="relative">
<div className="mb-4 flex flex-col justify-between gap-4 md:flex-row md:items-center">
<div>
<span className="mb-3 inline-flex select-none items-center gap-1.5 rounded-full border border-red-100 bg-red-50 px-3 py-1 text-xs font-semibold text-red-600">
<Sparkles className="size-3" />
系统集成版
</span>
<h1 className="flex items-center gap-3 text-2xl font-bold tracking-tight text-slate-800 md:text-3xl">
敏感词管理
<span className="rounded-lg border border-slate-200/60 bg-slate-100 px-2.5 py-0.5 font-mono text-xs font-medium text-slate-500">
{rows.length} 词 / {enabledCount} 启用
</span>
</h1>
</div>
<div className="flex items-center gap-2.5">
<button
type="button"
onClick={importSeed}
className="inline-flex cursor-pointer items-center gap-2 rounded-xl border border-slate-200 bg-white px-3.5 py-2 text-sm font-semibold text-slate-600 shadow-sm transition-all hover:border-slate-300 hover:bg-slate-50 hover:text-slate-800"
title="用前端内置种子词补齐当前缺失的词"
>
<Download className="size-[15px]" />
导入种子词
</button>
<button
type="button"
onClick={handleSave}
disabled={!dirty || save.isPending}
className="inline-flex cursor-pointer items-center gap-2 rounded-xl border border-red-600 bg-red-600 px-5 py-2 text-sm font-semibold text-white shadow-sm transition-all hover:bg-red-700 active:scale-95 disabled:cursor-not-allowed disabled:opacity-50"
>
{save.isPending ? (
<>
<div className="size-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
保存中…
</>
) : (
<>
<Save className="size-[15px]" />
保存修改
</>
)}
</button>
</div>
</div>
<p className="flex max-w-2xl items-start gap-2.5 rounded-2xl border border-slate-200/50 bg-slate-50/70 p-3.5 text-sm leading-relaxed text-slate-600">
<Info className="mt-0.5 size-4 shrink-0 text-slate-400" />
<span>
配置展示层<strong> 脱敏词映射</strong>(原始词 → 替换词)。保存后全站脱敏即时生效;
<strong> 停用 </strong>的词会保留但不参与替换。前端内置的种子词仅用于全新机器首次部署,
可用<strong>「导入种子词」</strong>一键写入后台。
</span>
</p>
</div>
</div>
{/* 搜索 + 新增栏 */}
<div className="mb-6 flex flex-col items-center justify-between gap-4 rounded-2xl border border-slate-200/60 bg-white p-4 shadow-sm md:flex-row md:p-5">
<div className="relative w-full md:w-80">
<Search className="absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-slate-400" />
<input
type="text"
placeholder="搜索原始词 / 替换词…"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="w-full rounded-xl border border-slate-200 bg-slate-50/50 py-2 pl-10 pr-4 text-sm transition-all hover:border-slate-300 focus:border-red-500 focus:outline-none focus:ring-4 focus:ring-red-500/5"
/>
</div>
<button
type="button"
onClick={addRow}
className="inline-flex w-full cursor-pointer items-center justify-center gap-2 rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-semibold text-slate-700 shadow-sm transition-all hover:border-red-300 hover:bg-red-50 hover:text-red-600 md:w-auto"
>
<Plus className="size-[15px]" />
新增敏感词
</button>
</div>
{/* 列表 */}
<div className="overflow-hidden rounded-2xl border border-slate-200/60 bg-white shadow-sm">
<div className="grid grid-cols-[1fr_1fr_auto_auto] items-center gap-3 border-b border-slate-200/60 bg-slate-50/70 px-4 py-2.5 text-xs font-semibold text-slate-500">
<span>原始词</span>
<span>替换词</span>
<span className="px-1 text-center">启用</span>
<span className="w-8" />
</div>
{isLoading ? (
<div className="px-4 py-12 text-center text-sm text-slate-400">加载中…</div>
) : visibleRows.length === 0 ? (
<div className="px-4 py-12 text-center text-sm text-slate-400">
{rows.length === 0 ? "暂无敏感词,点击「新增」或「导入种子词」开始配置" : "没有匹配的词"}
</div>
) : (
<ul className="divide-y divide-slate-100">
{visibleRows.map((row) => (
<li
key={row.key}
className={`grid grid-cols-[1fr_1fr_auto_auto] items-center gap-3 px-4 py-2.5 transition-colors hover:bg-slate-50/60 ${
row.enabled ? "" : "opacity-55"
}`}
>
<input
type="text"
value={row.word}
placeholder="原始词"
onChange={(e) => patchRow(row.key, { word: e.target.value })}
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-sm transition-all focus:border-red-500 focus:outline-none focus:ring-2 focus:ring-red-500/10"
/>
<input
type="text"
value={row.replacement}
placeholder="替换词"
onChange={(e) => patchRow(row.key, { replacement: e.target.value })}
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-sm transition-all focus:border-red-500 focus:outline-none focus:ring-2 focus:ring-red-500/10"
/>
<div className="px-1">
<Switch
checked={row.enabled}
onCheckedChange={(v) => patchRow(row.key, { enabled: v })}
/>
</div>
<button
type="button"
onClick={() => removeRow(row.key)}
title="删除"
className="flex size-8 items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-red-50 hover:text-red-600"
>
<Trash2 className="size-4" />
</button>
</li>
))}
</ul>
)}
</div>
</div>
</div>
);
}
+23 -1
View File
@@ -2,7 +2,7 @@ import { Navigate, Route, Routes, useParams } from "react-router-dom";
import { Toaster } from "sonner";
import { SidebarProvider } from "@/components/ui/sidebar";
import { getStoredAuth } from "@/core/auth";
import { getAccountDisplayName, getStoredAuth } from "@/core/auth";
import { RouteBaseProvider } from "@/core/route-base";
import GeneralQA from "@/strategy-components/components/chat-qa/GeneralQA";
import SentimentAnalyzes from "@/strategy-components/components/task-components/SentimentAnalyzes";
@@ -36,6 +36,8 @@ import PublicLeaderboardPage from "./PublicLeaderboardPage";
import LightAppIframeView from "./LightAppIframeView";
import LightAppListPage from "./LightAppListPage";
import LightAppManagementPage from "./LightAppManagementPage";
import MenuManagementPage from "./MenuManagementPage";
import SensitiveWordsPage from "./SensitiveWordsPage";
import LightAppNewNotePage from "./LightAppNewNotePage";
import LightAppNoteListPage from "./LightAppNoteListPage";
import LightAppNoteQAPage from "./LightAppNoteQAPage";
@@ -329,6 +331,26 @@ 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/sensitive-words"
element={
getAccountDisplayName().toLowerCase() === "lqq" ? (
<SensitiveWordsPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/scheduled-tasks"
element={
@@ -678,3 +678,18 @@
[data-theme="lightblue"] .light-app-management-page .lam-dialog-save:active {
background-color: #0056C6;
}
/* ── iframe 菜单排序:拖拽行 + 拖拽手柄 ───────────────────────────────── */
.light-app-management-page .lam-reorder-row {
cursor: grab;
}
.light-app-management-page .lam-reorder-row:active {
cursor: grabbing;
}
/* 深蓝主题下手柄/箭头用次级文字色,避免在深底上发灰看不清 */
.dark .light-app-management-page .lam-drag-handle {
color: #4A6491;
}
.dark .light-app-management-page .lam-reorder-row:hover .lam-drag-handle {
color: #6E88B0;
}
@@ -309,6 +309,16 @@ export async function getLatestDraftByTask(taskId: string): Promise<DraftRecord
return toRecord(data);
}
/**
* 按外部任务 id 列出该任务全部草稿(轻量 meta,newest first;后端忽略 user,不分权)。
* taskId 深链模式下历史下拉用:一个 taskId 可有多条会商记录。
*/
export async function listDraftsByTask(taskId: string): Promise<DraftMeta[]> {
const res = await apiFetch(`${BASE()}/by-task/${encodeURIComponent(taskId)}`, { method: "GET" });
const data = await readJson<{ drafts: ServerDraftMeta[] }>(res, "list drafts by task");
return (data.drafts ?? []).map(toMeta);
}
export async function createDraft(payload: DraftCreatePayload): Promise<DraftRecord> {
const res = await apiFetch(BASE(), {
method: "POST",
@@ -85,7 +85,7 @@ function FlowCardNode({ data }: NodeProps) {
<div className="mb-0.5 flex items-center gap-1.5">
<span style={{ background: meta.dot }} className="size-2 shrink-0 rounded-full" />
<span className="text-[9px] font-bold uppercase tracking-wider opacity-70" style={{ fontSize: `${9 * scale}px` }}>
{meta.label}
{node.group?.trim() || meta.label}
</span>
</div>
<div className="break-words font-bold leading-snug" style={{ fontSize: `${11.5 * scale}px` }}>{node.label}</div>
@@ -347,7 +347,7 @@ function NodeDetailModal({
className="shrink-0 rounded px-1.5 py-0.5 text-[10px] font-bold"
style={{ background: "#ffffffaa", color: meta.text }}
>
{meta.label}
{node.group?.trim() || meta.label}
</span>
<h3 className="min-w-0 truncate text-sm font-bold" style={{ color: meta.text }}>
{node.label}
@@ -34,6 +34,7 @@ function formatFileSize(bytes: number): string {
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
import { AutoGrowTextarea } from "@/components/ui/auto-grow-textarea";
import { Button } from "@/components/ui/button";
import { useLocalSettings } from "@/core/settings";
import type { MessageMetric } from "@/components/workspace/messages/use-message-metrics";
@@ -221,7 +222,7 @@ export function Step1Panel(props: Step1PanelProps) {
))}
</div>
)}
<textarea
<AutoGrowTextarea
id="input-text-area"
value={inputText}
onChange={(e) => setInputText(e.target.value)}
@@ -233,10 +234,11 @@ export function Step1Panel(props: Step1PanelProps) {
}}
placeholder={
pendingIntentClarificationMsgId
? "也可在此输入自定义回答 (Enter 快速发送)"
: "请输入补充信息 / 回答问题 / 继续澄清 (Enter 快速发送)"
? "也可在此输入自定义回答 (Enter 发送 / Shift+Enter 换行)"
: "请输入补充信息 / 回答问题 / 继续澄清 (Enter 发送 / Shift+Enter 换行)"
}
className="w-full text-xs text-slate-700 placeholder:text-slate-400 resize-none h-[48px] focus:outline-none leading-relaxed font-normal"
className="w-full text-xs text-slate-700 placeholder:text-slate-400 min-h-[48px] max-h-80 focus:outline-none leading-relaxed font-normal"
maxHeight={320}
maxLength={500}
/>
@@ -40,6 +40,7 @@ function formatFileSize(bytes: number): string {
/** 后台挂起入口 —— 后端 _run_dag 已支持业务链分层并行,放出该入口。 */
const SHOW_BACKGROUND_SUSPEND = true;
import { AutoGrowTextarea } from "@/components/ui/auto-grow-textarea";
import { useLocalSettings } from "@/core/settings";
import { cn } from "@/lib/utils";
import type { MessageMetric } from "@/components/workspace/messages/use-message-metrics";
@@ -696,16 +697,19 @@ export function Step2Panel(props: Step2PanelProps) {
</div>
)}
{pendingClarification.allowCustom !== false && (
<div className="flex items-center gap-2 pt-1">
<input
type="text"
<div className="flex items-end gap-2 pt-1">
<AutoGrowTextarea
value={clarificationDraft}
onChange={(e) => setClarificationDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") onSubmitClarification(clarificationDraft);
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
onSubmitClarification(clarificationDraft);
}
}}
placeholder="输入你的回答…"
className="flex-1 bg-white border border-amber-300 rounded-xl px-3 py-2 text-xs focus:outline-none focus:border-amber-500 focus:ring-1 focus:ring-amber-200 font-medium"
placeholder="输入你的回答…(Enter 发送 / Shift+Enter 换行)"
className="flex-1 bg-white border border-amber-300 rounded-xl px-3 py-2 text-xs leading-relaxed focus:outline-none focus:border-amber-500 focus:ring-1 focus:ring-amber-200 font-medium"
maxHeight={280}
/>
<button
type="button"
@@ -751,16 +755,19 @@ export function Step2Panel(props: Step2PanelProps) {
))}
</div>
)}
<div className="flex items-center gap-3">
<input
type="text"
<div className="flex items-end gap-3">
<AutoGrowTextarea
value={customAgentInput}
onChange={(e) => setCustomAgentInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleInject();
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleInject();
}
}}
placeholder="作为指挥官,您可以随时插话输入指令,对智能体博弈朝向实施人工干预..."
className="flex-1 bg-white border border-slate-200 rounded-xl px-3.5 py-2.5 text-xs focus:outline-none focus:border-primary focus:ring-1 focus:ring-[var(--brand-200)]/50 font-medium"
placeholder="作为指挥官,您可以随时插话输入指令,对智能体博弈朝向实施人工干预...(Enter 发送 / Shift+Enter 换行)"
className="flex-1 bg-white border border-slate-200 rounded-xl px-3.5 py-2.5 text-xs leading-relaxed focus:outline-none focus:border-primary focus:ring-1 focus:ring-[var(--brand-200)]/50 font-medium"
maxHeight={280}
/>
<input
@@ -35,6 +35,7 @@ import {
} from "lucide-react";
import { useLocalSettings } from "@/core/settings";
import { AutoGrowTextarea } from "@/components/ui/auto-grow-textarea";
import { desensitizeDeep } from "@/lib/desensitize";
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
import { DashboardApp } from "@/roundtable-planning/dashboard/DashboardApp";
@@ -545,7 +546,8 @@ function DashboardChatPanel({
{/* 问答修改输入框 */}
<form onSubmit={handleSubmit} className="shrink-0 border-t border-slate-100 bg-white px-3 py-3">
<div className="flex items-end gap-2 rounded-2xl border border-slate-200 bg-slate-50/70 px-3 py-2 focus-within:border-primary/60 focus-within:bg-white focus-within:ring-2 focus-within:ring-[var(--brand-200)]/40">
<textarea
<AutoGrowTextarea
maxHeight={224}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
@@ -561,7 +563,7 @@ function DashboardChatPanel({
? "随时输入修改要求——发送会打断当前绘制并按新要求重出数据"
: "输入修改要求,例如:把风险分布换成柱状图,新增一个执行路线席位"
}
className="min-h-8 max-h-28 flex-1 resize-none bg-transparent py-1.5 text-xs leading-relaxed text-slate-800 placeholder:text-slate-400 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60"
className="min-h-8 max-h-56 flex-1 resize-none bg-transparent py-1.5 text-xs leading-relaxed text-slate-800 placeholder:text-slate-400 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60"
/>
<button
type="submit"
@@ -19,6 +19,7 @@ import type { ReportIntent, UseStep3ReportReturn } from "@/roundtable-planning/h
import type { UseStep3SummaryReturn } from "@/roundtable-planning/hooks/useStep3Summary";
import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
import { stripReasoning } from "@/roundtable-planning/lib/reasoning";
import { useMemo } from "react";
export interface Step3PanelProps {
threadIds: ThreadIdMap | null;
@@ -81,18 +82,25 @@ export function Step3Panel(props: Step3PanelProps) {
seatStubMessages,
} = props;
const seatDeliveries = step2RoundtableDialogues
.filter((d) => d.confidence === "子智能体交付")
.map((d) => ({
sender: String(d.sender ?? ""),
avatarType: String(d.avatarType ?? "blue"),
// 交付内容只保留模型生成的主体输出,剥离 `<think>...</think>` 推理过程
// —— 既用于渲染各席位交付卡片,也会喂给总结/报告模型,避免推理噪声污染。
content: stripReasoning(String(d.content ?? "")),
}));
const seatDeliveries = useMemo(
() =>
step2RoundtableDialogues
.filter((d) => d.confidence === "子智能体交付")
.map((d) => ({
sender: String(d.sender ?? ""),
avatarType: String(d.avatarType ?? "blue"),
// 交付内容只保留模型生成的主体输出,剥离 `<think>...</think>` 推理过程
// —— 既用于渲染各席位交付卡片,也会喂给总结/报告模型,避免推理噪声污染。
content: stripReasoning(String(d.content ?? "")),
})),
[step2RoundtableDialogues],
);
const seatNameMap = Object.fromEntries(
Object.entries(agentNameToRole).map(([k, v]) => [k, v.displayName]),
const seatNameMap = useMemo(
() => Object.fromEntries(
Object.entries(agentNameToRole).map(([k, v]) => [k, v.displayName]),
),
[agentNameToRole],
);
return (
@@ -30,6 +30,7 @@ import {
X,
} from "lucide-react";
import { AutoGrowTextarea } from "@/components/ui/auto-grow-textarea";
import { useArtifacts } from "@/components/workspace/artifacts";
import { useLocalSettings } from "@/core/settings";
import { cn } from "@/lib/utils";
@@ -670,7 +671,8 @@ function AgentProgressPanel({
className="shrink-0 border-t border-slate-100 bg-white px-3 py-3"
>
<div className="flex items-end gap-2 rounded-2xl border border-slate-200 bg-slate-50/70 px-3 py-2 focus-within:border-primary/60 focus-within:bg-white focus-within:ring-2 focus-within:ring-[var(--brand-200)]/40">
<textarea
<AutoGrowTextarea
maxHeight={224}
value={revisionText}
onChange={(e) => setRevisionText(e.target.value)}
onKeyDown={(e) => {
@@ -686,7 +688,7 @@ function AgentProgressPanel({
? "随时输入修改要求——发送会打断当前绘制并按新要求继续"
: "输入修改要求,例如:增加风险矩阵,执行路线改成时间轴"
}
className="min-h-8 max-h-28 flex-1 resize-none bg-transparent py-1.5 text-xs leading-relaxed text-slate-800 placeholder:text-slate-400 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60"
className="min-h-8 max-h-56 flex-1 resize-none bg-transparent py-1.5 text-xs leading-relaxed text-slate-800 placeholder:text-slate-400 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60"
/>
<button
type="submit"
@@ -30,6 +30,7 @@ import {
X,
} from "lucide-react";
import { AutoGrowTextarea } from "@/components/ui/auto-grow-textarea";
import { useArtifacts } from "@/components/workspace/artifacts";
import { useLocalSettings } from "@/core/settings";
import { cn } from "@/lib/utils";
@@ -158,9 +159,11 @@ export function Step3SummaryMode({
if (!flowGraph || flowGraph.graph.nodes.length === 0) return;
if (flowAutoOpenedRef.current) return;
flowAutoOpenedRef.current = true;
// 没开沙箱 → 自动打开流程图画布;沙箱开着 → 不抢占,只露出切换按钮。
if (!artifactsOpen) setFlowView(true);
}, [flowGraph, artifactsOpen]);
// taskId/深链模式:结构化输出是本模式的主交付 → 流程图一出现就自动聚焦画布
// (即便此前 md 沙箱已自动打开,也切到流程图)。
// 普通模式:没开沙箱才自动打开;沙箱开着不抢占,只露出切换按钮。
if (summary.autoFlow || !artifactsOpen) setFlowView(true);
}, [flowGraph, artifactsOpen, summary.autoFlow]);
// 沙箱里已写出的 md 报告(流程图画布上提供「MD 报告」切回按钮的锚点)。
const mdArtifact = useMemo(() => {
@@ -574,6 +577,16 @@ function AgentProgressPanel({
}) {
const scrollRef = useRef<HTMLDivElement>(null);
const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [dialogues]);
// 新一轮气泡出现(如:md 报告完成后自动续跑的结构化输出轮、用户发问)→ 强制滚到底,
// 让用户看到新阶段(修「调用结构化输出时不自动下滑」)。仅在气泡**条数**增加时触发,
// 流式增量内容仍由 useAutoScroll 的「在底部才跟随」逻辑处理,不抢用户向上翻阅。
const prevDialogueCountRef = useRef(dialogues.length);
useEffect(() => {
if (dialogues.length > prevDialogueCountRef.current) {
scrollToBottom();
}
prevDialogueCountRef.current = dialogues.length;
}, [dialogues.length, scrollToBottom]);
// 「回答速度指示器」—— 与主页面共用全局开关 streamSpeedMeterEnabled。
const [localSettings] = useLocalSettings();
const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled;
@@ -729,7 +742,8 @@ function AgentProgressPanel({
</div>
)}
<div className="flex items-end gap-2 rounded-2xl border border-slate-200 bg-slate-50/70 px-3 py-2 focus-within:border-primary/60 focus-within:bg-white focus-within:ring-2 focus-within:ring-[var(--brand-200)]/40">
<textarea
<AutoGrowTextarea
maxHeight={224}
value={askText}
onChange={(e) => setAskText(e.target.value)}
onKeyDown={(e) => {
@@ -744,7 +758,7 @@ function AgentProgressPanel({
? "生成中也可随时插话干预:发送将打断当前轮并据此继续…"
: "继续对话:改报告(如「执行路线写细些」)/ 改流程图(如「总结成结构化输出」「流程图加上验收环节」)/ 提问"
}
className="min-h-8 max-h-28 flex-1 resize-none bg-transparent py-1.5 text-xs leading-relaxed text-slate-800 placeholder:text-slate-400 focus:outline-none"
className="min-h-8 max-h-56 flex-1 resize-none bg-transparent py-1.5 text-xs leading-relaxed text-slate-800 placeholder:text-slate-400 focus:outline-none"
/>
{/* 上传文件(参考第一/二步:本地选取,发送时上传到总结线程) */}
@@ -26,6 +26,7 @@ import {
getDraft,
getLatestDraftByTask,
listDrafts,
listDraftsByTask,
updateDraft,
type DraftRecord,
type DraftStep2Snapshot,
@@ -190,7 +191,10 @@ export function useDraftPersistence(
const refreshList = useCallback(async () => {
try {
const metas = await listDrafts();
// taskId 模式:历史下拉只列该任务的草稿(一个 taskId 多条记录、跨用户共享);
// 普通模式:列当前用户全部草稿。
const tid = taskIdRef.current;
const metas = tid ? await listDraftsByTask(tid) : await listDrafts();
setDrafts(
metas.map((m) => ({
id: m.id,
@@ -11,25 +11,30 @@ import { useCallback, useState } from "react";
import {
deleteDraft,
listDrafts,
listDraftsByTask,
updateDraft,
type DraftMeta,
} from "@/roundtable-planning/api/drafts";
export function useRoundtableDrafts() {
/**
* @param taskId 外部任务 id(深链 taskId 模式)。非空时只列该任务的草稿
* (一个 taskId 多条、跨用户共享);否则列当前用户全部草稿。
*/
export function useRoundtableDrafts(taskId?: string | null) {
const [drafts, setDrafts] = useState<DraftMeta[]>([]);
const [loading, setLoading] = useState(false);
const refresh = useCallback(async () => {
setLoading(true);
try {
const data = await listDrafts();
const data = taskId ? await listDraftsByTask(taskId) : await listDrafts();
setDrafts(data);
} catch {
// 历史记录非关键,静默忽略
} finally {
setLoading(false);
}
}, []);
}, [taskId]);
const rename = useCallback(async (id: string, title: string) => {
// 只发 title —— 与页面自动保存的「不带 title」互补,是改标题的唯一入口。
@@ -57,6 +57,17 @@ interface UseStep3SummaryOptions {
selectedModel: string;
/** 业务链写给总控的编排提示 —— 作为最高优先级口径注入总结智能体。 */
coordinatorPrompt?: string | null;
/**
* 业务链条层级(取自 stages 的「本阶段目标」goal,有序)—— taskId 深链模式下
* 自动结构化输出时,作流程图的层级骨架(任务 → 层级1 → 层级2 → …)。
* 例:链层级 目的→行为体→驱动因素 → 流程图 任务→目的→行为体→驱动因素。
*/
chainLevels?: string[];
/**
* taskId/深链模式:生成 md 报告后**自动**续跑一轮结构化输出(流程图)。
* 普通手动会商为 false —— 结构化输出仍按需(用户输入框打「流程图」才出)。
*/
autoFlow?: boolean;
/** 草稿里已存的上次总结结果 —— 有则进入时恢复展示,不重新生成。 */
savedSummary?: DraftStep3SummarySnapshot | null;
/** 一次总结成功完成时回调(传出 md/thread/model/时间/收尾语),供上层存草稿。 */
@@ -66,6 +77,8 @@ interface UseStep3SummaryOptions {
export interface UseStep3SummaryReturn {
phase: SummaryPhase;
error: string | null;
/** taskId/深链模式:md 报告生成后自动续跑流程图(组件据此让流程图画布自动聚焦)。 */
autoFlow: boolean;
summaryThreadId: string | null;
dialogues: Step2Dialogue[];
seatStubMessages: Record<string, StubAiMessage[]>;
@@ -152,6 +165,117 @@ function buildMaterials(opts: {
return parts.join("\n");
}
/**
* flow-json 形状契约(内联)。roundtable-summary 的 runtime SOUL 为空,模型拿不到契约,
* 所有要画流程图的 prompt 都得带上它,否则模型产出的 JSON 可能解析不出节点。
*/
const FLOW_JSON_SHAPE =
"flow-json 严格用下面这个形状(一个 JSON 对象,`nodes` 是节点数组;示例仅示意结构、勿照抄内容):\n" +
"```flow-json\n" +
'{"title":"流程图标题","nodes":[\n' +
' {"id":"n1","label":"任务简短名","type":"start","group":"任务","detail":"任务目标的完整描述"},\n' +
' {"id":"n2","label":"目的核心短语","type":"task","group":"目的","detail":"该目的的完整内容:名称 + 理由全文","from":["n1"]},\n' +
' {"id":"n3","label":"行为体核心短语","type":"role","group":"行为体","detail":"该行为体的完整内容:名称 + 理由全文","from":["n2"]}\n' +
"]}\n" +
"```\n" +
"字段:`id`(n1/n2… 唯一短串)、`label`(≤12 字,节点标题,取条目核心短语)、" +
"`type`(取 start/end/stage/task/decision/risk/deliverable/role 之一,仅决定配色)、" +
"**`group`(该节点所在的层级名称,如 任务 / 目的 / 行为体 / 预期行为 / 驱动因素 / 叙事——画布按它给节点打标签,必须填)**、" +
"**`detail`(该条目的名称 + 理由 / 说明,点击节点时展示——要把要点表达完整,但控制在约 2–4 句,**不必**逐字照抄报告整段)**、" +
"`from`(前驱节点 id 列表,只引用**前面已出现**的节点)。" +
"节点按先后排列、输出**全量**数据、全程简体中文。";
/**
* 「本轮只画流程图、不写报告」的强约束。关键:有的业务链 coordinatorPrompt 自身写着
* 「输出结构化结论 / 按阶段产出报告 / 最终方案总结报告按层级结构输出」,模型在同一线程里
* 会被它锚定,把用户的「结构化输出」当成「继续写结构化的文字报告」→ 产出 Markdown 而非
* flow-json(实测 deepseek-chat 在这类链上必现)。所以必须显式声明本轮压过那些报告措辞。
*/
const FLOW_ONLY_RULE =
"【硬性·本轮只画流程图】本轮唯一产物是一张**流程图**(由节点 + 连线组成的图),不是文字、不是报告、" +
"不是章节、不是结论清单、不是 Markdown:①**不要**调用 write_file、**不要**写或改任何 .md 文件;" +
"②回复里**不要**再出现任何报告正文 / 章节标题(如「## 四…」)/ 编号结论,正文最多一句话说明;" +
"③**忽略**此前关于「方案总结报告」「按层级结构输出报告」「输出结构化结论」「分阶段产出」的一切措辞——" +
"那些是给上一轮文字报告用的,本轮一律不适用;④在回复**最末尾**输出一个**完整**的 ```flow-json 代码块。";
/**
* 流程图的层级画法(修「结构画错」):直接从已有报告/席位交付里**提取实体**,
* 根=任务名称,每层取具体条目当节点,按报告里的对应关系串成分支,排除汇总/阶段名等无关段落。
*/
const FLOW_STRUCTURE_RULE =
"【流程图画法·严格遵守】\n" +
"① 根节点(n1) = 本次任务本身(即报告「任务总览」里的任务名称 / 目标),label 用任务的简短名称(≤12 字)," +
"**不要**用「任务」「目标」这种泛称当 label;其 `group` 填「任务」;\n" +
"② 从第二层起按层级顺序逐层展开:每个层级在报告/交付里通常列了**多条具体条目**" +
"(如『目的』层有 目的1 / 目的2 / 目的3,『行为体』层有各自对应的行为体,后面还有 预期行为·驱动因素、叙事…)," +
"把**每一条具体条目各画成一个节点**,label 取该条目核心短语(≤12 字);**不要**把一整层合并成一个笼统节点;\n" +
"③ **每个节点的 `group` 必须填它所在层级的名称**(任务 / 目的 / 行为体 / 预期行为 / 驱动因素 / 叙事…,取自报告里该层级的真实名称)——画布据此给节点打分类标签;\n" +
"④ **每个节点的 `detail`** 放该条目的名称 + 理由 / 说明,把要点表达完整、约 2–4 句即可(不必逐字照抄报告整段);" +
"**`detail` 必须写成一行**——内部不要出现真实换行,需要分隔就用「;」或「/」,避免 JSON 里出现裸换行;\n" +
"⑤ 连边严格按报告里的**对应关系**串成分支:根任务 → 各『目的』节点;每个『目的』→ 它对应的『行为体』节点;" +
"每个『行为体』→ 它对应的『预期行为·驱动因素』节点 → 对应的『叙事』节点……每一组各成一条自上而下的分支" +
"(报告里有几组就有几条分支,组与组之间不要混连);\n" +
"⑦ **务必把每个层级的每一条都输出成节点、不要漏层、不要只输出开头一两个节点就停**——" +
"典型规模约 13–16 个节点(如 任务1 + 目的3 + 行为体3 + 预期行为·驱动因素3 + 叙事3);\n" +
"⑥ **只画**这些层级实体节点;**不要**把『综合分析』『核心洞察』『分层传播策略 / 执行路线 / 建议』『结论』" +
"以及『阶段①②③④』等章节名 / 阶段名 / 汇总段落画成节点。";
/** 把各席位交付拼成一段内联材料(无报告时作流程图的数据来源)。 */
function inlineSeatDeliveries(seatDeliveries?: { sender: string; content: string }[]): string {
const list = (seatDeliveries ?? []).filter((s) => (s.content ?? "").trim());
if (list.length === 0) return "";
return list
.map((s, i) => `## 席位${i + 1}:${s.sender || "(未具名席位)"}\n${s.content.trim()}`)
.join("\n\n");
}
/**
* taskId/深链模式:md 报告生成完后自动续跑的「结构化输出」prompt。
*
* 数据基准(用户拍板):**有报告就基于报告**(报告已把各席位交付清晰总结过,直接提取实体、
* 不要再自己重新总结一遍);没有报告则基于**各席位交付产物**。两种来源都**整段内联**进 prompt
* (不依赖线程上下文),让模型逐条对着抽取节点。层级顺序优先用总控提示词里写明的层级,否则 chainLevels。
*/
function buildAutoFlowPrompt(opts: {
chainLevels?: string[];
coordinatorPrompt?: string | null;
reportMd?: string;
seatDeliveries?: { sender: string; content: string }[];
}): string {
const levels = (opts.chainLevels ?? []).map((s) => s.trim()).filter(Boolean);
const cp = opts.coordinatorPrompt?.trim();
const report = opts.reportMd?.trim() ?? "";
const seatMaterial = report ? "" : inlineSeatDeliveries(opts.seatDeliveries);
const parts: string[] = [];
if (report) {
parts.push(
"本轮只画一张流程图。**数据来源 = 下面这份你已写好的《方案总结报告》**(它已把各席位交付清晰总结过)——" +
"请**逐条对着报告抽取**已有的层级条目作为流程图节点,**不要再自己重新总结 / 概括 / 提炼**,也不要凭空新增报告里没有的内容。",
);
} else {
parts.push(
"本轮只画一张流程图。**数据来源 = 下面各席位的交付产物**——请**逐条对着这些交付抽取**已有的层级条目作为流程图节点,不要自己另起总结。",
);
}
parts.push(FLOW_ONLY_RULE);
// 整段内联数据来源(报告全文 / 席位交付),用分隔线包起来便于模型对照抽取。
if (report) {
parts.push("===== 方案总结报告全文(数据来源,逐条抽取,勿改写)=====\n" + report + "\n===== 报告结束 =====");
} else if (seatMaterial) {
parts.push("===== 各席位交付产物(数据来源,逐条抽取,勿改写)=====\n" + seatMaterial + "\n===== 交付结束 =====");
}
parts.push(FLOW_JSON_SHAPE);
parts.push(FLOW_STRUCTURE_RULE);
if (cp) {
parts.push("层级顺序优先采用「业务链总控编排提示」里写明的层级链(如它写的 任务 → 目的 → 行为体 → …)。");
} else if (levels.length > 0) {
parts.push(`层级顺序(自上而下):任务 → ${levels.join(" → ")}。`);
}
// 收尾再强调(首尾夹击,对抗线程里 coordinatorPrompt 的报告锚定)。
parts.push("再次强调:本轮不写任何 Markdown 报告正文 / 章节,整个回复除一句话说明外,只有一个完整的 ```flow-json 代码块。");
return parts.join("\n");
}
/** 首轮总结 prompt。`flowMode`=编排提示里明确要求了结构化输出/流程图 → 跳过 md 文件,直出流程图。 */
function buildSummaryPrompt(opts: {
intentReady: ReportIntent | null;
@@ -203,10 +327,15 @@ function buildSummaryPrompt(opts: {
* 强调「全量 flow-json + 不写文件」——画图数据必须完整自洽,否则前端增量解析会画出半张图。
*/
const FLOW_ASK_INSTRUCTION =
"(系统指令:用户要求结构化输出/流程图——本轮不要写或修改任何 .md 报告文件," +
"在回复正文给出简短的文字说明/总结(用户未指定结构时,默认对各席位交付内容逐席位格式化输出:" +
"席位名→核心结论→关键交付要点),并在回复最末尾输出一个**完整**的 ```flow-json 代码块" +
"(按你职责设定的「结构化流程数据」契约;输出全量数据而非增量)。)";
"(系统指令:用户要求结构化输出 / 流程图——这里的「结构化输出」指的是画一张**流程图**," +
"不是写结构化的文字报告。**有报告就直接基于报告提取层级条目**(报告已总结过各席位交付,不要重复总结);" +
"没有报告则基于各席位交付。\n" +
FLOW_ONLY_RULE +
"\n" +
FLOW_STRUCTURE_RULE +
"\n" +
FLOW_JSON_SHAPE +
")";
/**
* 上一轮主产物是流程图、但本条消息没显式提「结构化/流程图」时的意图引导指令。
@@ -299,6 +428,8 @@ export function useStep3Summary(
consensusText,
selectedModel,
coordinatorPrompt,
chainLevels,
autoFlow,
savedSummary,
onSummarySaved,
} = options;
@@ -901,7 +1032,8 @@ export function useStep3Summary(
try {
// 编排提示里明确要求了结构化输出/流程图 → 首轮就走流程图模式(不写 md,直出 flow-json)。
const flowMode = detectFlowIntent(coordinatorPrompt);
// 但 autoFlow(taskId/深链)模式要「先 md 再结构化输出」,故首轮强制走 md(忽略 detectFlowIntent)。
const flowMode = !autoFlow && detectFlowIntent(coordinatorPrompt);
flowActiveRef.current = flowMode; // 流式结束后会按真实产物校正
const prompt = buildSummaryPrompt({
intentReady,
@@ -911,6 +1043,35 @@ export function useStep3Summary(
flowMode,
});
await streamOnce({ bubbleId, prompt, aborter, flowRound: flowMode });
// taskId/深链模式:报告生成完,**自动**续跑一轮结构化输出(流程图)。
// 数据来源:有报告则内联报告全文、否则内联各席位交付(buildAutoFlowPrompt 处理)。
// 故触发条件放宽为「有报告 **或** 有席位交付」——保证「无报告也能据席位交付出图」。
// 复用同一真线程,与手动「结构化输出」走同一渲染链路。
if (
autoFlow &&
!aborter.signal.aborted &&
liveThreadRef.current &&
(currentMdRef.current.trim() || seatDeliveries.length > 0)
) {
const flowBubbleId = appendStreamingDialogue({
sender: "方案总结报告智能体",
avatarType: "blue",
});
const flowPrompt = buildAutoFlowPrompt({
chainLevels,
coordinatorPrompt,
reportMd: currentMdRef.current,
seatDeliveries,
});
await streamOnce({
bubbleId: flowBubbleId,
prompt: flowPrompt,
aborter,
seedThreadId: liveThreadRef.current,
flowRound: true,
});
}
} catch (err) {
if (aborter.signal.aborted) return;
const msg = err instanceof Error ? err.message : String(err);
@@ -926,6 +1087,8 @@ export function useStep3Summary(
seatDeliveries,
consensusText,
coordinatorPrompt,
chainLevels,
autoFlow,
appendStreamingDialogue,
finalizeDialogue,
streamOnce,
@@ -1166,6 +1329,7 @@ export function useStep3Summary(
return {
phase,
error,
autoFlow: !!autoFlow,
summaryThreadId,
dialogues,
seatStubMessages,
@@ -77,6 +77,62 @@ test("extractFlowGraph:无 fence / 无完整节点返回 null", () => {
assert.equal(extractFlowGraph('```flow-json\n{"nodes":[{"id":"a"'), null);
});
test("extractFlowGraph:group 层级名解析(兼容 group/level)", () => {
const text = [
"```flow-json",
'{"nodes":[',
'{"id":"n1","label":"暑期文旅","type":"start","group":"任务"},',
'{"id":"n2","label":"情绪管理","type":"task","level":"目的","from":["n1"]}',
"]}",
"```",
].join("\n");
const g = extractFlowGraph(text);
assert.ok(g);
assert.equal(g.nodes[0].group, "任务");
assert.equal(g.nodes[1].group, "目的"); // level 别名也认
});
test("extractFlowGraph:容错——detail 含裸换行的非法 JSON 节点不被整个丢弃", () => {
// 模型常在 JSON 字符串里吐真实换行(非法 JSON):n2 的 detail 跨行,
// 旧逻辑会 JSON.parse 失败把 n2/n3 整个丢掉,只剩 n1(即「只出两个节点」bug)。
const text = [
"```flow-json",
'{"title":"T","nodes":[',
'{"id":"n1","label":"任务X","type":"start","group":"任务"},',
'{"id":"n2","label":"目的A","type":"task","group":"目的","detail":"名称:目的A',
'理由:跨了行的说明文字","from":["n1"]},',
'{"id":"n3","label":"行为体A","type":"role","group":"行为体","detail":"名称:行为体A;理由:单行说明","from":["n2"]}',
"]}",
"```",
].join("\n");
const g = extractFlowGraph(text);
assert.ok(g);
// 三个节点都救回来(含裸换行的 n2 不再丢失)。
assert.equal(g.nodes.length, 3);
assert.equal(g.nodes[1].group, "目的");
assert.ok((g.nodes[1].detail ?? "").includes("目的A"));
// 连线也按 from 串起来。
assert.deepEqual(g.edges, [
{ from: "n1", to: "n2" },
{ from: "n2", to: "n3" },
]);
});
test("extractFlowGraph:容错——尾逗号的非法 JSON 节点仍解析", () => {
const text = [
"```flow-json",
'{"nodes":[',
'{"id":"a","label":"A","type":"task","group":"目的",},', // 尾逗号 → JSON.parse 失败
'{"id":"b","label":"B","type":"task","from":["a"]}',
"]}",
"```",
].join("\n");
const g = extractFlowGraph(text);
assert.ok(g);
assert.equal(g.nodes.length, 2);
assert.equal(g.nodes[0].group, "目的");
});
test("extractFlowGraph:兼容独立 edges 数组(source/target 别名)", () => {
const text = [
"```flow-json",
@@ -33,6 +33,12 @@ export interface FlowGraphNode {
label: string;
type: FlowNodeType;
detail?: string;
/**
* 该节点所在的**层级名称**(如 任务 / 目的 / 行为体 / 预期行为 / 驱动因素 / 叙事)。
* 由模型在 flow-json 里给出,画布的分类标签优先显示它(缺省回退到 type 的固定中文名)。
* 让流程图按业务链层级标注,而非只显示通用的 开始/任务/角色 等 type 名。
*/
group?: string;
}
export interface FlowGraphEdge {
@@ -66,10 +72,66 @@ const VALID_TYPES = new Set<string>(Object.keys(FLOW_NODE_TYPE_META));
export const FLOW_FENCE_OPEN = "```flow-json";
/**
* 容错解析单个对象切片(JSON.parse 失败时回退)——模型输出不可控,常在 JSON 字符串里吐
* **裸换行 / 尾逗号 / 中文标点**等非法内容,导致整对象 JSON.parse 失败、节点被整个丢弃
* (表现为「流程图只剩前两个节点」)。这里用正则逐字段抽取,最大限度把节点 / 边救回来。
* 字符串字段正则 `(?:\\.|[^"\\])*` 能跨裸换行匹配(换行既不是 `"` 也不是 `\`)。
*/
function looseParseObject(slice: string): Record<string, unknown> | null {
const pickStr = (...keys: string[]): string | undefined => {
for (const k of keys) {
const m = new RegExp(`"${k}"\\s*:\\s*"((?:\\\\.|[^"\\\\])*)"`).exec(slice);
if (m) {
const raw = m[1];
try {
return JSON.parse(`"${raw.replace(/\r?\n/g, "\\n")}"`) as string;
} catch {
return raw.replace(/\\n/g, "\n").replace(/\\"/g, '"');
}
}
}
return undefined;
};
const obj: Record<string, unknown> = {};
const id = pickStr("id");
if (id) obj.id = id;
const label = pickStr("label", "name");
if (label) obj.label = label;
const type = pickStr("type");
if (type) obj.type = type;
const group = pickStr("group", "level", "tier", "layer");
if (group) obj.group = group;
const detail = pickStr("detail", "description");
if (detail) obj.detail = detail;
// 边的简单形态(独立 edges 数组里):from/to 为字符串。
const toStr = pickStr("to", "target");
if (toStr) obj.to = toStr;
// 节点的 from 为数组(["n1"] 或 [{"id":"n1","label":"是"}]);先按数组抽,抽不到再当字符串。
const fm = /"(?:from|prev)"\s*:\s*\[([\s\S]*?)\]/.exec(slice);
if (fm) {
const inner = fm[1];
const ids: unknown[] = [];
const objRe = /\{[^{}]*?"id"\s*:\s*"([^"]+)"[^{}]*?\}/g;
let om: RegExpExecArray | null;
while ((om = objRe.exec(inner)) !== null) ids.push({ id: om[1] });
if (ids.length === 0) {
const sRe = /"([^"]+)"/g;
let sm: RegExpExecArray | null;
while ((sm = sRe.exec(inner)) !== null) ids.push(sm[1]);
}
if (ids.length > 0) obj.from = ids;
} else {
const fromStr = pickStr("from", "source");
if (fromStr) obj.from = fromStr;
}
return Object.keys(obj).length > 0 ? obj : null;
}
/**
* 平衡括号扫描:从 `key`(如 `"nodes"`)后面的 `[` 开始,把数组里**已完整闭合**的
* 顶层 `{…}` 逐个 JSON.parse 收集;跳过字符串内的括号与转义;数组未闭合(流式半截)
* 时收集到哪算哪,截断的尾对象忽略。
* 顶层 `{…}` 逐个解析收集(JSON.parse 失败则用 looseParseObject 容错回退);跳过字符串内的
* 括号与转义;数组未闭合(流式半截)时收集到哪算哪,截断的尾对象忽略。
*/
function collectObjects(text: string, key: string): Record<string, unknown>[] {
const keyIdx = text.indexOf(key);
@@ -101,14 +163,18 @@ function collectObjects(text: string, key: string): Record<string, unknown>[] {
if (ch === "}") {
depth--;
if (depth === 0 && objStart >= 0) {
const objSlice = text.slice(objStart, i + 1);
let parsed: Record<string, unknown> | null = null;
try {
const parsed = JSON.parse(text.slice(objStart, i + 1)) as unknown;
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
out.push(parsed as Record<string, unknown>);
const j = JSON.parse(objSlice) as unknown;
if (j && typeof j === "object" && !Array.isArray(j)) {
parsed = j as Record<string, unknown>;
}
} catch {
/* 截断/非法对象:跳过 */
// 非法 JSON(裸换行/尾逗号等)→ 容错正则抽取,别整节点丢掉。
parsed = looseParseObject(objSlice);
}
if (parsed) out.push(parsed);
objStart = -1;
}
continue;
@@ -130,7 +196,10 @@ function toNode(raw: Record<string, unknown>): FlowGraphNode | null {
const rawType = asString(raw.type).toLowerCase();
const type = (VALID_TYPES.has(rawType) ? rawType : "task") as FlowNodeType;
const detail = asString(raw.detail) || asString(raw.description) || undefined;
return { id, label, type, detail };
// 层级名(任务/目的/行为体/…):兼容 group / level / tier / layer 几种键名。
const group =
asString(raw.group) || asString(raw.level) || asString(raw.tier) || asString(raw.layer) || undefined;
return { id, label, type, detail, group };
}
/** 节点 `from` 字段(前驱列表,元素是 "n1" 或 {id:"n1",label:"是"})→ 入边列表。 */
@@ -43,6 +43,7 @@ import {
SearchIcon,
SendIcon,
SettingsIcon,
SlidersHorizontalIcon,
Trash2Icon,
UserIcon,
UsersIcon,
@@ -252,17 +253,27 @@ export default function BusinessChainListPage() {
</p>
</div>
<button
type="button"
onClick={() => goConfigure()}
className={cn(
"flex shrink-0 items-center gap-2.5 self-start rounded-xl px-4 py-2.5 text-xs font-bold shadow-sm shadow-[#CF1322]/10 transition active:scale-95 md:self-auto",
BRAND,
)}
>
<PlusIcon className="size-4" />
<span>新建链条</span>
</button>
<div className="flex shrink-0 items-center gap-2 self-start md:self-auto">
<button
type="button"
onClick={() => navigate("/page/strategy/business-chain-mapping")}
className="flex items-center gap-2 rounded-xl border border-zinc-200 bg-white px-4 py-2.5 text-xs font-bold text-zinc-700 shadow-sm transition hover:bg-zinc-50 active:scale-95 dark:border-[#1A2F5A] dark:bg-[#122347] dark:text-[#C5CFE0] dark:hover:bg-[#1A2F5A]"
>
<SlidersHorizontalIcon className="size-4" />
<span>配置管理</span>
</button>
<button
type="button"
onClick={() => goConfigure()}
className={cn(
"flex items-center gap-2.5 rounded-xl px-4 py-2.5 text-xs font-bold shadow-sm shadow-[#CF1322]/10 transition active:scale-95",
BRAND,
)}
>
<PlusIcon className="size-4" />
<span>新建链条</span>
</button>
</div>
</div>
</header>
@@ -751,6 +751,12 @@ export default function RoundtablePlanningPage() {
setStep2Runs([...syncedRuns, newRun]);
setActiveStep2RunId(newRunId);
setStep3Report(null);
// 开新一轮第二步 → 第三步必须基于这轮重新生成(含总结/流程图)。复位「已进入第三步」
// 闸门 + 中断上一轮第三步在途三流,否则再进第三步会沿用上一轮旧结果(bug:第三步与第二步对不上)。
step3EnteredRef.current = false;
summaryFlow.abort();
reportFlow.abort();
dashboardFlow.abort();
step2.resetStep2State();
step2.setSelectedAgents(normalized);
step2.resetInitGate();
@@ -997,12 +1003,26 @@ export default function RoundtablePlanningPage() {
setStep3SavedTick((t) => t + 1);
}, []);
// 业务链条层级(取 stages 的「本阶段目标」goal,有序)——taskId 深链模式自动结构化输出时
// 作流程图层级骨架(任务 → 层级1 → …)。memo 稳定数组身份,避免 run useCallback 频繁失效。
const chainLevels = useMemo(
() =>
(orchestrationPlan?.stages ?? [])
.map((s) => s.goal?.trim() ?? "")
.filter((g) => g.length > 0),
[orchestrationPlan],
);
// taskId/深链模式:进第三步生成 md 后自动续跑结构化输出(流程图)。普通手动会商不自动。
const autoSummaryFlow = !!prefillRef.current?.taskId;
const summaryFlow = useStep3Summary({
intentReady: step1.intentReady,
seatDeliveries: step3SeatDeliveries,
consensusText: step2.lastLeaderContent,
selectedModel,
coordinatorPrompt: orchestrationPlan?.coordinatorPrompt ?? null,
chainLevels,
autoFlow: autoSummaryFlow,
savedSummary: step3Report?.summaryReport ?? null,
onSummarySaved: handleSummarySaved,
});
@@ -1595,6 +1615,8 @@ export default function RoundtablePlanningPage() {
selectedChainId={autoChainResult?.chain?.id ?? null}
onSelectChain={handleSelectChainFromSidebar}
onOpenDraft={drafts.handleLoadDraft}
activeDraftId={drafts.currentDraftId}
entryRailTaskId={prefillRef.current?.taskId ?? null}
/>
{/* MIDDLE VIEWPORT */}
@@ -2100,6 +2122,10 @@ interface LeftSidebarProps {
onSelectChain: (chain: BusinessChain) => void;
/** 需求 0616 #2:Step1 落地左栏「分析记录」点击 → 打开历史会商分析(加载草稿)。 */
onOpenDraft: (draftId: string) => void;
/** 当前已加载草稿 id(分析记录选中高亮用),来自 drafts.currentDraftId。 */
activeDraftId: string | null;
/** 深链 taskId 模式:Step1 落地左栏「分析记录」只列该任务的草稿。 */
entryRailTaskId?: string | null;
}
function Step2SandboxSidebarSync({
@@ -2144,6 +2170,8 @@ function LeftSidebar({
selectedChainId,
onSelectChain,
onOpenDraft,
activeDraftId,
entryRailTaskId,
}: LeftSidebarProps) {
// 整片脱敏:左侧面板(任务概览/参与角色/人工参与等)的所有文本——标题、动态内容、
// 嵌套子组件文本一并脱敏。消息列表与沙箱是另外的容器,不挂此 Hook,保持原文。
@@ -2279,12 +2307,42 @@ function LeftSidebar({
selectedChainId={selectedChainId}
onSelectChain={onSelectChain}
onOpenDraft={onOpenDraft}
activeDraftId={activeDraftId}
taskId={entryRailTaskId ?? null}
/>
)}
</section>
);
}
/** 「展开关系图」示例芯片的轮换配色(仅作视觉区分,无业务语义)。 */
const SEAT_CHIP_PALETTE = [
"bg-rose-50 text-rose-700 border-rose-200",
"bg-amber-50 text-amber-700 border-amber-200",
"bg-teal-50 text-teal-700 border-teal-200",
"bg-purple-50 text-purple-700 border-purple-200",
"bg-sky-50 text-sky-700 border-sky-200",
"bg-emerald-50 text-emerald-700 border-emerald-200",
];
/**
* 把业务链席位按 `stages`(分层并行编排)分组:组内并行、组间串行;无 `stages`
* (线性链)时每个席位单独成一层。仅供左栏「展开关系图」做编排示意展示。
*/
function getChainStageGroups(chain: BusinessChain): BusinessChain["seats"][] {
const byId = new Map(chain.seats.map((s) => [s.agent_id, s]));
if (chain.stages && chain.stages.length > 0) {
return chain.stages
.map((stage) =>
stage
.map((id) => byId.get(id))
.filter((s): s is BusinessChain["seats"][number] => Boolean(s)),
)
.filter((stage) => stage.length > 0);
}
return chain.seats.map((s) => [s]);
}
/**
* 需求 0616 #2:多智能体会商 Step1(落地)左栏入口栏。
* - 业务链:仅公共链条(``listChains("public")``)前 3 个;点击以该链席位启动会商。
@@ -2295,13 +2353,19 @@ function RoundtableEntryRail({
selectedChainId,
onSelectChain,
onOpenDraft,
activeDraftId,
taskId,
}: {
selectedChainId: string | null;
onSelectChain: (chain: BusinessChain) => void;
onOpenDraft: (draftId: string) => void;
activeDraftId: string | null;
/** 深链 taskId 模式:分析记录只列该任务的草稿。 */
taskId?: string | null;
}) {
const [chains, setChains] = useState<BusinessChain[]>([]);
const { drafts: records, refresh } = useRoundtableDrafts();
const [expandedChainId, setExpandedChainId] = useState<string | null>(null);
const { drafts: records, refresh } = useRoundtableDrafts(taskId);
useEffect(() => {
let cancelled = false;
@@ -2333,51 +2397,170 @@ function RoundtableEntryRail({
return (
<>
{/* 业务链(公共,前 3 个) */}
<div className="bg-white border border-slate-200/50 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)]">
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-3">
<Layers className="w-4 h-4 text-primary" strokeWidth={2.5} />
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest">业务链</h2>
<div className="bg-white border border-[#ECE7E4] rounded-2xl p-4 shadow-[0_4px_20px_rgba(0,0,0,0.02)] transition-shadow duration-300 hover:shadow-[0_4px_24px_rgba(45,37,34,0.04)]">
<div className="flex items-center gap-2 mb-3">
<span className="flex items-center justify-center w-5 h-5 rounded-md bg-primary text-primary-foreground">
<Layers className="w-3 h-3" strokeWidth={2.5} />
</span>
<h2 className="text-sm font-semibold tracking-wide text-slate-900">业务链</h2>
</div>
{chains.length === 0 ? (
<p className="text-xs text-slate-400 py-1.5">暂无公共业务链</p>
) : (
<div className="space-y-2">
<div className="flex flex-col gap-2.5">
{chains.map((chain) => {
const selected = selectedChainId === chain.id;
const graphOpen = expandedChainId === chain.id;
const stageGroups = getChainStageGroups(chain);
let seatColorIndex = 0;
return (
<button
<div
key={chain.id}
type="button"
onClick={() => onSelectChain(chain)}
title={selected ? "再次点击取消勾选" : `勾选业务链「${chain.title}」`}
aria-pressed={selected}
className={cn(
"w-full rounded-xl border p-2.5 text-left transition-colors",
"rounded-xl border transition-all duration-200",
selected
? "border-primary/40 bg-primary/5"
: "border-slate-100 bg-slate-50/60 hover:border-primary/30 hover:bg-white",
? "border-primary/25 bg-primary/5 ring-1 ring-primary/20 shadow-sm"
: "border-[#ECE7E4] bg-neutral-50/50 hover:border-neutral-300 hover:bg-neutral-50",
)}
>
<div className="flex items-center justify-between gap-2">
<span
className={cn(
"truncate text-xs font-extrabold",
selected ? "text-primary" : "text-slate-800",
)}
>
{chain.title}
</span>
{selected ? (
<Check className="h-3.5 w-3.5 shrink-0 text-primary" />
<button
type="button"
onClick={() => onSelectChain(chain)}
title={selected ? "再次点击取消勾选" : `勾选业务链「${chain.title}」`}
aria-pressed={selected}
className="w-full rounded-xl p-3 text-left"
>
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
{/* 单选圆点 */}
<span
className={cn(
"flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-full border transition-all",
selected
? "border-primary bg-primary"
: "border-neutral-300 bg-white",
)}
>
{selected && <span className="h-1.5 w-1.5 rounded-full bg-white" />}
</span>
<span
className={cn(
"truncate text-xs font-semibold",
selected ? "text-primary" : "text-neutral-800",
)}
>
{chain.title}
</span>
</div>
<span
className={cn(
"shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-semibold",
selected
? "bg-primary/10 text-primary"
: "bg-neutral-100 text-neutral-500",
)}
>
{chain.seats.length} 席
</span>
</div>
{chain.description ? (
<p className="mt-1.5 line-clamp-2 text-[10.5px] leading-relaxed text-neutral-500">
{chain.description}
</p>
) : (
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-slate-400" />
<p className="mt-1.5 text-[10px] font-medium text-neutral-400">
{selected ? "已选 · 再次点击取消" : "点击勾选该业务链"}
</p>
)}
</div>
<div className="mt-1 text-[10px] font-semibold text-slate-400">
{chain.seats.length} 个席位
{selected ? " · 已选" : ""}
</div>
</button>
</button>
{/* 关系图示例:选中后可展开,把该链席位智能体用小图标排成编排示意 */}
{selected && (
<div className="mx-3 mb-3 border-t border-dashed border-[#E8D0CC]/60 pt-2.5">
<button
type="button"
onClick={() =>
setExpandedChainId(graphOpen ? null : chain.id)
}
className="flex w-full items-center justify-center gap-1 rounded-full border border-primary/15 bg-primary/5 px-3 py-1 text-[10.5px] font-semibold text-primary transition-colors hover:bg-primary/10"
>
<Layers className="h-3 w-3" />
<span>{graphOpen ? "收起关系图" : "展开关系图"}</span>
{graphOpen ? (
<ChevronUp className="h-3 w-3" />
) : (
<ChevronDown className="h-3 w-3" />
)}
</button>
{graphOpen && (
<div className="mt-2 rounded-xl border border-dashed border-[#E8D0CC]/60 bg-[#FAF6F5] p-2.5">
{stageGroups.length === 0 ? (
<p className="py-2 text-center text-[10px] text-neutral-400">
该业务链暂无智能体席位
</p>
) : (
<div className="flex flex-col items-center gap-1.5">
{stageGroups.map((stage, si) => (
<div
key={si}
className="flex flex-col items-center gap-1"
>
{si > 0 && (
<ChevronDown className="h-3 w-3 text-neutral-300" />
)}
{stage.length > 1 && (
<span className="rounded-full bg-primary/5 px-1.5 text-[8.5px] font-semibold text-primary">
并行 {stage.length}
</span>
)}
<div className="flex flex-wrap items-center justify-center gap-1.5">
{stage.map((seat, sj) => {
const palette =
SEAT_CHIP_PALETTE[
seatColorIndex++ %
SEAT_CHIP_PALETTE.length
];
return (
<div
key={`${si}-${sj}-${seat.agent_id}`}
className="group/seat relative"
>
<span
className={cn(
"flex h-6 w-6 items-center justify-center rounded-full border",
palette,
)}
>
<Cpu className="h-3 w-3" />
</span>
{/* 悬停浮层显示名称 / 描述 */}
<div className="pointer-events-none absolute bottom-full left-1/2 z-50 mb-1.5 hidden -translate-x-1/2 group-hover/seat:block">
<div className="max-w-[180px] rounded-lg bg-neutral-900 px-2 py-1 text-white shadow-lg">
<div className="whitespace-nowrap text-[10px] font-semibold">
{seat.name}
</div>
{seat.description && (
<div className="mt-0.5 whitespace-normal text-[9px] leading-snug text-neutral-300">
{seat.description}
</div>
)}
</div>
<div className="absolute left-1/2 top-full h-0 w-0 -translate-x-1/2 border-l-4 border-r-4 border-t-4 border-l-transparent border-r-transparent border-t-neutral-900" />
</div>
</div>
);
})}
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
)}
</div>
);
})}
</div>
@@ -2385,30 +2568,83 @@ function RoundtableEntryRail({
</div>
{/* 分析记录(圆桌历史草稿) */}
<div className="bg-white border border-slate-200/50 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)] flex-1 flex flex-col min-h-[200px]">
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-3">
<History className="w-4 h-4 text-primary" strokeWidth={2.5} />
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest">分析记录</h2>
<div className="bg-white border border-[#ECE7E4] rounded-2xl p-4 shadow-[0_4px_20px_rgba(0,0,0,0.02)] flex-1 flex flex-col min-h-[200px] overflow-hidden">
<div className="flex items-center gap-2 mb-3">
<span className="flex items-center justify-center w-5 h-5 rounded-md bg-primary text-primary-foreground">
<History className="w-3 h-3" strokeWidth={2.5} />
</span>
<h2 className="text-sm font-semibold tracking-wide text-slate-900">分析记录</h2>
</div>
{records.length === 0 ? (
<p className="text-xs text-slate-400 py-1.5">暂无分析记录</p>
<p className="flex-1 text-xs text-neutral-400 text-center py-8">暂无分析记录</p>
) : (
<div className="space-y-2 overflow-y-auto flex-1 pr-1 custom-scrollbar">
{records.map((rec) => (
<button
key={rec.id}
type="button"
onClick={() => onOpenDraft(rec.id)}
title={rec.title}
className="w-full rounded-xl border border-slate-100 bg-slate-50/60 p-2.5 text-left transition-colors hover:border-primary/20 hover:bg-white"
>
<div className="truncate text-xs font-bold text-slate-700">{rec.title}</div>
<div className="mt-1 text-[10px] font-semibold text-slate-400">
{formatTime(rec.updatedAt)}
</div>
</button>
))}
</div>
<>
<div className="space-y-2 overflow-y-auto flex-1 px-1 py-0.5 custom-scrollbar">
{records.map((rec) => {
// 已完成:进行到第 3 步(执行过方案总结报告)
const completed = rec.furthestStep === 3;
// 选中:当前已加载的草稿(正在查看的那条)
const active = activeDraftId === rec.id;
return (
<button
key={rec.id}
type="button"
onClick={() => onOpenDraft(rec.id)}
title={rec.title}
aria-current={active}
className={cn(
"group/rec w-full rounded-[5px] border px-3 py-2.5 text-left transition-all duration-200",
active
? "border-primary/40 bg-primary/5 ring-1 ring-primary/20 shadow-[0_2px_10px_rgba(45,37,34,0.05)]"
: "border-[#ECE7E4] bg-white hover:border-primary/30 hover:bg-neutral-50 hover:shadow-[0_2px_10px_rgba(45,37,34,0.05)]",
)}
>
{/* 上:标题(左) + 状态标签(右上、放大) */}
<div className="flex items-start justify-between gap-2">
<span
className={cn(
"truncate text-[13px] font-semibold",
active
? "text-primary"
: "text-neutral-800 group-hover/rec:text-primary",
)}
>
{rec.title}
</span>
<span
className={cn(
"inline-flex shrink-0 items-center gap-1 rounded-[5px] px-2 py-1 text-[11px] font-semibold",
completed
? "bg-emerald-50 text-emerald-600"
: "bg-amber-50 text-amber-600",
)}
>
<span
className={cn(
"h-1.5 w-1.5 rounded-full",
completed ? "bg-emerald-500" : "bg-amber-500",
)}
/>
{completed ? "已完成" : "未完成"}
</span>
</div>
{/* 下:时间(左下) + 步骤(右下) */}
<div className="mt-2 flex items-center justify-between">
<span className="font-mono text-[10px] text-neutral-400">
{formatTime(rec.updatedAt)}
</span>
<span className="rounded-[5px] bg-neutral-100 px-1.5 py-0.5 text-[10px] font-medium text-neutral-500">
第 {rec.furthestStep} 步
</span>
</div>
</button>
);
})}
</div>
<div className="mt-2 pt-2 border-t border-neutral-100 text-center text-[11px] text-neutral-400">
共 {records.length} 条分析记录
</div>
</>
)}
</div>
</>
+15 -22
View File
@@ -1,23 +1,24 @@
import { Bell, ChevronDown, FileText, Shirt, User } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button";
import "@/components/page-sdebar.css";
import { NotificationDialog } from "@/components/workspace/notification-dialog";
import { getStoredAuth } from "@/core/auth";
import { getStoredAuth, getAccountDisplayName } from "@/core/auth";
import { isWujieEnv } from "@/core/embed/wujie";
import { useUnreadCount } from "@/core/notifications";
import { useMyPosition } from "@/core/positions";
import {
getActiveThirdLevelItems,
menuItemContainsPath,
menuItemMatchesPath,
type MenuItem,
} from "@/core/page-layout/sidebar-menu";
import { useSidebarMenuItems } from "@/core/page-layout/use-sidebar-menu";
import { cn } from "@/lib/utils";
import { useTheme, type ThemeType } from "../contexts/ThemeContext";
import { readUserInfo } from "./lib/light-app";
const iconButtonClass = (isDark: boolean) =>
cn(
@@ -135,28 +136,14 @@ const Header = ({ collapsed }: HeaderProps) => {
const { data: myPosition } = useMyPosition();
const positionName = myPosition?.name?.trim() || "";
// 登录时写入的真实用户名(外部登录存于 localStorage.userInfo.userName);
// 缺省回退到 stored auth 的邮箱前缀。
const displayName = useMemo(() => {
const info = readUserInfo();
const name = typeof info.userName === "string" ? info.userName.trim() : "";
if (name) return name;
const email = auth?.email?.trim();
return email ? email.split("@")[0] : "用户";
}, [auth?.email]);
const displayName = getAccountDisplayName(auth);
const showTopNav = isDark && !collapsed && !isWujieEnv();
// 浅色(红/蓝)顶栏:一级导航正常横排进顶栏,不做深色那种围绕基座的特殊处理。
// 与 collapsed 解耦(折叠只影响侧边栏,一级导航始终可见);wujie 嵌入下隐藏,
// 此时一级导航回落到侧边栏(见 page-sidebar.tsx)。
const showLightTopNav = !isDark && !isWujieEnv();
const hasActiveChild = (item: MenuItem): boolean => {
if (item.path && (pathname === item.path || pathname.startsWith(item.path + "/"))) {
return true;
}
return item.children?.some(hasActiveChild) ?? false;
};
const hasActiveChild = (item: MenuItem): boolean => menuItemContainsPath(item, pathname);
const findFirstRoutePath = (items?: MenuItem[]): string | null => {
if (!items?.length) {
@@ -189,7 +176,9 @@ const Header = ({ collapsed }: HeaderProps) => {
};
const renderTopItem = (item: MenuItem) => {
const isSelected = item.path ? (pathname === item.path || pathname.startsWith(item.path + "/")) : hasActiveChild(item);
const isSelected = item.path || item.aliasPaths?.length
? menuItemMatchesPath(item, pathname)
: hasActiveChild(item);
return (
<button
key={item.id}
@@ -203,7 +192,9 @@ const Header = ({ collapsed }: HeaderProps) => {
};
const renderLightTopItem = (item: MenuItem) => {
const isSelected = item.path ? (pathname === item.path || pathname.startsWith(item.path + "/")) : hasActiveChild(item);
const isSelected = item.path || item.aliasPaths?.length
? menuItemMatchesPath(item, pathname)
: hasActiveChild(item);
return (
<button
key={item.id}
@@ -388,7 +379,9 @@ const Header = ({ collapsed }: HeaderProps) => {
<nav className="third-submenu-bar" aria-label="三级菜单">
<div className="third-submenu-list">
{thirdSubNavItems.map((item) => {
const isSelected = item.path ? (pathname === item.path || pathname.startsWith(item.path + "/")) : hasActiveChild(item);
const isSelected = item.path || item.aliasPaths?.length
? menuItemMatchesPath(item, pathname)
: hasActiveChild(item);
return (
<button
@@ -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] }),
}),
};
}
@@ -0,0 +1,117 @@
/**
* 敏感词(脱敏词)管理 API 客户端 + React Query hooks。
*
* 后端:`/api/sensitive-words`(见 app/gateway/routers/sensitive_words.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";
const BASE = () => `${getBackendBaseURL()}/api/sensitive-words`;
/** 一条敏感词配置(前端视图模型)。 */
export interface SensitiveWord {
/** 原始词 */
word: string;
/** 脱敏替换词 */
replacement: string;
/** 是否启用(停用的词不参与运行时脱敏,但保留以便随时恢复) */
enabled: boolean;
/** 展示/匹配排序 */
sortOrder: number;
updatedBy?: string | null;
updatedAt?: string | null;
}
interface ServerWord {
word: string;
replacement: string;
enabled: boolean;
sort_order: number;
updated_by?: string | null;
updated_at?: string | null;
}
function toWord(s: ServerWord): SensitiveWord {
return {
word: s.word,
replacement: s.replacement ?? "",
enabled: s.enabled ?? true,
sortOrder: s.sort_order ?? 0,
updatedBy: s.updated_by ?? null,
updatedAt: s.updated_at ?? null,
};
}
function toBody(w: SensitiveWord): Pick<ServerWord, "word" | "replacement" | "enabled" | "sort_order"> {
return {
word: w.word,
replacement: w.replacement ?? "",
enabled: w.enabled,
sort_order: w.sortOrder,
};
}
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 listSensitiveWords(): Promise<SensitiveWord[]> {
const res = await apiFetch(BASE(), { method: "GET" });
const data = await readJson<{ words: ServerWord[] }>(res, "list sensitive words");
return (data.words ?? []).map(toWord);
}
export async function replaceSensitiveWords(words: SensitiveWord[]): Promise<SensitiveWord[]> {
const res = await apiFetch(BASE(), {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ words: words.map(toBody) }),
});
const data = await readJson<{ words: ServerWord[] }>(res, "save sensitive words");
return (data.words ?? []).map(toWord);
}
/** 把「启用」的词条折叠成运行时脱敏所需的 `原始词 → 替换词` 映射。 */
export function wordsToMap(words: SensitiveWord[]): Record<string, string> {
const map: Record<string, string> = {};
for (const w of words) {
if (w.enabled && w.word) map[w.word] = w.replacement ?? "";
}
return map;
}
// ── React Query hooks(管理页 + 启动加载共用缓存) ──────────────────────────
export const SENSITIVE_WORDS_KEY = "sensitive-words";
export function useSensitiveWords() {
return useQuery({
queryKey: [SENSITIVE_WORDS_KEY],
queryFn: listSensitiveWords,
refetchOnWindowFocus: false,
staleTime: 60_000,
});
}
export function useSensitiveWordActions() {
const queryClient = useQueryClient();
return {
save: useMutation({
mutationFn: replaceSensitiveWords,
onSuccess: () => queryClient.invalidateQueries({ queryKey: [SENSITIVE_WORDS_KEY] }),
}),
};
}
File diff suppressed because it is too large. Load diff
@@ -45,6 +45,7 @@ from app.gateway.routers import (
llm_metrics,
mcp,
memory,
menu_overrides,
models,
multi_agent,
notifications,
@@ -63,6 +64,7 @@ from app.gateway.routers import (
roundtable_jobs,
runs,
scheduled_tasks,
sensitive_words,
skills,
suggestions,
system_settings,
@@ -1340,6 +1342,12 @@ 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)
# Sensitive-word map (敏感词管理 / 脱敏词) at /api/sensitive-words
app.include_router(sensitive_words.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,14 @@ 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.sensitive_words import make_sensitive_word_store
app.state.sensitive_word_store = make_sensitive_word_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)
@@ -74,6 +74,8 @@
- 每个节点字段:`id`(短唯一串 n1/n2…)、`label`(≤12 字)、`type`、`detail`(一两句说明,弹窗展示)、`from`(**前驱节点列表**,可空;分支边可写成 `{"id":"n3","label":"可行"}` 给边加标签);
- `type` 只能取:`start`(开始)/ `end`(结束)/ `stage`(阶段)/ `task`(任务动作)/ `decision`(判断分支)/ `risk`(风险点)/ `deliverable`(交付物)/ `role`(角色席位)——前端按类型着不同颜色;
- **`nodes` 必须按流程先后顺序排列**,且每个节点的 `from` 只引用**前面已出现**的节点 id——前端解析到一个节点就画一个节点和它的连线;
- **节点 label 命名约束(重要)**:label 必须是该层级 / 步骤对应的**具体概念或实体名**(如 目的 / 行为体 / 驱动因素,或它们下面的具体条目),**绝不能**写成「阶段1 / 阶段2 / 第N阶段 / 阶段几」这类阶段编号或泛称,也不要把「阶段」「环节」本身当成节点;不要在节点里塞阶段名称等与层级无关的内容;
- **按业务链条层级组织**:当本轮 prompt 给了「业务链层级」(如 任务→目的→行为体→驱动因素)时,以「任务」为根节点按该层级自上而下逐层展开(每层放该层级下提炼出的具体节点);总控编排提示里明确写了层级 / 结构时,以总控提示词为准(最高优先级);
- 节点 6–20 个为宜;flow-json 块**只放对话回复末尾**,不要写进 .md 报告文件;
- 用户没有要求结构化 / 流程图时,**不要**输出该块。
@@ -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])
@@ -194,6 +194,32 @@ async def get_latest_draft_by_task(request: Request, task_id: str) -> DraftRespo
return DraftResponse(**row)
@router.get("/by-task/{task_id}", response_model=DraftListResponse)
async def list_drafts_by_task(request: Request, task_id: str) -> DraftListResponse:
"""按外部任务 id 列出该任务全部草稿(轻量 meta,newest first,**不按 user 分权**)。
taskId 深链模式下历史下拉用:一个 taskId 可有多条会商记录;同 `list_drafts`
一样用最近后台作业状态富化(job store 缺失/失败时静默退化为无状态)。
"""
store = _get_store(request)
rows = await store.list_drafts_by_task(task_id)
job_store = getattr(request.app.state, "roundtable_job_store", None)
if job_store is not None and rows:
try:
status_map = await job_store.status_map_by_drafts([r.get("id") for r in rows])
except AttributeError:
status_map = {}
except Exception: # noqa: BLE001 — 富化失败不能拖垮列表
logger.warning("roundtable draft by-task list: job status enrichment failed", exc_info=True)
status_map = {}
for r in rows:
info = status_map.get(r.get("id"))
if info:
r["status"] = info.get("status")
r["job_id"] = info.get("jobId")
return DraftListResponse(drafts=[DraftMetaResponse(**r) for r in rows])
@router.get("/{draft_id}", response_model=DraftResponse)
async def get_draft(request: Request, draft_id: str) -> DraftResponse:
store = _get_store(request)
@@ -0,0 +1,106 @@
"""Sensitive-word API (脱敏词 / 敏感词管理).
The frontend ships a seed desensitize map in code (``src/lib/desensitize.ts``);
it is the default on a fresh deployment and the source admins can import from.
This router persists a **global / shared** word map that the frontend loads at
startup and uses to build its desensitize regex. Each entry maps an original
``word`` to its ``replacement``; ``enabled`` parks a word without deleting it.
Reads are open to any authenticated user (every page desensitizes); the whole
map is replaced atomically and only by an admin.
Routes (prefix ``/api/sensitive-words``):
GET "" list all words
PUT "" replace the entire word 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.sensitive_words import SensitiveWordStore
router = APIRouter(prefix="/api/sensitive-words", tags=["sensitive-words"])
# 敏感词管理对应的指定账号:用户名(邮箱 @ 前缀)为该值的用户可写,admin 也可写。
# 与前端 `getAccountDisplayName() === "lqq"` 的可见性门保持一致。
_MANAGER_USERNAME = "lqq"
def _username_of(user) -> str:
email = getattr(user, "email", None) or ""
return email.split("@")[0].strip().lower()
# ── schemas ──────────────────────────────────────────────────────────────
class SensitiveWord(BaseModel):
word: str = Field(..., min_length=1, max_length=191)
replacement: str = Field(default="", max_length=191)
enabled: bool = True
sort_order: int = 0
class SensitiveWordResponse(SensitiveWord):
updated_by: str | None = None
updated_at: datetime | str | None = None
class SensitiveWordListResponse(BaseModel):
words: list[SensitiveWordResponse]
class SensitiveWordReplaceRequest(BaseModel):
words: list[SensitiveWord] = Field(default_factory=list)
# ── helpers ──────────────────────────────────────────────────────────────
def _get_store(request: Request) -> SensitiveWordStore:
store = getattr(request.app.state, "sensitive_word_store", None)
if store is None:
raise HTTPException(status_code=503, detail="Sensitive word store not available")
return store
async def _require_manager(request: Request) -> None:
"""Allow the designated manager username (``lqq``) or an admin.
Lets the call through when auth is disabled (no user resolved).
"""
user = await get_optional_user_from_request(request)
if user is None:
return
if getattr(user, "system_role", None) == "admin":
return
if _username_of(user) == _MANAGER_USERNAME:
return
raise HTTPException(status_code=403, detail="敏感词管理仅限指定账号")
# ── routes ───────────────────────────────────────────────────────────────
@router.get("", response_model=SensitiveWordListResponse)
async def list_sensitive_words(request: Request) -> SensitiveWordListResponse:
store = _get_store(request)
rows = await store.list_words()
return SensitiveWordListResponse(words=[SensitiveWordResponse(**r) for r in rows])
@router.put("", response_model=SensitiveWordListResponse)
async def replace_sensitive_words(request: Request, body: SensitiveWordReplaceRequest) -> SensitiveWordListResponse:
await _require_manager(request)
store = _get_store(request)
user_id = await get_current_user(request)
rows = await store.replace_all(
[w.model_dump() for w in body.words],
updated_by=user_id,
)
return SensitiveWordListResponse(words=[SensitiveWordResponse(**r) for r in rows])
@@ -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)
@@ -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
@@ -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()]
@@ -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)
)
@@ -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]
@@ -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
@@ -54,6 +55,7 @@ from deerflow.persistence.roundtable_drafts.model import (
)
from deerflow.persistence.roundtable_jobs.model import RoundtableJobRow
from deerflow.persistence.run.model import RunRow
from deerflow.persistence.sensitive_words.model import SensitiveWordRow
from deerflow.persistence.scheduled_tasks.model import (
ScheduledTaskDeliveryProfileRow,
ScheduledTaskRow,
@@ -92,6 +94,7 @@ __all__ = [
"KnowledgeTagRow",
"LightAppRow",
"LlmCallMetricRow",
"MenuOverrideRow",
"NotificationRow",
"ParallelAgentTaskRow",
"PositionRow",
@@ -110,6 +113,7 @@ __all__ = [
"ScheduledTaskRunRow",
"ScheduledTaskSubscriptionRow",
"SchedulerThreadRow",
"SensitiveWordRow",
"SkillRow",
"SkillDisplayAdapterRow",
"SkillFavoriteRow",
@@ -100,6 +100,21 @@ class RoundtableDraftRepository:
row = result.scalars().first()
return self._draft_to_dict(row) if row is not None else None
async def list_drafts_by_task(self, task_id: str, *, limit: int = 100) -> list[dict[str, Any]]:
"""按外部任务 id 列出全部草稿(轻量 meta,newest first,**忽略 user_id** 不分权)。
taskId 深链模式下历史下拉用:一个 taskId 可有多条会商记录。
"""
stmt = (
select(RoundtableDraftRow)
.where(RoundtableDraftRow.task_id == task_id)
.order_by(RoundtableDraftRow.updated_at.desc())
.limit(limit)
)
async with self._sf() as session:
result = await session.execute(stmt)
return [self._draft_to_meta(row) for row in result.scalars()]
async def create_draft(self, user_id: str, data: dict[str, Any]) -> dict[str, Any]:
now = datetime.now(UTC)
row = RoundtableDraftRow(
@@ -236,6 +236,36 @@ class RoundtableJobRepository:
out[draft_id] = {"jobId": job_id, "status": status}
return out
async def status_map_by_drafts(
self,
draft_ids: list[str],
*,
limit: int = 400,
) -> dict[str, dict[str, str]]:
"""``{draft_id: {"jobId": ..., "status": ...}}`` for the latest job per draft.
Like :meth:`status_map_by_user` but filtered by an explicit draft-id list
instead of by owner — used by the taskId-mode history dropdown, where a
task's drafts may span multiple users (task-bound drafts are shared).
"""
ids = [d for d in draft_ids if d]
if not ids:
return {}
stmt = (
select(RoundtableJobRow.id, RoundtableJobRow.draft_id, RoundtableJobRow.status)
.where(RoundtableJobRow.draft_id.in_(ids))
.order_by(RoundtableJobRow.updated_at.desc())
.limit(limit)
)
out: dict[str, dict[str, str]] = {}
async with self._sf() as session:
result = await session.execute(stmt)
for job_id, draft_id, status in result.all():
if draft_id is None or draft_id in out:
continue
out[draft_id] = {"jobId": job_id, "status": status}
return out
# ── delete / cleanup ─────────────────────────────────────────────────────
async def delete(
@@ -0,0 +1,22 @@
"""Sensitive-word (脱敏词 / 敏感词管理) persistence."""
from __future__ import annotations
from deerflow.persistence.sensitive_words.base import SensitiveWordStore
from deerflow.persistence.sensitive_words.memory import MemorySensitiveWordStore
from deerflow.persistence.sensitive_words.model import SensitiveWordRow
from deerflow.persistence.sensitive_words.sql import SensitiveWordRepository
__all__ = [
"MemorySensitiveWordStore",
"SensitiveWordRepository",
"SensitiveWordRow",
"SensitiveWordStore",
"make_sensitive_word_store",
]
def make_sensitive_word_store(session_factory):
if session_factory is None:
return MemorySensitiveWordStore()
return SensitiveWordRepository(session_factory)
@@ -0,0 +1,33 @@
"""Abstract sensitive-word (脱敏词) persistence API.
Global / shared store: no ``user_id`` scoping; every account desensitizes with
the same word map. Reads are open to any caller (the frontend needs the map to
render desensitized text); the router gates writes behind admin. ``updated_by``
is audit-only.
The whole word map is treated as one document: the management page computes the
full desired set and replaces every row atomically via :meth:`replace_all`, so
the stored set never drifts out of sync with what the admin sees. This mirrors
the menu-override store.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any
class SensitiveWordStore(ABC):
@abstractmethod
async def list_words(self) -> list[dict[str, Any]]:
"""Return every stored word row, ordered by ``sort_order``."""
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 word set with ``rows``.
Each row is ``{word, replacement, enabled, sort_order}``. Returns the
persisted rows.
"""
raise NotImplementedError
@@ -0,0 +1,36 @@
"""In-memory sensitive-word store (used when no DB is configured / tests)."""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from deerflow.persistence.sensitive_words.base import SensitiveWordStore
def _normalize(data: dict[str, Any], updated_by: str | None) -> dict[str, Any]:
return {
"word": str(data["word"]),
"replacement": str(data.get("replacement") or ""),
"enabled": bool(data.get("enabled", True)),
"sort_order": int(data.get("sort_order", 0)),
"updated_by": updated_by,
"updated_at": datetime.now(UTC).isoformat(),
}
class MemorySensitiveWordStore(SensitiveWordStore):
def __init__(self) -> None:
self._rows: dict[str, dict[str, Any]] = {}
async def list_words(self) -> list[dict[str, Any]]:
return [dict(r) for r in sorted(self._rows.values(), key=lambda r: (r.get("sort_order", 0), r.get("word", "")))]
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:
word = r.get("word")
if word in (None, ""):
continue
self._rows[str(word)] = _normalize(r, updated_by)
return await self.list_words()
@@ -0,0 +1,38 @@
"""ORM model for sensitive words (脱敏词 / 敏感词管理).
Global / shared — there is no ``user_id`` scoping; every account desensitizes
with the same map. The frontend ships a seed map in code (used as the default on
a fresh deployment); once an admin saves here, this table is the source of truth.
Each row maps an original term (``word``, the primary key) to its desensitized
replacement (``replacement``). ``enabled`` lets an admin park a word without
deleting it; ``sort_order`` controls display order in the management page. The
desensitize regex is rebuilt from the **enabled** rows. ``updated_by`` is
audit-only.
``word`` is the primary key at ``String(191)`` so the utf8mb4 index stays within
the 767-byte legacy limit (191 × 4 = 764) — sensitive terms are never that long.
"""
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 SensitiveWordRow(Base):
__tablename__ = "sensitive_words"
word: Mapped[str] = mapped_column(String(191), primary_key=True)
replacement: Mapped[str] = mapped_column(String(191), default="")
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
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)
)
@@ -0,0 +1,57 @@
"""SQLAlchemy-backed sensitive-word 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.sensitive_words.base import SensitiveWordStore
from deerflow.persistence.sensitive_words.model import SensitiveWordRow
def _to_dict(row: SensitiveWordRow) -> 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) -> SensitiveWordRow:
return SensitiveWordRow(
word=str(data["word"]),
replacement=str(data.get("replacement") or ""),
enabled=bool(data.get("enabled", True)),
sort_order=int(data.get("sort_order", 0)),
updated_by=updated_by,
)
class SensitiveWordRepository(SensitiveWordStore):
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
async def list_words(self) -> list[dict[str, Any]]:
stmt = select(SensitiveWordRow).order_by(SensitiveWordRow.sort_order.asc(), SensitiveWordRow.word.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 word (last wins) so a malformed payload can't violate the PK.
by_word: dict[str, dict[str, Any]] = {}
for r in rows:
word = r.get("word")
if word not in (None, ""):
by_word[str(word)] = r
async with self._sf() as session:
await session.execute(delete(SensitiveWordRow))
new_rows = [_row_from(r, updated_by) for r in by_word.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]