- bzzoiro events/standings/stats 抓取失败写入 IngestFailure 死信(尽力而为, 写入失败不影响主流程);顺带修复 standings 失败路径 league_r 缺 errors 键 的 KeyError —— 该路径此前从未跑通,一旦失败会顶掉原始异常 - admin/api.ts 收敛为 lib/http.ts 薄门面,消除第二套 HTTP 实现; UNAUTHORIZED_EVENT 定义移至共享层,断开 lib→admin 反向依赖 - STATUS_META 死键 live 改为 in_play(对齐 normalize.py 口径), 补 paused/postponed/cancelled/suspended;移除无人消费的 DashboardStats.total_matches(items.length 近似,上限 100) - 新增 tests/test_ingest_deadletter.py(6 例,变异验证判别力)
1392 lines
58 KiB
TypeScript
1392 lines
58 KiB
TypeScript
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'
|
||
import type { MatchStatsDetail } from '../admin/types'
|
||
import { http } from '../lib/http'
|
||
|
||
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
|
||
alt_pred_home_goals: number | null
|
||
alt_pred_away_goals: number | null
|
||
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 {
|
||
agent: string
|
||
status: string
|
||
data_sufficiency: string
|
||
analysis: string
|
||
home_edge: number | null
|
||
subjective_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: '主客因素分析专家',
|
||
standings: '联赛排名分析专家',
|
||
}
|
||
|
||
const LEAGUES = [
|
||
{ code: 'E0', name: '英超' },
|
||
{ code: 'SP1', name: '西甲' },
|
||
{ code: 'D1', name: '德甲' },
|
||
{ code: 'I1', name: '意甲' },
|
||
{ code: 'F1', name: '法甲' },
|
||
]
|
||
|
||
/** 汉字编号,给专家意见排版用 */
|
||
const CN_NUM = ['一', '二', '三', '四', '五', '六', '七', '八']
|
||
|
||
const STATUS_META: Record<string, { label: string; cls: string }> = {
|
||
finished: { label: '已完赛', cls: 'text-ink-400' },
|
||
scheduled: { label: '未开赛', cls: 'text-ink-600' },
|
||
// 键与 normalize.py 的 VALID_STATUS 对齐: 库里存的是 in_play(上游 live 被归一化),不存在 'live' 状态
|
||
in_play: { label: '进行中', cls: 'text-press font-medium' },
|
||
paused: { label: '暂停', cls: 'text-press font-medium' },
|
||
postponed: { label: '延期', cls: 'text-ink-400' },
|
||
cancelled: { label: '取消', cls: 'text-ink-400' },
|
||
suspended: { label: '中止', cls: 'text-ink-400' },
|
||
}
|
||
|
||
/** 1x2 → 中文标签 */
|
||
const OUTCOME_LABEL: Record<string, string> = { '1': '主胜', X: '平局', '2': '客胜' }
|
||
|
||
/** 置信度细线:0~1 数值的低调可视化 */
|
||
function Meter({ value }: { value: number }) {
|
||
const pct = Math.max(0, Math.min(100, Math.round(value * 100)))
|
||
return (
|
||
<div className="h-px w-full bg-ink-200" role="presentation">
|
||
<div className="h-px bg-press transition-[width] duration-500" style={{ width: `${pct}%` }} />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** home_edge(-1~1,正=利主队)的可视化:以中线为原点的双向细条 */
|
||
function EdgeBar({ value }: { value: number }) {
|
||
const v = Math.max(-1, Math.min(1, value))
|
||
const half = Math.abs(v) * 50
|
||
return (
|
||
<div className="relative h-px w-full bg-ink-200" role="presentation">
|
||
<span className="absolute left-1/2 top-1/2 h-2 w-px -translate-x-1/2 -translate-y-1/2 bg-ink-400" />
|
||
<span
|
||
className={`absolute top-0 h-px transition-all duration-500 ${v >= 0 ? 'bg-press' : 'bg-ink-600'}`}
|
||
style={
|
||
v >= 0
|
||
? { left: '50%', width: `${half}%` }
|
||
: { right: '50%', width: `${half}%` }
|
||
}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** 骨架占位行:低调脉动灰块 */
|
||
function SkeletonRows({ n = 4 }: { n?: number }) {
|
||
return (
|
||
<>
|
||
{Array.from({ length: n }).map((_, i) => (
|
||
<div key={i} className="flex items-center gap-4 border-b border-ink-200 px-1 py-3.5">
|
||
<div className="skeleton h-3 w-16" />
|
||
<div className="skeleton h-3 flex-1" />
|
||
<div className="skeleton h-3 w-10" />
|
||
<div className="skeleton h-3 flex-1" />
|
||
<div className="skeleton h-3 w-16" />
|
||
</div>
|
||
))}
|
||
</>
|
||
)
|
||
}
|
||
|
||
function Spinner({ className = '' }: { className?: string }) {
|
||
return (
|
||
<svg
|
||
viewBox="0 0 20 20"
|
||
className={`h-3.5 w-3.5 animate-spin ${className}`}
|
||
fill="none"
|
||
aria-hidden="true"
|
||
>
|
||
<circle cx="10" cy="10" r="7.5" stroke="currentColor" strokeWidth="1.5" strokeOpacity="0.25" />
|
||
<path d="M17.5 10A7.5 7.5 0 0010 2.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||
</svg>
|
||
)
|
||
}
|
||
|
||
/** 胜平负一行文字:选中的红字加方块标记,未选中的退灰 */
|
||
function OutcomeLine({
|
||
pick,
|
||
confidence,
|
||
}: {
|
||
pick: string | null
|
||
confidence: number | null
|
||
}) {
|
||
const options = ['1', 'X', '2'] as const
|
||
return (
|
||
<div>
|
||
<div className="flex items-baseline justify-center gap-6 sm:gap-10">
|
||
{options.map(o => {
|
||
const on = pick === o
|
||
return (
|
||
<div key={o} className="flex flex-col items-center gap-1">
|
||
<span className={`flex items-center gap-1.5 text-sm ${on ? 'font-semibold text-press' : 'text-ink-400'}`}>
|
||
{on && <span className="inline-block h-2 w-2 bg-press" aria-hidden="true" />}
|
||
{OUTCOME_LABEL[o]}
|
||
</span>
|
||
{on && confidence !== null && (
|
||
<span className="text-2xs tabular-nums text-ink-500">
|
||
置信 {Math.round(confidence * 100)}%
|
||
</span>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
{pick && confidence !== null && (
|
||
<div className="mx-auto mt-3 max-w-xs">
|
||
<Meter value={confidence} />
|
||
<p className="mt-1 text-center text-2xs text-ink-400">主观置信度,非统计概率</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default function Matches() {
|
||
const [league, setLeague] = useState('E0')
|
||
const [status, setStatus] = useState('scheduled')
|
||
const [matches, setMatches] = useState<Match[]>([])
|
||
const [nextCursor, setNextCursor] = useState<string | null>(null)
|
||
const [loadingMore, setLoadingMore] = useState(false)
|
||
const [loading, setLoading] = useState(false)
|
||
const [showAllUpcoming, setShowAllUpcoming] = useState(false) // 默认仅展示未来 3 天;true 展开全部
|
||
const [liveMatches, setLiveMatches] = useState<Match[]>([]) // 进行中比赛(顶部独立区块)
|
||
const [showBackTop, setShowBackTop] = useState(false) // 回到顶部按钮显示态
|
||
|
||
// 监听滚动,超过 300px 显示回到顶部按钮
|
||
useEffect(() => {
|
||
const handleScroll = () => setShowBackTop(window.scrollY > 300)
|
||
window.addEventListener('scroll', handleScroll, { passive: true })
|
||
return () => window.removeEventListener('scroll', handleScroll)
|
||
}, [])
|
||
|
||
const scrollToTop = () => window.scrollTo({ top: 0, behavior: 'smooth' })
|
||
const [predictingId, setPredictingId] = useState<number | null>(null)
|
||
const [prediction, setPrediction] = useState<Prediction | null>(null)
|
||
const [predictionFor, setPredictionFor] = useState<Match | null>(null)
|
||
const [error, setError] = useState<string | null>(null)
|
||
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)
|
||
|
||
// 请求竞态防护:切换联赛/状态很快时,先发的慢请求可能后返回,
|
||
// 把旧结果覆盖到新筛选上。用递增序号只认最后一次请求的响应。
|
||
const loadSeq = useRef(0)
|
||
const predictSeq = useRef(0)
|
||
// 预测请求控制器:关闭弹窗时中止
|
||
const predictAbort = useRef<AbortController | null>(null)
|
||
|
||
function closePredict() {
|
||
predictAbort.current?.abort()
|
||
predictSeq.current++ // 令中止请求的 catch/then 全部失效,不再写入错误
|
||
setPredictingId(null)
|
||
setPrediction(null)
|
||
setPredictionFor(null)
|
||
setError(null)
|
||
}
|
||
|
||
const load = useCallback(async () => {
|
||
const seq = ++loadSeq.current
|
||
setLoading(true)
|
||
setLoadingMore(false)
|
||
setShowAllUpcoming(false) // 切换筛选重置为「未来 3 天」视图
|
||
setError(null)
|
||
try {
|
||
const params = new URLSearchParams({ league, status, limit: '50' })
|
||
const data = await http.get<{ items: Match[]; next_cursor: string | null }>(`/matches?${params}`)
|
||
if (seq !== loadSeq.current) return // 已有更新的请求,丢弃本次结果
|
||
setMatches(data.items)
|
||
setNextCursor(data.next_cursor ?? null)
|
||
} catch (e) {
|
||
if (seq !== loadSeq.current) return
|
||
setError(e instanceof Error ? e.message : String(e))
|
||
} finally {
|
||
if (seq === loadSeq.current) setLoading(false)
|
||
}
|
||
}, [league, status])
|
||
|
||
// 加载下一页(游标分页)
|
||
const loadMore = async () => {
|
||
if (!nextCursor || loadingMore) return
|
||
const seq = loadSeq.current // 不做自增:切换筛选会自增,这里只跟随当前序列
|
||
setLoadingMore(true)
|
||
try {
|
||
const params = new URLSearchParams({ league, status, limit: '50', cursor: nextCursor })
|
||
const data = await http.get<{ items: Match[]; next_cursor: string | null }>(`/matches?${params}`)
|
||
if (seq !== loadSeq.current) return
|
||
setMatches(prev => [...prev, ...data.items])
|
||
setNextCursor(data.next_cursor ?? null)
|
||
} catch (e) {
|
||
if (seq !== loadSeq.current) return
|
||
setError(e instanceof Error ? e.message : String(e))
|
||
} finally {
|
||
if (seq === loadSeq.current) setLoadingMore(false)
|
||
}
|
||
}
|
||
|
||
// 加载进行中比赛(顶部独立区块)
|
||
const loadLive = useCallback(async () => {
|
||
try {
|
||
const params = new URLSearchParams({ league, status: 'in_play', limit: '20' })
|
||
const data = await http.get<{ items: Match[] }>(`/matches?${params}`)
|
||
setLiveMatches(data.items ?? [])
|
||
} catch {
|
||
/* ignore:进行中非核心功能 */
|
||
}
|
||
}, [league])
|
||
|
||
useEffect(() => { load(); loadLive() }, [load, loadLive])
|
||
|
||
/** 日期 key 辅助:YYYY-MM-DD(本地时区) */
|
||
function dateKey(d: Date): string {
|
||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||
}
|
||
/** 未来 3 天窗口:今天 00:00 → 第 3 天 00:00(即今天/明天/后天) */
|
||
function addDays(d: Date, n: number): string {
|
||
const x = new Date(d)
|
||
x.setFullYear(x.getFullYear(), x.getMonth(), x.getDate() + n)
|
||
return dateKey(x)
|
||
}
|
||
const todayKey = dateKey(new Date())
|
||
const windowEnd = addDays(new Date(), 3) // 不含
|
||
|
||
/** 比赛是否在未来 3 天内(用于默认视图过滤) */
|
||
function withinNext3Days(matchDate: string): boolean {
|
||
const key = toLocalDateKey(matchDate)
|
||
return key >= todayKey && key < windowEnd
|
||
}
|
||
|
||
/** UTC ISO → 本地日期 YYYY-MM-DD(用于分组) */
|
||
function toLocalDateKey(iso: string): string {
|
||
const d = new Date(iso)
|
||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||
}
|
||
|
||
/** 按本地日期分组(非 UTC),保持时间序 */
|
||
function groupByDate(list: Match[]): Array<[string, Match[]]> {
|
||
const map = new Map<string, Match[]>()
|
||
for (const m of list) {
|
||
const key = toLocalDateKey(m.match_date)
|
||
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)
|
||
setPrediction(null)
|
||
setPredictionFor(m)
|
||
// LLM 多专家预测耗时可达数分钟,给足超时(与 nginx 代理 300s 对齐)
|
||
const controller = new AbortController()
|
||
predictAbort.current = controller
|
||
const timer = setTimeout(() => controller.abort(), 300_000)
|
||
try {
|
||
const data = await http.post<Prediction>('/predict', { match_id: m.id, mode: 'multi' }, {
|
||
timeoutMs: 300_000,
|
||
signal: controller.signal,
|
||
})
|
||
if (seq !== predictSeq.current) return
|
||
setPrediction(data)
|
||
} catch (e) {
|
||
if (seq !== predictSeq.current) return
|
||
setError(
|
||
e instanceof DOMException && e.name === 'AbortError'
|
||
? '预测超时(5 分钟),请稍后重试'
|
||
: readablePredictError(e),
|
||
)
|
||
} finally {
|
||
clearTimeout(timer)
|
||
if (seq === predictSeq.current) 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' })
|
||
}
|
||
|
||
/** 只显示时间 HH:mm(日期由分组头承担) */
|
||
const fmtTime = (s: string) => {
|
||
const d = new Date(s)
|
||
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false })
|
||
}
|
||
|
||
const leagueName = LEAGUES.find(l => l.code === league)?.name ?? league
|
||
|
||
/** 未开赛默认仅展示未来 3 天;其余状态展示全部。showAllUpcoming=true 时展开全部。 */
|
||
const isScheduledView = status === 'scheduled'
|
||
const visibleMatches = (!isScheduledView || showAllUpcoming)
|
||
? matches
|
||
: matches.filter(m => withinNext3Days(m.match_date))
|
||
// 是否有被折叠的未开赛比赛(用于显示「展开」按钮)
|
||
const hasHiddenUpcoming = isScheduledView && !showAllUpcoming && matches.length > visibleMatches.length
|
||
|
||
/** 状态/模式一组的文字切换 */
|
||
const Switch = ({ value, onChange, items }: {
|
||
value: string
|
||
onChange: (v: string) => void
|
||
items: { v: string; label: string; title?: string }[]
|
||
}) => (
|
||
<span className="inline-flex items-center gap-2.5">
|
||
{items.map((it, i) => (
|
||
<span key={it.v} className="inline-flex items-center gap-2.5">
|
||
{i > 0 && <span className="text-ink-300" aria-hidden="true">/</span>}
|
||
<button
|
||
onClick={() => onChange(it.v)}
|
||
title={it.title}
|
||
className={`relative tab ${value === it.v ? 'tab-on' : ''} text-xs`}
|
||
>
|
||
{it.label}
|
||
</button>
|
||
</span>
|
||
))}
|
||
</span>
|
||
)
|
||
|
||
return (
|
||
<div className="space-y-5">
|
||
{/* ── 联赛版面切换 ── */}
|
||
<nav className="flex items-center gap-6 overflow-x-auto border-b border-ink-900" aria-label="联赛">
|
||
{LEAGUES.map(l => (
|
||
<button
|
||
key={l.code}
|
||
onClick={() => setLeague(l.code)}
|
||
className={`relative tab ${league === l.code ? 'tab-on' : ''} font-serif`}
|
||
>
|
||
{l.name}
|
||
</button>
|
||
))}
|
||
</nav>
|
||
|
||
{/* ── 第二行:状态 / 模式 / 日期 / 计数 / 刷新(小屏 flex-wrap) ── */}
|
||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-ink-500 sm:gap-x-5">
|
||
<span className="inline-flex items-center gap-2">
|
||
<span className="text-2xs text-ink-400">状态</span>
|
||
<Switch
|
||
value={status}
|
||
onChange={setStatus}
|
||
items={[
|
||
{ v: 'scheduled', label: '未开赛' },
|
||
{ v: 'finished', label: '已完赛' },
|
||
{ v: '', label: '全部' },
|
||
]}
|
||
/>
|
||
</span>
|
||
|
||
<span className="ml-auto inline-flex items-center gap-3">
|
||
<span className="tabular-nums">
|
||
{isScheduledView && !showAllUpcoming && hasHiddenUpcoming
|
||
? `未来3天 ${visibleMatches.length} / 共 ${matches.length} 场`
|
||
: `共 ${visibleMatches.length} 场`}
|
||
</span>
|
||
<button onClick={load} disabled={loading} className="btn btn-sm">
|
||
{loading ? (<><Spinner /> 获取中</>) : '刷新'}
|
||
</button>
|
||
</span>
|
||
</div>
|
||
|
||
{/* ── 错误提示(统一 error-banner 样式) ── */}
|
||
{error && (
|
||
<div className="error-banner">
|
||
<div>
|
||
<p className="error-banner-title">请求失败</p>
|
||
<p className="error-banner-detail">{error}</p>
|
||
</div>
|
||
<button onClick={() => setError(null)} className="text-ink-400 transition-colors hover:text-ink-900 text-lg leading-none p-1" aria-label="关闭">×</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── 预测弹窗:进行中可视化 / 结果面板 ── */}
|
||
{predictionFor && (
|
||
<PredictModal
|
||
match={predictionFor}
|
||
predicting={predictingId === predictionFor.id}
|
||
prediction={predictingId === predictionFor.id ? null : prediction}
|
||
error={predictingId === predictionFor.id ? null : error}
|
||
onClose={closePredict}
|
||
/>
|
||
)}
|
||
|
||
{/* ── 进行中比赛(顶部独立区块,仅未开赛视图展示) ── */}
|
||
{isScheduledView && liveMatches.length > 0 && (
|
||
<section aria-label="进行中" className="border border-ink-900 bg-paper-100">
|
||
<div className="flex items-center gap-2 border-b border-ink-900 px-3 py-2">
|
||
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-press" />
|
||
<span className="text-xs font-medium tracking-wide text-ink-700">进行中 · 实时比分</span>
|
||
<span className="text-2xs text-ink-400">{liveMatches.length} 场</span>
|
||
</div>
|
||
<div className="divide-y divide-ink-200">
|
||
{liveMatches.map(m => {
|
||
const homeName = m.home_team_zh || m.home_team
|
||
const awayName = m.away_team_zh || m.away_team
|
||
return (
|
||
<div key={m.id} className="flex items-center justify-between gap-3 px-3 py-2.5">
|
||
<div className="flex items-center gap-2 min-w-0">
|
||
<span className="w-12 shrink-0 text-center font-serif text-lg font-bold tabular-nums text-ink-900">
|
||
{m.home_goals ?? '-'}
|
||
</span>
|
||
<span className="min-w-0 truncate text-xs text-ink-700">{homeName}</span>
|
||
</div>
|
||
<span className="shrink-0 text-2xs text-ink-400">vs</span>
|
||
<div className="flex items-center gap-2 min-w-0">
|
||
<span className="min-w-0 truncate text-right text-xs text-ink-700">{awayName}</span>
|
||
<span className="w-12 shrink-0 text-center font-serif text-lg font-bold tabular-nums text-ink-900">
|
||
{m.away_goals ?? '-'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{/* ── 赛程栏:表格化,行间细线,按日期分组 ── */}
|
||
<section aria-label="赛程">
|
||
{loading && <SkeletonRows n={4} />}
|
||
|
||
{!loading && visibleMatches.length === 0 && (
|
||
<div className="empty-state">
|
||
{matches.length > 0 ? (
|
||
<>
|
||
<p className="empty-state-title">未来 3 天暂无 {leagueName} 比赛</p>
|
||
<p className="empty-state-sub">已导入 {matches.length} 场未开赛,点击下方按钮查看</p>
|
||
</>
|
||
) : (
|
||
<>
|
||
<p className="empty-state-title">本版暂无赛程</p>
|
||
<p className="empty-state-sub">请先通过「数据采集」导入 {leagueName} 的比赛数据</p>
|
||
<a href="/admin/collection" className="empty-state-action">
|
||
前往数据采集 <span aria-hidden="true">→</span>
|
||
</a>
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{!loading && groupByDate(visibleMatches).map(([dateKey, group]) => (
|
||
<div key={dateKey}>
|
||
{/* 日期分组头:sticky 但 z 低于弹窗 z-50 */}
|
||
<div className="sticky top-0 z-20 border-b border-ink-900 bg-paper-100 px-3 py-2 text-xs font-medium tracking-wide text-ink-600">
|
||
{formatDateHeader(dateKey)}
|
||
<span className="ml-2 text-2xs font-normal text-ink-400">{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]
|
||
|
||
// 展开时懒加载详情(只读,不触发 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)
|
||
}
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div key={m.id}>
|
||
{/* 行:可点击展开 */}
|
||
<div
|
||
className={`border-b border-ink-200 px-4 py-5 transition-colors hover:bg-paper-100/70 cursor-pointer sm:px-1 sm:py-4 ${
|
||
expanded ? 'bg-paper-100/60' : ''
|
||
}`}
|
||
onClick={toggleExpand}
|
||
role="button"
|
||
tabIndex={0}
|
||
onKeyDown={e => { if (e.key === 'Enter') toggleExpand() }}
|
||
aria-expanded={expanded}
|
||
>
|
||
{/* 桌面 grid: 日期 | 主队 | 比分 | 客队 | 状态 | 按钮 */}
|
||
<div className="flex flex-col gap-3 sm:grid sm:grid-cols-[96px_minmax(0,1fr)_72px_minmax(0,1fr)_64px_88px] sm:items-center sm:gap-x-4 sm:gap-y-0">
|
||
{/* 日期 + 状态:小屏同行;桌面 date 单独一列 */}
|
||
<div className="flex items-center justify-between text-xs sm:contents">
|
||
<span className="tabular-nums text-ink-500 sm:text-xs">{fmtTime(m.match_date)}</span>
|
||
<span className={`sm:hidden ${st.cls}`}>{st.label}</span>
|
||
</div>
|
||
|
||
{/* 主队 + 比分 + 客队:移动端 grid 三列(严格居中),桌面端 grid 分列 */}
|
||
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-4 sm:contents">
|
||
{/* 主队(右对齐) */}
|
||
<span className="flex min-w-0 items-center justify-end gap-2">
|
||
<TeamSideTag side="home" />
|
||
<span className="truncate text-sm font-medium text-ink-900">{homeName}</span>
|
||
</span>
|
||
|
||
{/* 比分 / VS(严格居中) */}
|
||
<span className="flex flex-col items-center justify-center">
|
||
{m.home_goals !== null && m.away_goals !== null ? (
|
||
<span className="font-serif text-xl font-bold tabular-nums leading-none text-ink-900 sm:text-xl">
|
||
{m.home_goals}<span className="mx-1 font-normal text-ink-300">:</span>{m.away_goals}
|
||
</span>
|
||
) : (
|
||
<span className="text-sm tracking-[0.2em] text-ink-500">VS</span>
|
||
)}
|
||
{m.home_xg !== null && m.away_xg !== null && (
|
||
<span className="mt-0.5 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 items-center gap-2">
|
||
<TeamSideTag side="away" />
|
||
<span className="truncate text-sm font-medium text-ink-900">{awayName}</span>
|
||
</span>
|
||
</div>
|
||
|
||
{/* 状态标签:小屏隐藏(已有);桌面用徽标样式 */}
|
||
<span className="hidden text-right sm:block">
|
||
<span className={`inline-block border px-1.5 py-0.5 text-2xs leading-tight ${st.cls} ${
|
||
m.match_status === 'finished'
|
||
? 'border-ink-200 text-ink-500'
|
||
: m.match_status === 'scheduled'
|
||
? 'border-ink-300 text-ink-600'
|
||
: 'border-press/30 text-press'
|
||
}`}>
|
||
{st.label}
|
||
</span>
|
||
</span>
|
||
|
||
{/* 预测按钮(统一,响应式尺寸) */}
|
||
{!finished && (
|
||
<div className="flex justify-end" onClick={e => e.stopPropagation()}>
|
||
<button
|
||
onClick={() => predict(m)}
|
||
disabled={busy}
|
||
className={`btn ${busy ? '' : 'btn-solid'} w-full min-h-[44px] sm:w-[84px] sm:min-h-0 sm:btn-sm`}
|
||
title="以多专家模式预测这场"
|
||
>
|
||
{busy ? (<><Spinner /> 预测中</>) : '预测'}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>{/* 关闭可点击行 */}
|
||
|
||
{/* 展开详情面板 */}
|
||
{expanded && (
|
||
<MatchDetailPanel
|
||
match={m} detail={detail} ctx={ctx}
|
||
loading={detailLoading === m.id}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
))}
|
||
|
||
{/* 单一「加载更多」按钮:3 天视图时先展开,展开后从服务器拉取下一页 */}
|
||
{!loading && (hasHiddenUpcoming || nextCursor) && (
|
||
<div className="flex justify-center pt-4">
|
||
{hasHiddenUpcoming ? (
|
||
<button
|
||
onClick={() => setShowAllUpcoming(true)}
|
||
className="btn btn-outline min-h-[44px] w-full max-w-xs sm:w-auto"
|
||
>
|
||
显示后续 {matches.length - visibleMatches.length} 场未开赛
|
||
</button>
|
||
) : (
|
||
<button onClick={loadMore} disabled={loadingMore} className="btn min-h-[44px] w-full max-w-xs sm:w-auto">
|
||
{loadingMore ? (<><Spinner /> 获取中</>) : '载入更多赛程'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* 已展开全部但未开赛:提供「收起」回未来 3 天 */}
|
||
{!loading && isScheduledView && showAllUpcoming && matches.length > 0 && (
|
||
<div className="flex justify-center pt-2">
|
||
<button
|
||
onClick={() => setShowAllUpcoming(false)}
|
||
className="text-xs text-ink-400 hover:text-ink-700 transition-colors"
|
||
>
|
||
收起,仅显示未来 3 天
|
||
</button>
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
{/* 回到顶部按钮 */}
|
||
<button
|
||
onClick={scrollToTop}
|
||
className={`fixed bottom-6 right-6 z-40 flex h-10 w-10 items-center justify-center rounded-full border border-ink-200 bg-paper-50 text-ink-600 shadow-lg transition-all duration-300 hover:border-ink-400 hover:text-ink-900 ${
|
||
showBackTop ? 'translate-y-0 opacity-100' : 'translate-y-4 opacity-0 pointer-events-none'
|
||
}`}
|
||
aria-label="回到顶部"
|
||
>
|
||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** 日期分组头显示:今日/明天/周几 · 年月日 */
|
||
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.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
|
||
const tmr = new Date(today)
|
||
tmr.setDate(tmr.getDate() + 1)
|
||
const tmrKey = `${tmr.getFullYear()}-${String(tmr.getMonth() + 1).padStart(2, '0')}-${String(tmr.getDate()).padStart(2, '0')}`
|
||
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() {
|
||
const [elapsed, setElapsed] = useState(0)
|
||
useEffect(() => {
|
||
const t = setInterval(() => setElapsed(e => e + 0.5), 500)
|
||
return () => clearInterval(t)
|
||
}, [])
|
||
|
||
// 阶段阈值(秒): 切片 → 专家(各路依次点亮) → 终裁
|
||
const SLICE_END = 3
|
||
const AGENT_START = 4
|
||
const AGENT_STEP = 8 // 每路专家约 8s 点亮一路
|
||
const AGG_START = AGENT_START + AGENT_STEP * 5
|
||
const agents = ['form', 'stats', 'home_away', 'standings', 'h2h']
|
||
|
||
const phase = elapsed < SLICE_END ? 'slice'
|
||
: elapsed < AGG_START ? 'agents' : 'agg'
|
||
|
||
const pct = Math.min(95, Math.round((elapsed / 70) * 100))
|
||
|
||
return (
|
||
<div className="px-5 py-8 sm:px-8">
|
||
{/* 阶段标题 */}
|
||
<div className="flex items-center justify-center gap-2">
|
||
<Spinner className="text-press" />
|
||
<span className="font-serif text-sm font-bold text-ink-900">
|
||
{phase === 'slice' && '正在组装比赛数据切片'}
|
||
{phase === 'agents' && '五路专家并行分析中'}
|
||
{phase === 'agg' && '终裁专家汇总裁定中'}
|
||
</span>
|
||
<span className="text-2xs tabular-nums text-ink-400">{elapsed.toFixed(0)}s</span>
|
||
</div>
|
||
|
||
{/* 进度条:渐进式,不封顶到 100% */}
|
||
<div className="mx-auto mt-5 h-1 w-full max-w-md overflow-hidden bg-ink-100" role="progressbar" aria-valuenow={pct}>
|
||
<div
|
||
className={`h-full bg-press transition-all duration-500 ${phase === 'agg' ? 'animate-pulse' : ''}`}
|
||
style={{ width: `${pct}%` }}
|
||
/>
|
||
</div>
|
||
|
||
{/* 专家灯序(多专家模式) */}
|
||
<ul className="mx-auto mt-6 max-w-md space-y-1.5">
|
||
{agents.map((a, i) => {
|
||
const lit = elapsed >= AGENT_START + AGENT_STEP * (i + 1)
|
||
const activeNow = !lit && elapsed >= AGENT_START + AGENT_STEP * i
|
||
return (
|
||
<li
|
||
key={a}
|
||
className={`flex items-center justify-between border-b border-ink-200 pb-1.5 text-xs transition-colors ${
|
||
lit ? 'text-ink-800' : activeNow ? 'text-ink-900' : 'text-ink-300'
|
||
}`}
|
||
>
|
||
<span className="flex items-center gap-2">
|
||
<span
|
||
aria-hidden="true"
|
||
className={`inline-block h-1.5 w-1.5 ${lit ? 'bg-ink-900' : activeNow ? 'bg-press animate-pulse' : 'bg-ink-200'}`}
|
||
/>
|
||
{AGENT_LABELS[a] ?? a}
|
||
</span>
|
||
{lit && <span className="text-2xs text-ink-400">✓ 完成</span>}
|
||
{activeNow && <span className="text-2xs text-press">分析中…</span>}
|
||
</li>
|
||
)
|
||
})}
|
||
</ul>
|
||
|
||
<p className="mt-6 text-center text-2xs text-ink-400">
|
||
五路专家并行分析后终裁,约需 30-90 秒;多专家调用消耗较多 token,请按需使用。关闭窗口即取消
|
||
</p>
|
||
<p className="mt-1 text-center text-2xs text-ink-300">
|
||
提示:每分钟限 10 次预测,耗尽后需等待下一分钟。
|
||
</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** 预测弹窗:进行中显示过程可视化,完成后显示预测版,失败显示原因 */
|
||
function PredictModal({
|
||
match,
|
||
predicting,
|
||
prediction,
|
||
error,
|
||
onClose,
|
||
}: {
|
||
match: Match
|
||
predicting: boolean
|
||
prediction: Prediction | null
|
||
error: string | null
|
||
onClose: () => void
|
||
}) {
|
||
const homeName = match.home_team_zh || match.home_team
|
||
const awayName = match.away_team_zh || match.away_team
|
||
|
||
useEffect(() => {
|
||
const h = (e: KeyboardEvent) => {
|
||
if (e.key === 'Escape') onClose()
|
||
}
|
||
document.addEventListener('keydown', h)
|
||
return () => document.removeEventListener('keydown', h)
|
||
}, [onClose])
|
||
|
||
return (
|
||
<div
|
||
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-ink-900/50 p-4 sm:items-center"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-label={`预测 ${homeName} 对 ${awayName}`}
|
||
onClick={e => {
|
||
if (e.target === e.currentTarget) onClose()
|
||
}}
|
||
>
|
||
<div className="relative flex max-h-[92vh] w-full max-w-2xl flex-col overflow-hidden bg-paper-50 shadow-2xl">
|
||
{/* 弹窗报头 */}
|
||
<div className="flex flex-shrink-0 items-center justify-between 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">
|
||
预测版 ·
|
||
<TeamSideTag side="home" />
|
||
{homeName}
|
||
<span>对</span>
|
||
<TeamSideTag side="away" />
|
||
{awayName}
|
||
</h3>
|
||
<button
|
||
onClick={onClose}
|
||
className="flex h-11 w-11 flex-shrink-0 items-center justify-center text-ink-400 transition-colors hover:text-ink-900"
|
||
aria-label="关闭"
|
||
>
|
||
<svg className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||
<path d="M6.3 5.3a1 1 0 011.4 0L10 7.6l2.3-2.3a1 1 0 111.4 1.4L11.4 9l2.3 2.3a1 1 0 01-1.4 1.4L10 10.4l-2.3 2.3a1 1 0 01-1.4-1.4L8.6 9 6.3 6.7a1 1 0 010-1.4z" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
{/* 弹窗体(小屏可滚动) */}
|
||
<div className="flex-1 overflow-y-auto">
|
||
{predicting ? (
|
||
<PredictProgress />
|
||
) : error ? (
|
||
<div className="px-5 py-10 text-center sm:px-8">
|
||
<p className="font-serif text-sm font-bold text-press">预测失败</p>
|
||
<p className="mx-auto mt-3 max-w-md whitespace-pre-wrap text-left text-xs leading-relaxed text-ink-600">
|
||
{error}
|
||
</p>
|
||
<button onClick={onClose} className="btn btn-sm mt-6">关闭</button>
|
||
</div>
|
||
) : prediction ? (
|
||
<PredictionPanel prediction={prediction} match={match} embedded />
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function PredictionPanel({
|
||
prediction,
|
||
match,
|
||
embedded = false,
|
||
}: {
|
||
prediction: Prediction
|
||
match: Match
|
||
/** 弹窗嵌入模式:弹窗已提供报头,这里省略自带版头 */
|
||
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 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">
|
||
预测版 ·
|
||
<TeamSideTag side="home" />
|
||
{homeName}
|
||
<span>对</span>
|
||
<TeamSideTag side="away" />
|
||
{awayName}
|
||
</h3>
|
||
<span className="text-2xs tabular-nums text-ink-500">
|
||
{prediction.provider} / {prediction.model}
|
||
{prediction.latency_ms !== null && ` · ${(prediction.latency_ms / 1000).toFixed(1)}s`}
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
<div className="space-y-7 px-4 py-6 sm:px-5">
|
||
{/* ── 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>
|
||
<p className="mt-1.5 whitespace-pre-wrap text-xs leading-relaxed text-ink-600">
|
||
{prediction.reasoning || '所有专家均无有效数据或调用失败,无法生成可靠比分。'}
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── 主结论(仅 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 className="border-y border-ink-200 py-4">
|
||
<OutcomeLine pick={prediction.pred_1x2} confidence={prediction.subjective_confidence} />
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* ── 成本信息(耗时 + token + 限流余量) ── */}
|
||
{!degraded && (
|
||
<PredictionCost prediction={prediction} />
|
||
)}
|
||
|
||
{/* ── 元信息 ── */}
|
||
<p className="text-center text-2xs text-ink-500">
|
||
`多专家模式 · ${okReports.length}/${reports.length} 路有效`
|
||
{prediction.prompt_version && ` · prompt ${prediction.prompt_version}`}
|
||
</p>
|
||
|
||
{/* ── 终裁/降级说明意见 ── */}
|
||
{prediction.reasoning && degraded && (
|
||
<section>
|
||
<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>
|
||
</blockquote>
|
||
</section>
|
||
)}
|
||
|
||
{/* ── 专家意见(多模式):可折叠 + 状态摘要 + 权重条形图 ── */}
|
||
{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 && (
|
||
<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>
|
||
)
|
||
}
|
||
|
||
const STATUS_BADGE: Record<string, { label: string; cls: string }> = {
|
||
ok: { label: '正常', cls: 'text-ink-500' },
|
||
no_data: { label: '无数据', cls: 'text-ink-400' },
|
||
error: { label: '调用失败', cls: 'text-press' },
|
||
parse_error: { label: '解析失败', cls: 'text-press' },
|
||
}
|
||
|
||
const SUFFICIENCY_LABEL: Record<string, string> = {
|
||
high: '充分',
|
||
medium: '一般',
|
||
low: '偏少',
|
||
none: '无',
|
||
}
|
||
|
||
/** 单路专家意见:汉字编号 + 细线行 */
|
||
function AgentCard({ report: r, no }: { report: AgentReport; no: string }) {
|
||
const badge = STATUS_BADGE[r.status] ?? { label: r.status, cls: 'text-ink-400' }
|
||
const inactive = r.status !== 'ok'
|
||
|
||
return (
|
||
<details className="group border-b border-ink-200">
|
||
<summary className="flex cursor-pointer list-none items-baseline gap-2.5 px-1 py-3">
|
||
<span className="font-serif text-sm text-ink-400">{no}</span>
|
||
<span className="text-sm font-medium text-ink-900">{AGENT_LABELS[r.agent] ?? r.agent}</span>
|
||
<span className={`text-2xs ${badge.cls}`}>{badge.label}</span>
|
||
|
||
<span className="ml-auto flex items-baseline gap-3 text-2xs tabular-nums text-ink-500">
|
||
{r.status === 'ok' && r.subjective_confidence !== null && (
|
||
<span>信心 {Math.round(r.subjective_confidence * 100)}%</span>
|
||
)}
|
||
{r.status === 'ok' && r.probable_score && (
|
||
<span className="font-serif font-bold text-ink-800">{r.probable_score}</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-1 pb-4 pl-7">
|
||
{/* 无数据 / 失败时给出明确说明,避免用户以为是空白 bug */}
|
||
{inactive && (
|
||
<p className="text-xs leading-relaxed text-ink-500">
|
||
{r.status === 'no_data' && '该维度没有可用数据,已跳过 LLM 分析以节省额度(不影响其他专家)。'}
|
||
{r.status === 'error' && '该专家调用失败,本次结论未纳入其视角(fail-open 设计,不阻断整体预测)。'}
|
||
{r.status === 'parse_error' && '模型输出未通过格式校验,该报告已丢弃。'}
|
||
</p>
|
||
)}
|
||
|
||
{!inactive && r.home_edge !== null && (
|
||
<div>
|
||
<div className="mb-1.5 flex items-baseline justify-between text-2xs">
|
||
<span className="text-ink-500">主队优势</span>
|
||
<span className={`font-semibold tabular-nums ${r.home_edge > 0 ? 'text-press' : r.home_edge < 0 ? 'text-ink-700' : 'text-ink-500'}`}>
|
||
{r.home_edge > 0 ? '+' : ''}{r.home_edge.toFixed(2)}
|
||
</span>
|
||
</div>
|
||
<EdgeBar value={r.home_edge} />
|
||
<div className="mt-1 flex justify-between text-2xs text-ink-400">
|
||
<span>利客队</span>
|
||
<span>利主队</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{r.analysis && (
|
||
<p className="font-serif text-sm leading-loose text-ink-700">{r.analysis}</p>
|
||
)}
|
||
|
||
{r.key_evidence.length > 0 && (
|
||
<ul className="space-y-1.5">
|
||
{r.key_evidence.map((e, i) => (
|
||
<li key={i} className="flex gap-2 text-xs leading-relaxed text-ink-600">
|
||
<span className="flex-shrink-0 text-ink-300" aria-hidden="true">—</span>
|
||
<span>{e}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
|
||
{r.exp_home_goals !== null && r.exp_away_goals !== null && (
|
||
<p className="text-xs text-ink-500">
|
||
进球期望 <span className="font-serif font-bold tabular-nums text-ink-900">{r.exp_home_goals.toFixed(1)} - {r.exp_away_goals.toFixed(1)}</span>
|
||
</p>
|
||
)}
|
||
|
||
{!inactive && (
|
||
<p className="border-t border-ink-100 pt-2.5 text-2xs text-ink-400">
|
||
数据充分度 {SUFFICIENCY_LABEL[r.data_sufficiency] ?? r.data_sufficiency}
|
||
<span className="mx-2 text-ink-200">|</span>
|
||
<span className="font-mono">{r.model}</span>
|
||
{r.latency_ms !== null && <span className="ml-2 tabular-nums">{r.latency_ms}ms</span>}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</details>
|
||
)
|
||
}
|
||
|
||
/** 比赛详情面板:双方近况/H2H + 历史预测列表(只读) */
|
||
function MatchDetailPanel({
|
||
match, detail, ctx, loading,
|
||
}: {
|
||
match: Match
|
||
detail: MatchDetailOut | undefined
|
||
ctx: MatchContextOut | undefined
|
||
loading: boolean
|
||
}) {
|
||
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">
|
||
点击行首「预测」按钮发起多专家分析
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* 比赛详细统计(bzzoiro /events/{id}/stats/) */}
|
||
{detail?.stats && (
|
||
<MatchStatsPanel stats={detail.stats} homeName={homeName} awayName={awayName} />
|
||
)}
|
||
|
||
{/* 双方近况 + 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} />
|
||
))}
|
||
</div>
|
||
) : (
|
||
<p className="py-3 text-center text-2xs text-ink-400">该场比赛暂无预测记录</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** 比赛详细统计面板(bzzoiro /events/{id}/stats/) */
|
||
function MatchStatsPanel({
|
||
stats, homeName, awayName,
|
||
}: { stats: MatchStatsDetail; homeName: string; awayName: string }) {
|
||
const rows: Array<{ label: string; home: number | null; away: number | null; highlight?: 'high' | 'low' }> = [
|
||
{ label: '预期进球(xG)', home: stats.home_xg, away: stats.away_xg },
|
||
{ label: '射门', home: stats.home_shots, away: stats.away_shots },
|
||
{ label: '射正', home: stats.home_shots_on_target, away: stats.away_shots_on_target },
|
||
{ label: '角球', home: stats.home_corners, away: stats.away_corners },
|
||
{ label: '犯规', home: stats.home_fouls, away: stats.away_fouls },
|
||
{ label: '绝佳机会', home: stats.home_big_chances, away: stats.away_big_chances },
|
||
{ label: '黄牌', home: stats.home_yellow_cards, away: stats.away_yellow_cards },
|
||
{ label: '红牌', home: stats.home_red_cards, away: stats.away_red_cards },
|
||
]
|
||
const hasAny = rows.some(r => r.home != null || r.away != null)
|
||
if (!hasAny) return null
|
||
|
||
// 控球率用横条展示
|
||
const possHome = stats.home_possession
|
||
const possAway = possHome != null ? Math.max(0, 100 - possHome) : null
|
||
|
||
return (
|
||
<div>
|
||
<h4 className="section-head mb-2">比赛统计</h4>
|
||
|
||
{/* 控球率横条 */}
|
||
{possHome != null && possAway != null && (
|
||
<div className="mb-3">
|
||
<div className="mb-1 flex justify-between text-2xs text-ink-500">
|
||
<span>{possHome.toFixed(0)}%</span>
|
||
<span className="text-ink-400">控球率</span>
|
||
<span>{possAway.toFixed(0)}%</span>
|
||
</div>
|
||
<div className="flex h-1.5 overflow-hidden rounded-full bg-ink-200">
|
||
<div className="bg-ink-700 transition-[width] duration-500" style={{ width: `${possHome}%` }} />
|
||
<div className="bg-ink-300 transition-[width] duration-500" style={{ width: `${possAway}%` }} />
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 主客对比表 */}
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-xs">
|
||
<thead>
|
||
<tr className="border-b border-ink-200 text-ink-400">
|
||
<th className="py-1.5 text-left font-medium">{homeName}</th>
|
||
<th className="py-1.5 text-center font-medium text-ink-500">统计项</th>
|
||
<th className="py-1.5 text-right font-medium">{awayName}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{rows.filter(r => r.home != null || r.away != null).map(r => {
|
||
const h = r.home ?? 0
|
||
const a = r.away ?? 0
|
||
const winner = h > a ? 'home' : h < a ? 'away' : 'tie'
|
||
return (
|
||
<tr key={r.label} className="border-b border-ink-100">
|
||
<td className={`py-1.5 text-right tabular-nums ${winner === 'home' ? 'font-bold text-ink-900' : 'text-ink-500'}`}>
|
||
{r.home ?? '—'}
|
||
</td>
|
||
<td className="py-1.5 text-center text-ink-500">{r.label}</td>
|
||
<td className={`py-1.5 text-left tabular-nums ${winner === 'away' ? 'font-bold text-ink-900' : 'text-ink-500'}`}>
|
||
{r.away ?? '—'}
|
||
</td>
|
||
</tr>
|
||
)
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</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 ? new Date(r.match_date).toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' }) : '—'
|
||
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 }: { p: MatchRecentPrediction }) {
|
||
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 = 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 ? new Date(p.created_at).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }) : '—'}</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)
|
||
}
|