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

@@ -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] }),
}),
};
}