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 通过,渲染逻辑原样搬迁
This commit is contained in:
2026-09-21 19:31:42 +08:00
parent 5c7fdce0a3
commit 52d67863d6
7 changed files with 1375 additions and 1192 deletions
@@ -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<Match[]>([])
const [nextCursor, setNextCursor] = useState<string | null>(null)
const [loadingMore, setLoadingMore] = useState(false)
const [loading, setLoading] = useState(false)
const [showAllUpcoming, setShowAllUpcoming] = useState(false) // 默认仅展示未来 3 天;true 展开全部
const [liveMatches, setLiveMatches] = useState<Match[]>([]) // 进行中比赛(顶部独立区块)
// 请求竞态防护:切换联赛/状态很快时,先发的慢请求可能后返回,
// 把旧结果覆盖到新筛选上。用递增序号只认最后一次请求的响应。
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,
}
}