预测管理改为预测历史页面
- 删除 Predictions.tsx,新建 PredictionHistory.tsx - 不再支持触发预测(从主站比赛列表操作) - 支持查看所有预测记录(全部/待标注/已结算筛选) - 支持标注实际比分:行内编辑 + 保存,自动判断1X2是否命中 - 展开查看详情:预测比分/置信度/终裁意见/专家意见 - 侧边栏更名为「预测历史」,图标改为 logs Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>> )
This commit is contained in:
@@ -92,7 +92,7 @@ const NAV_PAGES: Array<{ to: string; label: string; group: string }> = [
|
|||||||
{ to: '/admin', label: '仪表盘', group: '概览' },
|
{ to: '/admin', label: '仪表盘', group: '概览' },
|
||||||
{ to: '/admin/collection', label: '数据采集', group: '数据流水线' },
|
{ to: '/admin/collection', label: '数据采集', group: '数据流水线' },
|
||||||
{ to: '/admin/data-completeness', label: '数据完整性', group: '数据流水线' },
|
{ to: '/admin/data-completeness', label: '数据完整性', group: '数据流水线' },
|
||||||
{ to: '/admin/predictions', label: '预测管理', group: '数据流水线' },
|
{ to: '/admin/predictions', label: '预测历史', group: '数据流水线' },
|
||||||
{ to: '/admin/backtest', label: '回测', group: '数据流水线' },
|
{ to: '/admin/backtest', label: '回测', group: '数据流水线' },
|
||||||
{ to: '/admin/monitoring', label: '监控', group: '评估与监控' },
|
{ to: '/admin/monitoring', label: '监控', group: '评估与监控' },
|
||||||
{ to: '/admin/eval', label: '评估', group: '评估与监控' },
|
{ to: '/admin/eval', label: '评估', group: '评估与监控' },
|
||||||
@@ -105,7 +105,7 @@ const ROUTE_LABELS: Record<string, string> = {
|
|||||||
'/admin': '仪表盘',
|
'/admin': '仪表盘',
|
||||||
'/admin/collection': '数据采集',
|
'/admin/collection': '数据采集',
|
||||||
'/admin/data-completeness': '数据完整性',
|
'/admin/data-completeness': '数据完整性',
|
||||||
'/admin/predictions': '预测管理',
|
'/admin/predictions': '预测历史',
|
||||||
'/admin/backtest': '回测',
|
'/admin/backtest': '回测',
|
||||||
'/admin/monitoring': '监控',
|
'/admin/monitoring': '监控',
|
||||||
'/admin/settings': '设置',
|
'/admin/settings': '设置',
|
||||||
@@ -119,7 +119,7 @@ const NAV_SECTIONS: { title: string; items: Array<{ to: string; label: string; i
|
|||||||
items: [
|
items: [
|
||||||
{ to: '/admin/collection', label: '数据采集', icon: 'collection' },
|
{ to: '/admin/collection', label: '数据采集', icon: 'collection' },
|
||||||
{ to: '/admin/data-completeness', label: '数据完整性', icon: 'chart' },
|
{ to: '/admin/data-completeness', label: '数据完整性', icon: 'chart' },
|
||||||
{ to: '/admin/predictions', label: '预测管理', icon: 'target' },
|
{ to: '/admin/predictions', label: '预测历史', icon: 'logs' },
|
||||||
{ to: '/admin/backtest', label: '回测', icon: 'repeat' },
|
{ to: '/admin/backtest', label: '回测', icon: 'repeat' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,378 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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'
|
|
||||||
|
|
||||||
const AGENT_LABELS: Record<string, string> = {
|
|
||||||
h2h: '历史交锋分析专家',
|
|
||||||
form: '近期状态分析专家',
|
|
||||||
stats: '攻防数据分析专家',
|
|
||||||
home_away: '主客因素分析专家',
|
|
||||||
standings: '联赛排名分析专家',
|
|
||||||
}
|
|
||||||
|
|
||||||
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 PredictionsPage() {
|
|
||||||
const [matches, setMatches] = useState<Match[]>([])
|
|
||||||
const [predictions, setPredictions] = useState<Prediction[]>([])
|
|
||||||
const [matchId, setMatchId] = useState('')
|
|
||||||
const [mode] = useState<'multi'>('multi')
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const [successMsg, setSuccessMsg] = useState<string | null>(null)
|
|
||||||
const [leagueFilter, setLeagueFilter] = useState('') // 联赛筛选
|
|
||||||
|
|
||||||
// 结算表单
|
|
||||||
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({ status: 'scheduled', limit: 200 }).then(d => setMatches(d.items))
|
|
||||||
}, [refreshPredictions])
|
|
||||||
|
|
||||||
/** match_id → 中文名对阵 */
|
|
||||||
const matchName = useMemo(() => {
|
|
||||||
const map = new Map<number, string>()
|
|
||||||
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}`
|
|
||||||
|
|
||||||
// 联赛列表(从比赛中提取) + 筛选后的比赛
|
|
||||||
const leagues = useMemo(() => {
|
|
||||||
const set = new Set(matches.map(m => m.league_code).filter(Boolean))
|
|
||||||
return [...set].sort()
|
|
||||||
}, [matches])
|
|
||||||
const filteredMatches = leagueFilter
|
|
||||||
? matches.filter(m => m.league_code === leagueFilter)
|
|
||||||
: matches
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<SectionHeader
|
|
||||||
title="预测管理"
|
|
||||||
description="触发 LLM 预测;赛后录入实际比分完成结算,供准确率统计使用。"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
|
||||||
{/* 新建预测 */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader title="新建预测" />
|
|
||||||
<CardBody>
|
|
||||||
<form onSubmit={handlePredict} className="space-y-4">
|
|
||||||
<div className="space-y-3">
|
|
||||||
{leagues.length > 1 && (
|
|
||||||
<div>
|
|
||||||
<label className="mb-1.5 block text-xs text-ink-500">联赛</label>
|
|
||||||
<select
|
|
||||||
value={leagueFilter}
|
|
||||||
onChange={e => { setLeagueFilter(e.target.value); setMatchId('') }}
|
|
||||||
className="field w-full"
|
|
||||||
>
|
|
||||||
<option value="">全部联赛</option>
|
|
||||||
{leagues.map(code => (
|
|
||||||
<option key={code} value={code}>{code}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div>
|
|
||||||
<label className="mb-1.5 block text-xs text-ink-500">比赛 <span className="text-ink-400">({filteredMatches.length} 场未开赛)</span></label>
|
|
||||||
<select
|
|
||||||
value={matchId}
|
|
||||||
onChange={e => setMatchId(e.target.value)}
|
|
||||||
className="field w-full"
|
|
||||||
>
|
|
||||||
<option value="">选择比赛</option>
|
|
||||||
{filteredMatches.map(m => (
|
|
||||||
<option key={m.id} value={m.id}>
|
|
||||||
{teamSidePrefix('home')}{(m.home_team_zh || m.home_team)} vs {teamSidePrefix('away')}{(m.away_team_zh || m.away_team)}
|
|
||||||
({m.match_date ? new Date(m.match_date).toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' }) : '—'})
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="mb-1.5 block text-xs text-ink-500">模式</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value="多专家 (5 路 + 终裁)"
|
|
||||||
readOnly
|
|
||||||
className="field w-full bg-paper-100 text-ink-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <Alert kind="error" title="预测失败" message={error} onClose={() => setError(null)} />}
|
|
||||||
{successMsg && (
|
|
||||||
<Alert kind="ok" title={successMsg} onClose={() => setSuccessMsg(null)} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={loading || !matchId}
|
|
||||||
className="btn btn-solid w-full"
|
|
||||||
>
|
|
||||||
{loading ? (<><Spinner /> 预测中,多专家模式约需 20-60 秒</>) : '触发预测'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 结算 */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader
|
|
||||||
title="预测结算"
|
|
||||||
description="录入实际比分,系统据此统计 1X2 准确率与比分 RMSE"
|
|
||||||
/>
|
|
||||||
<CardBody>
|
|
||||||
{unsettled.length === 0 ? (
|
|
||||||
<p className="py-6 text-center text-xs text-ink-400">
|
|
||||||
没有待结算的预测记录。预测完成后可在此录入实际比分。
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<form onSubmit={handleSettle} className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="mb-1.5 block text-xs text-ink-500">预测记录</label>
|
|
||||||
<select
|
|
||||||
value={settleId}
|
|
||||||
onChange={e => setSettleId(e.target.value)}
|
|
||||||
className="field w-full"
|
|
||||||
>
|
|
||||||
<option value="">选择待结算预测({unsettled.length} 条)</option>
|
|
||||||
{unsettled.map(p => (
|
|
||||||
<option key={p.id} value={p.id}>
|
|
||||||
#{p.id} {nameOf(p.match_id)} · 预测 {p.pred_home_goals ?? '-'}:{p.pred_away_goals ?? '-'}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{settleTarget && (
|
|
||||||
<p className="border-l-2 border-ink-300 pl-3 text-2xs text-ink-500">
|
|
||||||
预测:{settleTarget.pred_home_goals ?? '-'} : {settleTarget.pred_away_goals ?? '-'}
|
|
||||||
({OUTCOME_LABEL[settleTarget.pred_1x2 ?? ''] ?? '?'})
|
|
||||||
<span className="ml-2">{fmtTime(settleTarget.created_at)}</span>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<label className="mb-1.5 block text-xs text-ink-500">主队实际进球</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
max={30}
|
|
||||||
value={homeGoals}
|
|
||||||
onChange={e => setHomeGoals(e.target.value)}
|
|
||||||
className="field w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="mb-1.5 block text-xs text-ink-500">客队实际进球</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
max={30}
|
|
||||||
value={awayGoals}
|
|
||||||
onChange={e => setAwayGoals(e.target.value)}
|
|
||||||
className="field w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{settleMsg && (
|
|
||||||
<Alert
|
|
||||||
kind={settleMsg.kind}
|
|
||||||
title={settleMsg.kind === 'ok' ? '结算完成' : '结算失败'}
|
|
||||||
message={settleMsg.kind === 'error' ? settleMsg.text : undefined}
|
|
||||||
onClose={() => setSettleMsg(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={settling || !settleId || homeGoals === '' || awayGoals === ''}
|
|
||||||
className="btn btn-solid w-full"
|
|
||||||
>
|
|
||||||
{settling ? (<><Spinner /> 结算中</>) : '提交结算'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 最近预测 */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader title="预测记录" description="点击行可展开终裁理由与专家摘要" />
|
|
||||||
<CardBody className="px-0 sm:px-0">
|
|
||||||
{predictions.length === 0 ? (
|
|
||||||
<p className="py-10 text-center text-xs text-ink-400">
|
|
||||||
暂无预测记录,触发预测后将在此显示
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div>
|
|
||||||
{predictions.map(p => {
|
|
||||||
const okAgents = (p.agent_outputs ?? []).filter(a => a.status === 'ok')
|
|
||||||
return (
|
|
||||||
<details key={p.id} className="group border-b border-ink-200 last:border-b-0">
|
|
||||||
<summary className="flex cursor-pointer list-none flex-wrap items-baseline gap-x-3 gap-y-1 px-4 py-3 transition-colors hover:bg-paper-100 sm:px-5">
|
|
||||||
<span className="text-2xs tabular-nums text-ink-400">#{p.id}</span>
|
|
||||||
<span className="text-sm font-medium text-ink-900">{nameOf(p.match_id)}</span>
|
|
||||||
<span className="font-serif text-sm font-bold tabular-nums text-ink-900">
|
|
||||||
{p.pred_home_goals ?? '-'}<span className="mx-0.5 font-normal text-ink-300">:</span>{p.pred_away_goals ?? '-'}
|
|
||||||
</span>
|
|
||||||
<span className="text-2xs text-ink-500">
|
|
||||||
{OUTCOME_LABEL[p.pred_1x2 ?? ''] ?? '—'}
|
|
||||||
{p.subjective_confidence !== null && p.subjective_confidence !== undefined &&
|
|
||||||
` · ${Math.round(p.subjective_confidence * 100)}%`}
|
|
||||||
</span>
|
|
||||||
<span className="ml-auto flex items-baseline gap-3">
|
|
||||||
{p.settled ? (
|
|
||||||
<Badge status="success">已结算</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge status="pending">未结算</Badge>
|
|
||||||
)}
|
|
||||||
<span className="text-2xs tabular-nums text-ink-400">{fmtTime(p.created_at)}</span>
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
className="h-3 w-3 self-center text-ink-300 transition-transform group-open:rotate-90"
|
|
||||||
fill="currentColor"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path d="M7.3 5.3a1 1 0 011.4 0l4 4a1 1 0 010 1.4l-4 4a1 1 0 01-1.4-1.4L10.6 10 7.3 6.7a1 1 0 010-1.4z" />
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
</summary>
|
|
||||||
|
|
||||||
<div className="space-y-3 px-4 pb-4 pl-8 sm:px-6 sm:pl-9">
|
|
||||||
<p className="text-2xs text-ink-500">
|
|
||||||
`多专家 · ${okAgents.length}/${p.agent_outputs?.length ?? 0} 路有效`
|
|
||||||
{p.model && <span className="ml-2 font-mono">{p.model}</span>}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{p.reasoning && (
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{p.agent_outputs && p.agent_outputs.length > 0 && (
|
|
||||||
<ul className="space-y-1">
|
|
||||||
{p.agent_outputs.map((a, i) => (
|
|
||||||
<li key={i} className="flex items-baseline gap-2.5 text-xs">
|
|
||||||
<span className={`inline-block h-1.5 w-1.5 flex-shrink-0 self-center ${a.status === 'ok' ? 'bg-ink-900' : 'bg-ink-300'}`} aria-hidden="true" />
|
|
||||||
<span className="text-ink-800">{AGENT_LABELS[a.agent] ?? a.agent}</span>
|
|
||||||
{a.probable_score && (
|
|
||||||
<span className="font-serif font-bold tabular-nums text-ink-800">{a.probable_score}</span>
|
|
||||||
)}
|
|
||||||
<span className="text-2xs text-ink-400">
|
|
||||||
{a.status === 'ok' ? '' : a.status === 'no_data' ? '无数据' : '失败'}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{p.settled && (
|
|
||||||
<p className="border-t border-ink-100 pt-2.5 text-2xs text-ink-500">
|
|
||||||
实际比分 {p.actual_home_goals ?? '-'} : {p.actual_away_goals ?? '-'}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,7 @@ import AdminLayout from './AdminLayout'
|
|||||||
import Dashboard from './pages/Dashboard'
|
import Dashboard from './pages/Dashboard'
|
||||||
import CollectionPage from './pages/Collection'
|
import CollectionPage from './pages/Collection'
|
||||||
import DataCompletenessPage from './pages/DataCompleteness'
|
import DataCompletenessPage from './pages/DataCompleteness'
|
||||||
import PredictionsPage from './pages/Predictions'
|
import PredictionsPage from './pages/PredictionHistory'
|
||||||
import BacktestPage from './pages/Backtest'
|
import BacktestPage from './pages/Backtest'
|
||||||
import MonitoringPage from './pages/Monitoring'
|
import MonitoringPage from './pages/Monitoring'
|
||||||
import SettingsPage from './pages/Settings'
|
import SettingsPage from './pages/Settings'
|
||||||
|
|||||||
Reference in New Issue
Block a user