预测历史:实际比分自动填充 + 显示比赛日/队伍/主客徽标

- 实际比分自动从比赛赛果填充,无需手动输入
- 比赛列显示:比赛日(M/D) + 主客队徽标 + 队伍中文名
- 状态分三类:待赛果(灰)/可结算(蓝)/已结算(绿)
- 一键结算按钮:仅当比赛有赛果且未结算时显示
- 结算后1X2自动判断命中(绿✓/红✗)
- 顶部新增统计概览:已结算数/命中数/1X2准确率

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
)
This commit is contained in:
shangfangjian
2026-09-21 01:21:05 +08:00
parent f08fef8a22
commit 1fc799a5b0
+120 -126
View File
@@ -2,23 +2,23 @@
* Admin 后台 - 预测历史页面(报刊风) * Admin 后台 - 预测历史页面(报刊风)
* *
* 功能: * 功能:
* - 查看预测记录列表(支持联赛/状态筛选) * - 查看预测记录列表(支持状态筛选)
* - 赛后标注实际比分,供准确率评估 * - 实际比分自动从比赛赛果填充
* - 展开查看终裁理由与专家摘要 * - 赛后一键结算,供准确率评估
*/ */
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { fetchPredictions, settlePrediction, fetchMatches } from '../dal' import { fetchPredictions, settlePrediction, fetchMatches } from '../dal'
import type { Prediction, Match } from '../types' import type { Prediction, Match } from '../types'
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components' import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
import { useState as useStateRef } from 'react' import TeamSideTag from '../../components/TeamSideTag'
const OUTCOME_LABEL: Record<string, string> = { '1': '主胜', X: '平局', '2': '客胜' } const OUTCOME_LABEL: Record<string, string> = { '1': '主胜', X: '平局', '2': '客胜' }
function fmtTime(s?: string | null): string { function fmtDate(s?: string | null): string {
if (!s) return '—' if (!s) return '—'
const d = new Date(s) 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' }) return isNaN(d.getTime()) ? s : `${d.getMonth() + 1}/${d.getDate()}`
} }
export default function PredictionHistoryPage() { export default function PredictionHistoryPage() {
@@ -28,13 +28,7 @@ export default function PredictionHistoryPage() {
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [filter, setFilter] = useState<'all' | 'settled' | 'unsettled'>('all') const [filter, setFilter] = useState<'all' | 'settled' | 'unsettled'>('all')
const [expandedId, setExpandedId] = useState<number | null>(null) const [expandedId, setExpandedId] = useState<number | null>(null)
const [settlingId, setSettlingId] = useState<number | null>(null)
// 标注实际比分表单
const [editingId, setEditingId] = useState<number | null>(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 () => { const load = useCallback(async () => {
setLoading(true) setLoading(true)
@@ -42,10 +36,9 @@ export default function PredictionHistoryPage() {
try { try {
const list = await fetchPredictions(100) const list = await fetchPredictions(100)
setPredictions(list) setPredictions(list)
// 获取相关比赛信息
const matchIds = [...new Set(list.map(p => p.match_id))] const matchIds = [...new Set(list.map(p => p.match_id))]
if (matchIds.length > 0) { if (matchIds.length > 0) {
const matchesData = await fetchMatches({ limit: 200 }) const matchesData = await fetchMatches({ limit: 500 })
const matchMap = new Map<number, Match>() const matchMap = new Map<number, Match>()
matchesData.items.forEach(m => matchMap.set(m.id, m)) matchesData.items.forEach(m => matchMap.set(m.id, m))
setMatches(matchMap) setMatches(matchMap)
@@ -59,68 +52,70 @@ export default function PredictionHistoryPage() {
useEffect(() => { load() }, [load]) useEffect(() => { load() }, [load])
const handleSettle = async (p: Prediction) => {
const match = matches.get(p.match_id)
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 => { const filtered = predictions.filter(p => {
if (filter === 'settled') return p.settled if (filter === 'settled') return p.settled
if (filter === 'unsettled') return !p.settled if (filter === 'unsettled') return !p.settled
return true 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 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
<SectionHeader <SectionHeader
title="预测历史" title="预测历史"
description={`${predictions.length}预测记录${unsettledCount > 0 ? `, ${unsettledCount} 条待标注` : ''}`} description={`${predictions.length} 条记录${unsettledCount > 0 ? `, ${unsettledCount} 条待结算` : ''}`}
/> />
{saveMsg && ( {/* 统计概览 */}
<Alert kind={saveMsg.kind} title={saveMsg.text} onClose={() => setSaveMsg(null)} /> {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"> <div className="flex gap-2">
{([ {([
{ v: 'all', label: '全部' }, { v: 'all', label: '全部' },
{ v: 'unsettled', label: '待标注' }, { v: 'unsettled', label: '待结算' },
{ v: 'settled', label: '已结算' }, { v: 'settled', label: '已结算' },
] as const).map(opt => ( ] as const).map(opt => (
<button <button
@@ -153,12 +148,13 @@ export default function PredictionHistoryPage() {
<Card> <Card>
<CardBody className="px-0 sm:px-0"> <CardBody className="px-0 sm:px-0">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full min-w-[640px] text-sm"> <table className="w-full min-w-[700px] text-sm">
<thead> <thead>
<tr className="border-b border-ink-200 text-left text-ink-500"> <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"></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">1X2</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"></th>
@@ -166,96 +162,82 @@ export default function PredictionHistoryPage() {
</thead> </thead>
<tbody> <tbody>
{filtered.map(p => { {filtered.map(p => {
const match = matches.get(p.match_id)
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 isExpanded = expandedId === p.id
const isEditing = editingId === p.id const predHit = p.settled && hasActual
const matchInfo = matches.get(p.match_id) ? (actualHome! > actualAway! ? '1' : actualHome! < actualAway! ? '2' : 'X') === p.pred_1x2
const homeName = matchInfo?.home_team_zh || matchInfo?.home_team || `比赛 #${p.match_id}` : null
const awayName = matchInfo?.away_team_zh || matchInfo?.away_team || '?'
return ( return (
<tr key={p.id} className="border-b border-ink-100 hover:bg-paper-100"> <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"> <td className="px-4 py-3">
<div className="text-xs text-ink-800">{homeName} vs {awayName}</div> <div className="flex items-center gap-1 text-xs text-ink-800">
<div className="text-2xs text-ink-400">{fmtTime(p.created_at)}</div> <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>
<td className="px-4 py-3 font-serif font-bold text-ink-900"> <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 != null && p.pred_away_goals != null
? `${p.pred_home_goals} : ${p.pred_away_goals}` ? `${p.pred_home_goals} : ${p.pred_away_goals}` : '—'}
: '—'}
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
{isEditing ? ( {hasActual ? (
<div className="flex items-center gap-1"> <span className={`font-serif font-bold ${p.settled ? 'text-ink-900' : 'text-ink-400'}`}>
<input {actualHome} : {actualAway}
type="number"
min={0}
max={20}
value={homeGoals}
onChange={e => setHomeGoals(e.target.value)}
className="field w-12 px-1 py-0.5 text-center text-xs"
/>
<span className="text-xs text-ink-400">:</span>
<input
type="number"
min={0}
max={20}
value={awayGoals}
onChange={e => setAwayGoals(e.target.value)}
className="field w-12 px-1 py-0.5 text-center text-xs"
/>
</div>
) : (
<span className={`font-serif ${p.settled ? 'font-bold text-ink-900' : 'text-ink-400'}`}>
{p.actual_home_goals != null && p.actual_away_goals != null
? `${p.actual_home_goals} : ${p.actual_away_goals}`
: '—'}
</span> </span>
) : (
<span className="text-2xs text-ink-300"></span>
)} )}
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
{p.pred_1x2 ? ( {p.pred_1x2 ? (
<span className={`inline-block border px-1.5 py-0.5 text-2xs ${ <span className={`inline-block border px-1.5 py-0.5 text-2xs ${
p.settled && p.actual_home_goals != null && p.actual_away_goals != null predHit === true ? 'border-emerald-300 text-emerald-700 bg-emerald-50' :
? (() => { predHit === false ? 'border-press text-press bg-press-wash' :
const actual = p.actual_home_goals > p.actual_away_goals ? '1' : p.actual_home_goals < p.actual_away_goals ? '2' : 'X' 'border-ink-200 text-ink-600'
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} {OUTCOME_LABEL[p.pred_1x2] ?? p.pred_1x2}
{predHit === true && ' ✓'}
{predHit === false && ' ✗'}
</span> </span>
) : '—'} ) : '—'}
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
{p.settled ? ( {p.settled ? (
<Badge status="success"></Badge> <Badge status="success"></Badge>
) : hasActual ? (
<Badge status="info"></Badge>
) : ( ) : (
<Badge status="warning"></Badge> <Badge status="warning"></Badge>
)} )}
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex gap-2"> <div className="flex gap-2">
{isEditing ? ( {!p.settled && hasActual && (
<> <button
<button onClick={saveScore} disabled={saving} className="btn btn-sm btn-solid"> onClick={() => handleSettle(p)}
{saving ? <Spinner /> : '保存'} disabled={settlingId === p.id}
</button> className="btn btn-sm btn-solid"
<button onClick={cancelEdit} className="btn btn-sm"></button> >
</> {settlingId === p.id ? <Spinner /> : '结算'}
) : ( </button>
<>
{!p.settled && (
<button onClick={() => startEdit(p)} className="btn btn-sm">
</button>
)}
<button
onClick={() => setExpandedId(isExpanded ? null : p.id)}
className="btn btn-sm"
>
{isExpanded ? '收起' : '详情'}
</button>
</>
)} )}
<button
onClick={() => setExpandedId(isExpanded ? null : p.id)}
className="btn btn-sm"
>
{isExpanded ? '收起' : '详情'}
</button>
</div> </div>
</td> </td>
</tr> </tr>
@@ -272,11 +254,22 @@ export default function PredictionHistoryPage() {
{expandedId && (() => { {expandedId && (() => {
const p = predictions.find(pr => pr.id === expandedId) const p = predictions.find(pr => pr.id === expandedId)
if (!p) return null if (!p) return null
const match = matches.get(p.match_id)
const reports = p.agent_outputs ?? [] const reports = p.agent_outputs ?? []
return ( return (
<Card> <Card>
<CardHeader title="预测详情" /> <CardHeader title="预测详情" />
<CardBody className="space-y-4"> <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="grid gap-4 sm:grid-cols-3">
<div className="border-t-2 border-ink-900 pt-3"> <div className="border-t-2 border-ink-900 pt-3">
<p className="text-2xs text-ink-400"></p> <p className="text-2xs text-ink-400"></p>
@@ -286,15 +279,16 @@ export default function PredictionHistoryPage() {
</p> </p>
</div> </div>
<div className="border-t-2 border-ink-900 pt-3"> <div className="border-t-2 border-ink-900 pt-3">
<p className="text-2xs text-ink-400"></p> <p className="text-2xs text-ink-400"></p>
<p className="mt-1 font-serif text-xl font-bold text-ink-900"> <p className="mt-1 font-serif text-xl font-bold text-ink-900">
{p.subjective_confidence != null ? `${(p.subjective_confidence * 100).toFixed(0)}%` : '—'} {match?.home_goals != null && match?.away_goals != null
? `${match.home_goals} : ${match.away_goals}` : '—'}
</p> </p>
</div> </div>
<div className="border-t-2 border-ink-900 pt-3"> <div className="border-t-2 border-ink-900 pt-3">
<p className="text-2xs text-ink-400">1X2</p> <p className="text-2xs text-ink-400"></p>
<p className="mt-1 font-serif text-xl font-bold text-ink-900"> <p className="mt-1 font-serif text-xl font-bold text-ink-900">
{OUTCOME_LABEL[p.pred_1x2 ?? ''] ?? '—'} {p.subjective_confidence != null ? `${(p.subjective_confidence * 100).toFixed(0)}%` : '—'}
</p> </p>
</div> </div>
</div> </div>
@@ -312,7 +306,7 @@ export default function PredictionHistoryPage() {
<div> <div>
<h4 className="section-head mb-2"></h4> <h4 className="section-head mb-2"></h4>
<div className="space-y-2"> <div className="space-y-2">
{reports.map((r, i) => ( {reports.map((r) => (
<div key={r.agent} className="border-b border-ink-100 pb-2 last:border-b-0"> <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="text-xs font-medium text-ink-700">{r.agent}</p>
<p className="mt-0.5 text-xs text-ink-500">{r.analysis || '—'}</p> <p className="mt-0.5 text-xs text-ink-500">{r.analysis || '—'}</p>