Files
Profeto/frontend/src/pages/Matches.tsx
T
WorkBuddy a24017eb69 feat(api): 比赛上下文与联赛列表改公开只读(P1-2/P1-3)
- GET /matches/{id}/context 移除 require_admin:公开站详情页「近况/交锋」
  数据来源,匿名 401 会导致前端静默空态;响应结构不变,不触发 LLM
- GET /leagues 改公开只读:公开站联赛筛选动态加载;仅返回
  id/code/name/country 四个展示字段,不含配置或密钥信息
- 前端新增 useLeagues hook:优先请求 /api/v1/leagues,失败/空回退
  本地五大联赛常量(已知代码保留中文标签,新联赛按 API 名称追加)
- 新增 tests/test_public_readonly_api.py(6 项):匿名 200/404、响应形状、
  路由依赖声明检查(matches 路由无 require_admin)+ 判别力守卫
  (ingest 路由必须检出 require_admin,防检测器恒真)
2026-09-21 20:55:26 +08:00

283 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 { 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<string | null>(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<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>
)
}