From f08fef8a2221075dc830264a4065d38ca439dc18 Mon Sep 17 00:00:00 2001 From: shangfangjian Date: Mon, 21 Sep 2026 01:17:14 +0800 Subject: [PATCH] =?UTF-8?q?=E9=A2=84=E6=B5=8B=E7=AE=A1=E7=90=86=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E9=A2=84=E6=B5=8B=E5=8E=86=E5=8F=B2=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除 Predictions.tsx,新建 PredictionHistory.tsx - 不再支持触发预测(从主站比赛列表操作) - 支持查看所有预测记录(全部/待标注/已结算筛选) - 支持标注实际比分:行内编辑 + 保存,自动判断1X2是否命中 - 展开查看详情:预测比分/置信度/终裁意见/专家意见 - 侧边栏更名为「预测历史」,图标改为 logs Co-Authored-By: new-provider/LongCat-2.0 <> ) --- frontend/src/admin/AdminLayout.tsx | 6 +- .../src/admin/pages/PredictionHistory.tsx | 330 +++++++++++++++ frontend/src/admin/pages/Predictions.tsx | 378 ------------------ frontend/src/admin/routes.tsx | 2 +- 4 files changed, 334 insertions(+), 382 deletions(-) create mode 100644 frontend/src/admin/pages/PredictionHistory.tsx delete mode 100644 frontend/src/admin/pages/Predictions.tsx diff --git a/frontend/src/admin/AdminLayout.tsx b/frontend/src/admin/AdminLayout.tsx index 3f4856c..11ee660 100644 --- a/frontend/src/admin/AdminLayout.tsx +++ b/frontend/src/admin/AdminLayout.tsx @@ -92,7 +92,7 @@ const NAV_PAGES: Array<{ to: string; label: string; group: string }> = [ { to: '/admin', label: '仪表盘', group: '概览' }, { to: '/admin/collection', label: '数据采集', group: '数据流水线' }, { to: '/admin/data-completeness', label: '数据完整性', group: '数据流水线' }, - { to: '/admin/predictions', label: '预测管理', group: '数据流水线' }, + { to: '/admin/predictions', label: '预测历史', group: '数据流水线' }, { to: '/admin/backtest', label: '回测', group: '数据流水线' }, { to: '/admin/monitoring', label: '监控', group: '评估与监控' }, { to: '/admin/eval', label: '评估', group: '评估与监控' }, @@ -105,7 +105,7 @@ const ROUTE_LABELS: Record = { '/admin': '仪表盘', '/admin/collection': '数据采集', '/admin/data-completeness': '数据完整性', - '/admin/predictions': '预测管理', + '/admin/predictions': '预测历史', '/admin/backtest': '回测', '/admin/monitoring': '监控', '/admin/settings': '设置', @@ -119,7 +119,7 @@ const NAV_SECTIONS: { title: string; items: Array<{ to: string; label: string; i items: [ { to: '/admin/collection', label: '数据采集', icon: 'collection' }, { to: '/admin/data-completeness', label: '数据完整性', icon: 'chart' }, - { to: '/admin/predictions', label: '预测管理', icon: 'target' }, + { to: '/admin/predictions', label: '预测历史', icon: 'logs' }, { to: '/admin/backtest', label: '回测', icon: 'repeat' }, ], }, diff --git a/frontend/src/admin/pages/PredictionHistory.tsx b/frontend/src/admin/pages/PredictionHistory.tsx new file mode 100644 index 0000000..bcb64b4 --- /dev/null +++ b/frontend/src/admin/pages/PredictionHistory.tsx @@ -0,0 +1,330 @@ +/** + * Admin 后台 - 预测历史页面(报刊风) + * + * 功能: + * - 查看预测记录列表(支持联赛/状态筛选) + * - 赛后标注实际比分,供准确率评估 + * - 展开查看终裁理由与专家摘要 + */ + +import { useCallback, useEffect, useState } from 'react' +import { fetchPredictions, settlePrediction, fetchMatches } from '../dal' +import type { Prediction, Match } from '../types' +import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components' +import { useState as useStateRef } from 'react' + +const OUTCOME_LABEL: Record = { '1': '主胜', X: '平局', '2': '客胜' } + +function fmtTime(s?: string | null): string { + if (!s) return '—' + const d = new Date(s) + return isNaN(d.getTime()) ? s : d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) +} + +export default function PredictionHistoryPage() { + const [predictions, setPredictions] = useState([]) + const [matches, setMatches] = useState>(new Map()) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [filter, setFilter] = useState<'all' | 'settled' | 'unsettled'>('all') + const [expandedId, setExpandedId] = useState(null) + + // 标注实际比分表单 + const [editingId, setEditingId] = useState(null) + const [homeGoals, setHomeGoals] = useState('') + const [awayGoals, setAwayGoals] = useState('') + const [saving, setSaving] = useState(false) + const [saveMsg, setSaveMsg] = useState<{ kind: 'ok' | 'error'; text: string } | null>(null) + + const load = useCallback(async () => { + setLoading(true) + setError(null) + try { + const list = await fetchPredictions(100) + setPredictions(list) + // 获取相关比赛信息 + const matchIds = [...new Set(list.map(p => p.match_id))] + if (matchIds.length > 0) { + const matchesData = await fetchMatches({ limit: 200 }) + const matchMap = new Map() + matchesData.items.forEach(m => matchMap.set(m.id, m)) + setMatches(matchMap) + } + } catch (err) { + setError(err instanceof Error ? err.message : '加载失败') + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { load() }, [load]) + + const filtered = predictions.filter(p => { + if (filter === 'settled') return p.settled + if (filter === 'unsettled') return !p.settled + return true + }) + + const startEdit = (p: Prediction) => { + setEditingId(p.id) + setHomeGoals(p.actual_home_goals?.toString() ?? '') + setAwayGoals(p.actual_away_goals?.toString() ?? '') + setSaveMsg(null) + } + + const cancelEdit = () => { + setEditingId(null) + setHomeGoals('') + setAwayGoals('') + setSaveMsg(null) + } + + const saveScore = async () => { + if (!editingId) return + const hg = parseInt(homeGoals) + const ag = parseInt(awayGoals) + if (isNaN(hg) || isNaN(ag)) { + setSaveMsg({ kind: 'error', text: '请输入有效比分' }) + return + } + setSaving(true) + setSaveMsg(null) + try { + await settlePrediction(editingId, hg, ag) + setSaveMsg({ kind: 'ok', text: '已标注实际比分' }) + await load() + setEditingId(null) + setHomeGoals('') + setAwayGoals('') + } catch (err) { + setSaveMsg({ kind: 'error', text: err instanceof Error ? err.message : '保存失败' }) + } finally { + setSaving(false) + } + } + + const unsettledCount = predictions.filter(p => !p.settled).length + + return ( +
+ 0 ? `, ${unsettledCount} 条待标注` : ''}`} + /> + + {saveMsg && ( + setSaveMsg(null)} /> + )} + + {/* 筛选 */} +
+ {([ + { v: 'all', label: '全部' }, + { v: 'unsettled', label: '待标注' }, + { v: 'settled', label: '已结算' }, + ] as const).map(opt => ( + + ))} +
+ + {loading && ( +
+ )} + + {error && ( + setError(null)} /> + )} + + {!loading && !error && filtered.length === 0 && ( +
+

暂无预测记录

+

在主站比赛列表点击「预测」按钮生成预测记录

+
+ )} + + {/* 预测列表 */} + {!loading && !error && filtered.length > 0 && ( + + +
+ + + + + + + + + + + + + {filtered.map(p => { + const isExpanded = expandedId === p.id + const isEditing = editingId === p.id + const matchInfo = matches.get(p.match_id) + const homeName = matchInfo?.home_team_zh || matchInfo?.home_team || `比赛 #${p.match_id}` + const awayName = matchInfo?.away_team_zh || matchInfo?.away_team || '?' + return ( + + + + + + + + + ) + })} + +
比赛预测比分实际比分1X2状态操作
+
{homeName} vs {awayName}
+
{fmtTime(p.created_at)}
+
+ {p.pred_home_goals != null && p.pred_away_goals != null + ? `${p.pred_home_goals} : ${p.pred_away_goals}` + : '—'} + + {isEditing ? ( +
+ setHomeGoals(e.target.value)} + className="field w-12 px-1 py-0.5 text-center text-xs" + /> + : + setAwayGoals(e.target.value)} + className="field w-12 px-1 py-0.5 text-center text-xs" + /> +
+ ) : ( + + {p.actual_home_goals != null && p.actual_away_goals != null + ? `${p.actual_home_goals} : ${p.actual_away_goals}` + : '—'} + + )} +
+ {p.pred_1x2 ? ( + { + const actual = p.actual_home_goals > p.actual_away_goals ? '1' : p.actual_home_goals < p.actual_away_goals ? '2' : 'X' + return actual === p.pred_1x2 ? 'border-emerald-300 text-emerald-700' : 'border-press text-press' + })() + : 'border-ink-200 text-ink-600' + }`}> + {OUTCOME_LABEL[p.pred_1x2] ?? p.pred_1x2} + + ) : '—'} + + {p.settled ? ( + 已结算 + ) : ( + 待标注 + )} + +
+ {isEditing ? ( + <> + + + + ) : ( + <> + {!p.settled && ( + + )} + + + )} +
+
+
+
+
+ )} + + {/* 展开详情 */} + {expandedId && (() => { + const p = predictions.find(pr => pr.id === expandedId) + if (!p) return null + const reports = p.agent_outputs ?? [] + return ( + + + +
+
+

预测比分

+

+ {p.pred_home_goals != null && p.pred_away_goals != null + ? `${p.pred_home_goals} : ${p.pred_away_goals}` : '—'} +

+
+
+

置信度

+

+ {p.subjective_confidence != null ? `${(p.subjective_confidence * 100).toFixed(0)}%` : '—'} +

+
+
+

1X2

+

+ {OUTCOME_LABEL[p.pred_1x2 ?? ''] ?? '—'} +

+
+
+ + {p.reasoning && ( +
+

终裁意见

+
+

{p.reasoning}

+
+
+ )} + + {reports.length > 0 && ( +
+

专家意见

+
+ {reports.map((r, i) => ( +
+

{r.agent}

+

{r.analysis || '—'}

+
+ ))} +
+
+ )} +
+
+ ) + })()} +
+ ) +} diff --git a/frontend/src/admin/pages/Predictions.tsx b/frontend/src/admin/pages/Predictions.tsx deleted file mode 100644 index 22fd0b5..0000000 --- a/frontend/src/admin/pages/Predictions.tsx +++ /dev/null @@ -1,378 +0,0 @@ -/** - * Admin 后台 - 预测管理页面(报刊风) - * - * 功能: - * - 触发预测(选择比赛 + 模式) - * - 结算:录入实际比分,写入评估(接 /eval/settle) - * - 预测记录列表:可展开查看终裁理由与专家摘要 - * - * 响应式布局: 移动端单列,桌面端双列 - */ - -import { useCallback, useEffect, useMemo, useState } from 'react' -import { triggerPrediction, fetchPredictions, fetchMatches, settlePrediction } from '../dal' -import type { Match, Prediction } from '../types' -import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components' -import { teamSidePrefix } from '../../components/TeamSideTag' - -const AGENT_LABELS: Record = { - h2h: '历史交锋分析专家', - form: '近期状态分析专家', - stats: '攻防数据分析专家', - home_away: '主客因素分析专家', - standings: '联赛排名分析专家', -} - -const OUTCOME_LABEL: Record = { '1': '主胜', X: '平局', '2': '客胜' } - -function fmtTime(s?: string | null): string { - if (!s) return '—' - const d = new Date(s) - return isNaN(d.getTime()) - ? s - : d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) -} - -export default function PredictionsPage() { - const [matches, setMatches] = useState([]) - const [predictions, setPredictions] = useState([]) - const [matchId, setMatchId] = useState('') - const [mode] = useState<'multi'>('multi') - const [loading, setLoading] = useState(false) - const [error, setError] = useState(null) - const [successMsg, setSuccessMsg] = useState(null) - const [leagueFilter, setLeagueFilter] = useState('') // 联赛筛选 - - // 结算表单 - const [settleId, setSettleId] = useState('') - const [homeGoals, setHomeGoals] = useState('') - const [awayGoals, setAwayGoals] = useState('') - const [settling, setSettling] = useState(false) - const [settleMsg, setSettleMsg] = useState<{ kind: 'error' | 'ok'; text: string } | null>(null) - - const refreshPredictions = useCallback(async () => { - const list = await fetchPredictions(50) - setPredictions(list) - }, []) - - useEffect(() => { - refreshPredictions() - // 只加载未开赛的比赛(用于预测) - fetchMatches({ status: 'scheduled', limit: 200 }).then(d => setMatches(d.items)) - }, [refreshPredictions]) - - /** match_id → 中文名对阵 */ - const matchName = useMemo(() => { - const map = new Map() - for (const m of matches) { - const home = m.home_team_zh || m.home_team - const away = m.away_team_zh || m.away_team - map.set(m.id, `${teamSidePrefix('home')}${home} vs ${teamSidePrefix('away')}${away}`) - } - return map - }, [matches]) - - const nameOf = (id: number) => matchName.get(id) ?? `比赛 #${id}` - - // 联赛列表(从比赛中提取) + 筛选后的比赛 - const leagues = useMemo(() => { - const set = new Set(matches.map(m => m.league_code).filter(Boolean)) - return [...set].sort() - }, [matches]) - const filteredMatches = leagueFilter - ? matches.filter(m => m.league_code === leagueFilter) - : matches - - async function handlePredict(e: React.FormEvent) { - e.preventDefault() - if (!matchId) return - setLoading(true) - setError(null) - setSuccessMsg(null) - try { - await triggerPrediction({ match_id: parseInt(matchId), mode }) - setSuccessMsg('预测任务已完成,记录已更新') - await refreshPredictions() - } catch (err: unknown) { - setError(err instanceof Error ? err.message : '预测失败') - } finally { - setLoading(false) - } - } - - const unsettled = predictions.filter(p => !p.settled) - - async function handleSettle(e: React.FormEvent) { - e.preventDefault() - const pid = parseInt(settleId) - const hg = parseInt(homeGoals) - const ag = parseInt(awayGoals) - if (!pid || isNaN(hg) || isNaN(ag)) return - setSettling(true) - setSettleMsg(null) - try { - await settlePrediction(pid, hg, ag) - setSettleMsg({ kind: 'ok', text: '结算完成,准确率统计已更新' }) - setSettleId('') - setHomeGoals('') - setAwayGoals('') - await refreshPredictions() - } catch (err: unknown) { - setSettleMsg({ - kind: 'error', - text: err instanceof Error ? err.message : '结算失败', - }) - } finally { - setSettling(false) - } - } - - const settleTarget = predictions.find(p => p.id === parseInt(settleId)) - - return ( -
- - -
- {/* 新建预测 */} - - - -
-
- {leagues.length > 1 && ( -
- - -
- )} -
- - -
-
- -
- - -
- - {error && setError(null)} />} - {successMsg && ( - setSuccessMsg(null)} /> - )} - - - -
-
- - {/* 结算 */} - - - - {unsettled.length === 0 ? ( -

- 没有待结算的预测记录。预测完成后可在此录入实际比分。 -

- ) : ( -
-
- - -
- - {settleTarget && ( -

- 预测:{settleTarget.pred_home_goals ?? '-'} : {settleTarget.pred_away_goals ?? '-'} - ({OUTCOME_LABEL[settleTarget.pred_1x2 ?? ''] ?? '?'}) - {fmtTime(settleTarget.created_at)} -

- )} - -
-
- - setHomeGoals(e.target.value)} - className="field w-full" - /> -
-
- - setAwayGoals(e.target.value)} - className="field w-full" - /> -
-
- - {settleMsg && ( - setSettleMsg(null)} - /> - )} - - - - )} -
-
-
- - {/* 最近预测 */} - - - - {predictions.length === 0 ? ( -

- 暂无预测记录,触发预测后将在此显示 -

- ) : ( -
- {predictions.map(p => { - const okAgents = (p.agent_outputs ?? []).filter(a => a.status === 'ok') - return ( -
- - #{p.id} - {nameOf(p.match_id)} - - {p.pred_home_goals ?? '-'}:{p.pred_away_goals ?? '-'} - - - {OUTCOME_LABEL[p.pred_1x2 ?? ''] ?? '—'} - {p.subjective_confidence !== null && p.subjective_confidence !== undefined && - ` · ${Math.round(p.subjective_confidence * 100)}%`} - - - {p.settled ? ( - 已结算 - ) : ( - 未结算 - )} - {fmtTime(p.created_at)} - - - - -
-

- `多专家 · ${okAgents.length}/${p.agent_outputs?.length ?? 0} 路有效` - {p.model && {p.model}} -

- - {p.reasoning && ( -
-

- {p.reasoning} -

-
- )} - - {p.agent_outputs && p.agent_outputs.length > 0 && ( -
    - {p.agent_outputs.map((a, i) => ( -
  • -
  • - ))} -
- )} - - {p.settled && ( -

- 实际比分 {p.actual_home_goals ?? '-'} : {p.actual_away_goals ?? '-'} -

- )} -
-
- ) - })} -
- )} -
-
-
- ) -} diff --git a/frontend/src/admin/routes.tsx b/frontend/src/admin/routes.tsx index 1952682..57d8b47 100644 --- a/frontend/src/admin/routes.tsx +++ b/frontend/src/admin/routes.tsx @@ -10,7 +10,7 @@ import AdminLayout from './AdminLayout' import Dashboard from './pages/Dashboard' import CollectionPage from './pages/Collection' import DataCompletenessPage from './pages/DataCompleteness' -import PredictionsPage from './pages/Predictions' +import PredictionsPage from './pages/PredictionHistory' import BacktestPage from './pages/Backtest' import MonitoringPage from './pages/Monitoring' import SettingsPage from './pages/Settings'