diff --git a/frontend/src/admin/components.tsx b/frontend/src/admin/components.tsx index 002e7f7..2be2f87 100644 --- a/frontend/src/admin/components.tsx +++ b/frontend/src/admin/components.tsx @@ -396,3 +396,43 @@ export function Spinner({ className = '' }: { className?: string }) { export function SkeletonBlock({ className = '' }: { className?: string }) { return
} + + +/** 空状态文本 */ +export function EmptyText({ text }: { text: string }) { + return ( +
+ {text} +
+ ) +} + +/** Agent 权重条形图 */ +export function AgentWeightsBar({ weights, okCount }: { weights: Record; okCount: number }) { + const entries = Object.entries(weights).filter(([, w]) => w > 0) + if (entries.length === 0) return null + const total = entries.reduce((s, [, w]) => s + w, 0) || 1 + const colors = ['bg-ink-900', 'bg-ink-700', 'bg-ink-500', 'bg-press', 'bg-ink-300'] + return ( +
+
终裁专家权重
+
+ {entries.map(([k, w], i) => ( +
+
+
+
+ + {Math.round((w / total) * 100)}% + + {k} +
+ ))} +
+
有效专家:{okCount}/{entries.length}
+
+ ) +} diff --git a/frontend/src/admin/pages/EvalPage.tsx b/frontend/src/admin/pages/EvalPage.tsx index 812b845..220c2bf 100644 --- a/frontend/src/admin/pages/EvalPage.tsx +++ b/frontend/src/admin/pages/EvalPage.tsx @@ -10,7 +10,7 @@ import { useCallback, useEffect, useState } from 'react' import { fetchEvalSummary, fetchLeagues } from '../dal' import type { EvalSummary } from '../types' -import { Card, CardBody, CardHeader, StatCard, Badge, DataTable, Alert, Spinner, EmptyState } from '../components' +import { Card, CardBody, CardHeader, StatCard, Badge, DataTable, Alert, Spinner, EmptyText } from '../components' interface Filters { provider: string diff --git a/frontend/src/admin/pages/Predictions.tsx b/frontend/src/admin/pages/Predictions.tsx new file mode 100644 index 0000000..1851ba1 --- /dev/null +++ b/frontend/src/admin/pages/Predictions.tsx @@ -0,0 +1,363 @@ +/** + * 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' +import { AgentWeightsBar } from '../components' + +const AGENT_LABELS: Record = { + h2h: '历史交锋分析专家', + form: '近期状态分析专家', + stats: '攻防数据分析专家', + home_away: '主客因素分析专家', + injuries: '阵容完整性分析专家', +} + +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, setMode] = useState<'single' | 'multi'>('multi') + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [successMsg, setSuccessMsg] = useState(null) + + // 结算表单 + 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({ limit: 100 }).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}` + + 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 ( +
+ + +
+ {/* 新建预测 */} + + + +
+
+ + +
+ +
+ + +
+ + {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 ? ( + 已结算 + ) : ( + 未结算 + )} + {p.status === 'degraded' && ( + 降级·仅供参考 + )} + {p.status === 'failed' && ( + 预测失败 + )} + {fmtTime(p.created_at)} + + + + +
+

+ {p.mode === 'multi' ? `多专家 · ${okAgents.length}/${p.agent_outputs?.length ?? 0} 路有效` : '单次模式'} + {p.model && {p.model}} +

+ + {p.reasoning && ( +
+

+ {p.reasoning} +

+
+ )} + + {p.agent_weights && Object.keys(p.agent_weights).length > 0 && ( + + )} + + {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/types.ts b/frontend/src/admin/types.ts index fe88226..580d08e 100644 --- a/frontend/src/admin/types.ts +++ b/frontend/src/admin/types.ts @@ -74,6 +74,8 @@ export interface Prediction { subjective_confidence?: number | null reasoning?: string | null agent_outputs?: PredictionAgentOutput[] | null + agent_weights?: Record | null + status?: 'success' | 'failed' | 'degraded' created_at: string actual_home_goals?: number | null actual_away_goals?: number | null