study / web /src /app /settings /components /error-message-card.tsx
xiaolongmr
报错文案:新增「规则测试」,并把「未匹配兜底」开关真正落地
8d002eb
Raw History Blame Contribute Delete
34.3 kB
"use client";
import { useCallback, useEffect, useState } from "react";
import {
AlertCircle,
CircleCheck,
FlaskConical,
LoaderCircle,
Plus,
Power,
RefreshCw,
Save,
Sparkles,
Trash2,
WandSparkles,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
clearUnconfiguredErrors,
fetchUnconfiguredErrors,
summarizeErrorMessage,
type ErrorMessageRule,
type UnconfiguredError,
} from "@/lib/api";
import { useSettingsStore } from "../store";
/** 生成一个本地唯一 id:规则还没保存时也要能稳定定位每一行。 */
function nextRuleId(existing: ErrorMessageRule[]): string {
const used = new Set(existing.map((rule) => rule.id));
let index = existing.length + 1;
while (used.has(`rule-${index}`)) {
index += 1;
}
return `rule-${index}`;
}
/** 新生成规则的高亮时长:覆盖「模型返回 6–10 秒 + 用户滚下去看」的整段过程。 */
const HIGHLIGHT_MS = 6000;
/** 多行/逗号分隔的关键词文本 <-> 数组。 */
function keywordsToText(keywords: string[]): string {
return (keywords || []).join("\n");
}
function textToKeywords(text: string): string[] {
return text
.split(/[\n,,、]/)
.map((keyword) => keyword.trim())
.filter(Boolean);
}
/** 未命中规则时的兜底文案,和后端 FALLBACK_DISPLAY 保持一致。 */
const FALLBACK_DISPLAY = "操作失败,请稍后重试";
type RuleHit = { rule: ErrorMessageRule; index: number; keyword: string };
type RuleTestResult =
/** 输入框还是空的。 */
| { kind: "empty" }
/** 会被这条规则替换掉(按配置顺序取第一条命中的)。 */
| { kind: "hit"; first: RuleHit; later: RuleHit[] }
/** 没有生效的命中:可能命中了停用/缺文案的草稿,也可能一条都没中。 */
| { kind: "miss"; ignored: RuleHit[] };
function ruleCanMatch(rule: ErrorMessageRule): boolean {
return rule.enabled !== false && usableKeywords(rule).length > 0 && rule.display.trim() !== "";
}
/** 真正会参与匹配的关键词:后端保存时会把长度 < 2 的丢掉(太短会误伤一片报错)。 */
function usableKeywords(rule: ErrorMessageRule): string[] {
return (rule.keywords || []).map((item) => item.trim()).filter((item) => item.length >= 2);
}
/** 命中却不会生效的原因(和保存时的归一化口径一致)。 */
function ignoredReason(rule: ErrorMessageRule): string {
if (rule.enabled === false) {
return "已停用";
}
if (usableKeywords(rule).length === 0) {
return "关键词太短(少于 2 个字,保存时会被丢弃)";
}
return "没填「显示给用户的大白话」";
}
/**
* 规则测试:复刻后端的匹配口径,答「这条报错会被显示成什么」。
*
* ⚠️ 对齐 services/error_message_service.py::ErrorMessageService.translate:
* 原文包含**任意一个**关键词即命中(不区分大小写);按配置顺序取**第一条**命中的;
* 「停用」「关键词太短」「缺文案」(保存时会被归一化丢弃)的规则都不生效,只作提示。
*/
function testRuleMatch(text: string, rules: ErrorMessageRule[]): RuleTestResult {
const trimmed = text.trim();
if (!trimmed) {
return { kind: "empty" };
}
const lowered = trimmed.toLowerCase();
const hits: RuleHit[] = [];
rules.forEach((rule, index) => {
const keyword = usableKeywords(rule).find((item) => lowered.includes(item.toLowerCase()));
if (keyword) {
hits.push({ rule, index, keyword });
}
});
const active = hits.filter((hit) => ruleCanMatch(hit.rule));
if (active.length === 0) {
return { kind: "miss", ignored: hits };
}
const [first, ...later] = active;
return { kind: "hit", first, later };
}
export function ErrorMessageCard() {
const config = useSettingsStore((state) => state.config);
const isSaving = useSettingsStore((state) => state.isSavingConfig);
const setRules = useSettingsStore((state) => state.setErrorMessageRules);
const setFallbackEnabled = useSettingsStore((state) => state.setErrorMessageFallbackEnabled);
const setAiModel = useSettingsStore((state) => state.setErrorMessageAiModel);
const saveConfig = useSettingsStore((state) => state.saveConfig);
const rules = config?.error_message_rules ?? [];
const fallbackEnabled = config?.error_message_fallback_enabled === true;
const aiModel = String(config?.error_message_ai_model ?? "");
const [unconfigured, setUnconfigured] = useState<UnconfiguredError[]>([]);
const [loading, setLoading] = useState(false);
const [clearing, setClearing] = useState(false);
const [pasteText, setPasteText] = useState("");
const [summarizing, setSummarizing] = useState(false);
/** 规则测试:粘一条报错,立刻看到它会显示成什么(含未保存的编辑)。 */
const [testText, setTestText] = useState("");
// 匹配本身是几段子串查找,直接算;用 useMemo 反而会被 rules 的字面量依赖拖成每帧重算。
const testResult = testRuleMatch(testText, rules);
/** 刚生成的规则 id:滚过去并高亮一下,避免「点了没反应」的错觉。 */
const [highlightId, setHighlightId] = useState<string | null>(null);
/** 最近一次「AI 归纳」的结果,直接贴在输入框下面,不用去列表里翻。 */
const [lastSuggestion, setLastSuggestion] = useState<
{ ruleId: string; keywords: string[]; display: string; source: "ai" | "local"; model?: string } | null
>(null);
/** 归纳结果面板直接绑定刚生成的那条规则,可以在面板里就地修改。 */
const activeRule = lastSuggestion
? rules.find((rule) => rule.id === lastSuggestion.ruleId) ?? null
: null;
const activeRuleIndex = activeRule ? rules.findIndex((rule) => rule.id === activeRule.id) : -1;
const loadUnconfigured = useCallback(async (silent = false) => {
if (!silent) {
setLoading(true);
}
try {
const data = await fetchUnconfiguredErrors();
setUnconfigured(data.items || []);
} catch {
if (!silent) {
toast.error("读取未配置报错失败");
}
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void loadUnconfigured();
}, [loadUnconfigured]);
// 归纳出的规则在输入框下方,页面上很容易滚出视野;这里主动滚过去并高亮。
// ⚠️ 模型归纳要等 6–10 秒,高亮必须撑到用户滚下去看完,2.8 秒会被完全错过。
useEffect(() => {
if (!highlightId) {
return;
}
const node = document.getElementById(`error-rule-${highlightId}`);
node?.scrollIntoView({ behavior: "smooth", block: "center" });
const timer = window.setTimeout(() => setHighlightId(null), HIGHLIGHT_MS);
return () => window.clearTimeout(timer);
}, [highlightId]);
const updateRule = (id: string, patch: Partial<ErrorMessageRule>) => {
setRules(rules.map((rule) => (rule.id === id ? { ...rule, ...patch } : rule)));
};
const addRule = (seed?: Partial<ErrorMessageRule>) => {
const rule: ErrorMessageRule = {
id: nextRuleId(rules),
keywords: [],
display: "",
enabled: true,
note: "",
...seed,
};
setRules([...rules, rule]);
// 新加的行也滚过去高亮:不管从哪个入口加,都必须看得见。
setHighlightId(rule.id);
return rule.id;
};
/** 手动把视野拉回某条规则并重新高亮(高亮结束后还能再点一次)。 */
const focusRule = (id: string) => {
setHighlightId(null);
window.setTimeout(() => setHighlightId(id), 30);
};
const removeRule = (id: string) => {
setRules(rules.filter((rule) => rule.id !== id));
};
const handleClearUnconfigured = async (fingerprints?: string[]) => {
setClearing(true);
try {
const data = await clearUnconfiguredErrors(fingerprints);
setUnconfigured(data.items || []);
toast.success(fingerprints?.length ? "已忽略该条报错" : "已清空未配置报错");
} catch {
toast.error("操作失败");
} finally {
setClearing(false);
}
};
/**
* 粘贴一段报错,让模型归纳出关键词 + 大白话,直接追加成一条新规则。
* 后端保证不硬失败:模型不可用时会退化成「本地抽取关键词」。
*/
const handleSummarize = async () => {
const text = pasteText.trim();
if (!text) {
toast.error("先把报错原文粘贴进来");
return;
}
setSummarizing(true);
try {
const suggestion = await summarizeErrorMessage(text);
const keywords = suggestion.keywords || [];
const display = suggestion.display || "";
if (!keywords.length && !display) {
toast.error("没能从中归纳出规则,请确认粘贴的是报错原文");
return;
}
const ruleId = addRule({
keywords,
display,
note: suggestion.source === "ai"
? `AI 归纳(${suggestion.model || "默认模型"})`
: "本地抽取(AI 不可用,请自行补文案)",
});
// 结果必须立刻可见:面板里内联出这条规则,可直接改、可直接存(addRule 已负责滚动 + 高亮)。
setLastSuggestion({
ruleId,
keywords,
display,
source: suggestion.source,
model: suggestion.model,
});
setPasteText("");
if (suggestion.source === "ai") {
const preview = keywords.slice(0, 2).join("、");
toast.success(`已生成规则:命中「${preview}${keywords.length > 2 ? " 等" : ""}」`);
} else if (display) {
toast.warning("AI 不可用,已退化为本地抽取关键词");
} else {
toast.warning("AI 不可用,已抽取关键词,请自行填写「显示给用户的大白话」");
}
} catch {
toast.error("归纳失败,请稍后重试");
} finally {
setSummarizing(false);
}
};
return (
<Card className="rounded-2xl border-stone-200 bg-white/90">
<CardContent className="space-y-6 p-5 sm:p-6">
<div>
<h2 className="text-lg font-semibold tracking-tight">报错文案</h2>
<p className="mt-1 text-sm text-stone-500">
把上游/后端返回的技术报错翻译成用户看得懂的大白话。只替换前台展示,后台日志仍保留原始报错,方便排查。
</p>
</div>
{/* --------------------------------------------------- 粘贴 + AI 归纳 */}
<div className="space-y-3 border-t border-stone-100 pt-5">
<div className="rounded-xl border border-stone-200 bg-stone-50 p-3">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="flex items-center gap-2 text-sm font-medium text-stone-900">
<WandSparkles className="size-4 text-stone-500" />
粘贴报错,自动归纳
</h3>
<p className="mt-1 text-xs text-stone-500">
把后台日志或用户反馈里的原始报错整段粘进来,自动生成关键词和给用户看的大白话。
</p>
</div>
<Button
className="shrink-0 rounded-xl"
disabled={summarizing}
onClick={() => void handleSummarize()}
>
{summarizing ? <LoaderCircle className="size-4 animate-spin" /> : <WandSparkles className="size-4" />}
{summarizing ? "归纳中…" : "AI 归纳"}
</Button>
</div>
<Textarea
rows={3}
value={pasteText}
placeholder={'整段粘贴,例如:\n/backend-api/files failed: status=429, body={"detail": {"code": "throttled", "reason": "upload_protection_limit_exceeded"}}'}
onChange={(event) => setPasteText(event.target.value)}
className="mt-3 rounded-xl border-stone-200 bg-white font-mono text-xs"
/>
<div className="mt-2 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span className="whitespace-nowrap text-xs text-stone-500">归纳用模型</span>
<Input
value={aiModel}
placeholder="留空 = 池子默认文本模型"
onChange={(event) => setAiModel(event.target.value)}
className="h-8 w-56 rounded-lg border-stone-200 bg-white text-xs"
/>
</div>
{pasteText ? (
<Button variant="ghost" size="sm" className="rounded-lg" onClick={() => setPasteText("")}>
清空
</Button>
) : null}
</div>
{summarizing ? (
<p className="mt-2 text-xs text-stone-500">正在调用模型归纳,通常需要 5–10 秒…</p>
) : null}
{lastSuggestion && activeRule ? (
<div className="mt-3 rounded-xl border border-emerald-300 bg-emerald-50 p-3 text-xs text-emerald-900">
<div className="flex items-start justify-between gap-2">
<span className="flex items-center gap-1.5 font-medium">
<Sparkles className="size-3.5" />
{lastSuggestion.source === "ai"
? `已归纳出规则(${lastSuggestion.model || "默认模型"})`
: "AI 不可用,已用本地规则抽取关键词"}
</span>
<button
type="button"
className="shrink-0 text-emerald-700 hover:underline"
onClick={() => setLastSuggestion(null)}
>
收起
</button>
</div>
{/* 就地可改:这里改的就是列表里的那条规则,不用去下面找。 */}
<div className="mt-3 grid gap-3 md:grid-cols-[1fr_1fr]">
<div className="space-y-1.5">
<span className="font-medium text-emerald-800">关键词(每行一个)</span>
<Textarea
rows={3}
value={keywordsToText(activeRule.keywords)}
onChange={(event) => updateRule(activeRule.id, { keywords: textToKeywords(event.target.value) })}
className="rounded-lg border-emerald-200 bg-white font-mono text-xs"
/>
</div>
<div className="space-y-1.5">
<span className="font-medium text-emerald-800">显示给用户的大白话</span>
<Textarea
rows={3}
value={activeRule.display}
onChange={(event) => updateRule(activeRule.id, { display: event.target.value })}
className="rounded-lg border-emerald-200 bg-white text-xs"
/>
</div>
</div>
<div className="mt-3 flex flex-wrap items-center justify-between gap-2">
<span className="text-emerald-800">
已在下方「匹配规则」<b>第 {activeRuleIndex + 1} 条</b>(共 {rules.length} 条),并高亮{" "}
{HIGHLIGHT_MS / 1000} 秒。
</span>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="rounded-lg"
onClick={() => focusRule(activeRule.id)}
>
滚到列表查看
</Button>
<Button
size="sm"
className="rounded-lg"
disabled={isSaving}
onClick={() => void saveConfig()}
>
{isSaving ? <LoaderCircle className="size-3.5 animate-spin" /> : <Save className="size-3.5" />}
保存设置
</Button>
</div>
</div>
<p className="mt-2 text-emerald-700">
确认无误后点「保存设置」才会真正生效;不保存的话刷新页面就丢了。
</p>
</div>
) : null}
{lastSuggestion && !activeRule ? (
<div className="mt-3 rounded-xl border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800">
刚才归纳出的那条规则已经被删掉了。
<button
type="button"
className="ml-2 text-amber-700 underline"
onClick={() => setLastSuggestion(null)}
>
知道了
</button>
</div>
) : null}
</div>
</div>
{/* --------------------------------------------------------- 规则测试 */}
<div className="space-y-3 border-t border-stone-100 pt-5">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="flex items-center gap-2 font-medium text-stone-900">
<FlaskConical className="size-4 text-stone-500" />
规则测试
</h3>
<p className="mt-1 text-xs text-stone-500">
把一条报错原文粘进来,立刻看到它会不会命中、命中哪条、前台最终显示什么。测的是<b>当前编辑中的规则</b>(没保存也算),不用点按钮。
</p>
</div>
{testText ? (
<Button variant="ghost" size="sm" className="shrink-0 rounded-lg" onClick={() => setTestText("")}>
清空
</Button>
) : null}
</div>
<Textarea
rows={3}
value={testText}
placeholder={'整段粘贴报错,例如:\n你已达到 Free 套餐的图像生成请求上限。上限将在 23小时 后重置,届时可创建更多图像。'}
onChange={(event) => setTestText(event.target.value)}
className="rounded-xl border-stone-200 bg-white font-mono text-xs"
/>
{testResult.kind === "empty" ? (
<div className="rounded-xl border border-dashed border-stone-200 bg-stone-50 px-4 py-4 text-xs text-stone-500">
未命中规则的报错会进下面的「未配置报错」待办;这里可以先试一行,确认规则写得够准。
</div>
) : null}
{testResult.kind === "hit" ? (
<div className="space-y-2 rounded-xl border border-emerald-300 bg-emerald-50 p-3 text-xs text-emerald-900">
<div className="flex items-center gap-1.5 font-medium">
<CircleCheck className="size-3.5" />
命中规则,前台会显示:
</div>
<div className="rounded-lg bg-white px-3 py-2 text-sm text-stone-900">
{testResult.first.rule.display}
</div>
<p>
命中第 <b>{testResult.first.index + 1}</b> 条(关键词「{testResult.first.keyword}」)
{testResult.later.length > 0
? `;第 ${testResult.later.map((hit) => hit.index + 1).join("、")} 条也会命中,但按顺序只取第一条,它们不会生效。`
: "。"}
</p>
<p className="text-emerald-700">改完记得点最下方「保存设置」,否则刷新页面就丢了。</p>
</div>
) : null}
{testResult.kind === "miss" ? (
<div className="space-y-2 rounded-xl border border-amber-300 bg-amber-50 p-3 text-xs text-amber-900">
<div className="flex items-center gap-1.5 font-medium">
<AlertCircle className="size-3.5" />
没有规则会命中,前台会{fallbackEnabled ? "显示兜底文案:" : "原样显示这条技术报错:"}
</div>
<div className="break-all rounded-lg bg-white px-3 py-2 font-mono text-[11px] text-stone-700">
{fallbackEnabled ? FALLBACK_DISPLAY : testText.trim()}
</div>
{testResult.ignored.length > 0 ? (
<div className="space-y-1.5">
{testResult.ignored.map((hit) => (
<div
key={hit.rule.id}
className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-amber-200 bg-white/70 px-2 py-1.5"
>
<span>
第 <b>{hit.index + 1}</b> 条命中了关键词「{hit.keyword}」,但
{ignoredReason(hit.rule)},实际不生效。
</span>
<span className="flex items-center gap-1.5">
{hit.rule.enabled === false ? (
<Button
variant="outline"
size="sm"
className="h-7 rounded-lg px-2 text-xs"
onClick={() => updateRule(hit.rule.id, { enabled: true })}
>
<Power className="size-3" />
启用这条
</Button>
) : null}
<Button
variant="outline"
size="sm"
className="h-7 rounded-lg px-2 text-xs"
onClick={() => focusRule(hit.rule.id)}
>
去改这条
</Button>
</span>
</div>
))}
</div>
) : null}
<p className="text-amber-800">
要让它有话说:把这段报错粘到上面的「AI 归纳」生成一条规则,或手动加规则填关键词。
</p>
</div>
) : null}
</div>
{/* --------------------------------------------------------- 规则列表 */}
<div className="space-y-3 border-t border-stone-100 pt-5">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="font-medium text-stone-900">匹配规则</h3>
<p className="mt-1 text-xs text-stone-500">
报错原文包含<b>任意一个</b>关键词即命中(不区分大小写),命中后前台显示右边的大白话。多个关键词一行一个 —— <b>关键词越多,规则越宽</b>,不要写太通用的词(如 /backend-api/files、status=500 单独出现时会误伤其它报错)。
</p>
</div>
<Button variant="outline" className="shrink-0 rounded-xl" onClick={() => addRule()}>
<Plus className="size-4" />
添加规则
</Button>
</div>
{rules.length === 0 ? (
<div className="rounded-xl border border-dashed border-stone-200 bg-stone-50 px-4 py-8 text-center text-sm text-stone-500">
还没有任何规则。可以把报错粘到上面的框里点「AI 归纳」自动生成,或点右上角「添加规则」手动填。
<br />
未命中的报错会原样显示,并出现在下方「未配置报错」里。
</div>
) : (
<div className="space-y-3">
{rules.map((rule) => {
const keywordCount = rule.keywords.length;
const invalid = keywordCount === 0 || !rule.display.trim();
const highlighted = highlightId === rule.id;
return (
<div
key={rule.id}
id={`error-rule-${rule.id}`}
className={`rounded-xl border p-3 transition-all ${
highlighted
? "border-emerald-400 bg-emerald-50 shadow-md ring-2 ring-emerald-300"
: "border-stone-200 bg-stone-50"
}`}
>
{highlighted ? (
<div className="mb-2 flex items-center gap-2">
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-600 px-2 py-0.5 text-xs font-medium text-white">
<Sparkles className="size-3" />
刚生成,还没保存
</span>
<button
type="button"
className="text-xs text-emerald-700 hover:underline"
onClick={() => setHighlightId(null)}
>
取消高亮
</button>
</div>
) : null}
<div className="grid gap-3 md:grid-cols-[1fr_1fr]">
<div className="space-y-1.5">
<label className="text-xs font-medium text-stone-600">
关键词{keywordCount > 0 ? `(${keywordCount})` : ""}
</label>
<Textarea
rows={3}
value={keywordsToText(rule.keywords)}
placeholder={"每行一个,例如:\nstatus=429\nupload_protection_limit_exceeded"}
onChange={(event) => updateRule(rule.id, { keywords: textToKeywords(event.target.value) })}
className="rounded-xl border-stone-200 bg-white text-sm"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-stone-600">显示给用户的大白话</label>
<Textarea
rows={3}
value={rule.display}
placeholder="例如:当前上传人数较多,请稍后重试"
onChange={(event) => updateRule(rule.id, { display: event.target.value })}
className="rounded-xl border-stone-200 bg-white text-sm"
/>
</div>
</div>
<div className="mt-2 flex items-center justify-between gap-3">
<label className="flex items-center gap-2 text-sm text-stone-600">
<input
type="checkbox"
checked={rule.enabled}
onChange={(event) => updateRule(rule.id, { enabled: event.target.checked })}
/>
启用
</label>
<div className="flex items-center gap-3">
{invalid ? (
<span className="text-xs text-amber-600">缺关键词或文案,保存时会被丢弃</span>
) : null}
<Button
variant="ghost"
size="icon"
title="删除该规则"
onClick={() => removeRule(rule.id)}
>
<Trash2 className="size-4 text-stone-500" />
</Button>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
{/* --------------------------------------------------------- 兜底开关 */}
<div className="space-y-3 border-t border-stone-100 pt-5">
<label className="flex items-start gap-3">
<input
type="checkbox"
className="mt-1"
checked={fallbackEnabled}
onChange={(event) => setFallbackEnabled(event.target.checked)}
/>
<span>
<span className="text-sm font-medium text-stone-900">未匹配时报错也兜底成通用文案</span>
<span className="mt-1 block text-xs text-stone-500">
勾选后,未命中规则的报错前台统一显示「操作失败,请稍后重试」。不勾选则原样显示技术报错(默认,便于你先补规则)。
</span>
</span>
</label>
</div>
{/* ------------------------------------------------------- 未配置报错 */}
<div className="space-y-3 border-t border-stone-100 pt-5">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="flex items-center gap-2 font-medium text-stone-900">
<AlertCircle className="size-4 text-amber-500" />
未配置报错
{unconfigured.length > 0 ? (
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-700">
{unconfigured.length}
</span>
) : null}
</h3>
<p className="mt-1 text-xs text-stone-500">
自动收集还匹配不到规则的报错。照着补一条规则,前台就再也看不到这些代码了。
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
variant="outline"
size="icon"
className="rounded-xl"
title="刷新"
disabled={loading}
onClick={() => void loadUnconfigured()}
>
{loading ? <LoaderCircle className="size-4 animate-spin" /> : <RefreshCw className="size-4" />}
</Button>
<Button
variant="outline"
className="rounded-xl"
disabled={clearing || unconfigured.length === 0}
onClick={() => void handleClearUnconfigured()}
>
<Trash2 className="size-4" />
全部忽略
</Button>
</div>
</div>
{unconfigured.length === 0 ? (
<div className="rounded-xl border border-dashed border-stone-200 bg-stone-50 px-4 py-6 text-center text-sm text-stone-500">
{loading ? "读取中…" : "暂无未配置报错,规则已覆盖当前所有报错。"}
</div>
) : (
<div className="space-y-2">
{unconfigured.map((item) => (
<div
key={item.fingerprint}
className="rounded-xl border border-stone-200 bg-stone-50 p-3"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2 text-xs text-stone-500">
<span className="rounded-full bg-stone-200 px-2 py-0.5 font-medium text-stone-700">
出现 {item.count} 次
</span>
{item.channel ? <span>来源:{item.channel}</span> : null}
{item.last_seen ? <span>最近:{item.last_seen}</span> : null}
</div>
<pre className="mt-2 max-h-32 overflow-auto whitespace-pre-wrap break-all rounded-lg bg-white p-2 text-xs text-stone-600">
{item.sample}
</pre>
</div>
</div>
<div className="mt-2 flex items-center justify-end gap-2">
<Button
variant="outline"
className="rounded-xl"
onClick={() =>
addRule({
// 后端已从这段报错里抽好候选关键词;抽不到才退回原文片段。
keywords: item.keywords?.length ? item.keywords : [item.sample.slice(0, 60)],
note: `来自未配置报错 ${item.fingerprint}`,
})
}
>
<Sparkles className="size-4" />
以此为模板新建规则
</Button>
<Button
variant="ghost"
size="icon"
title="忽略该条"
disabled={clearing}
onClick={() => void handleClearUnconfigured([item.fingerprint])}
>
<Trash2 className="size-4 text-stone-500" />
</Button>
</div>
</div>
))}
</div>
)}
</div>
{/* ----------------------------------------------------------- 保存 */}
<div className="flex items-center justify-between gap-3 border-t border-stone-100 pt-5">
<p className="text-xs text-stone-500">
共 {rules.length} 条规则,其中 {rules.filter((rule) => rule.enabled).length} 条已启用。
</p>
<Button
className="rounded-xl"
disabled={isSaving}
onClick={() => void saveConfig()}
>
{isSaving ? <LoaderCircle className="size-4 animate-spin" /> : <Save className="size-4" />}
保存设置
</Button>
</div>
</CardContent>
</Card>
);
}