Files
Profeto/frontend/src/pages/Matches.tsx
T
WorkBuddy 52d67863d6 debt(D3): 拆分 Matches.tsx(~1400 行)为 hooks + 组件 + 共享层
- pages/matches/types.ts: Match/Prediction/AgentReport + 常量(LEAGUES/STATUS_META/AGENT_LABELS 等)
- pages/matches/ui.tsx: Spinner/SkeletonRows/Switch/日期分组工具(Switch 自组件体内提升到模块级)
- pages/matches/hooks/useMatchesList.ts: 列表筛选/游标分页/进行中比赛,竞态防护语义不变
- pages/matches/hooks/useMatchPredict.ts: 预测状态机(连点/竞态/中止/超时)+ 可读错误文案
- pages/matches/components/MatchPredictPanel.tsx: 预测弹窗全套(过程可视化/结果/专家意见)
- pages/matches/components/MatchDetailSection.tsx: MatchRow(原内联行 JSX 抽出)+ 详情面板
- Matches.tsx 组装页 279 行(< 300);error state 共享行为与拆分前一致
- 验证: tsc + vite build 通过,渲染逻辑原样搬迁
2026-09-21 19:31:42 +08:00

280 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 赛程页(组装层)。
*
* 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 { 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 [error, setError] = useState<string | null>(null) // 列表与预测共用(拆分前即如此)
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<number | null>(null)
const [detailMap, setDetailMap] = useState<Record<number, MatchDetailOut>>({})
const [contextMap, setContextMap] = useState<Record<number, MatchContextOut>>({})
const [detailLoading, setDetailLoading] = useState<number | null>(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 (
<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 => (
<MatchRow
key={m.id}
m={m}
busy={predictingId === m.id}
expanded={expandedId === m.id}
detail={detailMap[m.id]}
ctx={contextMap[m.id]}
detailLoading={detailLoading === m.id}
onToggle={() => toggleExpand(m)}
onPredict={predict}
/>
))}
</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>
)
}