diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index b6b8b12a..19f36035 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -45,6 +45,7 @@ import { scanRootClass } from "@/components/dashboard/scan-root-styles"; import { useRelativeTime } from "@/hooks/useRelativeTime"; import { Panel } from "@/components/dashboard/scan-terminal/Panel"; import { GroupedMarketTable } from "@/components/dashboard/scan-terminal/GroupedMarketTable"; +import { TrainingDashboard } from "@/components/dashboard/scan-terminal/TrainingDashboard"; import { LiveTemperatureThresholdChart } from "@/components/dashboard/scan-terminal/LiveTemperatureThresholdChart"; import { rowName, pct, money, temp, edgeClass } from "@/components/dashboard/scan-terminal/utils"; @@ -360,14 +361,11 @@ function PolyWeatherTerminal({ ]; const filteredRegionRows = useMemo(() => { - const byRegion = - selectedRegionKey === "all" - ? rows - : rows.filter( - (row) => - String(row.trading_region).toLowerCase() === selectedRegionKey, - ); - return byRegion.filter((row) => row.is_primary_signal !== false); + return rows.filter( + (row) => + String(row.trading_region).toLowerCase() === selectedRegionKey && + row.is_primary_signal !== false, + ); }, [rows, selectedRegionKey]); const watchRows = useMemo(() => { @@ -774,80 +772,6 @@ function RegionalWhaleWatch({ ); } -function TrainingDashboard({ isEn }: { isEn: boolean }) { - const [data, setData] = useState | null>(null); - - useEffect(() => { - let cancelled = false; - fetch("/api/ops/training/accuracy", { - cache: "no-store", - headers: { Accept: "application/json" }, - }) - .then(async (res) => { - if (!res.ok) return null; - return (res.json() as Promise<{ accuracy: typeof data }>); - }) - .then((payload) => { - if (cancelled || !payload?.accuracy) return; - setData(payload.accuracy.filter((c) => c.deb && c.deb.total_days >= 5)); - }) - .catch(() => {}); - return () => { cancelled = true; }; - }, []); - - const debSorted = (data || []) - .sort((a, b) => (b.deb?.hit_rate ?? 0) - (a.deb?.hit_rate ?? 0)); - - return ( - -
- - - - - - - - - - - {debSorted.length ? debSorted.map((c) => ( - - - - - - - )) : ( - - - - )} - -
{isEn ? "City" : "城市"}{isEn ? "Hit" : "命中"}MAE{isEn ? "Days" : "天"}
{c.name} - = 60 ? "bg-emerald-50 text-emerald-700" : - (c.deb?.hit_rate ?? 0) >= 30 ? "bg-amber-50 text-amber-700" : - "bg-red-50 text-red-700" - }`}> - {(c.deb?.hit_rate ?? 0).toFixed(0)}% - - - {(c.deb?.mae ?? 0).toFixed(1)}° - - {c.deb?.total_days ?? 0} -
- {data === null ? (isEn ? "Loading..." : "加载中...") : (isEn ? "No data" : "无数据")} -
-
-
- ); -} - - function ScanTerminalScreen() { const [proAccess, setProAccess] = useState(() => createEmptyAccess(true), diff --git a/frontend/components/dashboard/scan-terminal/TrainingDashboard.tsx b/frontend/components/dashboard/scan-terminal/TrainingDashboard.tsx new file mode 100644 index 00000000..29622933 --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/TrainingDashboard.tsx @@ -0,0 +1,127 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; + +type TrainingCity = { + city_id: string; + name: string; + deb?: { hit_rate: number; mae: number; total_days: number } | null; +}; + +export function TrainingDashboard({ isEn }: { isEn: boolean }) { + const [data, setData] = useState(null); + + useEffect(() => { + let cancelled = false; + fetch("/api/ops/training/accuracy", { + cache: "no-store", + headers: { Accept: "application/json" }, + }) + .then(async (res) => { + if (!res.ok) return null; + return (res.json() as Promise<{ accuracy: TrainingCity[] }>); + }) + .then((payload) => { + if (cancelled || !payload?.accuracy) return; + setData(payload.accuracy.filter((c) => c.deb && c.deb.total_days >= 5)); + }) + .catch(() => {}); + return () => { cancelled = true; }; + }, []); + + const debSorted = useMemo( + () => (data || []).sort((a, b) => (b.deb?.hit_rate ?? 0) - (a.deb?.hit_rate ?? 0)), + [data], + ); + + const stats = useMemo(() => { + if (!debSorted.length) return null; + const avgHit = debSorted.reduce((s, c) => s + (c.deb?.hit_rate ?? 0), 0) / debSorted.length; + const avgMae = debSorted.reduce((s, c) => s + (c.deb?.mae ?? 0), 0) / debSorted.length; + const totalDays = debSorted.reduce((s, c) => s + (c.deb?.total_days ?? 0), 0); + return { avgHit, avgMae, totalDays, cities: debSorted.length }; + }, [debSorted]); + + return ( +
+
+

+ {isEn ? "DEB Training Accuracy" : "DEB 训练数据准确率"} +

+

+ {isEn + ? "Per-city DEB prediction accuracy. Hit rate measures whether the forecast fell within the settlement window." + : "各城市 DEB 预报命中率。命中率衡量预报是否落入结算接受窗口。"} +

+ + {stats && ( +
+ {[ + [isEn ? "Cities" : "城市数", stats.cities], + [isEn ? "Avg Hit" : "平均命中率", `${stats.avgHit.toFixed(1)}%`], + ["Avg MAE", `${stats.avgMae.toFixed(1)}°`], + [isEn ? "Days" : "训练天数", stats.totalDays], + ].map(([label, value]) => ( +
+
{label}
+
{String(value)}
+
+ ))} +
+ )} + +
+ + + + + + + + + + + {debSorted.length ? debSorted.map((c) => { + const hr = c.deb?.hit_rate ?? 0; + return ( + + + + + + + ); + }) : ( + + + + )} + +
{isEn ? "City" : "城市"}{isEn ? "Hit Rate" : "命中率"}MAE{isEn ? "Days" : "训练天数"}
{c.name} +
+
+
= 60 ? "bg-emerald-500" : hr >= 30 ? "bg-amber-400" : "bg-red-400" + }`} + style={{ width: `${Math.min(100, hr)}%` }} + /> +
+ = 60 ? "text-emerald-700" : hr >= 30 ? "text-amber-700" : "text-red-600" + }`}> + {hr.toFixed(0)}% + +
+
+ {(c.deb?.mae ?? 0).toFixed(1)}° + + {c.deb?.total_days ?? 0} +
+ {data === null ? (isEn ? "Loading..." : "加载中...") : (isEn ? "No training data" : "暂无训练数据")} +
+
+
+
+ ); +}