预测管理改为预测历史页面

- 删除 Predictions.tsx,新建 PredictionHistory.tsx
- 不再支持触发预测(从主站比赛列表操作)
- 支持查看所有预测记录(全部/待标注/已结算筛选)
- 支持标注实际比分:行内编辑 + 保存,自动判断1X2是否命中
- 展开查看详情:预测比分/置信度/终裁意见/专家意见
- 侧边栏更名为「预测历史」,图标改为 logs

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
)
This commit is contained in:
shangfangjian
2026-09-21 01:17:14 +08:00
parent 857a21910b
commit f08fef8a22
4 changed files with 334 additions and 382 deletions
@@ -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<string, string> = { '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<Prediction[]>([])
const [matches, setMatches] = useState<Map<number, Match>>(new Map())
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 [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 () => {
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<number, Match>()
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 (
<div className="space-y-6">
<SectionHeader
title="预测历史"
description={`${predictions.length} 条预测记录${unsettledCount > 0 ? `, ${unsettledCount} 条待标注` : ''}`}
/>
{saveMsg && (
<Alert kind={saveMsg.kind} title={saveMsg.text} onClose={() => setSaveMsg(null)} />
)}
{/* 筛选 */}
<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-[640px] 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">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 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 (
<tr key={p.id} className="border-b border-ink-100 hover:bg-paper-100">
<td className="px-4 py-3">
<div className="text-xs text-ink-800">{homeName} vs {awayName}</div>
<div className="text-2xs text-ink-400">{fmtTime(p.created_at)}</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">
{isEditing ? (
<div className="flex items-center gap-1">
<input
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>
)}
</td>
<td className="px-4 py-3">
{p.pred_1x2 ? (
<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
? (() => {
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}
</span>
) : '—'}
</td>
<td className="px-4 py-3">
{p.settled ? (
<Badge status="success"></Badge>
) : (
<Badge status="warning"></Badge>
)}
</td>
<td className="px-4 py-3">
<div className="flex gap-2">
{isEditing ? (
<>
<button onClick={saveScore} disabled={saving} className="btn btn-sm btn-solid">
{saving ? <Spinner /> : '保存'}
</button>
<button onClick={cancelEdit} className="btn btn-sm"></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>
</>
)}
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</CardBody>
</Card>
)}
{/* 展开详情 */}
{expandedId && (() => {
const p = predictions.find(pr => pr.id === expandedId)
if (!p) return null
const reports = p.agent_outputs ?? []
return (
<Card>
<CardHeader title="预测详情" />
<CardBody className="space-y-4">
<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">
{p.subjective_confidence != null ? `${(p.subjective_confidence * 100).toFixed(0)}%` : '—'}
</p>
</div>
<div className="border-t-2 border-ink-900 pt-3">
<p className="text-2xs text-ink-400">1X2</p>
<p className="mt-1 font-serif text-xl font-bold text-ink-900">
{OUTCOME_LABEL[p.pred_1x2 ?? ''] ?? '—'}
</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, i) => (
<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>
)
}