feat: 管理界面报刊风统一改造 — 移除独立暗色主题,预测/回测/监控/配置页增强
This commit is contained in:
@@ -1,14 +1,36 @@
|
||||
/**
|
||||
* Admin 后台 - 预测管理页面
|
||||
* Admin 后台 - 预测管理页面(报刊风)
|
||||
*
|
||||
* 功能:
|
||||
* - 触发预测(选择比赛 + 模式)
|
||||
* - 结算:录入实际比分,写入评估(接 /eval/settle)
|
||||
* - 预测记录列表:可展开查看终裁理由与专家摘要
|
||||
*
|
||||
* 响应式布局: 移动端单列,桌面端双列
|
||||
* 触摸友好: 按钮最小 44px 高度
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { triggerPrediction, fetchPredictions, fetchMatches } from '../dal'
|
||||
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 } from '../components'
|
||||
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
|
||||
|
||||
const AGENT_LABELS: Record<string, string> = {
|
||||
h2h: '历史交锋',
|
||||
form: '近期状态',
|
||||
stats: '攻防数据',
|
||||
home_away: '主客因素',
|
||||
injuries: '阵容完整性',
|
||||
}
|
||||
|
||||
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[]>([])
|
||||
@@ -19,11 +41,36 @@ export default function PredictionsPage() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [successMsg, setSuccessMsg] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetchPredictions(20).then(setPredictions)
|
||||
fetchMatches({ status: 'scheduled', limit: 20 }).then(d => setMatches(d.items))
|
||||
// 结算表单
|
||||
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({ limit: 100 }).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, `${home} vs ${away}`)
|
||||
}
|
||||
return map
|
||||
}, [matches])
|
||||
|
||||
const nameOf = (id: number) => matchName.get(id) ?? `比赛 #${id}`
|
||||
|
||||
async function handlePredict(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!matchId) return
|
||||
@@ -32,8 +79,8 @@ export default function PredictionsPage() {
|
||||
setSuccessMsg(null)
|
||||
try {
|
||||
await triggerPrediction({ match_id: parseInt(matchId), mode })
|
||||
setSuccessMsg('预测任务已提交')
|
||||
fetchPredictions(20).then(setPredictions)
|
||||
setSuccessMsg('预测任务已完成,记录已更新')
|
||||
await refreshPredictions()
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : '预测失败')
|
||||
} finally {
|
||||
@@ -41,11 +88,40 @@ export default function PredictionsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
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 足球预测"
|
||||
description="触发 LLM 预测;赛后录入实际比分完成结算,供准确率统计使用。"
|
||||
/>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
@@ -55,84 +131,221 @@ export default function PredictionsPage() {
|
||||
<CardBody>
|
||||
<form onSubmit={handlePredict} className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-400">比赛</label>
|
||||
<label className="mb-1.5 block text-xs text-ink-500">比赛</label>
|
||||
<select
|
||||
value={matchId}
|
||||
onChange={e => setMatchId(e.target.value)}
|
||||
className="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2.5 text-white min-h-[44px]"
|
||||
className="field w-full"
|
||||
>
|
||||
<option value="">选择比赛</option>
|
||||
{matches.map(m => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.home_team} vs {m.away_team} ({m.match_date?.slice(0, 10)})
|
||||
{(m.home_team_zh || m.home_team)} vs {(m.away_team_zh || m.away_team)}
|
||||
({m.match_date?.slice(5, 10)})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-400">模式</label>
|
||||
<label className="mb-1.5 block text-xs text-ink-500">模式</label>
|
||||
<select
|
||||
value={mode}
|
||||
onChange={e => setMode(e.target.value as 'single' | 'multi')}
|
||||
className="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2.5 text-white min-h-[44px]"
|
||||
className="field w-full"
|
||||
>
|
||||
<option value="multi">多 Agent (5 专家 + 终裁)</option>
|
||||
<option value="single">单次调用</option>
|
||||
<option value="multi">多专家 (5 路 + 终裁,慢而稳)</option>
|
||||
<option value="single">单次调用 (快)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-500/10 p-3 text-sm text-red-400 border border-red-500/30">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{error && <Alert kind="error" title="预测失败" message={error} onClose={() => setError(null)} />}
|
||||
{successMsg && (
|
||||
<div className="rounded-md bg-emerald-500/10 p-3 text-sm text-emerald-400 border border-emerald-500/30">
|
||||
{successMsg}
|
||||
</div>
|
||||
<Alert kind="ok" title={successMsg} onClose={() => setSuccessMsg(null)} />
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !matchId}
|
||||
className="w-full rounded-md bg-blue-600 px-4 py-2.5 text-white hover:bg-blue-700 disabled:opacity-50 transition-colors min-h-[44px]"
|
||||
className="btn btn-solid w-full"
|
||||
>
|
||||
{loading ? '预测中...' : '触发预测'}
|
||||
{loading ? (<><Spinner /> 预测中,多专家模式约需 20-60 秒</>) : '触发预测'}
|
||||
</button>
|
||||
</form>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* 最近预测 */}
|
||||
{/* 结算 */}
|
||||
<Card>
|
||||
<CardHeader title="最近预测" />
|
||||
<CardHeader
|
||||
title="预测结算"
|
||||
description="录入实际比分,系统据此统计 1X2 准确率与比分 RMSE"
|
||||
/>
|
||||
<CardBody>
|
||||
{predictions.length === 0 ? (
|
||||
<div className="text-center py-8 text-sm text-gray-500">
|
||||
<p>暂无预测记录</p>
|
||||
<p className="mt-1 text-xs">触发预测后将在此显示</p>
|
||||
</div>
|
||||
{unsettled.length === 0 ? (
|
||||
<p className="py-6 text-center text-xs text-ink-400">
|
||||
没有待结算的预测记录。预测完成后可在此录入实际比分。
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{predictions.slice(0, 10).map(p => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="flex flex-col sm:flex-row sm:items-center justify-between rounded-lg border border-gray-800 p-3 gap-2"
|
||||
<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"
|
||||
>
|
||||
<span className="text-sm text-gray-300">
|
||||
Match #{p.match_id} · {p.model}
|
||||
</span>
|
||||
<Badge status={p.settled ? 'success' : 'warning'}>
|
||||
{p.pred_1x2 || '?'} · {p.subjective_confidence ? `${(p.subjective_confidence * 100).toFixed(0)}%` : '—'}
|
||||
</Badge>
|
||||
<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>
|
||||
<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">
|
||||
{p.mode === 'multi' ? `多专家 · ${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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user