feat: 导航/侧边栏菜单脱敏 + 圆桌会商各步输入框自适应高度

- 菜单标签展示层脱敏:useSidebarMenuItems 直接用 /api/sensitive-words
  结果构造脱敏器,覆盖导航栏/三套侧边栏皮肤/三级子导航;菜单管理页用原始树不受影响
- 新增可复用 AutoGrowTextarea(自适应高度多行框),替换圆桌 Step1/2/3 输入框:
  多行时自动扩展、到上限框内滚动;Step2 两个单行 input 改多行;统一 Enter 发送/Shift+Enter 换行
- 各步输入框最大展开高度翻倍(Step1 320 / Step2 280 / Step3 224)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
董宏凯andClaude Opus 4.8 committed 2026-06-17 19:13:48 +08:00
1 parent 25e4e3dbc9
commit 6746fe4a78
9 files changed
+141 -29

No files matched your search

+1 -1
View File
@@ -81,7 +81,7 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
- **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.
- **敏感词管理 (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/`
@@ -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}
/>
);
});
@@ -12,8 +12,10 @@ 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 {
@@ -58,23 +60,43 @@ export function injectLightApps(items: MenuItem[], apps: LightApp[]): MenuItem[]
return walk(items);
}
/** 递归对菜单标签做脱敏(仅改 `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 角色过滤后的菜单。
* 按 admin 角色过滤、并对标签做**展示层脱敏**后的菜单。
*
* 管线顺序:静态菜单 → 注入 iframe 轻应用 → 套覆盖层(停用项被剔除)→ 角色过滤。
* 管线顺序:静态菜单 → 注入 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(
applyMenuOverrides(injectLightApps(sidebarMenuItems, iframeApps), overrides),
isAdmin,
desensitizeLabels(
filterMenuItemsByRole(
applyMenuOverrides(injectLightApps(sidebarMenuItems, iframeApps), overrides),
isAdmin,
),
desensitizeLabel,
),
[iframeApps, overrides, isAdmin],
[iframeApps, overrides, isAdmin, desensitizeLabel],
);
}
+16
View File
@@ -89,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
@@ -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"
@@ -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";
@@ -729,7 +730,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 +746,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"
/>
{/* 上传文件(参考第一/二步:本地选取,发送时上传到总结线程) */}