/** * 赛程页(组装层)。 * * 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 } from '../admin/types' import { useMatchesList } from './matches/hooks/useMatchesList' import { useMatchPredict } from './matches/hooks/useMatchPredict' import { useLeagues } from './matches/hooks/useLeagues' import { PredictModal } from './matches/components/MatchPredictPanel' import { MatchRow } from './matches/components/MatchDetailSection' import { Spinner, SkeletonRows, Switch, formatDateHeader, groupByDate, withinNext3Days } from './matches/ui' import { type Match } from './matches/types' export default function Matches() { const [error, setError] = useState(null) // 列表与预测共用(拆分前即如此) // P1-3: 联赛列表优先请求 /api/v1/leagues,失败/空回退本地五大联赛常量 const leagues = useLeagues() const { league, setLeague, status, setStatus, matches, nextCursor, loading, loadingMore, showAllUpcoming, setShowAllUpcoming, liveMatches, load, loadMore, } = useMatchesList({ onError: setError }) const { predictingId, prediction, predictionFor, predict, closePredict, } = useMatchPredict({ onError: setError }) // ── 详情展开(懒加载,只读,不触发 LLM) ── const [expandedId, setExpandedId] = useState(null) const [detailMap, setDetailMap] = useState>({}) const [contextMap, setContextMap] = useState>({}) const [detailLoading, setDetailLoading] = useState(null) // 监听滚动,超过 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 /** 未开赛默认仅展示未来 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 /** 展开时懒加载详情: 缓存命中则不再请求 */ 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 (
{/* ── 联赛版面切换 ── */} {/* ── 第二行:状态 / 模式 / 日期 / 计数 / 刷新(小屏 flex-wrap) ── */}
状态 {isScheduledView && !showAllUpcoming && hasHiddenUpcoming ? `未来3天 ${visibleMatches.length} / 共 ${matches.length} 场` : `共 ${visibleMatches.length} 场`}
{/* ── 错误提示(统一 error-banner 样式) ── */} {error && (

请求失败

{error}

)} {/* ── 预测弹窗:进行中可视化 / 结果面板 ── */} {predictionFor && ( )} {/* ── 进行中比赛(顶部独立区块,仅未开赛视图展示) ── */} {isScheduledView && liveMatches.length > 0 && (
进行中 · 实时比分 {liveMatches.length} 场
{liveMatches.map(m => { const homeName = m.home_team_zh || m.home_team const awayName = m.away_team_zh || m.away_team return (
{m.home_goals ?? '-'} {homeName}
vs
{awayName} {m.away_goals ?? '-'}
) })}
)} {/* ── 赛程栏:表格化,行间细线,按日期分组 ── */}
{loading && } {!loading && visibleMatches.length === 0 && (
{matches.length > 0 ? ( <>

未来 3 天暂无 {leagueName} 比赛

已导入 {matches.length} 场未开赛,点击下方按钮查看

) : ( <>

本版暂无赛程

请先通过「数据采集」导入 {leagueName} 的比赛数据

前往数据采集 )}
)} {!loading && groupByDate(visibleMatches).map(([dateKey, group]) => (
{/* 日期分组头:sticky 但 z 低于弹窗 z-50 */}
{formatDateHeader(dateKey)} {group.length} 场
{group.map(m => ( toggleExpand(m)} onPredict={predict} /> ))}
))} {/* 单一「加载更多」按钮:3 天视图时先展开,展开后从服务器拉取下一页 */} {!loading && (hasHiddenUpcoming || nextCursor) && (
{hasHiddenUpcoming ? ( ) : ( )}
)} {/* 已展开全部但未开赛:提供「收起」回未来 3 天 */} {!loading && isScheduledView && showAllUpcoming && matches.length > 0 && (
)}
{/* 回到顶部按钮 */}
) }