diff --git a/src/features/hall/hall-betting-grid.tsx b/src/features/hall/hall-betting-grid.tsx index e8660b7..0997b44 100644 --- a/src/features/hall/hall-betting-grid.tsx +++ b/src/features/hall/hall-betting-grid.tsx @@ -184,6 +184,14 @@ type ClosedPlayCleanupData = { cleanup_lines?: Array<{ client_line_no?: number; play_code?: string }>; }; +type TicketPreviewSubmissionSnapshot = { + drawId: string; + currencyCode: string; + clientTraceId: string; + lines: TicketLineInput[]; + expectedConfigVersions: TicketPreviewData["config_versions"]; +}; + type PlayToggleWsEvent = { play_code?: string; enabled?: boolean; @@ -601,7 +609,6 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } const [syncAmountColumns, setSyncAmountColumns] = useState>({}); const [quickFillState, setQuickFillState] = useState(() => loadQuickFillState()); const [quickFillExpanded, setQuickFillExpanded] = useState(false); - const [riskStateDrawNo, setRiskStateDrawNo] = useState(display?.draw_no ?? null); const [liveSoldOutNumbers, setLiveSoldOutNumbers] = useState>(() => new Set()); const [liveWarningNumbers, setLiveWarningNumbers] = useState>(() => new Set()); const [debouncedSummary, setDebouncedSummary] = useState({ bet: 0, rebate: 0, actual: 0 }); @@ -615,6 +622,8 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } }); /** 单次预览→确认共用,重试 place 复用,避免重复扣款 */ const placeTraceIdRef = useRef(null); + const previewRequestSeqRef = useRef(0); + const previewSubmissionRef = useRef(null); const newPlaceTraceId = (): string => typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() @@ -623,6 +632,13 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } const clearPlaceTraceId = useCallback(() => { placeTraceIdRef.current = null; }, []); + const invalidatePreview = useCallback(() => { + previewRequestSeqRef.current += 1; + previewSubmissionRef.current = null; + setPreviewOpen(false); + setPreviewData(null); + clearPlaceTraceId(); + }, [clearPlaceTraceId]); const catalogSeqRef = useRef(0); const walletSeqRef = useRef(0); const selectedCatalogProviderCode = @@ -697,16 +713,14 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } void loadCatalog(); const source = (ev as CustomEvent<{ source?: PlayCatalogRefreshSource }>).detail ?.source; - if (source === "play_config" || source === "risk_cap") { - setPreviewOpen(false); - setPreviewData(null); - clearPlaceTraceId(); + if (source !== undefined) { + invalidatePreview(); toast.message(t("hall.playConfig.updated")); } }; window.addEventListener(PLAY_CATALOG_REFRESH_EVENT, onCatalogRefresh); return () => window.removeEventListener(PLAY_CATALOG_REFRESH_EVENT, onCatalogRefresh); - }, [clearPlaceTraceId, loadCatalog, t]); + }, [invalidatePreview, loadCatalog, t]); const openPlays = useMemo(() => { if (catalogState.kind !== "ok") return []; @@ -801,11 +815,19 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } const drawNo = display?.draw_no ?? null; - if (riskStateDrawNo !== drawNo) { - setRiskStateDrawNo(drawNo); - setLiveSoldOutNumbers(new Set()); - setLiveWarningNumbers(new Set()); - } + useEffect(() => { + let cancelled = false; + queueMicrotask(() => { + if (cancelled) return; + setLiveSoldOutNumbers(new Set()); + setLiveWarningNumbers(new Set()); + invalidatePreview(); + }); + + return () => { + cancelled = true; + }; + }, [currencyCode, drawNo, invalidatePreview]); const alertRows = useMemo( () => display?.risk_pool_alerts ?? [], @@ -1169,9 +1191,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } if (evt.enabled === false && typeof evt.play_code === "string") { const removed = hasAmountsForPlay(evt.play_code); clearAmountsForPlay(evt.play_code); - setPreviewOpen(false); - setPreviewData(null); - clearPlaceTraceId(); + invalidatePreview(); toast.warning( removed ? t("hall.playConfig.playClosedDraftCleared", { @@ -1185,9 +1205,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } }; const onOddsUpdate = (evt: OddsUpdateWsEvent) => { - setPreviewOpen(false); - setPreviewData(null); - clearPlaceTraceId(); + invalidatePreview(); toast.message(evt.message ?? t("hall.playConfig.oddsUpdated")); }; @@ -1207,6 +1225,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } next.delete(normalized); return next; }); + invalidatePreview(); void reloadDraw(); }; @@ -1234,7 +1253,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } channel.stopListening(".risk.sold_out"); channel.stopListening(".risk.warning"); }; - }, [clearAmountsForPlay, clearPlaceTraceId, drawNo, hasAmountsForPlay, reloadDraw, t]); + }, [clearAmountsForPlay, drawNo, hasAmountsForPlay, invalidatePreview, reloadDraw, t]); const collectDraftLineIssues = useCallback((): DraftLineIssue[] => { const issues: DraftLineIssue[] = []; @@ -1471,14 +1490,30 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } } setPreviewLoading(true); + const requestSeq = ++previewRequestSeqRef.current; + const traceId = newPlaceTraceId(); + const drawId = display.draw_no; + const previewCurrencyCode = currencyCode; + const frozenLines = lines.map((line) => ({ + ...line, + provider_codes: line.provider_codes ? [...line.provider_codes] : undefined, + })); + placeTraceIdRef.current = traceId; try { - placeTraceIdRef.current = newPlaceTraceId(); const data = await postTicketPreview({ - draw_id: display.draw_no, - currency_code: currencyCode, - client_trace_id: placeTraceIdRef.current, - lines, + draw_id: drawId, + currency_code: previewCurrencyCode, + client_trace_id: traceId, + lines: frozenLines, }); + if (requestSeq !== previewRequestSeqRef.current) return; + previewSubmissionRef.current = { + drawId: data.draw.draw_id.trim() || drawId, + currencyCode: previewCurrencyCode, + clientTraceId: traceId, + lines: frozenLines, + expectedConfigVersions: data.config_versions, + }; setPreviewData(data); setPreviewOpen(true); rows.forEach((row) => { @@ -1493,9 +1528,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } return; } if (e instanceof LotteryApiBizError && code === 2008) { - setPreviewOpen(false); - setPreviewData(null); - clearPlaceTraceId(); + invalidatePreview(); } if (e instanceof LotteryApiBizError && (code === 2001 || code === 2006)) { void reloadDraw(); @@ -1507,7 +1540,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } }; const handlePlace = async () => { - if (!display || !previewData) return; + if (!previewData) return; if (placeLoading) { return; } @@ -1516,29 +1549,13 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } return; } - const lineIssues = collectDraftLineIssues(); - const missingProviderAt = providerMissingRow(); - if (missingProviderAt !== null) { - toast.error(t("hall.providers.rowRequired", { row: missingProviderAt, defaultValue: `第 ${missingProviderAt} 行请选择至少一个开注商` })); - return; - } - if (lineIssues.length > 0) { - toast.error(formatDraftLineIssue(lineIssues[0])); - return; - } - - const lines = buildLines(); - if (lines.length === 0) { + const snapshot = previewSubmissionRef.current; + if (snapshot === null) { toast.error(t("hall.changedBeforeSubmit")); return; } - const traceId = placeTraceIdRef.current ?? newPlaceTraceId(); - placeTraceIdRef.current = traceId; - - const drawIdForBet = - previewData.draw.draw_id.trim() || display.draw_no.trim(); - if (drawIdForBet === "") { + if (snapshot.drawId === "") { toast.error(t("hall.notBettable")); return; } @@ -1546,12 +1563,13 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } setPlaceLoading(true); try { const data = await postTicketPlace({ - draw_id: drawIdForBet, - currency_code: currencyCode, - client_trace_id: traceId, - lines, - expected_config_versions: previewData.config_versions, + draw_id: snapshot.drawId, + currency_code: snapshot.currencyCode, + client_trace_id: snapshot.clientTraceId, + lines: snapshot.lines, + expected_config_versions: snapshot.expectedConfigVersions, }); + previewSubmissionRef.current = null; clearPlaceTraceId(); setPreviewOpen(false); setPreviewData(null); @@ -1591,15 +1609,11 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } if (e instanceof LotteryApiBizError && code === 2002 && applyClosedPlayCleanup(e.data)) { const payload = e.data as ClosedPlayCleanupData; toast.error(payload.cleanup_hint ?? t("hall.ticketError.2002")); - setPreviewOpen(false); - setPreviewData(null); - clearPlaceTraceId(); + invalidatePreview(); return; } if (e instanceof LotteryApiBizError && (code === 2008 || code === 2009)) { - setPreviewOpen(false); - setPreviewData(null); - clearPlaceTraceId(); + invalidatePreview(); } if (e instanceof LotteryApiBizError && (code === 2001 || code === 2006)) { void reloadDraw(); @@ -2422,6 +2436,8 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } if (!open) { setPreviewData(null); if (!placeLoading) { + previewRequestSeqRef.current += 1; + previewSubmissionRef.current = null; clearPlaceTraceId(); } }