diff --git a/CLAUDE.md b/CLAUDE.md index 54243962..91f9c7b3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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-`). 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//{roundtable,agent/:agent_id/chats/:thread_id}` in `StrategyRoutes.tsx` (`` = `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//{roundtable,agent/:agent_id/chats/:thread_id}` in `StrategyRoutes.tsx` (`` = `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 diff --git a/frontend-web/src/App.tsx b/frontend-web/src/App.tsx index 88ee59df..6c1db30f 100644 --- a/frontend-web/src/App.tsx +++ b/frontend-web/src/App.tsx @@ -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 }) { + {children} diff --git a/frontend-web/src/components/desensitize-words-loader.tsx b/frontend-web/src/components/desensitize-words-loader.tsx new file mode 100644 index 00000000..b58b20ca --- /dev/null +++ b/frontend-web/src/components/desensitize-words-loader.tsx @@ -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; +} diff --git a/frontend-web/src/components/page-sidebar-dark.tsx b/frontend-web/src/components/page-sidebar-dark.tsx index af274aff..4851a5d5 100644 --- a/frontend-web/src/components/page-sidebar-dark.tsx +++ b/frontend-web/src/components/page-sidebar-dark.tsx @@ -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( @@ -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 (
+ 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() { 个人信息 + {isAdmin && ( + navigate("/page/strategy/admin/menu-management")} + > + + 菜单管理 + + )} + {canManageSensitiveWords() && ( + navigate("/page/strategy/admin/sensitive-words")} + > + + 敏感词管理 + + )} ) : null} @@ -211,9 +232,8 @@ function SecondaryNavSidebar() {