/** * 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(null) const [prediction, setPrediction] = useState(null) const [predictionFor, setPredictionFor] = useState(null) const predictSeq = useRef(0) // 预测请求控制器:关闭弹窗时中止 const predictAbort = useRef(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('/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, } }