Download web/src/app/settings/components/error-message-card.tsx from zlcy/study: direct link, hf CLI and curl.
- Browser
- Download file 34.3 kB
-
https://huggingface.co/spaces/zlcy/study/resolve/main/web/src/app/settings/components/error-message-card.tsx
- Command line
-
hf download hf://spaces/zlcy/study/web/src/app/settings/components/error-message-card.tsx
-
curl -L -o error-message-card.tsx https://huggingface.co/spaces/zlcy/study/resolve/main/web/src/app/settings/components/error-message-card.tsx
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> | |
| ); | |
| } | |