diff --git a/frontend/components/ops/training/TrainingPageClient.tsx b/frontend/components/ops/training/TrainingPageClient.tsx index 620143fc..8771c03c 100644 --- a/frontend/components/ops/training/TrainingPageClient.tsx +++ b/frontend/components/ops/training/TrainingPageClient.tsx @@ -1,13 +1,17 @@ "use client"; -import { useEffect, useState } from "react"; -import { RefreshCcw } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { RefreshCcw, TrendingUp, TrendingDown, Target, Activity } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { opsApi } from "@/lib/ops-api"; import type { SystemStatusPayload } from "@/types/ops"; import Link from "next/link"; +import { + BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, + ComposedChart, Line, Legend, Cell, LabelList, +} from "recharts"; function StatRow({ label, value }: { label: string; value: React.ReactNode }) { return ( @@ -18,6 +22,26 @@ function StatRow({ label, value }: { label: string; value: React.ReactNode }) { ); } +function KpiCard({ label, value, sub, icon: Icon, color }: { + label: string; value: string | number; sub?: string; + icon: React.ComponentType<{ className?: string }>; color: string; +}) { + return ( + + +
+ +
+
+
{value}
+
{label}
+ {sub ?
{sub}
: null} +
+
+
+ ); +} + interface CityAccuracy { city_id: string; name: string; @@ -36,6 +60,37 @@ interface CityAccuracy { } | null; } +const CHART_COLORS = { + green: "#22c55e", + yellow: "#eab308", + red: "#ef4444", + blue: "#3b82f6", + cyan: "#06b6d4", + purple: "#a855f7", + slate: "#64748b", + emerald: "#10b981", + amber: "#f59e0b", + rose: "#f43f5e", +}; + +function hitColor(hitRate: number) { + if (hitRate >= 80) return CHART_COLORS.green; + if (hitRate >= 60) return CHART_COLORS.yellow; + return CHART_COLORS.red; +} + +function maeColor(mae: number) { + if (mae <= 1.5) return CHART_COLORS.green; + if (mae <= 2.5) return CHART_COLORS.yellow; + return CHART_COLORS.red; +} + +function brierColor(score: number) { + if (score <= 0.1) return CHART_COLORS.green; + if (score <= 0.25) return CHART_COLORS.yellow; + return CHART_COLORS.red; +} + export function TrainingPageClient() { const [loading, setLoading] = useState(true); const [status, setStatus] = useState(null); @@ -46,16 +101,55 @@ export function TrainingPageClient() { try { const [s, accData] = await Promise.all([ opsApi.systemStatus() as Promise, - opsApi.trainingAccuracy().catch(() => ({ accuracy: [] })) + opsApi.trainingAccuracy().catch(() => ({ accuracy: [] as CityAccuracy[] })), ]); setStatus(s); - setAccuracy(accData.accuracy); + setAccuracy((accData as { accuracy: CityAccuracy[] }).accuracy ?? []); } catch { /* */ } setLoading(false); }; useEffect(() => { void load(); }, []); + const kpis = useMemo(() => { + if (!accuracy?.length) return null; + const debCities = accuracy.filter((c) => c.deb); + const avgHit = debCities.reduce((s, c) => s + (c.deb?.hit_rate ?? 0), 0) / debCities.length; + const avgMae = debCities.reduce((s, c) => s + (c.deb?.mae ?? 0), 0) / debCities.length; + const best = debCities.reduce((a, b) => ((a.deb?.hit_rate ?? 0) > (b.deb?.hit_rate ?? 0) ? a : b)); + const worst = debCities.reduce((a, b) => ((a.deb?.mae ?? 0) > (b.deb?.mae ?? 0) ? a : b)); + return { avgHit, avgMae, best, worst }; + }, [accuracy]); + + const debChartData = useMemo(() => { + if (!accuracy?.length) return []; + return accuracy + .filter((c) => c.deb && c.deb.total_days >= 5) + .sort((a, b) => (b.deb?.hit_rate ?? 0) - (a.deb?.hit_rate ?? 0)) + .map((c) => ({ + name: c.name, + cityId: c.city_id, + hitRate: Number((c.deb?.hit_rate ?? 0).toFixed(1)), + mae: Number((c.deb?.mae ?? 0).toFixed(1)), + days: c.deb?.total_days ?? 0, + })); + }, [accuracy]); + + const muChartData = useMemo(() => { + if (!accuracy?.length) return []; + return accuracy + .filter((c) => c.mu && c.mu.total_days >= 5 && c.mu.brier_score !== null) + .sort((a, b) => ((a.mu?.brier_score ?? 1) - (b.mu?.brier_score ?? 1))) + .map((c) => ({ + name: c.name, + cityId: c.city_id, + brierScore: Number((c.mu?.brier_score ?? 0).toFixed(4)), + hitRate: Number((c.mu?.hit_rate ?? 0).toFixed(1)), + mae: Number((c.mu?.mae ?? 0).toFixed(1)), + days: c.mu?.total_days ?? 0, + })); + }, [accuracy]); + if (loading) return
加载中...
; if (!status) return
加载失败
; @@ -74,6 +168,7 @@ export function TrainingPageClient() { + {/* Data volume KPIs */}
真值记录 @@ -101,6 +196,141 @@ export function TrainingPageClient() {
+ {/* Accuracy KPI row */} + {kpis ? ( +
+ + + + +
+ ) : null} + + {/* DEB Accuracy Charts */} + {debChartData.length > 0 ? ( +
+ + DEB 命中率 by 城市 + +
+ + + + + + [`${Number(value).toFixed(1)}%`, "命中率"]} + /> + + {debChartData.map((entry, i) => ( + + ))} + `${Number(v)}%`} /> + + + +
+
+
+ + + DEB MAE by 城市 + +
+ + + + + + [`${Number(value).toFixed(1)}°`, "MAE"]} + /> + + {debChartData.map((entry, i) => ( + + ))} + `${Number(v)}°`} /> + + + +
+
+
+
+ ) : null} + + {/* Mu Probability Charts */} + {muChartData.length > 0 ? ( +
+ + 概率 μ Brier Score by 城市 + +
+ + + + + + [Number(value).toFixed(4), "Brier Score"]} + /> + + {muChartData.map((entry, i) => ( + + ))} + Number(v).toFixed(3)} /> + + + +
+
+
+ + + 概率 μ 命中率 by 城市 + +
+ + + + + + [`${Number(value).toFixed(1)}%`, "命中率"]} + /> + + {muChartData.map((entry, i) => ( + + ))} + `${Number(v)}%`} /> + + + +
+
+
+
+ ) : null} + + {/* City coverage */} {modelCities ? (
@@ -133,9 +363,10 @@ export function TrainingPageClient() {
) : null} + {/* Detail table */} - 模型融合与预测准确率效果 (DEB & 概率引擎) + 模型融合与预测准确率详情
@@ -143,13 +374,13 @@ export function TrainingPageClient() { 城市 - DEB 结算命中 + DEB 命中 DEB MAE DEB 天数 - 概率 μ 结算命中 - 概率 μ MAE - Brier Score - 概率天数 + μ 命中 + μ MAE + Brier + μ 天数 @@ -222,7 +453,7 @@ export function TrainingPageClient() { ) : ( - 无有效准确率记录,请确认 daily_records.json 中已有历史结算数据。 + 无有效准确率记录 )} @@ -246,4 +477,3 @@ export function TrainingPageClient() {
); } -