Files
Profeto/frontend/src/admin/pages/PredictionHistory.tsx
T
shangfangjian 0a6af0657a 预测历史:比赛信息内嵌到预测API + 实际比分自动填充
后端:
- PredictionOut 新增 match 字段(内嵌比赛信息)
- list_predictions 返回完整比赛数据(日期/队名/赛果/主客徽标)
- 新增 _match_dict() 辅助函数序列化比赛对象

前端:
- 移除独立的 fetchMatches 调用,直接使用 p.match
- 实际比分自动从比赛赛果填充
- 显示:比赛日(M/D) + 主客队徽标 + 队伍中文名
- 赛后一键结算(使用比赛实际赛果)

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
)
2026-09-21 01:54:30 +08:00

317 lines
13 KiB
TypeScript

/**
* Admin 后台 - 预测历史页面(报刊风)
*
* 功能:
* - 查看预测记录列表(支持状态筛选)
* - 实际比分自动从比赛赛果填充
* - 赛后一键结算,供准确率评估
*/
import { useCallback, useEffect, useState } from 'react'
import { fetchPredictions, settlePrediction } from '../dal'
import type { Prediction } from '../types'
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
import TeamSideTag from '../../components/TeamSideTag'
const OUTCOME_LABEL: Record<string, string> = { '1': '主胜', X: '平局', '2': '客胜' }
function fmtDate(s?: string | null): string {
if (!s) return '—'
const d = new Date(s)
return isNaN(d.getTime()) ? s : `${d.getMonth() + 1}/${d.getDate()}`
}
export default function PredictionHistoryPage() {
const [predictions, setPredictions] = useState<Prediction[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [filter, setFilter] = useState<'all' | 'settled' | 'unsettled'>('all')
const [expandedId, setExpandedId] = useState<number | null>(null)
const [settlingId, setSettlingId] = useState<number | null>(null)
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const list = await fetchPredictions(200)
setPredictions(list)
} catch (err) {
setError(err instanceof Error ? err.message : '加载失败')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
const handleSettle = async (p: Prediction) => {
const match = (p as any).match
if (!match || match.home_goals == null || match.away_goals == null) return
setSettlingId(p.id)
try {
await settlePrediction(p.id, match.home_goals, match.away_goals)
await load()
} catch (err) {
console.error('结算失败:', err)
} finally {
setSettlingId(null)
}
}
const filtered = predictions.filter(p => {
if (filter === 'settled') return p.settled
if (filter === 'unsettled') return !p.settled
return true
})
const unsettledCount = predictions.filter(p => !p.settled).length
const settledWithScore = predictions.filter(p => p.settled && p.actual_home_goals != null && p.actual_away_goals != null)
const hitCount = settledWithScore.filter(p => {
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
}).length
const accuracy = settledWithScore.length > 0 ? Math.round(hitCount / settledWithScore.length * 100) : 0
return (
<div className="space-y-6">
<SectionHeader
title="预测历史"
description={`共 ${predictions.length} 条记录${unsettledCount > 0 ? `, ${unsettledCount} 条待结算` : ''}`}
/>
{/* 统计概览 */}
{settledWithScore.length > 0 && (
<div className="grid gap-4 sm:grid-cols-3">
<Card>
<CardBody className="text-center">
<p className="text-2xl font-bold text-ink-900">{settledWithScore.length}</p>
<p className="text-xs text-ink-500">已结算</p>
</CardBody>
</Card>
<Card>
<CardBody className="text-center">
<p className="text-2xl font-bold text-emerald-700">{hitCount}</p>
<p className="text-xs text-ink-500">命中</p>
</CardBody>
</Card>
<Card>
<CardBody className="text-center">
<p className="text-2xl font-bold text-press">{accuracy}%</p>
<p className="text-xs text-ink-500">1X2 准确率</p>
</CardBody>
</Card>
</div>
)}
{/* 筛选 */}
<div className="flex gap-2">
{([
{ v: 'all', label: '全部' },
{ v: 'unsettled', label: '待结算' },
{ v: 'settled', label: '已结算' },
] as const).map(opt => (
<button
key={opt.v}
onClick={() => setFilter(opt.v)}
className={`btn btn-sm ${filter === opt.v ? 'btn-solid' : ''}`}
>
{opt.label}
</button>
))}
</div>
{loading && (
<div className="flex justify-center py-12"><Spinner /></div>
)}
{error && (
<Alert kind="error" title="加载失败" message={error} onClose={() => setError(null)} />
)}
{!loading && !error && filtered.length === 0 && (
<div className="empty-state">
<p className="empty-state-title">暂无预测记录</p>
<p className="empty-state-sub">在主站比赛列表点击「预测」按钮生成预测记录</p>
</div>
)}
{/* 预测列表 */}
{!loading && !error && filtered.length > 0 && (
<Card>
<CardBody className="px-0 sm:px-0">
<div className="overflow-x-auto">
<table className="w-full min-w-[700px] text-sm">
<thead>
<tr className="border-b border-ink-200 text-left text-ink-500">
<th className="px-4 py-2 font-medium">比赛日</th>
<th className="px-4 py-2 font-medium">比赛</th>
<th className="px-4 py-2 font-medium">预测</th>
<th className="px-4 py-2 font-medium">实际</th>
<th className="px-4 py-2 font-medium">1X2</th>
<th className="px-4 py-2 font-medium">状态</th>
<th className="px-4 py-2 font-medium">操作</th>
</tr>
</thead>
<tbody>
{filtered.map(p => {
const match = (p as any).match
const matchDate = match?.match_date
const homeName = match?.home_team_zh || match?.home_team || '?'
const awayName = match?.away_team_zh || match?.away_team || '?'
const actualHome = match?.home_goals
const actualAway = match?.away_goals
const hasActual = actualHome != null && actualAway != null
const isExpanded = expandedId === p.id
const predHit = p.settled && hasActual
? (actualHome! > actualAway! ? '1' : actualHome! < actualAway! ? '2' : 'X') === p.pred_1x2
: null
return (
<tr key={p.id} className="border-b border-ink-100 hover:bg-paper-100">
<td className="px-4 py-3 text-2xs text-ink-400">{fmtDate(matchDate)}</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1 text-xs text-ink-800">
<TeamSideTag side="home" />
<span className="truncate max-w-[100px]">{homeName}</span>
</div>
<div className="flex items-center gap-1 text-xs text-ink-600 mt-0.5">
<TeamSideTag side="away" />
<span className="truncate max-w-[100px]">{awayName}</span>
</div>
</td>
<td className="px-4 py-3 font-serif font-bold text-ink-900">
{p.pred_home_goals != null && p.pred_away_goals != null
? `${p.pred_home_goals} : ${p.pred_away_goals}` : '—'}
</td>
<td className="px-4 py-3">
{hasActual ? (
<span className={`font-serif font-bold ${p.settled ? 'text-ink-900' : 'text-ink-400'}`}>
{actualHome} : {actualAway}
</span>
) : (
<span className="text-2xs text-ink-300">暂无赛果</span>
)}
</td>
<td className="px-4 py-3">
{p.pred_1x2 ? (
<span className={`inline-block border px-1.5 py-0.5 text-2xs ${
predHit === true ? 'border-emerald-300 text-emerald-700 bg-emerald-50' :
predHit === false ? 'border-press text-press bg-press-wash' :
'border-ink-200 text-ink-600'
}`}>
{OUTCOME_LABEL[p.pred_1x2] ?? p.pred_1x2}
{predHit === true && ' ✓'}
{predHit === false && ' ✗'}
</span>
) : '—'}
</td>
<td className="px-4 py-3">
{p.settled ? (
<Badge status="success">已结算</Badge>
) : hasActual ? (
<Badge status="info">可结算</Badge>
) : (
<Badge status="warning">待赛果</Badge>
)}
</td>
<td className="px-4 py-3">
<div className="flex gap-2">
{!p.settled && hasActual && (
<button
onClick={() => handleSettle(p)}
disabled={settlingId === p.id}
className="btn btn-sm btn-solid"
>
{settlingId === p.id ? <Spinner /> : '结算'}
</button>
)}
<button
onClick={() => setExpandedId(isExpanded ? null : p.id)}
className="btn btn-sm"
>
{isExpanded ? '收起' : '详情'}
</button>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</CardBody>
</Card>
)}
{/* 展开详情 */}
{expandedId && (() => {
const p = predictions.find(pr => pr.id === expandedId)
if (!p) return null
const match = (p as any).match
const reports = p.agent_outputs ?? []
return (
<Card>
<CardHeader title="预测详情" />
<CardBody className="space-y-4">
<div className="flex items-center gap-2 text-sm text-ink-700">
<span>{fmtDate(match?.match_date)}</span>
<span className="text-ink-300">|</span>
<TeamSideTag side="home" />
<span>{match?.home_team_zh || match?.home_team || '?'}</span>
<span className="text-ink-400">vs</span>
<TeamSideTag side="away" />
<span>{match?.away_team_zh || match?.away_team || '?'}</span>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<div className="border-t-2 border-ink-900 pt-3">
<p className="text-2xs text-ink-400">预测比分</p>
<p className="mt-1 font-serif text-xl font-bold text-ink-900">
{p.pred_home_goals != null && p.pred_away_goals != null
? `${p.pred_home_goals} : ${p.pred_away_goals}` : '—'}
</p>
</div>
<div className="border-t-2 border-ink-900 pt-3">
<p className="text-2xs text-ink-400">实际比分</p>
<p className="mt-1 font-serif text-xl font-bold text-ink-900">
{match?.home_goals != null && match?.away_goals != null
? `${match.home_goals} : ${match.away_goals}` : '—'}
</p>
</div>
<div className="border-t-2 border-ink-900 pt-3">
<p className="text-2xs text-ink-400">置信度</p>
<p className="mt-1 font-serif text-xl font-bold text-ink-900">
{p.subjective_confidence != null ? `${(p.subjective_confidence * 100).toFixed(0)}%` : '—'}
</p>
</div>
</div>
{p.reasoning && (
<div>
<h4 className="section-head mb-2">终裁意见</h4>
<blockquote className="border-l-2 border-press pl-4">
<p className="whitespace-pre-wrap font-serif text-sm leading-loose text-ink-700">{p.reasoning}</p>
</blockquote>
</div>
)}
{reports.length > 0 && (
<div>
<h4 className="section-head mb-2">专家意见</h4>
<div className="space-y-2">
{reports.map((r) => (
<div key={r.agent} className="border-b border-ink-100 pb-2 last:border-b-0">
<p className="text-xs font-medium text-ink-700">{r.agent}</p>
<p className="mt-0.5 text-xs text-ink-500">{r.analysis || '—'}</p>
</div>
))}
</div>
</div>
)}
</CardBody>
</Card>
)
})()}
</div>
)
}