- 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,防检测器恒真)
283 lines
12 KiB
TypeScript
283 lines
12 KiB
TypeScript
/**
|
||
* 赛程页(组装层)。
|
||
*
|
||
* 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>
|
||
)
|
||
}
|