feat(admin): 「自动评测」页做实 —— 接真评测数据,去 mock (P1)

审计 P1「admin 三页纯 mock」之一。此前 EvalsPage 是写死的质量趋势+编造的
错题本+虚构纠偏轨迹。现接 sundynix_eval 真数据(评测经 JetStream eval 流持久
落库,刚升级)。

- store/eval_query.go:EvalTrend(按天 avg 综合分/忠实度+低分计数)、EvalSummaryFor
  (ok/warn/poor/corrected 计数+均值)、PoorEvals(错题本,level in poor/warn +
  评语+纠偏标记+租户名)。全 WithoutTenant 平台口径;忠实度均值只算 sources>0
  (无来源的忠实度恒0会压低失真)。
- GET /admin/evals?days=(RequireAdmin);admin api.ts + EvalsPage 重写:
  总览卡片(综合分/合格率/低分占比/纠偏采纳率)+质量&忠实度趋势(纯SVG折线+低分
  背景条)+错题本(点行展开评语)。
- 诚实边界:纠偏「前后全文对照」后端未持久化,只存了 Reason/Corrected/各维度分,
  故错题本展示评语+「已纠偏」标记,不再编造 before/after。

live:/admin/evals 返 57 次评测 avg=0.88、错题本10条、11天趋势;浏览器渲染
真数据(趋势线07-15真实下探)。go+tsc+41 vitest 全绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-18 12:03:54 +08:00
parent a1c35852ef
commit d04d830c37
5 changed files with 329 additions and 241 deletions
+44
View File
@@ -301,6 +301,50 @@ export async function adminReconcile(): Promise<{ diffs: ReconcileDiff[]; ok: bo
return { diffs: d.diffs ?? [], ok: !!d.ok };
}
// ---- 自动评测观测(真数据,来自 sundynix_eval----
export interface EvalDay {
day: string; // YYYYMMDD
avg_overall: number;
avg_faithful: number;
count: number;
poor_count: number;
}
export interface EvalSummary {
total: number;
ok: number;
warn: number;
poor: number;
corrected: number;
avg_overall: number;
}
export interface PoorEval {
task_id: string;
tenant_name: string;
owner: string;
overall: number;
rule: number;
llm: number;
faithful: number;
level: string;
reason: string;
sources: number;
corrected: boolean;
created_at: string;
}
export async function adminEvals(days = 14): Promise<{ from: string; to: string; trend: EvalDay[]; summary: EvalSummary; poor: PoorEval[] }> {
const res = guard(await fetch(`${ADMIN}/evals?days=${days}`, { headers: authHeaders() }));
const d = (await res.json().catch(() => ({}))) as { from?: string; to?: string; trend?: EvalDay[]; summary?: EvalSummary; poor?: PoorEval[]; error?: string };
if (!res.ok) throw new Error(d.error ?? `evals failed: ${res.status}`);
return {
from: d.from ?? "",
to: d.to ?? "",
trend: d.trend ?? [],
summary: d.summary ?? { total: 0, ok: 0, warn: 0, poor: 0, corrected: 0, avg_overall: 0 },
poor: d.poor ?? [],
};
}
// gatewayOnline 用公开的 /healthz 探活(不受鉴权影响)。
export async function gatewayOnline(): Promise<boolean> {
try {
+167 -241
View File
@@ -1,258 +1,184 @@
import { useState } from "react";
import { Fragment, useEffect, useState, type ReactNode } from "react";
import { adminEvals, type EvalDay, type EvalSummary, type PoorEval } from "../api";
// Mock 评测趋势数据
const QUALITY_TREND = [0.82, 0.84, 0.79, 0.81, 0.85, 0.88, 0.89, 0.87, 0.86, 0.91, 0.92, 0.88, 0.87, 0.88];
const HALLUCINATION_TREND = [18, 15, 22, 19, 14, 11, 10, 12, 13, 8, 7, 11, 12, 10]; // % 比例
const DATES = ["06-14", "06-15", "06-16", "06-17", "06-18", "06-19", "06-20", "06-21", "06-22", "06-23", "06-24", "06-25", "06-26", "06-27"];
// Mock 低分评测记录(错题本)与纠偏轨迹
const MOCK_POOR_RUNS = [
{
id: "task_e8f2a1b9",
time: "11:47:05",
user: "Bob",
agentName: "法律合同审查 Agent",
overall: 0.42,
ruleScore: 0.60,
llmScore: 0.50,
faithful: 0.15,
level: "poor",
reason: "幻觉严重。模型声称合同中包含‘三年内无条件退款限制条款’,但所附 RAG 参考材料中仅提及‘按比例折算退款规则’,属于严重的知识库脱轨和无中生有(无立足依据)。",
trace: {
initialAnswer: "根据合同第 4 条,本合同包含三年内无条件全额退款条款,客户可随时申请解除合作。",
critique: "【评测发现异常】RAG 知识块[文档: 退款政策_2026.pdf]中明确规定退款须‘按合作月份比例折算,扣除已产生渠道服务费后退还余款,且需提前30天书面申请’。模型回答‘无条件全额退款’属严重事实性捏造,忠实度(Faithful)分值判定为 0.15。",
refinePrompt: "你是一个严肃的合同审查纠偏助手。在前一次生成中,模型产生了事实性幻觉。请根据参考材料【退款政策_2026.pdf】:‘退款须按合作月份比例折算,扣除已产生渠道服务费后退还余款,且需提前30天书面申请’,对前次答案【根据合同第 4 条...】进行修改纠正,必须忠实于材料,杜绝捏造无条件条款。",
refinedAnswer: "根据退款政策附件规则,退款非无条件全额,而是必须按合作月份比例折算,且扣除已产生渠道服务费后退还余款。此外,客户申请退款需提前30天提交书面申请,合同第4条仅规定了申请路径,而非‘无条件退款’。",
newScore: 0.85
}
},
{
id: "task_ff3c0b12",
time: "10:12:30",
user: "Alice",
agentName: "医学文献总结 Agent",
overall: 0.55,
ruleScore: 0.50,
llmScore: 0.60,
faithful: 0.55,
level: "poor",
reason: "引用缺失。用户要求‘回答时必须在事实断言处标注 RAG 参考的文档来源’,但模型生成的总结文本中未包含任何形如 [1] 或 [doc_xxx] 的引用锚点,违反了输入 DSL 的强制规范规则。",
trace: {
initialAnswer: "该临床研究表明,使用该抗体偶联药物能提高 15% 的无进展生存期(PFS),且中位缓解期达到了 12.4 个月。",
critique: "【评测发现异常】模型得出了准确的结论,但未按照强制指令将数据来源指向知识块 [文献_ADC研究_Phase3.pdf]。违反强制引用约束,规则分降低为 0.50。",
refinePrompt: "前次生成中未包含数据来源引用。请在前次总结中,为‘提高 15% 的无进展生存期’与‘中位缓解期达到 12.4 个月’数据,在行内添加形如 [文献_ADC研究_Phase3.pdf] 的数据来源引用标签。",
refinedAnswer: "最新临床三期研究表明,使用该抗体偶联药物能提高 15% 的无进展生存期(PFS[文献_ADC研究_Phase3.pdf],且中位缓解期达到了 12.4 个月[文献_ADC研究_Phase3.pdf]。",
newScore: 0.92
}
},
{
id: "task_a5fd2510",
time: "09:30:15",
user: "Alice",
agentName: "财务审计核对 Agent",
overall: 0.48,
ruleScore: 0.40,
llmScore: 0.55,
faithful: 0.50,
level: "poor",
reason: "计算口径不一致。知识库给出的 2025 Q4 营业收入为‘1.2 亿元(含未结算款项)’,但财务报表核查中模型漏计了未结算款,算出了 1.05 亿元,与账面发生额不符。",
trace: {
initialAnswer: "核对完毕,Q4 累计核算收入为 1.05 亿元,与系统申报一致。",
critique: "【评测发现异常】少计算了未结算款项 1500 万元。知识库规定必须将未结算款项计入 Q4 营业收入内。规则评分降为 0.40。",
refinePrompt: "计算遗漏。请把财务参考中的‘1500万未结算款项’加进 Q4 营业收入核对中,重新计算总营收并核对金额。",
refinedAnswer: "重新核算后,Q4 累计核算总收入为 1.2 亿元(已包含 1500 万元未结算款项),与系统申报发生额完全吻合。",
newScore: 0.89
}
}
];
// 自动评测观测:真数据(来自 sundynix_eval,评测经 JetStream eval 流持久落库)。
// 质量趋势 + 计数总览 + 错题本(低分评测 + 评语 + 纠偏标记)。
// 注:纠偏前后全文轨迹后端未持久化,错题本展示评语(Reason)与「已纠偏」标记,不含 before/after 对照。
const pct = (v: number) => `${Math.round(v * 100)}%`;
const LEVEL_BADGE: Record<string, string> = { poor: "bg-rose-50 text-rose-600", warn: "bg-amber-50 text-amber-600", ok: "bg-emerald-50 text-emerald-600" };
const LEVEL_LABEL: Record<string, string> = { poor: "低分", warn: "警告", ok: "合格" };
const mmdd = (ymd: string) => (ymd.length === 8 ? `${ymd.slice(4, 6)}-${ymd.slice(6, 8)}` : ymd);
export function EvalsPage() {
const [runs, setRuns] = useState(MOCK_POOR_RUNS);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [days, setDays] = useState(14);
const [trend, setTrend] = useState<EvalDay[]>([]);
const [summary, setSummary] = useState<EvalSummary | null>(null);
const [poor, setPoor] = useState<PoorEval[]>([]);
const [open, setOpen] = useState<string | null>(null); // 展开评语的 task_id
const [loading, setLoading] = useState(true);
const [err, setErr] = useState("");
const toggleExpand = (id: string) => {
setExpandedId((prev) => (prev === id ? null : id));
};
useEffect(() => {
setLoading(true);
adminEvals(days)
.then((r) => {
setTrend(r.trend);
setSummary(r.summary);
setPoor(r.poor);
setErr("");
})
.catch((e) => setErr((e as Error).message))
.finally(() => setLoading(false));
}, [days]);
// SVG 趋势图宽高
const chartW = 260;
const chartH = 70;
const pad = 10;
if (loading) return <div className="text-sm text-gray-400"></div>;
if (err) return <div className="text-sm text-rose-500">{err}</div>;
// 1. 质量曲线点计算
const maxValQ = 1.0;
const pointsQ = QUALITY_TREND.map((val, idx) => {
const x = pad + (idx * (chartW - pad * 2)) / (QUALITY_TREND.length - 1);
const y = chartH - pad - (val * (chartH - pad * 2)) / maxValQ;
return { x, y };
});
const pathQ = pointsQ.reduce((p, pt, i) => p + `${i === 0 ? "M" : "L"} ${pt.x.toFixed(1)} ${pt.y.toFixed(1)}`, "");
// 2. 幻觉率曲线点计算
const maxValH = 30; // 最大 30% 刻度
const pointsH = HALLUCINATION_TREND.map((val, idx) => {
const x = pad + (idx * (chartW - pad * 2)) / (HALLUCINATION_TREND.length - 1);
const y = chartH - pad - (val * (chartH - pad * 2)) / maxValH;
return { x, y };
});
const pathH = pointsH.reduce((p, pt, i) => p + `${i === 0 ? "M" : "L"} ${pt.x.toFixed(1)} ${pt.y.toFixed(1)}`, "");
const s = summary!;
const correctRate = s.poor + s.warn > 0 ? s.corrected / (s.poor + s.warn) : 0;
return (
<div className="space-y-6">
{/* 顶部大盘指标与微缩趋势图 */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
{/* 指标 1:综合评测均分 */}
<section className="rounded-xl border border-gray-100 bg-white p-4 shadow-sm flex items-center justify-between gap-4">
<div>
<span className="text-xs font-medium text-gray-400"></span>
<h3 className="mt-1 text-2xl font-bold text-gray-800">0.88</h3>
<span className="text-[10px] text-emerald-600"> 3%</span>
</div>
{/* 微型折线图 */}
<div className="w-36 h-12 bg-gray-50/50 rounded border p-1">
<svg viewBox={`0 0 ${chartW} ${chartH}`} className="w-full h-full overflow-visible">
<path d={pathQ} fill="none" stroke="#7c3aed" strokeWidth="2" strokeLinecap="round" />
</svg>
</div>
</section>
{/* 指标 2:忠实度评测与幻觉率 */}
<section className="rounded-xl border border-gray-100 bg-white p-4 shadow-sm flex items-center justify-between gap-4">
<div>
<span className="text-xs font-medium text-gray-400"></span>
<h3 className="mt-1 text-2xl font-bold text-rose-600">10%</h3>
<span className="text-[10px] text-emerald-600"> 8%</span>
</div>
{/* 微型折线图 */}
<div className="w-36 h-12 bg-gray-50/50 rounded border p-1">
<svg viewBox={`0 0 ${chartW} ${chartH}`} className="w-full h-full overflow-visible">
<path d={pathH} fill="none" stroke="#f43f5e" strokeWidth="2" strokeLinecap="round" />
</svg>
</div>
</section>
{/* 指标 3:纠偏系统效能 */}
<section className="rounded-xl border border-gray-100 bg-white p-4 shadow-sm">
<span className="text-xs font-medium text-gray-400"> ()</span>
<div className="mt-2 flex items-baseline gap-2">
<h3 className="text-2xl font-bold text-gray-800">84.2%</h3>
<span className="text-xs text-gray-500"> 228 </span>
</div>
<div className="mt-2 h-1.5 w-full bg-gray-100 rounded-full overflow-hidden">
<div className="h-full bg-violet-600 rounded-full" style={{ width: "84.2%" }} />
</div>
</section>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-base font-semibold text-gray-800"></h2>
<p className="text-xs text-gray-400"> · · </p>
</div>
<div className="flex overflow-hidden rounded-lg border border-gray-200 text-xs">
{[7, 14, 30].map((d) => (
<button key={d} onClick={() => setDays(d)}
className={`px-3 py-1.5 ${days === d ? "bg-violet-600 text-white" : "bg-white text-gray-500 hover:bg-gray-50"}`}>
{d}
</button>
))}
</div>
</div>
{/* 自动纠偏错题本(失败记录) */}
<section className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
<div className="mb-4">
<h3 className="text-sm font-semibold text-gray-700"> ()</h3>
<p className="text-[11px] text-gray-400">poorRefinement</p>
{/* 总览计数 */}
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<Stat label="综合质量分" value={pct(s.avg_overall)} tone="violet" sub={`${s.total} 次评测`} />
<Stat label="合格率" value={s.total ? pct(s.ok / s.total) : "—"} tone="emerald" sub={`${s.ok} 合格 · ${s.warn} 警告 · ${s.poor} 低分`} />
<Stat label="低分占比" value={s.total ? pct(s.poor / s.total) : "—"} tone="rose" sub="幻觉/规则违背/低质" />
<Stat label="纠偏采纳率" value={s.poor + s.warn ? pct(correctRate) : "—"} tone="cyan" sub={`${s.corrected} 次自动纠偏被采纳`} />
</div>
{/* 质量 & 忠实度趋势 */}
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
<div className="mb-4 flex items-center justify-between">
<h4 className="text-sm font-semibold text-gray-700"></h4>
<div className="flex gap-4 text-[11px]">
<span className="flex items-center gap-1"><span className="h-2 w-2 rounded-full bg-violet-500" /></span>
<span className="flex items-center gap-1"><span className="h-2 w-2 rounded-full bg-cyan-500" /></span>
<span className="flex items-center gap-1"><span className="h-2 w-2 rounded-full bg-rose-300" /></span>
</div>
</div>
<TrendChart trend={trend} />
</div>
<div className="space-y-3">
{runs.map((r) => {
const isExpanded = expandedId === r.id;
return (
<div key={r.id} className="rounded-lg border border-gray-100 overflow-hidden">
{/* 简要行 */}
<div
onClick={() => toggleExpand(r.id)}
className={`flex flex-col md:flex-row md:items-center justify-between gap-4 p-3.5 cursor-pointer hover:bg-gray-50/50 transition-colors ${
isExpanded ? "bg-violet-50/20 border-b border-violet-100" : ""
}`}
>
<div className="flex items-center gap-3">
<span className="text-xs font-mono font-bold text-gray-400">[{r.time}]</span>
<div>
<div className="text-xs font-semibold text-gray-800">{r.agentName}</div>
<div className="text-[10px] text-gray-400">: {r.user} | ID: {r.id}</div>
</div>
</div>
{/* 分数指标组 */}
<div className="flex items-center gap-3">
<div className="text-center">
<div className="text-[9px] text-gray-400"></div>
<div className="text-xs font-bold text-rose-600">{r.overall.toFixed(2)}</div>
</div>
<div className="text-center border-l pl-3">
<div className="text-[9px] text-gray-400"></div>
<div className="text-xs font-semibold text-gray-600">{r.ruleScore.toFixed(2)}</div>
</div>
<div className="text-center border-l pl-3">
<div className="text-[9px] text-gray-400">RAG忠实度</div>
<div className={`text-xs font-semibold ${r.faithful <= 0.3 ? "text-rose-600" : "text-gray-600"}`}>
{r.faithful.toFixed(2)}
</div>
</div>
<div className="text-center border-l pl-3">
<div className="text-[9px] text-gray-400"></div>
<div className="text-xs font-bold text-emerald-600"> {r.trace.newScore.toFixed(2)}</div>
</div>
{/* 展开折叠箭头 */}
<span className="text-gray-400 text-xs pl-2 font-mono">{isExpanded ? "▲" : "▼"}</span>
</div>
</div>
{/* 展开详细信息(纠偏对齐轨迹详情) */}
{isExpanded && (
<div className="p-4 bg-gray-50/30 text-xs space-y-4 animate-fadeIn">
{/* 1. 问题定位 */}
<div className="border-l-2 border-rose-500 pl-3">
<h5 className="font-bold text-gray-800"> (Evaluator Diagnosis)</h5>
<p className="mt-1 text-gray-600 leading-snug">{r.reason}</p>
</div>
{/* 2. 纠偏流转卡片组 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* 初次答案 */}
<div className="rounded border bg-white p-3">
<div className="text-[10px] font-bold text-rose-600 flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-rose-500" />
/ Score: {r.overall.toFixed(2)}
</div>
<p className="mt-2 text-gray-500 font-mono leading-relaxed">{r.trace.initialAnswer}</p>
</div>
{/* 初次评测评语 */}
<div className="rounded border bg-white p-3">
<div className="text-[10px] font-bold text-amber-600 flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-amber-500" />
(Evaluator Critique)
</div>
<p className="mt-2 text-gray-500 leading-relaxed">{r.trace.critique}</p>
</div>
{/* 纠偏 Prompt 注入 */}
<div className="rounded border bg-white p-3 md:col-span-2">
<div className="text-[10px] font-bold text-violet-600 flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-violet-500 animate-pulse" />
(Refinement Rewrite Prompt)
</div>
<p className="mt-2 text-gray-500 font-mono leading-relaxed bg-gray-50 p-2 rounded border border-gray-100">
{r.trace.refinePrompt}
</p>
</div>
{/* 纠偏后最终答案 */}
<div className="rounded border bg-white p-3 md:col-span-2 border-emerald-200 bg-emerald-50/10">
<div className="text-[10px] font-bold text-emerald-600 flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
Score: {r.trace.newScore.toFixed(2)}
</div>
<p className="mt-2 text-gray-700 font-mono leading-relaxed">{r.trace.refinedAnswer}</p>
</div>
</div>
</div>
)}
</div>
);
})}
{/* 错题本 */}
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
<div className="mb-3 flex items-center justify-between">
<h4 className="text-sm font-semibold text-gray-700">/</h4>
<span className="text-[11px] text-gray-400">= </span>
</div>
</section>
{poor.length === 0 ? (
<div className="py-8 text-center text-xs text-gray-400"> </div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100 text-left text-[11px] uppercase tracking-wide text-gray-400">
<th className="py-2 pr-3 font-medium"></th>
<th className="py-2 pr-3 font-medium"> / </th>
<th className="py-2 pr-3 text-right font-medium"></th>
<th className="py-2 pr-3 text-right font-medium"></th>
<th className="py-2 pr-3 text-right font-medium"></th>
<th className="py-2 pr-3 text-right font-medium"></th>
<th className="py-2 font-medium"></th>
</tr>
</thead>
<tbody>
{poor.map((r) => (
<Fragment key={r.task_id}>
<tr onClick={() => setOpen(open === r.task_id ? null : r.task_id)}
className="cursor-pointer border-b border-gray-50 hover:bg-gray-50">
<td className="py-2 pr-3 text-xs text-gray-500">{r.created_at}</td>
<td className="py-2 pr-3">
<div className="font-mono text-[11px] text-gray-600">{r.task_id}</div>
<div className="text-[11px] text-gray-400">{r.tenant_name || "—"}</div>
</td>
<td className="py-2 pr-3 text-right tabular-nums text-gray-800">{pct(r.overall)}</td>
<td className="py-2 pr-3 text-right tabular-nums text-gray-500">{pct(r.rule)}</td>
<td className="py-2 pr-3 text-right tabular-nums text-gray-500">{pct(r.llm)}</td>
<td className="py-2 pr-3 text-right tabular-nums text-gray-500">{r.sources > 0 ? pct(r.faithful) : "—"}</td>
<td className="py-2">
<div className="flex items-center gap-1">
<span className={`rounded px-1.5 py-0.5 text-[10px] ${LEVEL_BADGE[r.level] ?? "bg-gray-100 text-gray-500"}`}>{LEVEL_LABEL[r.level] ?? r.level}</span>
{r.corrected && <span className="rounded bg-cyan-50 px-1.5 py-0.5 text-[10px] text-cyan-600"></span>}
</div>
</td>
</tr>
{open === r.task_id && (
<tr className="bg-gray-50/60">
<td colSpan={7} className="px-3 py-3">
<div className="text-[11px] font-medium text-gray-500"></div>
<p className="mt-1 whitespace-pre-wrap text-xs leading-relaxed text-gray-700">{r.reason || "(无评语)"}</p>
<div className="mt-2 text-[11px] text-gray-400"> {r.sources} · {r.owner || "—"}</div>
</td>
</tr>
)}
</Fragment>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}
const TONE: Record<string, string> = { violet: "text-violet-600", emerald: "text-emerald-600", rose: "text-rose-500", cyan: "text-cyan-600" };
function Stat({ label, value, sub, tone }: { label: string; value: ReactNode; sub?: ReactNode; tone: string }) {
return (
<div className="rounded-xl border border-gray-100 bg-white p-4 shadow-sm">
<div className="text-xs text-gray-400">{label}</div>
<div className={`mt-1 text-2xl font-semibold tabular-nums ${TONE[tone] ?? "text-gray-800"}`}>{value}</div>
{sub && <div className="mt-1 text-[11px] text-gray-400">{sub}</div>}
</div>
);
}
// TrendChart:综合分/忠实度折线(左轴 [0,1])+ 低分条数背景条。纯 SVG,无依赖。
function TrendChart({ trend }: { trend: EvalDay[] }) {
if (trend.length === 0) return <div className="py-8 text-center text-xs text-gray-400"></div>;
const w = 720, h = 160, pad = 24;
const n = trend.length;
const x = (i: number) => pad + (n === 1 ? (w - 2 * pad) / 2 : (i * (w - 2 * pad)) / (n - 1));
const y = (v: number) => h - pad - v * (h - 2 * pad);
const maxPoor = Math.max(1, ...trend.map((d) => d.poor_count));
const line = (get: (d: EvalDay) => number) => trend.map((d, i) => `${i === 0 ? "M" : "L"}${x(i).toFixed(1)},${y(get(d)).toFixed(1)}`).join(" ");
return (
<div className="overflow-x-auto">
<svg viewBox={`0 0 ${w} ${h + 20}`} className="w-full" style={{ minWidth: 480 }}>
{[0, 0.5, 1].map((g) => (
<g key={g}>
<line x1={pad} y1={y(g)} x2={w - pad} y2={y(g)} stroke="#f1f1f4" />
<text x={4} y={y(g) + 3} fontSize="9" fill="#bbb">{g}</text>
</g>
))}
{/* 低分条数背景条 */}
{trend.map((d, i) => (
<rect key={i} x={x(i) - 6} y={h - pad - (d.poor_count / maxPoor) * (h - 2 * pad) * 0.5} width={12}
height={(d.poor_count / maxPoor) * (h - 2 * pad) * 0.5} fill="#fecdd3" opacity={0.6} rx={2} />
))}
<path d={line((d) => d.avg_overall)} fill="none" stroke="#7c3aed" strokeWidth={2} />
<path d={line((d) => d.avg_faithful || 0)} fill="none" stroke="#06b6d4" strokeWidth={2} strokeDasharray="3 2" />
{trend.map((d, i) => (
<text key={i} x={x(i)} y={h + 12} fontSize="9" fill="#999" textAnchor="middle">{mmdd(d.day)}</text>
))}
</svg>
</div>
);
}