fix:批量修复了一些问题
This commit is contained in:
+487
-124
@@ -1,5 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import TeamSideTag from '../components/TeamSideTag'
|
||||
import { fetchMatchDetail, fetchMatchContext } from '../admin/dal'
|
||||
import type { MatchDetailOut, MatchContextOut, MatchRecentPrediction, TeamRecentMatch } from '../admin/types'
|
||||
|
||||
interface Match {
|
||||
id: number
|
||||
@@ -31,10 +33,14 @@ interface Prediction {
|
||||
pred_1x2: string | null
|
||||
subjective_confidence: number | null
|
||||
reasoning: string | null
|
||||
status: string
|
||||
agent_outputs: AgentReport[] | null
|
||||
agent_weights: Record<string, number> | null
|
||||
context: string
|
||||
latency_ms: number | null
|
||||
prompt_tokens: number | null
|
||||
completion_tokens: number | null
|
||||
rate_limit_remaining: number | null
|
||||
}
|
||||
|
||||
interface AgentReport {
|
||||
@@ -182,6 +188,7 @@ function OutcomeLine({
|
||||
export default function Matches() {
|
||||
const [league, setLeague] = useState('E0')
|
||||
const [status, setStatus] = useState('scheduled')
|
||||
const [date, setDate] = useState('') // 日期筛选(空=全部),"today"=今日
|
||||
const [matches, setMatches] = useState<Match[]>([])
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
@@ -191,6 +198,10 @@ export default function Matches() {
|
||||
const [predictionFor, setPredictionFor] = useState<Match | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [mode, setMode] = useState<'single' | 'multi'>('multi')
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null)
|
||||
const [detailMap, setDetailMap] = useState<Record<number, MatchDetailOut>>({})
|
||||
const [contextMap, setContextMap] = useState<Record<number, MatchContextOut>>({})
|
||||
const [detailLoading, setDetailLoading] = useState<number | null>(null)
|
||||
|
||||
// 请求竞态防护:切换联赛/状态很快时,先发的慢请求可能后返回,
|
||||
// 把旧结果覆盖到新筛选上。用递增序号只认最后一次请求的响应。
|
||||
@@ -211,11 +222,12 @@ export default function Matches() {
|
||||
const load = useCallback(async () => {
|
||||
const seq = ++loadSeq.current
|
||||
setLoading(true)
|
||||
// 切换筛选时作废进行中的「加载更多」,避免其标志位卡住
|
||||
setLoadingMore(false)
|
||||
setError(null)
|
||||
try {
|
||||
const params = new URLSearchParams({ league, status, limit: '50' })
|
||||
if (date === 'today') params.set('date', todayStr())
|
||||
else if (date) params.set('date', date)
|
||||
const res = await fetch(`/api/v1/matches?${params}`)
|
||||
if (seq !== loadSeq.current) return // 已有更新的请求,丢弃本次结果
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
@@ -229,7 +241,7 @@ export default function Matches() {
|
||||
} finally {
|
||||
if (seq === loadSeq.current) setLoading(false)
|
||||
}
|
||||
}, [league, status])
|
||||
}, [league, status, date])
|
||||
|
||||
// 加载下一页(游标分页)
|
||||
const loadMore = async () => {
|
||||
@@ -238,6 +250,8 @@ export default function Matches() {
|
||||
setLoadingMore(true)
|
||||
try {
|
||||
const params = new URLSearchParams({ league, status, limit: '50', cursor: nextCursor })
|
||||
if (date === 'today') params.set('date', todayStr())
|
||||
else if (date) params.set('date', date)
|
||||
const res = await fetch(`/api/v1/matches?${params}`)
|
||||
if (seq !== loadSeq.current) return
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
@@ -255,7 +269,26 @@ export default function Matches() {
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
/** 今日日期 YYYY-MM-DD(用于「今日」快速筛选) */
|
||||
function todayStr(): string {
|
||||
return new Date().toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
/** 按日期分组(YYYY-MM-DD → Match[]),保持时间序 */
|
||||
function groupByDate(list: Match[]): Array<[string, Match[]]> {
|
||||
const map = new Map<string, Match[]>()
|
||||
for (const m of list) {
|
||||
const key = (m.match_date || '').slice(0, 10)
|
||||
const arr = map.get(key)
|
||||
if (arr) arr.push(m)
|
||||
else map.set(key, [m])
|
||||
}
|
||||
return [...map.entries()]
|
||||
}
|
||||
|
||||
const predict = async (m: Match) => {
|
||||
// 防连点:若该场比赛已在预测中,直接忽略
|
||||
if (predictingId === m.id) return
|
||||
const seq = ++predictSeq.current
|
||||
setPredictingId(m.id)
|
||||
setError(null)
|
||||
@@ -285,9 +318,7 @@ export default function Matches() {
|
||||
setError(
|
||||
e instanceof DOMException && e.name === 'AbortError'
|
||||
? '预测超时(5 分钟),请稍后重试或改用单次模式'
|
||||
: e instanceof Error
|
||||
? e.message
|
||||
: String(e),
|
||||
: readablePredictError(e),
|
||||
)
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
@@ -366,6 +397,27 @@ export default function Matches() {
|
||||
/>
|
||||
</span>
|
||||
|
||||
<span className="inline-flex items-center gap-2.5">
|
||||
<span className="text-2xs text-ink-400">日期</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setDate(date === 'today' ? '' : 'today')}
|
||||
className={`btn btn-sm px-2 ${date === 'today' ? 'btn-solid' : ''}`}
|
||||
title="只看今日"
|
||||
>今日</button>
|
||||
<input
|
||||
type="date"
|
||||
value={date === 'today' ? todayStr() : date}
|
||||
onChange={e => setDate(e.target.value)}
|
||||
className="field px-1.5 py-1 text-xs"
|
||||
aria-label="按日期筛选"
|
||||
/>
|
||||
{date && (
|
||||
<button onClick={() => setDate('')} className="text-ink-400 hover:text-ink-900" aria-label="清除日期" title="清除">×</button>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span className="ml-auto inline-flex items-center gap-3">
|
||||
<span className="tabular-nums">共 {matches.length} 场</span>
|
||||
<button onClick={load} disabled={loading} className="btn btn-sm">
|
||||
@@ -401,7 +453,7 @@ export default function Matches() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── 赛程栏:表格化,行间细线 ── */}
|
||||
{/* ── 赛程栏:表格化,行间细线,按日期分组 ── */}
|
||||
<section aria-label="赛程">
|
||||
{loading && <SkeletonRows n={4} />}
|
||||
|
||||
@@ -412,79 +464,129 @@ export default function Matches() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && matches.map(m => {
|
||||
const st = STATUS_META[m.match_status] ?? { label: m.match_status, cls: 'text-ink-400' }
|
||||
const homeName = m.home_team_zh || m.home_team
|
||||
const awayName = m.away_team_zh || m.away_team
|
||||
const busy = predictingId === m.id
|
||||
const active = predictionFor?.id === m.id
|
||||
const finished = m.match_status === 'finished'
|
||||
{!loading && groupByDate(matches).map(([dateKey, group]) => (
|
||||
<div key={dateKey}>
|
||||
{/* 日期分组头 */}
|
||||
<div className="sticky top-0 z-10 border-y border-ink-200 bg-paper-100 px-2 py-1.5 text-2xs font-medium tracking-wide text-ink-500">
|
||||
{formatDateHeader(dateKey)} <span className="ml-1 text-ink-300">· {group.length} 场</span>
|
||||
</div>
|
||||
{group.map(m => {
|
||||
const st = STATUS_META[m.match_status] ?? { label: m.match_status, cls: 'text-ink-400' }
|
||||
const homeName = m.home_team_zh || m.home_team
|
||||
const awayName = m.away_team_zh || m.away_team
|
||||
const busy = predictingId === m.id
|
||||
const finished = m.match_status === 'finished'
|
||||
const expanded = expandedId === m.id
|
||||
const detail = detailMap[m.id]
|
||||
const ctx = contextMap[m.id]
|
||||
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
className={`border-b border-ink-200 px-1 py-3 transition-colors hover:bg-paper-100 ${
|
||||
active ? 'bg-press-wash/50' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col gap-2 sm:grid sm:grid-cols-[88px_minmax(0,1fr)_64px_minmax(0,1fr)_56px_auto] sm:items-center sm:gap-x-3 sm:gap-y-0">
|
||||
{/* 日期 + 状态:移动端同行,桌面端日期单独归列 */}
|
||||
<div className="flex items-center justify-between sm:contents">
|
||||
<span className="text-2xs tabular-nums text-ink-400">{fmtDate(m.match_date)}</span>
|
||||
<span className={`text-2xs sm:hidden ${st.cls}`}>{st.label}</span>
|
||||
</div>
|
||||
// 展开时懒加载详情(只读,不触发 LLM)
|
||||
async function toggleExpand() {
|
||||
if (expanded) { setExpandedId(null); return }
|
||||
setExpandedId(m.id)
|
||||
if (!detailMap[m.id] || !contextMap[m.id]) {
|
||||
setDetailLoading(m.id)
|
||||
try {
|
||||
const [d, c] = await Promise.all([
|
||||
fetchMatchDetail(m.id).catch(() => null),
|
||||
fetchMatchContext(m.id).catch(() => null),
|
||||
])
|
||||
if (d) setDetailMap(prev => ({ ...prev, [m.id]: d }))
|
||||
if (c) setContextMap(prev => ({ ...prev, [m.id]: c }))
|
||||
} finally {
|
||||
setDetailLoading(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
{/* 对阵:移动端主队/比分/客队同一行,桌面端 sm:contents 拆回 grid 列 */}
|
||||
<div className="flex items-center gap-2 sm:contents">
|
||||
{/* 主队(右对齐) */}
|
||||
<div className="flex min-w-0 flex-1 items-center justify-end gap-1.5">
|
||||
<TeamSideTag side="home" />
|
||||
<span className="truncate text-sm font-medium text-ink-900">{homeName}</span>
|
||||
return (
|
||||
<div key={m.id}>
|
||||
{/* 行:可点击展开 */}
|
||||
<div
|
||||
className={`border-b border-ink-200 px-1 py-3 transition-colors hover:bg-paper-100 cursor-pointer ${
|
||||
expanded ? 'bg-paper-100/60' : ''
|
||||
}`}
|
||||
onClick={toggleExpand}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={e => { if (e.key === 'Enter') toggleExpand() }}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
{/* 小屏:日期+状态行;桌面:日期单独一列 */}
|
||||
<div className="flex items-center justify-between sm:contents">
|
||||
<span className="text-2xs tabular-nums text-ink-400">{fmtDate(m.match_date)}</span>
|
||||
<span className={`text-2xs sm:hidden ${st.cls}`}>{st.label}</span>
|
||||
</div>
|
||||
|
||||
{/* 比分 / VS */}
|
||||
<div className="flex w-14 flex-shrink-0 flex-col items-center sm:w-auto">
|
||||
{m.home_goals !== null && m.away_goals !== null ? (
|
||||
<span className="font-serif text-base font-bold tabular-nums text-ink-900">
|
||||
{m.home_goals}<span className="mx-0.5 font-normal text-ink-300">:</span>{m.away_goals}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-2xs tracking-widest text-ink-400">VS</span>
|
||||
)}
|
||||
{m.home_xg !== null && m.away_xg !== null && (
|
||||
<span className="text-2xs tabular-nums text-ink-400">
|
||||
xG {m.home_xg.toFixed(1)}-{m.away_xg.toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
{/* 对阵行:小屏主队(弹性)/比分/客队(弹性)三格;桌面 sm:contents 走 grid */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex min-w-0 flex-1 items-center justify-end gap-1.5">
|
||||
<TeamSideTag side="home" />
|
||||
<span className="truncate text-sm font-medium text-ink-900">{homeName}</span>
|
||||
</span>
|
||||
<span className="flex w-16 flex-shrink-0 flex-col items-center">
|
||||
{m.home_goals !== null && m.away_goals !== null ? (
|
||||
<span className="font-serif text-base font-bold tabular-nums text-ink-900">
|
||||
{m.home_goals}<span className="mx-0.5 font-normal text-ink-300">:</span>{m.away_goals}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-2xs tracking-widest text-ink-400">VS</span>
|
||||
)}
|
||||
{m.home_xg !== null && m.away_xg != null && (
|
||||
<span className="text-2xs tabular-nums text-ink-400">
|
||||
xG {m.home_xg.toFixed(1)}-{m.away_xg.toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<TeamSideTag side="away" />
|
||||
<span className="truncate text-sm font-medium text-ink-900">{awayName}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 客队(左对齐) */}
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<TeamSideTag side="away" />
|
||||
<span className="truncate text-sm font-medium text-ink-900">{awayName}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 状态列(桌面) */}
|
||||
<span className={`hidden text-right text-2xs sm:block ${st.cls}`}>{st.label}</span>
|
||||
|
||||
{/* 预测按钮 */}
|
||||
<div className="flex justify-end">
|
||||
{/* 预测按钮:小屏独占一行(桌面端 sm:contents 下隐藏) */}
|
||||
{!finished && (
|
||||
<button
|
||||
onClick={() => predict(m)}
|
||||
disabled={busy}
|
||||
className="btn btn-sm w-[76px]"
|
||||
title={`以${mode === 'multi' ? '多专家' : '单次'}模式预测这场`}
|
||||
>
|
||||
{busy ? (<><Spinner /> 预测中</>) : '预测'}
|
||||
</button>
|
||||
<div className="flex justify-end sm:hidden" onClick={e => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={() => predict(m)}
|
||||
disabled={busy}
|
||||
className="btn min-h-[44px] px-4"
|
||||
title={`以${mode === 'multi' ? '多专家' : '单次'}模式预测这场`}
|
||||
>
|
||||
{busy ? (<><Spinner /> 预测中</>) : '预测'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 桌面端按钮(小屏隐藏) */}
|
||||
<span className={`hidden text-right text-2xs sm:block ${st.cls}`}>{st.label}</span>
|
||||
<div className="hidden sm:flex sm:justify-end" onClick={e => e.stopPropagation()}>
|
||||
{!finished && (
|
||||
<button
|
||||
onClick={() => predict(m)}
|
||||
disabled={busy}
|
||||
className="btn btn-sm w-[76px]"
|
||||
title={`以${mode === 'multi' ? '多专家' : '单次'}模式预测这场`}
|
||||
>
|
||||
{busy ? (<><Spinner /> 预测中</>) : '预测'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>{/* 关闭可点击行(clickable row) */}
|
||||
|
||||
{/* 展开详情面板(只读数据 + 预测按钮 + 历史预测 + 专家报告入口) */}
|
||||
{expanded && (
|
||||
<MatchDetailPanel
|
||||
match={m} detail={detail} ctx={ctx}
|
||||
loading={detailLoading === m.id}
|
||||
mode={mode}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!loading && nextCursor && (
|
||||
<div className="flex justify-center pt-4">
|
||||
@@ -499,6 +601,55 @@ export default function Matches() {
|
||||
)
|
||||
}
|
||||
|
||||
/** 日期分组头显示:今日/明天/周几 · 年月日 */
|
||||
function formatDateHeader(dateKey: string): string {
|
||||
if (!dateKey) return '未开赛'
|
||||
const d = new Date(dateKey + 'T00:00:00')
|
||||
if (isNaN(d.getTime())) return dateKey
|
||||
const today = new Date()
|
||||
const todayKey = today.toISOString().slice(0, 10)
|
||||
const tmr = new Date(today)
|
||||
tmr.setDate(tmr.getDate() + 1)
|
||||
const tmrKey = tmr.toISOString().slice(0, 10)
|
||||
const weekday = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][d.getDay()]
|
||||
if (dateKey === todayKey) return `今日 ${weekday}`
|
||||
if (dateKey === tmrKey) return `明日 ${weekday}`
|
||||
return `${d.getMonth() + 1}月${d.getDate()}日 ${weekday}`
|
||||
}
|
||||
|
||||
/** 预测成本展示:耗时 + token + 限流余量 */
|
||||
function PredictionCost({ prediction }: { prediction: Prediction }) {
|
||||
const latency = prediction.latency_ms != null ? `${(prediction.latency_ms / 1000).toFixed(1)}s` : null
|
||||
const tokens = prediction.prompt_tokens != null || prediction.completion_tokens != null
|
||||
? `${prediction.prompt_tokens ?? '?'}/${prediction.completion_tokens ?? '?'}`
|
||||
: null
|
||||
|
||||
if (!latency && !tokens && prediction.rate_limit_remaining == null) return null
|
||||
|
||||
return (
|
||||
<div className="border-t border-ink-200 pt-3 text-2xs text-ink-500">
|
||||
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-1">
|
||||
{latency && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span aria-hidden="true" className="opacity-60">⏱</span>耗时 {latency}
|
||||
</span>
|
||||
)}
|
||||
{tokens && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span aria-hidden="true" className="opacity-60">Tok</span>prompt/completion: {tokens}
|
||||
</span>
|
||||
)}
|
||||
{prediction.rate_limit_remaining != null && prediction.rate_limit_remaining <= 3 && (
|
||||
<span className="text-press" title="每分钟最多 10 次预测">
|
||||
剩余配额: {prediction.rate_limit_remaining}/10(分钟)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** 预测过程阶段(按时长模拟;结果到达即跳到完成) */
|
||||
function PredictProgress({ mode }: { mode: 'single' | 'multi' }) {
|
||||
@@ -573,8 +724,15 @@ function PredictProgress({ mode }: { mode: 'single' | 'multi' }) {
|
||||
)}
|
||||
|
||||
<p className="mt-6 text-center text-2xs text-ink-400">
|
||||
{mode === 'multi' ? '五路专家并行分析后终裁,约需 30-90 秒;关闭窗口即取消' : '单次调用,约需 5-20 秒;关闭窗口即取消'}
|
||||
{mode === 'multi'
|
||||
? '五路专家并行分析后终裁,约需 30-90 秒;多专家调用消耗较多 token,请按需使用。关闭窗口即取消'
|
||||
: '单次调用,约需 5-20 秒;关闭窗口即取消'}
|
||||
</p>
|
||||
{mode === 'multi' && (
|
||||
<p className="mt-1 text-center text-2xs text-ink-300">
|
||||
提示:每分钟限 10 次预测,耗尽后需等待下一分钟。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -670,12 +828,14 @@ function PredictionPanel({
|
||||
embedded?: boolean
|
||||
}) {
|
||||
const homeName = match.home_team_zh || match.home_team
|
||||
const [expertsOpen, setExpertsOpen] = useState(false)
|
||||
const awayName = match.away_team_zh || match.away_team
|
||||
const okReports = (prediction.agent_outputs ?? []).filter(r => r.status === 'ok')
|
||||
const degraded = prediction.status === 'degraded' || prediction.status === 'failed'
|
||||
const reports = prediction.agent_outputs ?? []
|
||||
const okReports = reports.filter(r => r.status === 'ok')
|
||||
|
||||
return (
|
||||
<article className={embedded ? 'bg-paper-50' : 'border border-ink-900 bg-paper-50'}>
|
||||
{/* 版头(嵌入模式由弹窗报头承担) */}
|
||||
{!embedded && (
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2 border-b border-ink-900 bg-paper-100 px-4 py-2.5 sm:px-5">
|
||||
<h3 className="flex flex-wrap items-center gap-1.5 font-serif text-sm font-bold text-ink-900">
|
||||
@@ -694,71 +854,113 @@ function PredictionPanel({
|
||||
)}
|
||||
|
||||
<div className="space-y-7 px-4 py-6 sm:px-5">
|
||||
{/* ── 预测比分:版面核心,大号宋体 ── */}
|
||||
<div className="text-center">
|
||||
<p className="font-serif text-5xl font-bold tabular-nums leading-none text-ink-900 sm:text-6xl">
|
||||
{prediction.pred_home_goals ?? '-'}
|
||||
<span className="mx-3 font-normal text-ink-300">:</span>
|
||||
{prediction.pred_away_goals ?? '-'}
|
||||
</p>
|
||||
<p className="mt-3 text-2xs tracking-[0.5em] text-ink-400">预测比分</p>
|
||||
{prediction.alt_pred_home_goals !== null && prediction.alt_pred_away_goals !== null && (
|
||||
<p className="mt-2 text-2xs tabular-nums text-ink-400">
|
||||
备选{' '}
|
||||
<span className="font-serif text-sm font-bold tabular-nums text-ink-600">
|
||||
{prediction.alt_pred_home_goals}<span className="mx-0.5 font-normal text-ink-300">:</span>{prediction.alt_pred_away_goals}
|
||||
</span>
|
||||
{/* ── degraded / failed 态:醒目警示 + 原因,不展示虚假比分 ── */}
|
||||
{degraded && (
|
||||
<div className="border-l-2 border-press bg-press-wash/40 px-4 py-3">
|
||||
<p className="font-serif text-sm font-bold text-press-dark">
|
||||
{prediction.status === 'failed' ? '预测失败' : '预测降级(degraded)'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1.5 whitespace-pre-wrap text-xs leading-relaxed text-ink-600">
|
||||
{prediction.reasoning || '所有专家均无有效数据或调用失败,无法生成可靠比分。'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 胜平负 ── */}
|
||||
<div className="border-y border-ink-200 py-4">
|
||||
<OutcomeLine pick={prediction.pred_1x2} confidence={prediction.subjective_confidence} />
|
||||
</div>
|
||||
|
||||
{/* ── 元信息一行 ── */}
|
||||
<p className="text-center text-2xs text-ink-500">
|
||||
{mode === 'multi' ? `多专家模式 · ${okReports.length}/${prediction.agent_outputs?.length ?? 0} 路有效` : '单次模式'}
|
||||
{prediction.prompt_version && ` · prompt ${prediction.prompt_version}`}
|
||||
{prediction.latency_ms !== null && ` · 终裁耗时 ${(prediction.latency_ms / 1000).toFixed(1)} 秒`}
|
||||
</p>
|
||||
|
||||
{/* ── 专家意见 ── */}
|
||||
{prediction.agent_outputs && prediction.agent_outputs.length > 0 && (
|
||||
<section>
|
||||
<div className="section-head flex flex-wrap items-baseline justify-between gap-1">
|
||||
<span>五路专家意见</span>
|
||||
{prediction.agent_weights && (
|
||||
<span className="font-sans text-2xs font-normal text-ink-500">
|
||||
终裁权重:{Object.entries(prediction.agent_weights)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([k, v]) => `${AGENT_LABELS[k] ?? k} ${Math.round(v * 100)}%`)
|
||||
.join(' / ')}
|
||||
</span>
|
||||
{/* ── 主结论(仅 success 展示) ── */}
|
||||
{!degraded && (
|
||||
<>
|
||||
<div className="text-center">
|
||||
<p className="font-serif text-5xl font-bold tabular-nums leading-none text-ink-900 sm:text-6xl">
|
||||
{prediction.pred_home_goals ?? '-'}
|
||||
<span className="mx-3 font-normal text-ink-300">:</span>
|
||||
{prediction.pred_away_goals ?? '-'}
|
||||
</p>
|
||||
<p className="mt-3 text-2xs tracking-[0.5em] text-ink-400">预测比分</p>
|
||||
{prediction.alt_pred_home_goals != null && prediction.alt_pred_away_goals != null && (
|
||||
<p className="mt-2 text-2xs tabular-nums text-ink-400">
|
||||
备选{' '}
|
||||
<span className="font-serif text-sm font-bold tabular-nums text-ink-600">
|
||||
{prediction.alt_pred_home_goals}<span className="mx-0.5 font-normal text-ink-300">:</span>{prediction.alt_pred_away_goals}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{prediction.agent_outputs.map((r, i) => (
|
||||
<AgentCard key={r.agent} report={r} no={CN_NUM[i] ?? String(i + 1)} />
|
||||
))}
|
||||
<div className="border-y border-ink-200 py-4">
|
||||
<OutcomeLine pick={prediction.pred_1x2} confidence={prediction.subjective_confidence} />
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── 终裁意见:引文式,红竖线 ── */}
|
||||
{prediction.reasoning && (
|
||||
{/* ── 成本信息(耗时 + token + 限流余量) ── */}
|
||||
{!degraded && (
|
||||
<PredictionCost prediction={prediction} />
|
||||
)}
|
||||
|
||||
{/* ── 元信息 ── */}
|
||||
<p className="text-center text-2xs text-ink-500">
|
||||
{mode === 'multi' ? `多专家模式 · ${okReports.length}/${reports.length} 路有效` : '单次模式'}
|
||||
{prediction.prompt_version && ` · prompt ${prediction.prompt_version}`}
|
||||
</p>
|
||||
|
||||
{/* ── 终裁/降级说明意见 ── */}
|
||||
{prediction.reasoning && degraded && (
|
||||
<section>
|
||||
<h4 className="section-head mb-3">终裁意见</h4>
|
||||
<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">
|
||||
{prediction.reasoning}
|
||||
</p>
|
||||
<p className="whitespace-pre-wrap font-serif text-sm leading-loose text-ink-700">{prediction.reasoning}</p>
|
||||
</blockquote>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── 专家意见(多模式):可折叠 + 状态摘要 + 权重条形图 ── */}
|
||||
{mode === 'multi' && reports.length > 0 && (
|
||||
<section>
|
||||
<button
|
||||
onClick={() => setExpertsOpen(o => !o)}
|
||||
className="flex w-full items-center justify-between border-b border-ink-200 pb-2 text-left"
|
||||
>
|
||||
<span className="section-head mb-0">五路专家意见({okReports.length}/{reports.length} 路有效)</span>
|
||||
<span className="text-2xs text-ink-400">{expertsOpen ? '收起' : '展开'}</span>
|
||||
</button>
|
||||
|
||||
{/* 权重条形图(仅 success 且有权重时显示) */}
|
||||
{!degraded && prediction.agent_weights && Object.keys(prediction.agent_weights).length > 0 && (
|
||||
<div className="mt-3 space-y-1.5">
|
||||
<span className="text-2xs text-ink-500">终裁权重分布</span>
|
||||
{Object.entries(prediction.agent_weights)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([k, v]) => (
|
||||
<div key={k} className="grid grid-cols-[96px_minmax(0,1fr)_40px] items-center gap-2">
|
||||
<span className="truncate text-2xs text-ink-500">{AGENT_LABELS[k] ?? k}</span>
|
||||
<div className="h-1.5 bg-paper-100">
|
||||
<div className="h-full bg-press" style={{ width: `${Math.round(v * 100)}%` }} />
|
||||
</div>
|
||||
<span className="text-right text-2xs tabular-nums text-ink-500">{Math.round(v * 100)}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{expertsOpen && (
|
||||
<div className="mt-2">
|
||||
{reports.map((r, i) => (
|
||||
<AgentCard key={r.agent} report={r} no={CN_NUM[i] ?? String(i + 1)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── 终裁意见(success) ── */}
|
||||
{prediction.reasoning && !degraded && mode === 'multi' && (
|
||||
<section>
|
||||
<h4 className="section-head mb-3">终裁意见</h4>
|
||||
<blockquote className="border-l-2 border-press pl-4">
|
||||
<p className="whitespace-pre-wrap font-serif text-sm leading-loose text-ink-700">{prediction.reasoning}</p>
|
||||
</blockquote>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
@@ -862,3 +1064,164 @@ function AgentCard({ report: r, no }: { report: AgentReport; no: string }) {
|
||||
</details>
|
||||
)
|
||||
}
|
||||
|
||||
/** 比赛详情面板:双方近况/H2H + 历史预测列表(只读) */
|
||||
function MatchDetailPanel({
|
||||
match, detail, ctx, loading, mode,
|
||||
}: {
|
||||
match: Match
|
||||
detail: MatchDetailOut | undefined
|
||||
ctx: MatchContextOut | undefined
|
||||
loading: boolean
|
||||
mode: 'single' | 'multi'
|
||||
}) {
|
||||
const homeName = match.home_team_zh || match.home_team
|
||||
const awayName = match.away_team_zh || match.away_team
|
||||
const finished = match.match_status === 'finished'
|
||||
|
||||
return (
|
||||
<div className="border-b border-ink-200 bg-paper-100/50 px-3 py-4">
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 text-xs text-ink-500"><Spinner /> 加载详情中…</div>
|
||||
)}
|
||||
|
||||
{!loading && !detail && !ctx && (
|
||||
<p className="py-4 text-center text-xs text-ink-400">暂无详情数据</p>
|
||||
)}
|
||||
|
||||
{!loading && (detail || ctx) && (
|
||||
<div className="space-y-5">
|
||||
{/* 比分区(终场/当前比分 + 状态 + 预测按钮) */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="text-center">
|
||||
<p className="font-serif text-3xl font-bold tabular-nums leading-none text-ink-900">
|
||||
{match.home_goals ?? '-'}{' '}<span className="text-ink-300">:</span>{' '}{match.away_goals ?? '-'}
|
||||
</p>
|
||||
<p className="mt-1 text-2xs text-ink-500">
|
||||
{match.match_stage || ''} {match.match_status === 'finished' ? '· 已完赛' : match.match_status === 'scheduled' ? '· 未开赛' : `· ${match.match_status}`}
|
||||
</p>
|
||||
{match.home_xg != null && match.away_xg != null && (
|
||||
<p className="text-2xs tabular-nums text-ink-400">xG {match.home_xg.toFixed(1)}–{match.away_xg.toFixed(1)}</p>
|
||||
)}
|
||||
</div>
|
||||
{!finished && (
|
||||
<span className="text-2xs text-ink-500">
|
||||
点击行首「预测」按钮发起{mode === 'multi' ? '多专家' : '单次'}分析
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 双方近况 + H2H */}
|
||||
{(ctx?.home_recent?.length || ctx?.away_recent?.length || ctx?.h2h?.length) ? (
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<RecentBlock title={`${homeName} 近况`} rows={ctx?.home_recent} side="home" />
|
||||
<RecentBlock title={`${awayName} 近况`} rows={ctx?.away_recent} side="away" />
|
||||
<RecentBlock title="历史交锋(H2H)" rows={ctx?.h2h} side="h2h" />
|
||||
</div>
|
||||
) : (
|
||||
!loading && <p className="text-2xs text-ink-400">暂无近期对战数据</p>
|
||||
)}
|
||||
|
||||
{/* 历史预测列表 */}
|
||||
<div>
|
||||
<h4 className="section-head mb-2">历史预测({detail?.recent_predictions?.length ?? 0})</h4>
|
||||
{detail?.recent_predictions?.length ? (
|
||||
<div className="space-y-2">
|
||||
{detail.recent_predictions.map(p => (
|
||||
<PredictionHistoryRow key={p.id} p={p} mode={mode} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-3 text-center text-2xs text-ink-400">该场比赛暂无预测记录</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 近况/H2H 单区块 */
|
||||
function RecentBlock({ title, rows, side }: { title: string; rows?: TeamRecentMatch[]; side: 'home' | 'away' | 'h2h' }) {
|
||||
return (
|
||||
<div>
|
||||
<h5 className="mb-1.5 text-2xs font-medium text-ink-500">{title}</h5>
|
||||
{rows && rows.length > 0 ? (
|
||||
<ul className="space-y-1">
|
||||
{rows.map((r, i) => {
|
||||
const date = r.match_date ? r.match_date.slice(5, 10) : '—'
|
||||
const score = (r.home_goals != null && r.away_goals != null) ? `${r.home_goals}-${r.away_goals}` : 'vs'
|
||||
const label = side === 'h2h'
|
||||
? `${r.home_team ?? '?'} ${score} ${r.away_team ?? '?'}`
|
||||
: `${score}`
|
||||
return (
|
||||
<li key={i} className="flex items-center justify-between text-2xs tabular-nums text-ink-600">
|
||||
<span className="text-ink-400">{date}</span>
|
||||
<span className="truncate">{label}</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-2xs text-ink-300">暂无</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 历史预测单行(含专家报告入口) */
|
||||
function PredictionHistoryRow({ p, mode }: { p: MatchRecentPrediction; mode: 'single' | 'multi' }) {
|
||||
const badge = p.status === 'degraded'
|
||||
? { label: 'degraded', cls: 'text-press' }
|
||||
: p.settled
|
||||
? { label: p.correct_1x2 === undefined ? '已结算' : p.correct_1x2 ? '命中' : '未中', cls: p.correct_1x2 ? 'text-ink-900' : 'text-ink-400' }
|
||||
: { label: p.status === 'success' ? '成功' : p.status, cls: 'text-ink-600' }
|
||||
const score = (p.pred_home_goals != null && p.pred_away_goals != null)
|
||||
? `${p.pred_home_goals.toFixed(1)}-${p.pred_away_goals.toFixed(1)}`
|
||||
: '—'
|
||||
const alt = (p.alt_pred_home_goals != null && p.alt_pred_away_goals != null)
|
||||
? `${p.alt_pred_home_goals.toFixed(1)}-${p.alt_pred_away_goals.toFixed(1)}` : null
|
||||
const hasAgents = mode === 'multi' && p.agent_outputs && p.agent_outputs.length > 0
|
||||
|
||||
return (
|
||||
<div className="border-b border-ink-200 pb-2 last:border-b-0">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="tabular-nums text-ink-600">
|
||||
{score} {p.pred_1x2 ? `(${p.pred_1x2})` : ''}
|
||||
{alt && <span className="ml-1 text-ink-400">备选 {alt}</span>}
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
{p.subjective_confidence != null && (
|
||||
<span className="text-2xs tabular-nums text-ink-400">信心 {Math.round(p.subjective_confidence * 100)}%</span>
|
||||
)}
|
||||
<span className={`text-2xs ${badge.cls}`}>{badge.label}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center justify-between text-2xs text-ink-400">
|
||||
<span className="truncate">{p.model} · {p.mode} · {p.created_at?.slice(0, 16).replace('T', ' ') ?? '—'}</span>
|
||||
{hasAgents && <span className="text-press">{p.agent_outputs!.length} 路专家报告</span>}
|
||||
</div>
|
||||
{p.reasoning && (
|
||||
<p className="mt-1 line-clamp-2 font-serif text-2xs leading-relaxed text-ink-500">{p.reasoning}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
function readablePredictError(e: unknown): string {
|
||||
if (e instanceof Error) {
|
||||
const m = e.message
|
||||
if (/429/.test(m)) {
|
||||
// 429 来自后端限流(每分钟 10 次),非上游 LLM
|
||||
return '操作过于频繁:每分钟最多 10 次预测。为保护 LLM 额度,请稍后再试。'
|
||||
}
|
||||
if (/502/.test(m)) return 'LLM 服务暂时不可用(502),请稍后重试'
|
||||
if (/402|Payment Required|额度|余额/.test(m)) return 'LLM 额度不足(402),请检查 API Key 余额'
|
||||
if (/400|已完赛/.test(m)) return '该比赛已完赛,不再支持预测'
|
||||
if (/409|已结算/.test(m)) return '该预测已结算,不能重新预测'
|
||||
if (/timeout|超时|timed out/i.test(m)) return '请求超时,请稍后重试或改用单次模式'
|
||||
return m
|
||||
}
|
||||
return String(e)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user