diff --git a/frontend/src/pages/Matches.tsx b/frontend/src/pages/Matches.tsx index 4f102ef..a34372e 100644 --- a/frontend/src/pages/Matches.tsx +++ b/frontend/src/pages/Matches.tsx @@ -1,372 +1,62 @@ -import { useCallback, useEffect, useRef, useState } from 'react' -import TeamSideTag from '../components/TeamSideTag' +/** + * 赛程页(组装层)。 + * + * D3(工程债): 原文件 ~1400 行,已拆分为: + * matches/types.ts — 类型与常量 + * matches/ui.tsx — 原子 UI 小件(Spinner/Skeleton/Switch/日期工具) + * matches/hooks/useMatchesList.ts — 列表/分页/进行中比赛加载 + * matches/hooks/useMatchPredict.ts — 预测流程状态机 + * matches/components/MatchPredictPanel.tsx — 预测弹窗全套 + * matches/components/MatchDetailSection.tsx — 赛程行 + 展开详情 + * 本文件只负责状态装配与版面组织,不含数据获取与展示细节。 + */ +import { useEffect, useState } from 'react' 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 | 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 = { - 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 = { - 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 = { '1': '主胜', X: '平局', '2': '客胜' } - -/** 置信度细线:0~1 数值的低调可视化 */ -function Meter({ value }: { value: number }) { - const pct = Math.max(0, Math.min(100, Math.round(value * 100))) - return ( -
-
-
- ) -} - -/** 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 ( -
- - = 0 ? 'bg-press' : 'bg-ink-600'}`} - style={ - v >= 0 - ? { left: '50%', width: `${half}%` } - : { right: '50%', width: `${half}%` } - } - /> -
- ) -} - -/** 骨架占位行:低调脉动灰块 */ -function SkeletonRows({ n = 4 }: { n?: number }) { - return ( - <> - {Array.from({ length: n }).map((_, i) => ( -
-
-
-
-
-
-
- ))} - - ) -} - -function Spinner({ className = '' }: { className?: string }) { - return ( - - ) -} - -/** 胜平负一行文字:选中的红字加方块标记,未选中的退灰 */ -function OutcomeLine({ - pick, - confidence, -}: { - pick: string | null - confidence: number | null -}) { - const options = ['1', 'X', '2'] as const - return ( -
-
- {options.map(o => { - const on = pick === o - return ( -
- - {on && - {on && confidence !== null && ( - - 置信 {Math.round(confidence * 100)}% - - )} -
- ) - })} -
- {pick && confidence !== null && ( -
- -

主观置信度,非统计概率

-
- )} -
- ) -} +import type { MatchDetailOut, MatchContextOut } from '../admin/types' +import { useMatchesList } from './matches/hooks/useMatchesList' +import { useMatchPredict } from './matches/hooks/useMatchPredict' +import { PredictModal } from './matches/components/MatchPredictPanel' +import { MatchRow } from './matches/components/MatchDetailSection' +import { Spinner, SkeletonRows, Switch, formatDateHeader, groupByDate, withinNext3Days } from './matches/ui' +import { LEAGUES, type Match } from './matches/types' export default function Matches() { - const [league, setLeague] = useState('E0') - const [status, setStatus] = useState('scheduled') - const [matches, setMatches] = useState([]) - const [nextCursor, setNextCursor] = useState(null) - const [loadingMore, setLoadingMore] = useState(false) - const [loading, setLoading] = useState(false) - const [showAllUpcoming, setShowAllUpcoming] = useState(false) // 默认仅展示未来 3 天;true 展开全部 - const [liveMatches, setLiveMatches] = useState([]) // 进行中比赛(顶部独立区块) - const [showBackTop, setShowBackTop] = useState(false) // 回到顶部按钮显示态 + const [error, setError] = useState(null) // 列表与预测共用(拆分前即如此) + const { + league, setLeague, + status, setStatus, + matches, + nextCursor, + loading, + loadingMore, + showAllUpcoming, setShowAllUpcoming, + liveMatches, + load, + loadMore, + } = useMatchesList({ onError: setError }) - // 监听滚动,超过 300px 显示回到顶部按钮 - useEffect(() => { - const handleScroll = () => setShowBackTop(window.scrollY > 300) - window.addEventListener('scroll', handleScroll, { passive: true }) - return () => window.removeEventListener('scroll', handleScroll) - }, []) + const { + predictingId, + prediction, + predictionFor, + predict, + closePredict, + } = useMatchPredict({ onError: setError }) - const scrollToTop = () => window.scrollTo({ top: 0, behavior: 'smooth' }) - const [predictingId, setPredictingId] = useState(null) - const [prediction, setPrediction] = useState(null) - const [predictionFor, setPredictionFor] = useState(null) - const [error, setError] = useState(null) + // ── 详情展开(懒加载,只读,不触发 LLM) ── const [expandedId, setExpandedId] = useState(null) const [detailMap, setDetailMap] = useState>({}) const [contextMap, setContextMap] = useState>({}) const [detailLoading, setDetailLoading] = useState(null) - // 请求竞态防护:切换联赛/状态很快时,先发的慢请求可能后返回, - // 把旧结果覆盖到新筛选上。用递增序号只认最后一次请求的响应。 - const loadSeq = useRef(0) - const predictSeq = useRef(0) - // 预测请求控制器:关闭弹窗时中止 - const predictAbort = useRef(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() - 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('/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 }) - } + // 监听滚动,超过 300px 显示回到顶部按钮 + const [showBackTop, setShowBackTop] = useState(false) + 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 leagueName = LEAGUES.find(l => l.code === league)?.name ?? league @@ -378,27 +68,24 @@ export default function Matches() { // 是否有被折叠的未开赛比赛(用于显示「展开」按钮) 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 }[] - }) => ( - - {items.map((it, i) => ( - - {i > 0 && } - - - ))} - - ) + /** 展开时懒加载详情: 缓存命中则不再请求 */ + async function toggleExpand(m: Match) { + if (expandedId === m.id) { 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 (
@@ -528,126 +215,19 @@ export default function Matches() { {formatDateHeader(dateKey)} {group.length} 场
- {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 ( -
- {/* 行:可点击展开 */} -
{ if (e.key === 'Enter') toggleExpand() }} - aria-expanded={expanded} - > - {/* 桌面 grid: 日期 | 主队 | 比分 | 客队 | 状态 | 按钮 */} -
- {/* 日期 + 状态:小屏同行;桌面 date 单独一列 */} -
- {fmtTime(m.match_date)} - {st.label} -
- - {/* 主队 + 比分 + 客队:移动端 grid 三列(严格居中),桌面端 grid 分列 */} -
- {/* 主队(右对齐) */} - - - {homeName} - - - {/* 比分 / VS(严格居中) */} - - {m.home_goals !== null && m.away_goals !== null ? ( - - {m.home_goals}:{m.away_goals} - - ) : ( - VS - )} - {m.home_xg !== null && m.away_xg !== null && ( - - xG {m.home_xg.toFixed(1)}–{m.away_xg.toFixed(1)} - - )} - - - {/* 客队(左对齐) */} - - - {awayName} - -
- - {/* 状态标签:小屏隐藏(已有);桌面用徽标样式 */} - - - {st.label} - - - - {/* 预测按钮(统一,响应式尺寸) */} - {!finished && ( -
e.stopPropagation()}> - -
- )} -
-
{/* 关闭可点击行 */} - - {/* 展开详情面板 */} - {expanded && ( - - )} -
- ) - })} + {group.map(m => ( + toggleExpand(m)} + onPredict={predict} + /> + ))}
))} @@ -697,695 +277,3 @@ 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.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 ( -
-
- {latency && ( - - 耗时 {latency} - - )} - {tokens && ( - - prompt/completion: {tokens} - - )} - {prediction.rate_limit_remaining != null && prediction.rate_limit_remaining <= 3 && ( - - 剩余配额: {prediction.rate_limit_remaining}/10(分钟) - - )} -
-
- ) -} - - - -/** 预测过程阶段(按时长模拟;结果到达即跳到完成) */ -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 ( -
- {/* 阶段标题 */} -
- - - {phase === 'slice' && '正在组装比赛数据切片'} - {phase === 'agents' && '五路专家并行分析中'} - {phase === 'agg' && '终裁专家汇总裁定中'} - - {elapsed.toFixed(0)}s -
- - {/* 进度条:渐进式,不封顶到 100% */} -
-
-
- - {/* 专家灯序(多专家模式) */} -
    - {agents.map((a, i) => { - const lit = elapsed >= AGENT_START + AGENT_STEP * (i + 1) - const activeNow = !lit && elapsed >= AGENT_START + AGENT_STEP * i - return ( -
  • - - - {lit && ✓ 完成} - {activeNow && 分析中…} -
  • - ) - })} -
- -

- 五路专家并行分析后终裁,约需 30-90 秒;多专家调用消耗较多 token,请按需使用。关闭窗口即取消 -

-

- 提示:每分钟限 10 次预测,耗尽后需等待下一分钟。 -

-
- ) -} - -/** 预测弹窗:进行中显示过程可视化,完成后显示预测版,失败显示原因 */ -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 ( -
{ - if (e.target === e.currentTarget) onClose() - }} - > -
- {/* 弹窗报头 */} -
-

- 预测版 · - - {homeName} - - - {awayName} -

- -
- - {/* 弹窗体(小屏可滚动) */} -
- {predicting ? ( - - ) : error ? ( -
-

预测失败

-

- {error} -

- -
- ) : prediction ? ( - - ) : null} -
-
-
- ) -} - -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 ( -
- {!embedded && ( -
-

- 预测版 · - - {homeName} - - - {awayName} -

- - {prediction.provider} / {prediction.model} - {prediction.latency_ms !== null && ` · ${(prediction.latency_ms / 1000).toFixed(1)}s`} - -
- )} - -
- {/* ── degraded / failed 态:醒目警示 + 原因,不展示虚假比分 ── */} - {degraded && ( -
-

- {prediction.status === 'failed' ? '预测失败' : '预测降级(degraded)'} -

-

- {prediction.reasoning || '所有专家均无有效数据或调用失败,无法生成可靠比分。'} -

-
- )} - - {/* ── 主结论(仅 success 展示) ── */} - {!degraded && ( - <> -
-

- {prediction.pred_home_goals ?? '-'} - : - {prediction.pred_away_goals ?? '-'} -

-

预测比分

- {prediction.alt_pred_home_goals != null && prediction.alt_pred_away_goals != null && ( -

- 备选{' '} - - {prediction.alt_pred_home_goals}:{prediction.alt_pred_away_goals} - -

- )} -
- -
- -
- - )} - - {/* ── 成本信息(耗时 + token + 限流余量) ── */} - {!degraded && ( - - )} - - {/* ── 元信息 ── */} -

- `多专家模式 · ${okReports.length}/${reports.length} 路有效` - {prediction.prompt_version && ` · prompt ${prediction.prompt_version}`} -

- - {/* ── 终裁/降级说明意见 ── */} - {prediction.reasoning && degraded && ( -
-

降级原因

-
-

{prediction.reasoning}

-
-
- )} - - {/* ── 专家意见(多模式):可折叠 + 状态摘要 + 权重条形图 ── */} - {reports.length > 0 && ( -
- - - {/* 权重条形图(仅 success 且有权重时显示) */} - {!degraded && prediction.agent_weights && Object.keys(prediction.agent_weights).length > 0 && ( -
- 终裁权重分布 - {Object.entries(prediction.agent_weights) - .sort((a, b) => b[1] - a[1]) - .map(([k, v]) => ( -
- {AGENT_LABELS[k] ?? k} -
-
-
- {Math.round(v * 100)}% -
- ))} -
- )} - - {expertsOpen && ( -
- {reports.map((r, i) => ( - - ))} -
- )} -
- )} - - {/* ── 终裁意见(success) ── */} - {prediction.reasoning && !degraded && ( -
-

终裁意见

-
-

{prediction.reasoning}

-
-
- )} -
-
- ) -} - -const STATUS_BADGE: Record = { - 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 = { - 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 ( -
- - {no} - {AGENT_LABELS[r.agent] ?? r.agent} - {badge.label} - - - {r.status === 'ok' && r.subjective_confidence !== null && ( - 信心 {Math.round(r.subjective_confidence * 100)}% - )} - {r.status === 'ok' && r.probable_score && ( - {r.probable_score} - )} - - - - -
- {/* 无数据 / 失败时给出明确说明,避免用户以为是空白 bug */} - {inactive && ( -

- {r.status === 'no_data' && '该维度没有可用数据,已跳过 LLM 分析以节省额度(不影响其他专家)。'} - {r.status === 'error' && '该专家调用失败,本次结论未纳入其视角(fail-open 设计,不阻断整体预测)。'} - {r.status === 'parse_error' && '模型输出未通过格式校验,该报告已丢弃。'} -

- )} - - {!inactive && r.home_edge !== null && ( -
-
- 主队优势 - 0 ? 'text-press' : r.home_edge < 0 ? 'text-ink-700' : 'text-ink-500'}`}> - {r.home_edge > 0 ? '+' : ''}{r.home_edge.toFixed(2)} - -
- -
- 利客队 - 利主队 -
-
- )} - - {r.analysis && ( -

{r.analysis}

- )} - - {r.key_evidence.length > 0 && ( -
    - {r.key_evidence.map((e, i) => ( -
  • - - {e} -
  • - ))} -
- )} - - {r.exp_home_goals !== null && r.exp_away_goals !== null && ( -

- 进球期望 {r.exp_home_goals.toFixed(1)} - {r.exp_away_goals.toFixed(1)} -

- )} - - {!inactive && ( -

- 数据充分度 {SUFFICIENCY_LABEL[r.data_sufficiency] ?? r.data_sufficiency} - | - {r.model} - {r.latency_ms !== null && {r.latency_ms}ms} -

- )} -
-
- ) -} - -/** 比赛详情面板:双方近况/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 ( -
- {loading && ( -
加载详情中…
- )} - - {!loading && !detail && !ctx && ( -

暂无详情数据

- )} - - {!loading && (detail || ctx) && ( -
- {/* 比分区(终场/当前比分 + 状态 + 预测按钮) */} -
-
-

- {match.home_goals ?? '-'}{' '}:{' '}{match.away_goals ?? '-'} -

-

- {match.match_stage || ''} {match.match_status === 'finished' ? '· 已完赛' : match.match_status === 'scheduled' ? '· 未开赛' : `· ${match.match_status}`} -

- {match.home_xg != null && match.away_xg != null && ( -

xG {match.home_xg.toFixed(1)}–{match.away_xg.toFixed(1)}

- )} -
- {!finished && ( - - 点击行首「预测」按钮发起多专家分析 - - )} -
- - {/* 比赛详细统计(bzzoiro /events/{id}/stats/) */} - {detail?.stats && ( - - )} - - {/* 双方近况 + H2H */} - {(ctx?.home_recent?.length || ctx?.away_recent?.length || ctx?.h2h?.length) ? ( -
- - - -
- ) : ( - !loading &&

暂无近期对战数据

- )} - - {/* 历史预测列表 */} -
-

历史预测({detail?.recent_predictions?.length ?? 0})

- {detail?.recent_predictions?.length ? ( -
- {detail.recent_predictions.map(p => ( - - ))} -
- ) : ( -

该场比赛暂无预测记录

- )} -
-
- )} -
- ) -} - -/** 比赛详细统计面板(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 ( -
-

比赛统计

- - {/* 控球率横条 */} - {possHome != null && possAway != null && ( -
-
- {possHome.toFixed(0)}% - 控球率 - {possAway.toFixed(0)}% -
-
-
-
-
-
- )} - - {/* 主客对比表 */} -
- - - - - - - - - - {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 ( - - - - - - ) - })} - -
{homeName}统计项{awayName}
- {r.home ?? '—'} - {r.label} - {r.away ?? '—'} -
-
-
- ) -} - -/** 近况/H2H 单区块 */ -function RecentBlock({ title, rows, side }: { title: string; rows?: TeamRecentMatch[]; side: 'home' | 'away' | 'h2h' }) { - return ( -
-
{title}
- {rows && rows.length > 0 ? ( -
    - {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 ( -
  • - {date} - {label} -
  • - ) - })} -
- ) : ( -

暂无

- )} -
- ) -} - -/** 历史预测单行(含专家报告入口) */ -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 ( -
-
- - {score} {p.pred_1x2 ? `(${p.pred_1x2})` : ''} - {alt && 备选 {alt}} - - - {p.subjective_confidence != null && ( - 信心 {Math.round(p.subjective_confidence * 100)}% - )} - {badge.label} - -
-
- {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 }) : '—'} - {hasAgents && {p.agent_outputs!.length} 路专家报告} -
- {p.reasoning && ( -

{p.reasoning}

- )} -
- ) -} - - -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) -} diff --git a/frontend/src/pages/matches/components/MatchDetailSection.tsx b/frontend/src/pages/matches/components/MatchDetailSection.tsx new file mode 100644 index 0000000..60025b3 --- /dev/null +++ b/frontend/src/pages/matches/components/MatchDetailSection.tsx @@ -0,0 +1,369 @@ +/** + * MatchDetailSection: 赛程行(MatchRow)+ 展开详情面板(统计/近况/H2H/历史预测)。 + * + * D3: 从 Matches.tsx 拆出,渲染逻辑原样搬迁。MatchRow 原先是主组件内 + * group.map 的内联 JSX,现接收回调(onToggle/onPredict)保持行为一致; + * 展开懒加载的 state 仍由页面持有。 + */ +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 { STATUS_META } from '../types' +import type { Match } from '../types' +import { Spinner } from '../ui' + +/** 比赛详情面板:双方近况/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 ( +
+ {loading && ( +
加载详情中…
+ )} + + {!loading && !detail && !ctx && ( +

暂无详情数据

+ )} + + {!loading && (detail || ctx) && ( +
+ {/* 比分区(终场/当前比分 + 状态 + 预测按钮) */} +
+
+

+ {match.home_goals ?? '-'}{' '}:{' '}{match.away_goals ?? '-'} +

+

+ {match.match_stage || ''} {match.match_status === 'finished' ? '· 已完赛' : match.match_status === 'scheduled' ? '· 未开赛' : `· ${match.match_status}`} +

+ {match.home_xg != null && match.away_xg != null && ( +

xG {match.home_xg.toFixed(1)}–{match.away_xg.toFixed(1)}

+ )} +
+ {!finished && ( + + 点击行首「预测」按钮发起多专家分析 + + )} +
+ + {/* 比赛详细统计(bzzoiro /events/{id}/stats/) */} + {detail?.stats && ( + + )} + + {/* 双方近况 + H2H */} + {(ctx?.home_recent?.length || ctx?.away_recent?.length || ctx?.h2h?.length) ? ( +
+ + + +
+ ) : ( + !loading &&

暂无近期对战数据

+ )} + + {/* 历史预测列表 */} +
+

历史预测({detail?.recent_predictions?.length ?? 0})

+ {detail?.recent_predictions?.length ? ( +
+ {detail.recent_predictions.map(p => ( + + ))} +
+ ) : ( +

该场比赛暂无预测记录

+ )} +
+
+ )} +
+ ) +} + +/** 赛程表一行:可点击展开详情;展开时懒加载详情(只读,不触发 LLM) */ +export function MatchRow({ + m, + busy, + expanded, + detail, + ctx, + detailLoading, + onToggle, + onPredict, +}: { + m: Match + busy: boolean + expanded: boolean + detail: MatchDetailOut | undefined + ctx: MatchContextOut | undefined + detailLoading: boolean + onToggle: () => void + onPredict: (m: Match) => void +}) { + 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 finished = m.match_status === 'finished' + + return ( +
+ {/* 行:可点击展开 */} +
{ if (e.key === 'Enter') onToggle() }} + aria-expanded={expanded} + > + {/* 桌面 grid: 日期 | 主队 | 比分 | 客队 | 状态 | 按钮 */} +
+ {/* 日期 + 状态:小屏同行;桌面 date 单独一列 */} +
+ {fmtTime(m.match_date)} + {st.label} +
+ + {/* 主队 + 比分 + 客队:移动端 grid 三列(严格居中),桌面端 grid 分列 */} +
+ {/* 主队(右对齐) */} + + + {homeName} + + + {/* 比分 / VS(严格居中) */} + + {m.home_goals !== null && m.away_goals !== null ? ( + + {m.home_goals}:{m.away_goals} + + ) : ( + VS + )} + {m.home_xg !== null && m.away_xg !== null && ( + + xG {m.home_xg.toFixed(1)}–{m.away_xg.toFixed(1)} + + )} + + + {/* 客队(左对齐) */} + + + {awayName} + +
+ + {/* 状态标签:小屏隐藏(已有);桌面用徽标样式 */} + + + {st.label} + + + + {/* 预测按钮(统一,响应式尺寸) */} + {!finished && ( +
e.stopPropagation()}> + +
+ )} +
+
{/* 关闭可点击行 */} + + {/* 展开详情面板 */} + {expanded && ( + + )} +
+ ) +} + +/** 行内时间展示:只显示 HH:mm(日期由分组头承担) */ +function fmtTime(s: string): string { + const d = new Date(s) + return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false }) +} + +/** 比赛详细统计面板(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 ( +
+

比赛统计

+ + {/* 控球率横条 */} + {possHome != null && possAway != null && ( +
+
+ {possHome.toFixed(0)}% + 控球率 + {possAway.toFixed(0)}% +
+
+
+
+
+
+ )} + + {/* 主客对比表 */} +
+ + + + + + + + + + {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 ( + + + + + + ) + })} + +
{homeName}统计项{awayName}
+ {r.home ?? '—'} + {r.label} + {r.away ?? '—'} +
+
+
+ ) +} + +/** 近况/H2H 单区块 */ +function RecentBlock({ title, rows, side }: { title: string; rows?: TeamRecentMatch[]; side: 'home' | 'away' | 'h2h' }) { + return ( +
+
{title}
+ {rows && rows.length > 0 ? ( +
    + {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 ( +
  • + {date} + {label} +
  • + ) + })} +
+ ) : ( +

暂无

+ )} +
+ ) +} + +/** 历史预测单行(含专家报告入口) */ +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 ( +
+
+ + {score} {p.pred_1x2 ? `(${p.pred_1x2})` : ''} + {alt && 备选 {alt}} + + + {p.subjective_confidence != null && ( + 信心 {Math.round(p.subjective_confidence * 100)}% + )} + {badge.label} + +
+
+ {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 }) : '—'} + {hasAgents && {p.agent_outputs!.length} 路专家报告} +
+ {p.reasoning && ( +

{p.reasoning}

+ )} +
+ ) +} + +// 详情懒加载取数在此文件内聚:页面只需切换 expandedId 并缓存结果 +export async function loadMatchDetailBundle( + matchId: number, +): Promise<{ detail: MatchDetailOut | null; ctx: MatchContextOut | null }> { + const [d, c] = await Promise.all([ + fetchMatchDetail(matchId).catch(() => null), + fetchMatchContext(matchId).catch(() => null), + ]) + return { detail: d, ctx: c } +} diff --git a/frontend/src/pages/matches/components/MatchPredictPanel.tsx b/frontend/src/pages/matches/components/MatchPredictPanel.tsx new file mode 100644 index 0000000..a551cd9 --- /dev/null +++ b/frontend/src/pages/matches/components/MatchPredictPanel.tsx @@ -0,0 +1,527 @@ +/** + * MatchPredictPanel: 预测弹窗全套(过程可视化 / 结果版面 / 专家意见)。 + * + * D3: 从 Matches.tsx 拆出,渲染逻辑原样搬迁。对外只导出 PredictModal; + * PredictionPanel 复用 Prediction 的 embedded 模式由弹窗内渲染。 + */ +import { useEffect, useState } from 'react' +import TeamSideTag from '../../../components/TeamSideTag' +import type { AgentReport, Match, Prediction } from '../types' +import { AGENT_LABELS, CN_NUM, OUTCOME_LABEL } from '../types' + +/** 置信度细线:0~1 数值的低调可视化 */ +function Meter({ value }: { value: number }) { + const pct = Math.max(0, Math.min(100, Math.round(value * 100))) + return ( +
+
+
+ ) +} + +/** 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 ( +
+ + = 0 ? 'bg-press' : 'bg-ink-600'}`} + style={ + v >= 0 + ? { left: '50%', width: `${half}%` } + : { right: '50%', width: `${half}%` } + } + /> +
+ ) +} + +function Spinner({ className = '' }: { className?: string }) { + return ( + + ) +} + +/** 胜平负一行文字:选中的红字加方块标记,未选中的退灰 */ +function OutcomeLine({ + pick, + confidence, +}: { + pick: string | null + confidence: number | null +}) { + const options = ['1', 'X', '2'] as const + return ( +
+
+ {options.map(o => { + const on = pick === o + return ( +
+ + {on && + {on && confidence !== null && ( + + 置信 {Math.round(confidence * 100)}% + + )} +
+ ) + })} +
+ {pick && confidence !== null && ( +
+ +

主观置信度,非统计概率

+
+ )} +
+ ) +} + +/** 预测成本展示:耗时 + 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 ( +
+
+ {latency && ( + + 耗时 {latency} + + )} + {tokens && ( + + prompt/completion: {tokens} + + )} + {prediction.rate_limit_remaining != null && prediction.rate_limit_remaining <= 3 && ( + + 剩余配额: {prediction.rate_limit_remaining}/10(分钟) + + )} +
+
+ ) +} + +/** 预测过程阶段(按时长模拟;结果到达即跳到完成) */ +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 ( +
+ {/* 阶段标题 */} +
+ + + {phase === 'slice' && '正在组装比赛数据切片'} + {phase === 'agents' && '五路专家并行分析中'} + {phase === 'agg' && '终裁专家汇总裁定中'} + + {elapsed.toFixed(0)}s +
+ + {/* 进度条:渐进式,不封顶到 100% */} +
+
+
+ + {/* 专家灯序(多专家模式) */} +
    + {agents.map((a, i) => { + const lit = elapsed >= AGENT_START + AGENT_STEP * (i + 1) + const activeNow = !lit && elapsed >= AGENT_START + AGENT_STEP * i + return ( +
  • + + + {lit && ✓ 完成} + {activeNow && 分析中…} +
  • + ) + })} +
+ +

+ 五路专家并行分析后终裁,约需 30-90 秒;多专家调用消耗较多 token,请按需使用。关闭窗口即取消 +

+

+ 提示:每分钟限 10 次预测,耗尽后需等待下一分钟。 +

+
+ ) +} + +const STATUS_BADGE: Record = { + 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 = { + 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 ( +
+ + {no} + {AGENT_LABELS[r.agent] ?? r.agent} + {badge.label} + + + {r.status === 'ok' && r.subjective_confidence !== null && ( + 信心 {Math.round(r.subjective_confidence * 100)}% + )} + {r.status === 'ok' && r.probable_score && ( + {r.probable_score} + )} + + + + +
+ {/* 无数据 / 失败时给出明确说明,避免用户以为是空白 bug */} + {inactive && ( +

+ {r.status === 'no_data' && '该维度没有可用数据,已跳过 LLM 分析以节省额度(不影响其他专家)。'} + {r.status === 'error' && '该专家调用失败,本次结论未纳入其视角(fail-open 设计,不阻断整体预测)。'} + {r.status === 'parse_error' && '模型输出未通过格式校验,该报告已丢弃。'} +

+ )} + + {!inactive && r.home_edge !== null && ( +
+
+ 主队优势 + 0 ? 'text-press' : r.home_edge < 0 ? 'text-ink-700' : 'text-ink-500'}`}> + {r.home_edge > 0 ? '+' : ''}{r.home_edge.toFixed(2)} + +
+ +
+ 利客队 + 利主队 +
+
+ )} + + {r.analysis && ( +

{r.analysis}

+ )} + + {r.key_evidence.length > 0 && ( +
    + {r.key_evidence.map((e, i) => ( +
  • + + {e} +
  • + ))} +
+ )} + + {r.exp_home_goals !== null && r.exp_away_goals !== null && ( +

+ 进球期望 {r.exp_home_goals.toFixed(1)} - {r.exp_away_goals.toFixed(1)} +

+ )} + + {!inactive && ( +

+ 数据充分度 {SUFFICIENCY_LABEL[r.data_sufficiency] ?? r.data_sufficiency} + | + {r.model} + {r.latency_ms !== null && {r.latency_ms}ms} +

+ )} +
+
+ ) +} + +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 ( +
+ {!embedded && ( +
+

+ 预测版 · + + {homeName} + + + {awayName} +

+ + {prediction.provider} / {prediction.model} + {prediction.latency_ms !== null && ` · ${(prediction.latency_ms / 1000).toFixed(1)}s`} + +
+ )} + +
+ {/* ── degraded / failed 态:醒目警示 + 原因,不展示虚假比分 ── */} + {degraded && ( +
+

+ {prediction.status === 'failed' ? '预测失败' : '预测降级(degraded)'} +

+

+ {prediction.reasoning || '所有专家均无有效数据或调用失败,无法生成可靠比分。'} +

+
+ )} + + {/* ── 主结论(仅 success 展示) ── */} + {!degraded && ( + <> +
+

+ {prediction.pred_home_goals ?? '-'} + : + {prediction.pred_away_goals ?? '-'} +

+

预测比分

+ {prediction.alt_pred_home_goals != null && prediction.alt_pred_away_goals != null && ( +

+ 备选{' '} + + {prediction.alt_pred_home_goals}:{prediction.alt_pred_away_goals} + +

+ )} +
+ +
+ +
+ + )} + + {/* ── 成本信息(耗时 + token + 限流余量) ── */} + {!degraded && ( + + )} + + {/* ── 元信息 ── */} +

+ `多专家模式 · ${okReports.length}/${reports.length} 路有效` + {prediction.prompt_version && ` · prompt ${prediction.prompt_version}`} +

+ + {/* ── 终裁/降级说明意见 ── */} + {prediction.reasoning && degraded && ( +
+

降级原因

+
+

{prediction.reasoning}

+
+
+ )} + + {/* ── 专家意见(多模式):可折叠 + 状态摘要 + 权重条形图 ── */} + {reports.length > 0 && ( +
+ + + {/* 权重条形图(仅 success 且有权重时显示) */} + {!degraded && prediction.agent_weights && Object.keys(prediction.agent_weights).length > 0 && ( +
+ 终裁权重分布 + {Object.entries(prediction.agent_weights) + .sort((a, b) => b[1] - a[1]) + .map(([k, v]) => ( +
+ {AGENT_LABELS[k] ?? k} +
+
+
+ {Math.round(v * 100)}% +
+ ))} +
+ )} + + {expertsOpen && ( +
+ {reports.map((r, i) => ( + + ))} +
+ )} +
+ )} + + {/* ── 终裁意见(success) ── */} + {prediction.reasoning && !degraded && ( +
+

终裁意见

+
+

{prediction.reasoning}

+
+
+ )} +
+
+ ) +} + +/** 预测弹窗:进行中显示过程可视化,完成后显示预测版,失败显示原因 */ +export 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 ( +
{ + if (e.target === e.currentTarget) onClose() + }} + > +
+ {/* 弹窗报头 */} +
+

+ 预测版 · + + {homeName} + + + {awayName} +

+ +
+ + {/* 弹窗体(小屏可滚动) */} +
+ {predicting ? ( + + ) : error ? ( +
+

预测失败

+

+ {error} +

+ +
+ ) : prediction ? ( + + ) : null} +
+
+
+ ) +} diff --git a/frontend/src/pages/matches/hooks/useMatchPredict.ts b/frontend/src/pages/matches/hooks/useMatchPredict.ts new file mode 100644 index 0000000..798d99a --- /dev/null +++ b/frontend/src/pages/matches/hooks/useMatchPredict.ts @@ -0,0 +1,93 @@ +/** + * useMatchPredict: 预测流程状态机(发起/进行中/结果/失败/关闭中止)。 + * + * D3: 从 Matches.tsx 拆出。语义不变: + * - 连点防护:同一场比赛预测中再次点击直接忽略 + * - 竞态防护:递增序号,过期响应丢弃 + * - 关闭弹窗 = 中止在途请求 + 序号失效(catch/then 不再写入) + * - 5 分钟超时,与 nginx 代理 300s 对齐 + */ +import { useRef, useState } from 'react' +import { http } from '../../../lib/http' +import type { Match, Prediction } from '../types' + +/** 把后端/网络错误翻译成用户可读文案 */ +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) +} + +interface UseMatchPredictOptions { + /** 共享 error state(拆分前列表与预测共用同一个 error,行为保持一致) */ + onError: (msg: string | null) => void +} + +export function useMatchPredict({ onError }: UseMatchPredictOptions) { + const [predictingId, setPredictingId] = useState(null) + const [prediction, setPrediction] = useState(null) + const [predictionFor, setPredictionFor] = useState(null) + const predictSeq = useRef(0) + // 预测请求控制器:关闭弹窗时中止 + const predictAbort = useRef(null) + + function closePredict() { + predictAbort.current?.abort() + predictSeq.current++ // 令中止请求的 catch/then 全部失效,不再写入错误 + setPredictingId(null) + setPrediction(null) + setPredictionFor(null) + onError(null) + } + + const predict = async (m: Match) => { + // 防连点:若该场比赛已在预测中,直接忽略 + if (predictingId === m.id) return + const seq = ++predictSeq.current + setPredictingId(m.id) + onError(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('/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 + onError( + e instanceof DOMException && e.name === 'AbortError' + ? '预测超时(5 分钟),请稍后重试' + : readablePredictError(e), + ) + } finally { + clearTimeout(timer) + if (seq === predictSeq.current) setPredictingId(null) + } + } + + return { + predictingId, + prediction, + predictionFor, + predict, + closePredict, + } +} diff --git a/frontend/src/pages/matches/hooks/useMatchesList.ts b/frontend/src/pages/matches/hooks/useMatchesList.ts new file mode 100644 index 0000000..be78b9e --- /dev/null +++ b/frontend/src/pages/matches/hooks/useMatchesList.ts @@ -0,0 +1,94 @@ +/** + * useMatchesList: 赛程列表数据获取(筛选/游标分页/进行中比赛)。 + * + * D3: 从 Matches.tsx 拆出。竞态防护语义不变 —— 递增序号只认最后一次请求; + * loadMore 不自增序号(切换筛选才自增,翻页跟随当前序列)。 + */ +import { useCallback, useEffect, useRef, useState } from 'react' +import { http } from '../../../lib/http' +import type { Match } from '../types' + +interface UseMatchesListOptions { + /** 共享 error state(拆分前列表与预测共用同一个 error,行为保持一致) */ + onError: (msg: string | null) => void +} + +export function useMatchesList({ onError }: UseMatchesListOptions) { + const [league, setLeague] = useState('E0') + const [status, setStatus] = useState('scheduled') + const [matches, setMatches] = useState([]) + const [nextCursor, setNextCursor] = useState(null) + const [loadingMore, setLoadingMore] = useState(false) + const [loading, setLoading] = useState(false) + const [showAllUpcoming, setShowAllUpcoming] = useState(false) // 默认仅展示未来 3 天;true 展开全部 + const [liveMatches, setLiveMatches] = useState([]) // 进行中比赛(顶部独立区块) + + // 请求竞态防护:切换联赛/状态很快时,先发的慢请求可能后返回, + // 把旧结果覆盖到新筛选上。用递增序号只认最后一次请求的响应。 + const loadSeq = useRef(0) + + const load = useCallback(async () => { + const seq = ++loadSeq.current + setLoading(true) + setLoadingMore(false) + setShowAllUpcoming(false) // 切换筛选重置为「未来 3 天」视图 + onError(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 + onError(e instanceof Error ? e.message : String(e)) + } finally { + if (seq === loadSeq.current) setLoading(false) + } + }, [league, status, onError]) + + // 加载下一页(游标分页) + 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 + onError(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]) + + return { + league, setLeague, + status, setStatus, + matches, + nextCursor, + loading, + loadingMore, + showAllUpcoming, setShowAllUpcoming, + liveMatches, + load, + loadMore, + } +} diff --git a/frontend/src/pages/matches/types.ts b/frontend/src/pages/matches/types.ts new file mode 100644 index 0000000..778c366 --- /dev/null +++ b/frontend/src/pages/matches/types.ts @@ -0,0 +1,98 @@ +/** + * Matches 页面族共享类型与常量。 + * + * D3(工程债): Matches.tsx 原本 ~1400 行,类型/常量/预测面板/详情面板/ + * 列表逻辑全部内联。本文件是拆分后的共享层 —— 只放数据契约与纯常量, + * 不含 React 组件。 + */ + +/** /matches 列表项(公开接口) */ +export 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 +} + +/** POST /predict 响应 */ +export 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 | null + context: string + latency_ms: number | null + prompt_tokens: number | null + completion_tokens: number | null + rate_limit_remaining: number | null +} + +/** 多专家单路报告 */ +export 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 +} + +export const AGENT_LABELS: Record = { + h2h: '历史交锋分析专家', + form: '近期状态分析专家', + stats: '攻防数据分析专家', + home_away: '主客因素分析专家', + standings: '联赛排名分析专家', +} + +export const LEAGUES = [ + { code: 'E0', name: '英超' }, + { code: 'SP1', name: '西甲' }, + { code: 'D1', name: '德甲' }, + { code: 'I1', name: '意甲' }, + { code: 'F1', name: '法甲' }, +] + +/** 汉字编号,给专家意见排版用 */ +export const CN_NUM = ['一', '二', '三', '四', '五', '六', '七', '八'] + +export const STATUS_META: Record = { + 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 → 中文标签 */ +export const OUTCOME_LABEL: Record = { '1': '主胜', X: '平局', '2': '客胜' } diff --git a/frontend/src/pages/matches/ui.tsx b/frontend/src/pages/matches/ui.tsx new file mode 100644 index 0000000..539ac3a --- /dev/null +++ b/frontend/src/pages/matches/ui.tsx @@ -0,0 +1,114 @@ +/** + * Matches 页面族共享的原子 UI 小件(无业务状态)。 + * + * D3: 从 Matches.tsx 内联定义上移到模块级 —— Switch 原先定义在组件函数 + * 体内(每次渲染重建组件对象),它没有内部 state,提升后渲染结果一致。 + */ +import type { Match } from './types' + +export function Spinner({ className = '' }: { className?: string }) { + return ( + + ) +} + +/** 骨架占位行:低调脉动灰块 */ +export function SkeletonRows({ n = 4 }: { n?: number }) { + return ( + <> + {Array.from({ length: n }).map((_, i) => ( +
+
+
+
+
+
+
+ ))} + + ) +} + +/** 状态/模式一组的文字切换 */ +export function Switch({ value, onChange, items }: { + value: string + onChange: (v: string) => void + items: { v: string; label: string; title?: string }[] +}) { + return ( + + {items.map((it, i) => ( + + {i > 0 && } + + + ))} + + ) +} + +/** 日期分组头显示:今日/明天/周几 · 年月日 */ +export 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}` +} + +/** UTC ISO → 本地日期 YYYY-MM-DD(用于分组) */ +export 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),保持时间序 */ +export function groupByDate(list: Match[]): Array<[string, Match[]]> { + const map = new Map() + 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()] +} + +/** 日期 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) +} + +/** 比赛是否在未来 3 天内(用于默认视图过滤) */ +export function withinNext3Days(matchDate: string): boolean { + const key = toLocalDateKey(matchDate) + return key >= dateKey(new Date()) && key < addDays(new Date(), 3) +}