Files
ttstd-infinite-canvas/web/src/pages/admin/settings/response-interception-settings-page.tsx
T
ddCatandCursor e345a1b3b3 refactor(admin): 管理后台 UI - 独立设计系统并与用户端隔离
把后台 token、Ant 主题、壳层控件和抽屉弹窗从用户工作台拆开,去掉树状侧栏装饰线,色板仍用原来的中性黑白台阶。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-17 21:29:35 +08:00

556 lines
33 KiB
TypeScript

import { App, Button, Input, Skeleton } from "antd";
import { Switch } from "@/pages/admin/ui/controls";
import { AlertTriangle, ArrowDown, ArrowUp, BadgeCheck, Eye, ListOrdered, Plus, RefreshCw, RotateCcw, Save, Search, ShieldAlert, ShieldCheck, Trash2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useBlocker } from "react-router";
import { cn } from "@/lib/utils";
import { getAdminResponseInterceptionSetting, updateAdminResponseInterceptionSetting, type ResponseInterceptionRule, type ResponseInterceptionSetting } from "@/services/api/response-interception";
import { AdminPageFrame } from "../components/admin-shell";
import { AdminStatusBadge, SettingsSectionCard } from "../components/admin-ui";
const MAX_RULES = 100;
const MAX_MATCH_RUNES = 200;
const MAX_REPLACE_RUNES = 500;
const emptyRule = (): ResponseInterceptionRule => ({ contains: "", replace: "" });
export default function ResponseInterceptionSettingsPage() {
const { message, modal } = App.useApp();
const [setting, setSetting] = useState<ResponseInterceptionSetting | null>(null);
const [enabled, setEnabled] = useState(false);
const [rules, setRules] = useState<ResponseInterceptionRule[]>([]);
const [previewInput, setPreviewInput] = useState("");
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [saving, setSaving] = useState(false);
const [loadError, setLoadError] = useState("");
const [saveError, setSaveError] = useState("");
const requestVersionRef = useRef(0);
const navigationConfirmOpenRef = useRef(false);
const navigationTriggerRef = useRef<HTMLElement | null>(null);
const draft = useMemo(() => normalizeResponseInterceptionSetting({ enabled, rules }), [enabled, rules]);
const dirty = useMemo(() => Boolean(setting && !responseInterceptionSettingsEqual(draft, setting)), [draft, setting]);
const enabledDirty = Boolean(setting && enabled !== setting.enabled);
const duplicateRuleIndexes = useMemo(() => findDuplicateRuleIndexes(draft.rules), [draft.rules]);
const preview = useMemo(() => previewResponseInterception(previewInput, draft), [draft, previewInput]);
const load = useCallback(
async (initial = false, announce = false) => {
const requestVersion = ++requestVersionRef.current;
if (initial) setLoading(true);
else setRefreshing(true);
setLoadError("");
try {
const result = await getAdminResponseInterceptionSetting();
if (requestVersion !== requestVersionRef.current) return;
if (!isResponseInterceptionSetting(result.setting)) throw new Error("服务端返回的模型响应拦截配置格式无效");
setSetting(normalizeResponseInterceptionSetting(result.setting));
setEnabled(result.setting.enabled);
setRules(result.setting.rules.map((rule) => ({ ...rule })));
setSaveError("");
if (announce) message.success("已重新读取模型响应拦截配置");
} catch (error) {
if (requestVersion !== requestVersionRef.current) return;
const errorMessage = error instanceof Error ? error.message : "读取模型响应拦截配置失败";
setLoadError(errorMessage);
if (!initial) message.error(errorMessage);
} finally {
if (requestVersion === requestVersionRef.current) {
setLoading(false);
setRefreshing(false);
}
}
},
[message],
);
useEffect(() => {
void load(true);
return () => {
requestVersionRef.current += 1;
};
}, [load]);
const blocker = useBlocker(dirty && !saving);
useEffect(() => {
const beforeUnload = (event: BeforeUnloadEvent) => {
if (!dirty || saving) return;
event.preventDefault();
};
window.addEventListener("beforeunload", beforeUnload);
return () => window.removeEventListener("beforeunload", beforeUnload);
}, [dirty, saving]);
useEffect(() => {
if (blocker.state !== "blocked" || navigationConfirmOpenRef.current) return;
navigationConfirmOpenRef.current = true;
navigationTriggerRef.current = document.activeElement instanceof HTMLElement && document.activeElement !== document.body ? document.activeElement : null;
modal.confirm({
title: "放弃响应拦截调整?",
content: "当前页面有尚未保存的开关、规则内容或优先级调整,离开后这些草稿会丢失。服务端正在使用的拦截配置不会改变。",
okText: "放弃并离开",
cancelText: "继续编辑",
okButtonProps: { danger: true },
onOk: () => {
navigationConfirmOpenRef.current = false;
navigationTriggerRef.current = null;
blocker.proceed();
},
onCancel: () => {
navigationConfirmOpenRef.current = false;
blocker.reset();
window.requestAnimationFrame(() => {
const fallback = document.querySelector<HTMLButtonElement>(".admin-intercept-command-actions button");
const target = navigationTriggerRef.current?.isConnected ? navigationTriggerRef.current : fallback;
target?.focus();
navigationTriggerRef.current = null;
});
},
});
}, [blocker, modal]);
const resetDraft = () => {
if (!setting || saving) return;
setEnabled(setting.enabled);
setRules(setting.rules.map((rule) => ({ ...rule })));
setSaveError("");
message.info("已撤销模型响应拦截的未保存调整");
};
const requestRefresh = () => {
if (!dirty) {
void load(false, true);
return;
}
modal.confirm({
title: "放弃调整并重新读取?",
content: "重新读取会丢弃当前开关、规则内容和优先级草稿,并以服务端配置为准。",
okText: "放弃并刷新",
cancelText: "继续编辑",
okButtonProps: { danger: true },
onOk: () => load(false, true),
});
};
const updateRule = (index: number, field: keyof ResponseInterceptionRule, value: string) => {
setRules((current) => current.map((rule, ruleIndex) => (ruleIndex === index ? { ...rule, [field]: value } : rule)));
setSaveError("");
};
const moveRule = (index: number, offset: -1 | 1) => {
setRules((current) => {
const target = index + offset;
if (target < 0 || target >= current.length) return current;
const next = current.map((rule) => ({ ...rule }));
[next[index], next[target]] = [next[target], next[index]];
return next;
});
setSaveError("");
};
const removeRule = (index: number) => {
setRules((current) => current.filter((_, ruleIndex) => ruleIndex !== index));
setSaveError("");
};
const addRule = () => {
if (rules.length >= MAX_RULES) {
message.warning(`模型响应拦截规则不能超过 ${MAX_RULES} 条`);
return;
}
setRules((current) => [...current, emptyRule()]);
setSaveError("");
window.requestAnimationFrame(() => document.querySelector<HTMLInputElement>(`[aria-label="第 ${rules.length + 1} 条匹配文案"]`)?.focus());
};
const save = async (next?: ResponseInterceptionSetting) => {
if (!setting) return;
const expected = normalizeResponseInterceptionSetting(next || { enabled, rules });
const validationError = validateResponseInterceptionSetting(expected);
if (validationError) {
message.error(validationError);
return;
}
setSaving(true);
setSaveError("");
try {
const result = await updateAdminResponseInterceptionSetting(expected);
if (!isResponseInterceptionSetting(result.setting)) throw new Error("服务端返回的模型响应拦截配置格式无效");
const normalizedResult = normalizeResponseInterceptionSetting(result.setting);
if (!responseInterceptionSettingsEqual(normalizedResult, expected)) throw new Error("服务端返回的模型响应拦截配置与本次保存内容不一致,请重新读取后核对");
setSetting(normalizedResult);
setEnabled(normalizedResult.enabled);
setRules(normalizedResult.rules.map((rule) => ({ ...rule })));
message.success("模型响应拦截配置已保存");
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "保存模型响应拦截配置失败";
setSaveError(`${errorMessage}。未自动重试,请重新读取当前配置后再决定是否保存。`);
message.error(errorMessage);
throw error;
} finally {
setSaving(false);
}
};
const submitSave = async () => {
if (!setting) return;
const expected = normalizeResponseInterceptionSetting({ enabled, rules });
const validationError = validateResponseInterceptionSetting(expected);
if (validationError) {
message.error(validationError);
return;
}
try {
await save(expected);
} catch {
// 保存错误已在 save 中就地提示。
}
};
const changeEnabled = (nextEnabled: boolean) => {
if (!setting || saving || nextEnabled === enabled) return;
setEnabled(nextEnabled);
setSaveError("");
};
if (loading && !setting) {
return (
<AdminPageFrame title="模型响应拦截" description="先决定是否替换用户错误,再配置规则与优先级" scroll>
<div className="admin-settings-stack admin-intercept-settings" aria-label="正在读取模型响应拦截配置" role="status">
<div className="admin-intercept-command-bar">
<Skeleton active title={{ width: 190 }} paragraph={false} />
</div>
<div className="admin-intercept-loading-card">
<Skeleton active paragraph={{ rows: 7 }} />
</div>
</div>
</AdminPageFrame>
);
}
if (!setting) {
return (
<AdminPageFrame title="模型响应拦截" description="先决定是否替换用户错误,再配置规则与优先级" scroll>
<div className="admin-settings-stack admin-intercept-settings">
<div className="admin-intercept-load-error" role="alert">
<span className="admin-intercept-load-error-icon">
<AlertTriangle className="size-5" aria-hidden="true" />
</span>
<div>
<h2>无法读取模型响应拦截配置</h2>
<p>{loadError || "当前没有可显示的配置,请稍后重试。"}</p>
</div>
<Button icon={<RefreshCw className="size-4" />} loading={refreshing} onClick={() => void load(false, true)}>
重新读取
</Button>
</div>
</div>
</AdminPageFrame>
);
}
return (
<AdminPageFrame title="模型响应拦截" description="先决定是否替换用户错误,再配置规则与优先级" scroll>
<div className="admin-settings-stack admin-intercept-settings">
<div className={cn("admin-intercept-command-bar", dirty && "is-dirty")}>
<div className="admin-intercept-command-copy" aria-live="polite">
<span className="admin-intercept-command-icon">
<ShieldAlert className="size-4" aria-hidden="true" />
</span>
<div>
<div className="flex flex-wrap items-center gap-2">
<strong>{dirty ? "响应拦截有调整待保存" : "响应拦截配置已同步"}</strong>
<AdminStatusBadge label={dirty ? "尚未生效" : "服务端当前值"} tone={dirty ? "warning" : "neutral"} />
</div>
<p>{dirty ? "当前开关、文案和优先级只在本页暂存;本地预览不会调用后端。" : "仅投影用户最终看到的错误文案,原始响应与请求明细保持不变。"}</p>
</div>
</div>
<div className="admin-intercept-command-actions">
{dirty ? (
<Button icon={<RotateCcw className="size-4" />} disabled={saving} onClick={resetDraft}>
撤销调整
</Button>
) : null}
<Button icon={<RefreshCw className="size-4" />} loading={refreshing} disabled={saving} onClick={requestRefresh}>
刷新状态
</Button>
</div>
</div>
{loadError || saveError ? (
<div className="admin-intercept-inline-alert" role="alert">
<AlertTriangle className="size-4 shrink-0" aria-hidden="true" />
<span>{saveError || `${loadError}。页面仍显示上一次成功读取的配置。`}</span>
</div>
) : null}
<div id="admin-intercept-policy" className="admin-settings-anchor">
<SettingsSectionCard
className="admin-intercept-section admin-intercept-policy-section"
icon={<ShieldAlert className="size-4" aria-hidden="true" />}
title="1. 是否替换用户可见的上游错误"
description="这是响应拦截的主开关。关闭时规则继续保留,但不会改写用户最终看到的错误文案。"
status={<AdminStatusBadge label={enabledDirty ? (enabled ? "待启用" : "待停用") : enabled ? "已启用" : "已停用"} tone={enabledDirty ? "warning" : enabled ? "success" : "neutral"} />}
footer={
!enabled ? (
<>
<div className="admin-intercept-footer-note">
<ShieldCheck className="size-4" aria-hidden="true" />
<span>关闭后规则仍保存在服务端,需要时可重新启用</span>
</div>
<div className="flex flex-wrap items-center gap-2">
{dirty ? (
<Button icon={<RotateCcw className="size-4" />} disabled={saving} onClick={resetDraft}>
撤销
</Button>
) : null}
<Button type="primary" icon={<Save className="size-4" />} loading={saving} disabled={!dirty || loading || refreshing} onClick={() => void submitSave()}>
保存修改
</Button>
</div>
</>
) : undefined
}
>
<div className="admin-intercept-policy-control">
<span className="admin-intercept-policy-icon">
<Eye className="size-4" aria-hidden="true" />
</span>
<div className="admin-intercept-policy-copy">
<strong>投影替换用户可见错误</strong>
<p>启用后按规则顺序检查错误文案,忽略英文大小写;第一条包含匹配命中后,用“替换为”整条覆盖用户所见文案。</p>
<span>{enabled ? (draft.rules.length ? "当前草稿会依次检查所有规则,未命中时原样显示。" : "当前没有规则,即使启用也不会改变用户所见错误。") : "当前停用,所有用户可见错误均原样显示。"}</span>
</div>
<div className="admin-intercept-policy-switch">
<span>{enabled ? "启用" : "停用"}</span>
<Switch checked={enabled} disabled={loading || refreshing || saving} aria-label="启用响应拦截规则" onChange={changeEnabled} />
</div>
</div>
<div className="admin-intercept-context-note">
<ShieldCheck className="size-4" aria-hidden="true" />
<span>该能力只改变任务失败和代理请求最终返回给用户的可见文案,不修改上游响应体、内部错误、失败记录或请求明细;宽泛关键词可能掩盖更具体的规则,应放在列表后方。</span>
</div>
</SettingsSectionCard>
</div>
{enabled ? (
<>
<div id="admin-intercept-rules" className="admin-settings-anchor">
<SettingsSectionCard
className="admin-intercept-section admin-intercept-rules-section"
icon={<ListOrdered className="size-4" aria-hidden="true" />}
title="2. 配置替换规则与优先级"
description="越靠前的规则越先匹配;命中后停止继续检查。匹配文案最多 200 字,替换文案最多 500 字。"
status={<AdminStatusBadge label={`${draft.rules.length}/${MAX_RULES} 条`} tone={duplicateRuleIndexes.size ? "warning" : draft.rules.length ? "info" : "neutral"} />}
footer={
<>
<div className="admin-intercept-footer-note">
<BadgeCheck className="size-4" aria-hidden="true" />
<span>保存不会调用模型或制造测试错误 · 原始上游内容继续保留</span>
</div>
<div className="flex flex-wrap items-center gap-2">
{dirty ? (
<Button icon={<RotateCcw className="size-4" />} disabled={saving} onClick={resetDraft}>
撤销
</Button>
) : null}
<Button type="primary" icon={<Save className="size-4" />} loading={saving} disabled={!dirty || loading || refreshing} onClick={() => void submitSave()}>
保存修改
</Button>
</div>
</>
}
>
<div className="admin-intercept-rule-toolbar">
<div>
<strong>按优先级从上到下排列</strong>
<p>重复或包含关系过宽的文案可能使后续规则无法命中,可通过箭头调整顺序并在下方本地预览。</p>
</div>
<Button icon={<Plus className="size-4" />} disabled={loading || refreshing || saving || rules.length >= MAX_RULES} onClick={addRule}>
添加规则
</Button>
</div>
{duplicateRuleIndexes.size ? (
<div className="admin-intercept-duplicate-alert" role="alert">
<AlertTriangle className="size-4" aria-hidden="true" />
<span>存在忽略大小写后完全相同的匹配文案;相同文案只会命中最靠前的一条,请核对优先级或删除重复规则。</span>
</div>
) : null}
{rules.length ? (
<div className="admin-intercept-rule-table" aria-label="模型响应拦截规则">
<div className="admin-intercept-rule-header" aria-hidden="true">
<span>优先级</span>
<span>上游文案包含</span>
<span>整条替换为</span>
<span>操作</span>
</div>
<div className="admin-intercept-rule-list">
{rules.map((rule, index) => (
<div key={index} className={cn("admin-intercept-rule-row", duplicateRuleIndexes.has(index) && "has-duplicate")}>
<div className="admin-intercept-rule-index">
<span>{index + 1}</span>
<small>{index === 0 ? "最先" : `第 ${index + 1}`}</small>
</div>
<div className="admin-intercept-rule-field">
<Input
value={rule.contains}
maxLength={MAX_MATCH_RUNES}
showCount
disabled={loading || refreshing || saving}
aria-label={`第 ${index + 1} 条匹配文案`}
placeholder="例如:余额不足、HTTP 429"
onChange={(event) => updateRule(index, "contains", event.target.value)}
/>
<small>忽略英文大小写,使用包含判断。</small>
</div>
<div className="admin-intercept-rule-field">
<Input.TextArea
value={rule.replace}
maxLength={MAX_REPLACE_RUNES}
showCount
autoSize={{ minRows: 1, maxRows: 4 }}
disabled={loading || refreshing || saving}
aria-label={`第 ${index + 1} 条替换文案`}
placeholder="例如:服务暂时繁忙,请稍后重试"
onChange={(event) => updateRule(index, "replace", event.target.value)}
/>
<small>命中后整条替换,不是局部替换。</small>
</div>
<div className="admin-intercept-rule-actions">
<Button type="text" icon={<ArrowUp className="size-4" />} aria-label={`上移第 ${index + 1} 条规则`} disabled={loading || refreshing || saving || index === 0} onClick={() => moveRule(index, -1)} />
<Button
type="text"
icon={<ArrowDown className="size-4" />}
aria-label={`下移第 ${index + 1} 条规则`}
disabled={loading || refreshing || saving || index === rules.length - 1}
onClick={() => moveRule(index, 1)}
/>
<Button type="text" danger icon={<Trash2 className="size-4" />} aria-label={`删除第 ${index + 1} 条规则`} disabled={loading || refreshing || saving} onClick={() => removeRule(index)} />
</div>
</div>
))}
</div>
</div>
) : (
<div className="admin-intercept-empty">
<span>
<Search className="size-5" aria-hidden="true" />
</span>
<div>
<strong>还没有拦截规则</strong>
<p>用户可见错误会保持原样。添加规则后可先在本地预览,再决定是否保存和启用。</p>
</div>
<Button icon={<Plus className="size-4" />} disabled={loading || refreshing || saving} onClick={addRule}>
添加第一条规则
</Button>
</div>
)}
</SettingsSectionCard>
</div>
<div id="admin-intercept-preview" className="admin-settings-anchor">
<SettingsSectionCard
className="admin-intercept-section admin-intercept-preview-section"
icon={<Eye className="size-4" aria-hidden="true" />}
title="3. 本地预览用户最终文案"
description="使用当前草稿模拟服务端的裁剪、忽略大小写包含和首条命中逻辑;内容不会发送或保存。"
status={<AdminStatusBadge label="仅当前浏览器" tone="neutral" />}
>
<div className="admin-intercept-preview-grid">
<div className="admin-intercept-preview-input">
<label htmlFor="admin-intercept-preview-input">模拟上游错误文案</label>
<Input.TextArea id="admin-intercept-preview-input" value={previewInput} autoSize={{ minRows: 4, maxRows: 8 }} placeholder="粘贴一段用于本地预览的错误文案" onChange={(event) => setPreviewInput(event.target.value)} />
<p>只在内存中计算,不进入保存请求、审计或请求明细。</p>
</div>
<div className="admin-intercept-preview-result" aria-live="polite">
<div className="admin-intercept-preview-heading">
<strong>用户最终可见文案</strong>
<AdminStatusBadge
label={!preview.raw ? "等待输入" : preview.matchIndex < 0 ? "未命中" : enabled ? `命中第 ${preview.matchIndex + 1} 条` : `可命中第 ${preview.matchIndex + 1} 条但全局停用`}
tone={!preview.raw || preview.matchIndex < 0 || !enabled ? "neutral" : "success"}
/>
</div>
<p className={cn(!preview.output && "is-placeholder")}>{preview.output || "输入模拟错误后,这里会显示按当前开关和规则计算的结果。"}</p>
<small>
{preview.matchIndex >= 0
? enabled
? "已按首条命中规则整条替换。"
: "规则可以命中,但全局开关停用,所以实际仍原样显示。"
: preview.raw
? "没有规则包含该文案,因此保持原样。"
: "预览不验证真实上游响应,也不会触发模型请求。"}
</small>
</div>
</div>
</SettingsSectionCard>
</div>
</>
) : null}
</div>
</AdminPageFrame>
);
}
function normalizeResponseInterceptionSetting(value: ResponseInterceptionSetting): ResponseInterceptionSetting {
return {
enabled: Boolean(value.enabled),
rules: Array.isArray(value.rules) ? value.rules.map((rule) => ({ contains: rule.contains.trim(), replace: rule.replace.trim() })) : [],
};
}
function validateResponseInterceptionSetting(value: ResponseInterceptionSetting) {
if (value.rules.length > MAX_RULES) return `模型响应拦截规则不能超过 ${MAX_RULES} 条`;
for (let index = 0; index < value.rules.length; index += 1) {
const rule = value.rules[index];
if (!rule.contains) return `第 ${index + 1} 条拦截规则的匹配文案不能为空`;
if (Array.from(rule.contains).length > MAX_MATCH_RUNES) return `第 ${index + 1} 条拦截规则的匹配文案不能超过 ${MAX_MATCH_RUNES} 个字符`;
if (!rule.replace) return `第 ${index + 1} 条拦截规则的替换文案不能为空`;
if (Array.from(rule.replace).length > MAX_REPLACE_RUNES) return `第 ${index + 1} 条拦截规则的替换文案不能超过 ${MAX_REPLACE_RUNES} 个字符`;
}
return "";
}
function responseInterceptionSettingsEqual(left: ResponseInterceptionSetting, right: ResponseInterceptionSetting) {
const normalizedLeft = normalizeResponseInterceptionSetting(left);
const normalizedRight = normalizeResponseInterceptionSetting(right);
if (normalizedLeft.enabled !== normalizedRight.enabled || normalizedLeft.rules.length !== normalizedRight.rules.length) return false;
return normalizedLeft.rules.every((rule, index) => rule.contains === normalizedRight.rules[index].contains && rule.replace === normalizedRight.rules[index].replace);
}
function findDuplicateRuleIndexes(rules: ResponseInterceptionRule[]) {
const seen = new Map<string, number>();
const duplicates = new Set<number>();
rules.forEach((rule, index) => {
const key = rule.contains.trim().toLocaleLowerCase();
if (!key) return;
const first = seen.get(key);
if (first === undefined) seen.set(key, index);
else {
duplicates.add(first);
duplicates.add(index);
}
});
return duplicates;
}
function previewResponseInterception(input: string, setting: ResponseInterceptionSetting) {
const raw = input.trim();
if (!raw) return { raw: "", output: "", matchIndex: -1 };
const lower = raw.toLocaleLowerCase();
const matchIndex = setting.rules.findIndex((rule) => rule.contains && lower.includes(rule.contains.toLocaleLowerCase()));
return {
raw,
matchIndex,
output: setting.enabled && matchIndex >= 0 ? setting.rules[matchIndex].replace : raw,
};
}
function isResponseInterceptionSetting(value: unknown): value is ResponseInterceptionSetting {
if (!value || typeof value !== "object") return false;
const setting = value as Partial<ResponseInterceptionSetting>;
return typeof setting.enabled === "boolean" && Array.isArray(setting.rules) && setting.rules.every((rule) => Boolean(rule && typeof rule === "object" && typeof rule.contains === "string" && typeof rule.replace === "string"));
}