|
|
|
@@ -0,0 +1,285 @@
|
|
|
|
|
import { useCallback, useEffect, useState } from 'react'
|
|
|
|
|
|
|
|
|
|
interface Match {
|
|
|
|
|
id: number
|
|
|
|
|
league_code: string | null
|
|
|
|
|
season: string | null
|
|
|
|
|
home_team: string
|
|
|
|
|
away_team: string
|
|
|
|
|
home_team_zh: string | null
|
|
|
|
|
away_team_zh: string | null
|
|
|
|
|
match_date: string
|
|
|
|
|
match_status: string
|
|
|
|
|
home_goals: number | null
|
|
|
|
|
away_goals: number | null
|
|
|
|
|
match_stage: string | null
|
|
|
|
|
home_xg: number | null
|
|
|
|
|
away_xg: number | null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface Prediction {
|
|
|
|
|
prediction_id: number
|
|
|
|
|
provider: string
|
|
|
|
|
model: string
|
|
|
|
|
prompt_version: string | null
|
|
|
|
|
mode: string
|
|
|
|
|
pred_home_goals: number | null
|
|
|
|
|
pred_away_goals: number | null
|
|
|
|
|
pred_1x2: string | null
|
|
|
|
|
confidence: number | null
|
|
|
|
|
reasoning: string | null
|
|
|
|
|
agent_outputs: AgentReport[] | null
|
|
|
|
|
agent_weights: Record<string, number> | null
|
|
|
|
|
context: string
|
|
|
|
|
latency_ms: number | null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface AgentReport {
|
|
|
|
|
agent: string
|
|
|
|
|
status: string
|
|
|
|
|
data_sufficiency: string
|
|
|
|
|
analysis: string
|
|
|
|
|
home_edge: number | null
|
|
|
|
|
confidence: number | null
|
|
|
|
|
key_evidence: string[]
|
|
|
|
|
exp_home_goals: number | null
|
|
|
|
|
exp_away_goals: number | null
|
|
|
|
|
probable_score: string | null
|
|
|
|
|
model: string
|
|
|
|
|
latency_ms: number | null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const AGENT_LABELS: Record<string, string> = {
|
|
|
|
|
h2h: '历史交锋',
|
|
|
|
|
form: '近期状态',
|
|
|
|
|
stats: '攻防数据',
|
|
|
|
|
home_away: '主客因素',
|
|
|
|
|
injuries: '阵容完整性',
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const LEAGUES = [
|
|
|
|
|
{ code: 'E0', name: '英超' },
|
|
|
|
|
{ code: 'SP1', name: '西甲' },
|
|
|
|
|
{ code: 'D1', name: '德甲' },
|
|
|
|
|
{ code: 'I1', name: '意甲' },
|
|
|
|
|
{ code: 'F1', name: '法甲' },
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
export default function Matches() {
|
|
|
|
|
const [league, setLeague] = useState('E0')
|
|
|
|
|
const [status, setStatus] = useState('scheduled')
|
|
|
|
|
const [matches, setMatches] = useState<Match[]>([])
|
|
|
|
|
const [loading, setLoading] = useState(false)
|
|
|
|
|
const [predictingId, setPredictingId] = useState<number | null>(null)
|
|
|
|
|
const [prediction, setPrediction] = useState<Prediction | null>(null)
|
|
|
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
|
|
|
|
|
|
const load = useCallback(async () => {
|
|
|
|
|
setLoading(true)
|
|
|
|
|
setError(null)
|
|
|
|
|
try {
|
|
|
|
|
const params = new URLSearchParams({ league, status, limit: '50' })
|
|
|
|
|
const res = await fetch(`/api/v1/matches?${params}`)
|
|
|
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
|
|
|
const data = await res.json()
|
|
|
|
|
setMatches(data.items)
|
|
|
|
|
} catch (e) {
|
|
|
|
|
setError(e instanceof Error ? e.message : String(e))
|
|
|
|
|
} finally {
|
|
|
|
|
setLoading(false)
|
|
|
|
|
}
|
|
|
|
|
}, [league, status])
|
|
|
|
|
|
|
|
|
|
useEffect(() => { load() }, [load])
|
|
|
|
|
|
|
|
|
|
const predict = async (matchId: number) => {
|
|
|
|
|
setPredictingId(matchId)
|
|
|
|
|
setError(null)
|
|
|
|
|
setPrediction(null)
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch('/api/v1/predict', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify({ match_id: matchId }),
|
|
|
|
|
})
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
const t = await res.text()
|
|
|
|
|
throw new Error(`HTTP ${res.status}: ${t}`)
|
|
|
|
|
}
|
|
|
|
|
const data = await res.json()
|
|
|
|
|
setPrediction(data)
|
|
|
|
|
} catch (e) {
|
|
|
|
|
setError(e instanceof Error ? e.message : String(e))
|
|
|
|
|
} finally {
|
|
|
|
|
setPredictingId(null)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const fmtDate = (s: string) => {
|
|
|
|
|
const d = new Date(s)
|
|
|
|
|
return d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
{/* 筛选 */}
|
|
|
|
|
<div className="flex gap-3 items-center flex-wrap">
|
|
|
|
|
<select value={league} onChange={e => setLeague(e.target.value)}
|
|
|
|
|
className="border rounded px-3 py-1.5 text-sm">
|
|
|
|
|
{LEAGUES.map(l => <option key={l.code} value={l.code}>{l.name}</option>)}
|
|
|
|
|
</select>
|
|
|
|
|
<select value={status} onChange={e => setStatus(e.target.value)}
|
|
|
|
|
className="border rounded px-3 py-1.5 text-sm">
|
|
|
|
|
<option value="scheduled">未开赛</option>
|
|
|
|
|
<option value="finished">已完赛</option>
|
|
|
|
|
<option value="">全部</option>
|
|
|
|
|
</select>
|
|
|
|
|
<button onClick={load} disabled={loading}
|
|
|
|
|
className="bg-blue-600 text-white text-sm px-4 py-1.5 rounded hover:bg-blue-700 disabled:opacity-50">
|
|
|
|
|
{loading ? '加载中...' : '刷新'}
|
|
|
|
|
</button>
|
|
|
|
|
<span className="text-sm text-gray-500">共 {matches.length} 场</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{error && <div className="bg-red-50 border border-red-200 text-red-700 px-4 py-2 rounded text-sm">{error}</div>}
|
|
|
|
|
|
|
|
|
|
{/* 比赛表 */}
|
|
|
|
|
<div className="bg-white rounded border overflow-hidden">
|
|
|
|
|
<table className="w-full text-sm">
|
|
|
|
|
<thead className="bg-gray-100 text-gray-600">
|
|
|
|
|
<tr>
|
|
|
|
|
<th className="text-left px-4 py-2">日期</th>
|
|
|
|
|
<th className="text-left px-4 py-2">主队</th>
|
|
|
|
|
<th className="text-left px-4 py-2">客队</th>
|
|
|
|
|
<th className="text-center px-4 py-2">比分</th>
|
|
|
|
|
<th className="text-center px-4 py-2">状态</th>
|
|
|
|
|
<th className="text-center px-4 py-2">操作</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>
|
|
|
|
|
{matches.length === 0 && !loading && (
|
|
|
|
|
<tr><td colSpan={6} className="text-center text-gray-400 py-8">暂无数据,请先采集</td></tr>
|
|
|
|
|
)}
|
|
|
|
|
{matches.map(m => (
|
|
|
|
|
<tr key={m.id} className="border-t hover:bg-gray-50">
|
|
|
|
|
<td className="px-4 py-2 text-gray-600">{fmtDate(m.match_date)}</td>
|
|
|
|
|
<td className="px-4 py-2 font-medium">{m.home_team_zh || m.home_team}</td>
|
|
|
|
|
<td className="px-4 py-2 font-medium">{m.away_team_zh || m.away_team}</td>
|
|
|
|
|
<td className="px-4 py-2 text-center">
|
|
|
|
|
{m.home_goals !== null ? `${m.home_goals} - ${m.away_goals}` : '-'}
|
|
|
|
|
</td>
|
|
|
|
|
<td className="px-4 py-2 text-center">
|
|
|
|
|
<span className={`text-xs px-2 py-0.5 rounded ${
|
|
|
|
|
m.match_status === 'finished' ? 'bg-green-100 text-green-700' :
|
|
|
|
|
m.match_status === 'scheduled' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100'
|
|
|
|
|
}`}>
|
|
|
|
|
{m.match_status === 'finished' ? '完赛' : m.match_status === 'scheduled' ? '未开赛' : m.match_status}
|
|
|
|
|
</span>
|
|
|
|
|
</td>
|
|
|
|
|
<td className="px-4 py-2 text-center">
|
|
|
|
|
<button onClick={() => predict(m.id)}
|
|
|
|
|
disabled={predictingId === m.id}
|
|
|
|
|
className="text-blue-600 hover:underline text-xs disabled:opacity-50">
|
|
|
|
|
{predictingId === m.id ? '预测中...' : 'LLM 预测'}
|
|
|
|
|
</button>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
))}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 预测结果 */}
|
|
|
|
|
{prediction && (
|
|
|
|
|
<div className="bg-white rounded border p-5 space-y-3">
|
|
|
|
|
<h3 className="font-bold text-lg">🤖 LLM 预测结果</h3>
|
|
|
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
|
|
|
|
<div className="bg-blue-50 rounded p-3">
|
|
|
|
|
<div className="text-gray-500 text-xs">主进球</div>
|
|
|
|
|
<div className="text-xl font-bold">{prediction.pred_home_goals ?? '-'}</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="bg-blue-50 rounded p-3">
|
|
|
|
|
<div className="text-gray-500 text-xs">客进球</div>
|
|
|
|
|
<div className="text-xl font-bold">{prediction.pred_away_goals ?? '-'}</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="bg-amber-50 rounded p-3">
|
|
|
|
|
<div className="text-gray-500 text-xs">胜平负</div>
|
|
|
|
|
<div className="text-xl font-bold">{prediction.pred_1x2 ?? '-'}</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="bg-green-50 rounded p-3">
|
|
|
|
|
<div className="text-gray-500 text-xs">置信度</div>
|
|
|
|
|
<div className="text-xl font-bold">
|
|
|
|
|
{prediction.confidence !== null ? `${(prediction.confidence * 100).toFixed(0)}%` : '-'}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="text-xs text-gray-400">
|
|
|
|
|
{prediction.provider} / {prediction.model} · 耗时 {prediction.latency_ms}ms
|
|
|
|
|
{prediction.mode === 'multi' && ' · 多 Agent 模式'}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 各专家 agent 报告 */}
|
|
|
|
|
{prediction.agent_outputs && prediction.agent_outputs.length > 0 && (
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<div className="text-sm font-medium text-gray-700">专家 Agent 报告</div>
|
|
|
|
|
{prediction.agent_outputs.map((r) => (
|
|
|
|
|
<details key={r.agent} className="bg-white border rounded">
|
|
|
|
|
<summary className="cursor-pointer px-3 py-2 text-sm flex items-center justify-between">
|
|
|
|
|
<span className="font-medium">
|
|
|
|
|
{AGENT_LABELS[r.agent] || r.agent}
|
|
|
|
|
{r.status !== 'ok' && (
|
|
|
|
|
<span className={`ml-2 text-xs px-1.5 py-0.5 rounded ${
|
|
|
|
|
r.status === 'no_data' ? 'bg-gray-100 text-gray-500' : 'bg-red-100 text-red-600'
|
|
|
|
|
}`}>
|
|
|
|
|
{r.status === 'no_data' ? '无数据' : '失败'}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</span>
|
|
|
|
|
<span className="flex gap-3 text-xs text-gray-500">
|
|
|
|
|
{r.home_edge !== null && (
|
|
|
|
|
<span className={r.home_edge > 0 ? 'text-blue-600' : r.home_edge < 0 ? 'text-amber-600' : ''}>
|
|
|
|
|
主队优势 {r.home_edge > 0 ? '+' : ''}{r.home_edge.toFixed(2)}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
{r.confidence !== null && <span>信心 {(r.confidence * 100).toFixed(0)}%</span>}
|
|
|
|
|
{r.probable_score && <span>比分 {r.probable_score}</span>}
|
|
|
|
|
</span>
|
|
|
|
|
</summary>
|
|
|
|
|
<div className="px-3 pb-3 pt-1 space-y-2 text-sm">
|
|
|
|
|
{r.analysis && <p className="text-gray-700">{r.analysis}</p>}
|
|
|
|
|
{r.key_evidence.length > 0 && (
|
|
|
|
|
<ul className="text-xs text-gray-500 list-disc pl-4">
|
|
|
|
|
{r.key_evidence.map((e, i) => <li key={i}>{e}</li>)}
|
|
|
|
|
</ul>
|
|
|
|
|
)}
|
|
|
|
|
{r.exp_home_goals !== null && r.exp_away_goals !== null && (
|
|
|
|
|
<div className="text-xs text-gray-500">
|
|
|
|
|
进球期望: {r.exp_home_goals.toFixed(1)} - {r.exp_away_goals.toFixed(1)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
<div className="text-xs text-gray-400">
|
|
|
|
|
数据充分度 {r.data_sufficiency} · {r.model} · {r.latency_ms}ms
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</details>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{prediction.reasoning && (
|
|
|
|
|
<div className="bg-gray-50 rounded p-3">
|
|
|
|
|
<div className="text-xs text-gray-500 mb-1">推理过程</div>
|
|
|
|
|
<div className="text-sm whitespace-pre-wrap">{prediction.reasoning}</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
<details className="text-xs">
|
|
|
|
|
<summary className="cursor-pointer text-gray-500 hover:text-gray-700">查看完整上下文</summary>
|
|
|
|
|
<pre className="mt-2 bg-gray-900 text-green-300 p-3 rounded overflow-x-auto text-xs">
|
|
|
|
|
{prediction.context}
|
|
|
|
|
</pre>
|
|
|
|
|
</details>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|