feat: 敏感词管理页面(脱敏词后台化,限 lqq 账号)

- 后端 /api/sensitive-words:全局共享词表,开放读、admin 或 lqq 整体替换写
- 前端 desensitize 种子化:SEED_DESENSITIZE_MAP + 运行时 replaceDesensitizeMap
- 启动加载后台配置回灌脱敏映射;管理页支持增删改启停 + 一键导入种子词
- 入口置于左下角设置下拉(全量/简洁两种侧边栏均有),仅用户名 lqq 可见

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 18:05:07 +08:00
1 parent 39d7dab3e2
commit 25e4e3dbc9
18 files changed
+788 -5

No files matched your search

+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;
}
+21 -1
View File
@@ -9,6 +9,7 @@ import {
FolderOpen,
ListTree,
Settings,
ShieldAlert,
User,
Wrench,
} from "lucide-react";
@@ -22,7 +23,12 @@ 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";
@@ -207,6 +213,14 @@ function SecondaryNavSidebar() {
菜单管理
</DropdownMenuItem>
)}
{canManageSensitiveWords() && (
<DropdownMenuItem
onClick={() => navigate("/page/strategy/admin/sensitive-words")}
>
<ShieldAlert className="size-4" />
敏感词管理
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
) : null}
@@ -479,6 +493,12 @@ function LegacyAccordionSidebar({
菜单管理
</DropdownMenuItem>
)}
{canManageSensitiveWords() && (
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/sensitive-words")}>
<ShieldAlert className="size-4" />
敏感词管理
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
) : (
+32 -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 编译为单个正则,避免多次遍历字符串。
@@ -107,3 +121,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);
}
@@ -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>
);
}
+12 -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";
@@ -32,6 +32,7 @@ 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";
@@ -321,6 +322,16 @@ export default function StrategyRoutes() {
)
}
/>
<Route
path="admin/sensitive-words"
element={
getAccountDisplayName().toLowerCase() === "lqq" ? (
<SensitiveWordsPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/scheduled-tasks"
element={
@@ -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] }),
}),
};
}