预测管理改为预测历史页面
- 删除 Predictions.tsx,新建 PredictionHistory.tsx - 不再支持触发预测(从主站比赛列表操作) - 支持查看所有预测记录(全部/待标注/已结算筛选) - 支持标注实际比分:行内编辑 + 保存,自动判断1X2是否命中 - 展开查看详情:预测比分/置信度/终裁意见/专家意见 - 侧边栏更名为「预测历史」,图标改为 logs Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>> )
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user