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:
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* useMatchPredict: 预测流程状态机(发起/进行中/结果/失败/关闭中止)。
|
||||
*
|
||||
* D3: 从 Matches.tsx 拆出。语义不变:
|
||||
* - 连点防护:同一场比赛预测中再次点击直接忽略
|
||||
* - 竞态防护:递增序号,过期响应丢弃
|
||||
* - 关闭弹窗 = 中止在途请求 + 序号失效(catch/then 不再写入)
|
||||
* - 5 分钟超时,与 nginx 代理 300s 对齐
|
||||
*/
|
||||
import { useRef, useState } from 'react'
|
||||
import { http } from '../../../lib/http'
|
||||
import type { Match, Prediction } from '../types'
|
||||
|
||||
/** 把后端/网络错误翻译成用户可读文案 */
|
||||
function readablePredictError(e: unknown): string {
|
||||
if (e instanceof Error) {
|
||||
const m = e.message
|
||||
if (/429/.test(m)) {
|
||||
// 429 来自后端限流(每分钟 10 次),非上游 LLM
|
||||
return '操作过于频繁:每分钟最多 10 次预测。为保护 LLM 额度,请稍后再试。'
|
||||
}
|
||||
if (/502/.test(m)) return 'LLM 服务暂时不可用(502),请稍后重试'
|
||||
if (/402|Payment Required|额度|余额/.test(m)) return 'LLM 额度不足(402),请检查 API Key 余额'
|
||||
if (/400|已完赛/.test(m)) return '该比赛已完赛,不再支持预测'
|
||||
if (/409|已结算/.test(m)) return '该预测已结算,不能重新预测'
|
||||
if (/timeout|超时|timed out/i.test(m)) return '请求超时,请稍后重试'
|
||||
return m
|
||||
}
|
||||
return String(e)
|
||||
}
|
||||
|
||||
interface UseMatchPredictOptions {
|
||||
/** 共享 error state(拆分前列表与预测共用同一个 error,行为保持一致) */
|
||||
onError: (msg: string | null) => void
|
||||
}
|
||||
|
||||
export function useMatchPredict({ onError }: UseMatchPredictOptions) {
|
||||
const [predictingId, setPredictingId] = useState<number | null>(null)
|
||||
const [prediction, setPrediction] = useState<Prediction | null>(null)
|
||||
const [predictionFor, setPredictionFor] = useState<Match | null>(null)
|
||||
const predictSeq = useRef(0)
|
||||
// 预测请求控制器:关闭弹窗时中止
|
||||
const predictAbort = useRef<AbortController | null>(null)
|
||||
|
||||
function closePredict() {
|
||||
predictAbort.current?.abort()
|
||||
predictSeq.current++ // 令中止请求的 catch/then 全部失效,不再写入错误
|
||||
setPredictingId(null)
|
||||
setPrediction(null)
|
||||
setPredictionFor(null)
|
||||
onError(null)
|
||||
}
|
||||
|
||||
const predict = async (m: Match) => {
|
||||
// 防连点:若该场比赛已在预测中,直接忽略
|
||||
if (predictingId === m.id) return
|
||||
const seq = ++predictSeq.current
|
||||
setPredictingId(m.id)
|
||||
onError(null)
|
||||
setPrediction(null)
|
||||
setPredictionFor(m)
|
||||
// LLM 多专家预测耗时可达数分钟,给足超时(与 nginx 代理 300s 对齐)
|
||||
const controller = new AbortController()
|
||||
predictAbort.current = controller
|
||||
const timer = setTimeout(() => controller.abort(), 300_000)
|
||||
try {
|
||||
const data = await http.post<Prediction>('/predict', { match_id: m.id, mode: 'multi' }, {
|
||||
timeoutMs: 300_000,
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (seq !== predictSeq.current) return
|
||||
setPrediction(data)
|
||||
} catch (e) {
|
||||
if (seq !== predictSeq.current) return
|
||||
onError(
|
||||
e instanceof DOMException && e.name === 'AbortError'
|
||||
? '预测超时(5 分钟),请稍后重试'
|
||||
: readablePredictError(e),
|
||||
)
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
if (seq === predictSeq.current) setPredictingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
predictingId,
|
||||
prediction,
|
||||
predictionFor,
|
||||
predict,
|
||||
closePredict,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user