diff --git a/src/api/draw.ts b/src/api/draw.ts index 2c861f2..dbe882e 100644 --- a/src/api/draw.ts +++ b/src/api/draw.ts @@ -6,10 +6,18 @@ import type { DrawResultsListPayload, } from "@/types/api/draw-results"; +export type GetDrawCurrentParams = { + /** 与 `play/effective` 一致,决定嵌入快照中的 jackpot 币种 */ + currency?: string; +}; + /** `GET /api/v1/draw/current`(无需登录;无当前期时 `data` 为 `null`) */ -export function getDrawCurrent(): Promise { +export function getDrawCurrent( + params?: GetDrawCurrentParams, +): Promise { return lotteryRequest.get( `${API_V1_PREFIX}/draw/current`, + { params: params?.currency ? { currency: params.currency } : undefined }, ); } @@ -19,6 +27,7 @@ export type GetDrawResultsParams = { size?: number; /** `YYYY-MM-DD`,按业务日过滤 */ business_date?: string; + currency?: string; }; /** `GET /api/v1/draw/results` */ @@ -27,16 +36,29 @@ export function getDrawResults( ): Promise { return lotteryRequest.get( `${API_V1_PREFIX}/draw/results`, - { params: { page: params?.page, size: params?.size, business_date: params?.business_date } }, + { + params: { + page: params?.page, + size: params?.size, + business_date: params?.business_date, + currency: params?.currency, + }, + }, ); } +export type GetDrawResultByNoParams = { + currency?: string; +}; + /** `GET /api/v1/draw/results/{draw_no}` */ export function getDrawResultByNo( drawNo: string, + params?: GetDrawResultByNoParams, ): Promise { const encoded = encodeURIComponent(drawNo); return lotteryRequest.get( `${API_V1_PREFIX}/draw/results/${encoded}`, + { params: params?.currency ? { currency: params.currency } : undefined }, ); } diff --git a/src/app/globals.css b/src/app/globals.css index e18b72d..6747975 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -7,7 +7,7 @@ @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); - --font-sans: var(--font-sans); + --font-sans: var(--font-geist-sans); --font-mono: var(--font-geist-mono); --font-heading: var(--font-sans); --color-sidebar-ring: var(--sidebar-ring); @@ -127,6 +127,13 @@ html { @apply font-sans; } + html:lang(ne) { + font-family: + var(--font-noto-sans-devanagari), + var(--font-geist-sans), + system-ui, + sans-serif; + } } /* 玩家端 Toast:顶部居中、紧凑尺寸(位置见 components/ui/sonner.tsx) */ diff --git a/src/app/layout.tsx b/src/app/layout.tsx index cd009bc..0eede12 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,5 @@ import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; +import { Geist, Geist_Mono, Noto_Sans_Devanagari } from "next/font/google"; import { Providers } from "@/components/providers"; import { DEFAULT_LANGUAGE } from "@/i18n/language"; @@ -15,6 +15,11 @@ const geistMono = Geist_Mono({ subsets: ["latin"], }); +const notoSansDevanagari = Noto_Sans_Devanagari({ + variable: "--font-noto-sans-devanagari", + subsets: ["devanagari", "latin"], +}); + export const metadata: Metadata = { title: "Lottery", description: "Lottery player", @@ -34,7 +39,7 @@ export default function RootLayout({ {children} diff --git a/src/components/iframe-bridge.tsx b/src/components/iframe-bridge.tsx index 60dd518..b636358 100644 --- a/src/components/iframe-bridge.tsx +++ b/src/components/iframe-bridge.tsx @@ -116,7 +116,7 @@ export function IframeBridge({ children }: { children: ReactNode }): ReactNode { console.log("[IframeBridge] Received initial token"); setBearerToken(data.token); setPlayerBearerToken(data.token); - notifyReady(); + // 勿再 notifyReady(),否则主站会重复 MAIN_INIT_TOKEN 导致消息刷屏 } break; diff --git a/src/components/play-effective-ws-listener.tsx b/src/components/play-effective-ws-listener.tsx new file mode 100644 index 0000000..ef0013c --- /dev/null +++ b/src/components/play-effective-ws-listener.tsx @@ -0,0 +1,12 @@ +"use client"; + +import type { ReactNode } from "react"; + +import { usePlayEffectiveWs } from "@/hooks/use-play-effective-ws"; + +/** 全局挂载:后台发布玩法/赔率/封顶后刷新 `play/effective` 订阅方。 */ +export function PlayEffectiveWsListener(): ReactNode { + usePlayEffectiveWs(); + + return null; +} diff --git a/src/components/providers.tsx b/src/components/providers.tsx index 5678d1f..1c6aa9a 100644 --- a/src/components/providers.tsx +++ b/src/components/providers.tsx @@ -5,8 +5,9 @@ import { useEffect, type ReactNode } from "react"; import { Toaster } from "@/components/ui/sonner"; import { ErrorProvider } from "@/components/error-provider"; import { IframeBridge } from "@/components/iframe-bridge"; +import { PlayEffectiveWsListener } from "@/components/play-effective-ws-listener"; import { PlayerBalanceWsListener } from "@/components/player-balance-ws-listener"; -import { TokenRefreshIndicator } from "@/components/token-refresh-indicator"; +import { TokenSilentRefresh } from "@/components/token-silent-refresh"; import "@/i18n"; import { syncPreferredLanguage } from "@/i18n"; @@ -26,8 +27,9 @@ export function Providers({ children }: ProvidersProps): ReactNode { {children} - {/* Token 续签指示器 - 显示在右下角 */} - + + {/* Token 静默续签(无 UI) */} + diff --git a/src/components/token-refresh-indicator.tsx b/src/components/token-refresh-indicator.tsx deleted file mode 100644 index 60672cd..0000000 --- a/src/components/token-refresh-indicator.tsx +++ /dev/null @@ -1,123 +0,0 @@ -"use client"; - -import { useState, useEffect } from "react"; -import { RefreshCw, AlertCircle } from "lucide-react"; -import { useTranslation } from "react-i18next"; - -import { useTokenRefresh } from "@/hooks/use-token-refresh"; -import { Button } from "@/components/ui/button"; -import { ERROR_COLORS } from "@/stores/error-store"; -import { cn } from "@/lib/utils"; - -/** - * Token 续签状态指示器组件 - * - * 当 Token 即将过期或正在刷新时显示提示 - */ -export function TokenRefreshIndicator(): React.ReactElement | null { - const { isTokenExpiringSoon, getTokenRemainingTime, refreshToken } = - useTokenRefresh(); - const { t } = useTranslation("player"); - const [isRefreshing, setIsRefreshing] = useState(false); - const [showWarning, setShowWarning] = useState(false); - const [remainingSeconds, setRemainingSeconds] = useState(null); - - // 检测 Token 状态 - useEffect(() => { - const checkToken = (): void => { - const remaining = getTokenRemainingTime(); - - if (remaining > 0 && remaining < 120000) { - // 2 分钟内显示 - setShowWarning(true); - setRemainingSeconds(Math.floor(remaining / 1000)); - } else { - setShowWarning(false); - setRemainingSeconds(null); - } - }; - - checkToken(); - const interval = setInterval(checkToken, 10000); // 每 10 秒检查 - - return () => clearInterval(interval); - }, [getTokenRemainingTime, isTokenExpiringSoon]); - - // 手动刷新 - const handleRefresh = async (): Promise => { - setIsRefreshing(true); - try { - await refreshToken(); - } finally { - setIsRefreshing(false); - } - }; - - if (!showWarning) { - return null; - } - - const isCritical = remainingSeconds !== null && remainingSeconds < 60; - - return ( -
- - -
- - {isCritical ? t("token.critical") : t("token.warning")} - - - {remainingSeconds !== null && ( - <> - {t("token.remaining", { - time: `${Math.floor(remainingSeconds / 60)}:${String( - remainingSeconds % 60, - ).padStart(2, "0")}`, - })}{" "} - - )} - {t("token.autoRenewing")} - -
- - -
- ); -} diff --git a/src/components/token-silent-refresh.tsx b/src/components/token-silent-refresh.tsx new file mode 100644 index 0000000..1ed0b06 --- /dev/null +++ b/src/components/token-silent-refresh.tsx @@ -0,0 +1,11 @@ +"use client"; + +import { useTokenRefresh } from "@/hooks/use-token-refresh"; + +/** + * 挂载 Token 自动续签逻辑,不展示任何 UI(产品要求静默续签)。 + */ +export function TokenSilentRefresh(): null { + useTokenRefresh(); + return null; +} diff --git a/src/features/hall/hall-bet-result-dialog.tsx b/src/features/hall/hall-bet-result-dialog.tsx index d7ad22a..a38f665 100644 --- a/src/features/hall/hall-bet-result-dialog.tsx +++ b/src/features/hall/hall-bet-result-dialog.tsx @@ -38,9 +38,13 @@ export function HallBetResultDialog({ const { t } = useTranslation("player"); const successItems = - data?.items.filter((item) => SUCCESS_ITEM_STATUSES.has(item.status)) ?? []; + data?.items.filter( + (item) => item.status != null && SUCCESS_ITEM_STATUSES.has(item.status), + ) ?? []; const failedItems = - data?.items.filter((item) => FAILURE_ITEM_STATUSES.has(item.status)) ?? []; + data?.items.filter( + (item) => item.status != null && FAILURE_ITEM_STATUSES.has(item.status), + ) ?? []; const pendingItems = data?.items.filter((item) => item.status === "pending_confirm") ?? []; diff --git a/src/features/hall/hall-betting-grid.tsx b/src/features/hall/hall-betting-grid.tsx index 170fff6..d9ebe0b 100644 --- a/src/features/hall/hall-betting-grid.tsx +++ b/src/features/hall/hall-betting-grid.tsx @@ -29,6 +29,10 @@ import { getLotteryEcho } from "@/lib/lottery-echo"; import { getLotteryRequestLocale } from "@/lib/lottery-locale"; import { formatMinorAsCurrency, parseDecimalInputToMinor } from "@/lib/money"; import { playLabel } from "@/lib/play-labels"; +import { + PLAY_CATALOG_REFRESH_EVENT, + type PlayCatalogRefreshSource, +} from "@/lib/play-catalog-events"; import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference"; import { cn } from "@/lib/utils"; import { LotteryApiBizError } from "@/types/api/errors"; @@ -36,7 +40,6 @@ import type { PlayEffectivePayload, PlayEffectivePlayRow } from "@/types/api/pla import type { TicketLineInput, TicketPlaceData, TicketPreviewData } from "@/types/api/ticket"; import type { DrawCurrentRiskPoolAlert } from "@/types/api/draw-current"; -const DEFAULT_POLL_MS = 120_000; const MAX_ROWS = 20; type HallCategory = "D2" | "D3" | "D4" | "JACKPOT"; @@ -421,7 +424,7 @@ function cellRiskState( for (const alert of alerts) { if (matchesRiskAlert(alert.normalized_number, play.play_code, normalizedRow, category, digitSlot)) { - return alert.is_sold_out ? "sold_out" : "warning"; + return alert.status === "sold_out" ? "sold_out" : "warning"; } } @@ -512,12 +515,20 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } }, [loadCatalog, refreshWallet]); useEffect(() => { - const id = window.setInterval(() => { + const onCatalogRefresh = (ev: Event) => { void loadCatalog(); - void refreshWallet(); - }, DEFAULT_POLL_MS); - return () => window.clearInterval(id); - }, [loadCatalog, refreshWallet]); + const source = (ev as CustomEvent<{ source?: PlayCatalogRefreshSource }>).detail + ?.source; + if (source === "play_config" || source === "risk_cap") { + setPreviewOpen(false); + setPreviewData(null); + clearPlaceTraceId(); + toast.message(t("hall.playConfig.updated")); + } + }; + window.addEventListener(PLAY_CATALOG_REFRESH_EVENT, onCatalogRefresh); + return () => window.removeEventListener(PLAY_CATALOG_REFRESH_EVENT, onCatalogRefresh); + }, [clearPlaceTraceId, loadCatalog, t]); const openPlays = useMemo(() => { if (catalogState.kind !== "ok") return []; @@ -731,7 +742,6 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } const channel = echo.channel("lottery-hall"); const onPlayToggle = (evt: PlayToggleWsEvent) => { - void loadCatalog(); if (evt.enabled === false && typeof evt.play_code === "string") { const removed = clearAmountsForPlay(evt.play_code); setPreviewOpen(false); @@ -746,13 +756,10 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } playCode: evt.play_code, }), ); - } else { - toast.message(t("hall.playConfig.updated")); } }; const onOddsUpdate = (evt: OddsUpdateWsEvent) => { - void loadCatalog(); setPreviewOpen(false); setPreviewData(null); clearPlaceTraceId(); diff --git a/src/features/hall/hall-play-catalog-panel.tsx b/src/features/hall/hall-play-catalog-panel.tsx index 44b62b7..a54d189 100644 --- a/src/features/hall/hall-play-catalog-panel.tsx +++ b/src/features/hall/hall-play-catalog-panel.tsx @@ -23,6 +23,7 @@ import { } from "@/components/ui/table"; import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency"; import { getLotteryRequestLocale } from "@/lib/lottery-locale"; +import { PLAY_CATALOG_REFRESH_EVENT } from "@/lib/play-catalog-events"; import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference"; import { cn } from "@/lib/utils"; import { LotteryApiBizError } from "@/types/api/errors"; @@ -117,6 +118,12 @@ export function HallPlayCatalogPanel() { return () => window.removeEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onCurrencyChange); }, [load]); + useEffect(() => { + const onCatalogRefresh = () => void load(); + window.addEventListener(PLAY_CATALOG_REFRESH_EVENT, onCatalogRefresh); + return () => window.removeEventListener(PLAY_CATALOG_REFRESH_EVENT, onCatalogRefresh); + }, [load]); + const body = (() => { if (state.kind === "loading") { return ( diff --git a/src/features/hall/hall-wallet-strip.tsx b/src/features/hall/hall-wallet-strip.tsx index cb5d558..ec8e9d0 100644 --- a/src/features/hall/hall-wallet-strip.tsx +++ b/src/features/hall/hall-wallet-strip.tsx @@ -84,6 +84,10 @@ export function HallWalletStrip() { }, [mode, refresh]); const availableMinor = Number(balance?.available_balance ?? balance?.balance ?? 0); + const mainMinor = + balance?.main_balance === null || balance?.main_balance === undefined + ? null + : Number(balance.main_balance); return (
@@ -124,6 +128,7 @@ export function HallWalletStrip() { triggerClassName="h-12 rounded-lg text-base font-bold" currency={currency} lotteryMinor={availableMinor} + mainMinor={mainMinor} onSuccess={refresh} /> (undefined); const [serverNowMs, setServerNowMs] = useState(() => Date.now()); const [emittedAtMs, setEmittedAtMs] = useState(() => Date.now()); @@ -78,40 +109,44 @@ export function useHallDrawLive(): HallDrawLiveSnapshot { const setDrawPollingIntervalId = useNetworkConnectionStore( (s) => s.setDrawPollingIntervalId, ); - const setWalletPollingIntervalId = useNetworkConnectionStore( - (s) => s.setWalletPollingIntervalId, - ); - const setWalletPollingExpiryAt = useNetworkConnectionStore( - (s) => s.setWalletPollingExpiryAt, - ); + const clearDrawPolling = useNetworkConnectionStore((s) => s.clearDrawPolling); const latestSnapshotMsRef = useRef(0); - const applySnapshot = useCallback((anchorMs: number, data: DrawCurrentPayload | null) => { - if (anchorMs < latestSnapshotMsRef.current) { - return; - } - latestSnapshotMsRef.current = anchorMs; - setServerNowMs(anchorMs); - setRaw(data); - setEmittedAtMs(anchorMs); - }, []); + const applySnapshot = useCallback( + (anchorMs: number, data: DrawCurrentPayload | null) => { + if (anchorMs < latestSnapshotMsRef.current) { + return; + } + latestSnapshotMsRef.current = anchorMs; + setServerNowMs(anchorMs); + setRaw((prev) => mergeJackpotForCurrency(data, prev, activeCurrency)); + setEmittedAtMs(anchorMs); + }, + [activeCurrency], + ); - const mergeFromWs = useCallback((evt: HallWsEnvelope) => { - const anchor = evt.emitted_at_ms ?? Date.now(); - applySnapshot(anchor, evt.data); - }, [applySnapshot]); + const mergeFromWs = useCallback( + (evt: HallWsEnvelope) => { + const anchor = evt.emitted_at_ms ?? Date.now(); + applySnapshot(anchor, evt.data); + }, + [applySnapshot], + ); - const mergeCountdownFromWs = useCallback((evt: HallWsEnvelope) => { - if (evt.data === null) return; - const anchor = evt.emitted_at_ms ?? Date.now(); - applySnapshot(anchor, evt.data); - }, [applySnapshot]); + const mergeCountdownFromWs = useCallback( + (evt: HallWsEnvelope) => { + if (evt.data === null) return; + const anchor = evt.emitted_at_ms ?? Date.now(); + applySnapshot(anchor, evt.data); + }, + [applySnapshot], + ); const load = useCallback(async (options?: { force?: boolean }) => { try { setError(null); - const d = await getDrawCurrent(); + const d = await getDrawCurrent({ currency: activeCurrency }); const wsConnected = useNetworkConnectionStore.getState().isWebSocketConnected; if (!options?.force && wsConnected && d.server_now_ms < latestSnapshotMsRef.current) { return; @@ -121,7 +156,15 @@ export function useHallDrawLive(): HallDrawLiveSnapshot { setError("draw.loadFailedRefresh"); setRaw(undefined); } - }, [applySnapshot]); + }, [activeCurrency, applySnapshot]); + + useEffect(() => { + const onCurrencyChange = () => { + void load({ force: true }); + }; + window.addEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onCurrencyChange); + return () => window.removeEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onCurrencyChange); + }, [load]); // 初始加载 useEffect(() => { @@ -155,124 +198,26 @@ export function useHallDrawLive(): HallDrawLiveSnapshot { }; }, []); - // WebSocket 订阅 + 降级轮询逻辑 + // WebSocket 订阅(期号 HTTP 轮询由下方单一 effect 负责) useEffect(() => { const echo = getLotteryEcho(); if (!echo) return; - // 监听 WebSocket 事件 const channel = echo.channel("lottery-hall"); - // 设置事件监听 channel.listen(".draw.countdown", mergeCountdownFromWs); channel.listen(".draw.status_change", mergeFromWs); channel.listen(".result.published", (evt: HallWsEnvelope) => { - // 开奖结果发布时触发钱包轮询 mergeFromWs(evt); - - // 触发钱包余额轮询(开奖后需要更新余额) - const intervalId = window.setInterval(() => { - window.dispatchEvent(new Event("lottery-wallet-refresh")); - }, 30_000); - setWalletPollingIntervalId(intervalId); - // 设置2分钟后停止轮询 - setWalletPollingExpiryAt(Date.now() + 2 * 60 * 1000); - - // 2分钟后自动清理 - window.setTimeout(() => { - window.clearInterval(intervalId); - setWalletPollingIntervalId(null); - }, 2 * 60 * 1000); + startWalletRefreshBurst(); }); - // 连接状态变化处理 - const handleConnected = () => { - // WebSocket 连接成功,停止降级轮询 - const currentPollingId = useNetworkConnectionStore.getState().drawPollingIntervalId; - if (currentPollingId) { - window.clearInterval(currentPollingId); - setDrawPollingIntervalId(null); - } - }; - - const handleDisconnected = () => { - // WebSocket 断开,启动降级轮询(如果还没启动) - const currentPollingId = useNetworkConnectionStore.getState().drawPollingIntervalId; - if (!currentPollingId) { - const intervalId = window.setInterval(() => { - void load(); - }, 30_000); // WebSocket断开时使用30秒轮询 - setDrawPollingIntervalId(intervalId); - } - }; - - // 订阅 Echo 的连接事件(如果支持) - if (echo.connector?.pusher) { - echo.connector.pusher.connection.bind("connected", handleConnected); - echo.connector.pusher.connection.bind("disconnected", handleDisconnected); - } - - // 如果当前是轮询模式,启动轮询 - if (mode === "polling" || mode === "offline") { - handleDisconnected(); - } - return () => { channel.stopListening(".draw.countdown"); channel.stopListening(".draw.status_change"); channel.stopListening(".result.published"); - if (echo.connector?.pusher) { - echo.connector.pusher.connection.unbind("connected", handleConnected); - echo.connector.pusher.connection.unbind( - "disconnected", - handleDisconnected, - ); - } }; - }, [ - mergeCountdownFromWs, - mergeFromWs, - load, - setDrawPollingIntervalId, - setWalletPollingIntervalId, - setWalletPollingExpiryAt, - mode, - ]); - - // WebSocket 断开时的兜底轮询(独立于 Echo 事件监听) - useEffect(() => { - let intervalId: number | null = null; - - // 只在 WebSocket 未连接且没有轮询计时器时启动轮询 - if (!isWebSocketConnected && mode !== "websocket") { - const currentPollingId = useNetworkConnectionStore.getState().drawPollingIntervalId; - if (!currentPollingId) { - const initialLoadId = window.setTimeout(() => { - void load(); - }, 0); - - // 设置30秒轮询 - intervalId = window.setInterval(() => { - void load(); - }, 30_000); - setDrawPollingIntervalId(intervalId); - return () => { - window.clearTimeout(initialLoadId); - if (intervalId) { - window.clearInterval(intervalId); - setDrawPollingIntervalId(null); - } - }; - } - } - - return () => { - if (intervalId) { - window.clearInterval(intervalId); - setDrawPollingIntervalId(null); - } - }; - }, [isWebSocketConnected, mode, load, setDrawPollingIntervalId]); + }, [mergeCountdownFromWs, mergeFromWs]); const display: DrawCurrentPayload | null | undefined = raw === undefined || raw === null @@ -339,28 +284,32 @@ export function useHallDrawLive(): HallDrawLiveSnapshot { nowMs, ); - // 封盘/待开奖/开奖中:调度推进状态前每 3 秒拉一次,避免 0:00 卡几十秒 + // 单一期号 HTTP 轮询:降级 30s / 待开奖 3s / 常态保险 45s(避免多套 interval 叠加) useEffect(() => { - if (!needsFastDrawPoll) { - return; - } - const intervalId = window.setInterval(() => { - void load(); - }, 3000); - return () => window.clearInterval(intervalId); - }, [needsFastDrawPoll, load]); + const wsOk = isWebSocketConnected && mode === "websocket"; + const intervalMs = !wsOk ? 30_000 : needsFastDrawPoll ? 3_000 : 45_000; + + const initialTimer = window.setTimeout(() => { + void load(); + }, 0); - // WebSocket 已连接时的兜底轮询(tick 延迟时的保险;常态下由 WS 推送,避免 HTTP 覆盖新快照) - useEffect(() => { - if (isWebSocketConnected && !needsFastDrawPoll) { - return; - } - const intervalMs = needsFastDrawPoll ? 15_000 : 45_000; const intervalId = window.setInterval(() => { void load(); }, intervalMs); - return () => window.clearInterval(intervalId); - }, [load, needsFastDrawPoll, isWebSocketConnected]); + setDrawPollingIntervalId(intervalId); + + return () => { + window.clearTimeout(initialTimer); + clearDrawPolling(); + }; + }, [ + isWebSocketConnected, + mode, + needsFastDrawPoll, + load, + setDrawPollingIntervalId, + clearDrawPolling, + ]); return { raw, display, serverNowMs, nowMs, error, reload: load, isBettable }; } diff --git a/src/features/player/entry-gate.tsx b/src/features/player/entry-gate.tsx index fa65395..65bc8eb 100644 --- a/src/features/player/entry-gate.tsx +++ b/src/features/player/entry-gate.tsx @@ -68,6 +68,15 @@ function initialSteps(): EntryStep[] { ]; } +function stripSearchParamFromBrowserUrl(name: string): void { + if (typeof window === "undefined") return; + const url = new URL(window.location.href); + if (!url.searchParams.has(name)) return; + url.searchParams.delete(name); + const next = `${url.pathname}${url.search}${url.hash}`; + window.history.replaceState(null, "", next); +} + function shouldRetryEntryRequest(error: unknown): boolean { if (error instanceof LotteryApiBizError) { return false; @@ -93,12 +102,22 @@ export function EntryGate() { const { t: tc } = useTranslation("common"); const tokenFromUrl = searchParams.get("token") ?? ""; + const sessionExpired = searchParams.get("session") === "expired"; const { bearerToken, setBearerToken, setProfile, setCurrencies, clearBearerToken } = usePlayerSessionStore(); - const [phase, setPhase] = useState("loading"); - const [failureDetails, setFailureDetails] = useState([]); + const [phase, setPhase] = useState(sessionExpired ? "failed" : "loading"); + const [failureDetails, setFailureDetails] = useState(() => + sessionExpired + ? [ + { + code: "SESSION_EXPIRED", + detailKey: "errors.sessionExpiredDetail", + }, + ] + : [], + ); const [steps, setSteps] = useState(initialSteps()); const effectiveToken = tokenFromUrl || bearerToken; @@ -113,12 +132,6 @@ export function EntryGate() { return Math.round(((doneCount + inProgressCount * 0.5) / steps.length) * 100); }, [steps]); - const handleRetry = useCallback(() => { - setPhase("loading"); - setFailureDetails([]); - setSteps(initialSteps()); - }, []); - const doEntry = useCallback(async () => { if (!effectiveToken) { setPhase("failed"); @@ -128,6 +141,7 @@ export function EntryGate() { if (tokenFromUrl) { setBearerToken(tokenFromUrl); + stripSearchParamFromBrowserUrl("token"); } setSteps((prev) => @@ -231,12 +245,26 @@ export function EntryGate() { t, ]); + const handleRetry = useCallback(() => { + setPhase("loading"); + setFailureDetails([]); + setSteps(initialSteps()); + void doEntry(); + }, [doEntry]); + useEffect(() => { + if (!sessionExpired) return; + clearBearerToken(); + stripSearchParamFromBrowserUrl("session"); + }, [sessionExpired, clearBearerToken]); + + useEffect(() => { + if (sessionExpired) return; const tmr = window.setTimeout(() => { void doEntry(); }, 300); return () => window.clearTimeout(tmr); - }, [doEntry]); + }, [doEntry, sessionExpired]); return (
diff --git a/src/features/results/draw-result-detail-screen.tsx b/src/features/results/draw-result-detail-screen.tsx index 1e6fb9d..a2df6b2 100644 --- a/src/features/results/draw-result-detail-screen.tsx +++ b/src/features/results/draw-result-detail-screen.tsx @@ -50,7 +50,7 @@ export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps) setLoading(true); setError(null); try { - const row = await getDrawResultByNo(drawNo); + const row = await getDrawResultByNo(drawNo, { currency: activeCurrency }); setData(row); } catch { setData(null); @@ -58,7 +58,7 @@ export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps) } finally { setLoading(false); } - }, [drawNo, t]); + }, [activeCurrency, drawNo, t]); useEffect(() => { queueMicrotask(() => { diff --git a/src/features/results/draw-results-list-screen.tsx b/src/features/results/draw-results-list-screen.tsx index 095ec59..37bff0d 100644 --- a/src/features/results/draw-results-list-screen.tsx +++ b/src/features/results/draw-results-list-screen.tsx @@ -65,6 +65,7 @@ export function DrawResultsListScreen() { page: targetPage, size: RESULTS_PAGE_SIZE, business_date: businessDate, + currency: activeCurrency, }); setItems((current) => (append && current ? [...current, ...res.items] : res.items)); setPage(res.page); @@ -81,7 +82,7 @@ export function DrawResultsListScreen() { setLoading(false); } } - }, [businessDate, t]); + }, [activeCurrency, businessDate, t]); useEffect(() => { queueMicrotask(() => { diff --git a/src/features/wallet/transfer-in-screen.tsx b/src/features/wallet/transfer-in-screen.tsx index a4b25b8..7725629 100644 --- a/src/features/wallet/transfer-in-screen.tsx +++ b/src/features/wallet/transfer-in-screen.tsx @@ -60,6 +60,11 @@ export function TransferInScreen() { ); diff --git a/src/features/wallet/wallet-screen.tsx b/src/features/wallet/wallet-screen.tsx index 7c38885..c4d573a 100644 --- a/src/features/wallet/wallet-screen.tsx +++ b/src/features/wallet/wallet-screen.tsx @@ -192,6 +192,11 @@ export function WalletScreen() { idPrefix="wallet-" currency={currency} lotteryMinor={Number(balance?.balance ?? 0)} + mainMinor={ + balance?.main_balance === null || balance?.main_balance === undefined + ? null + : Number(balance.main_balance) + } onSuccess={refreshAll} triggerVariant="hall" triggerLabel={t("wallet.transferIn", { defaultValue: "Transfer In" })} diff --git a/src/features/wallet/wallet-transfer-dialogs.tsx b/src/features/wallet/wallet-transfer-dialogs.tsx index 1c30a5a..9ffc34b 100644 --- a/src/features/wallet/wallet-transfer-dialogs.tsx +++ b/src/features/wallet/wallet-transfer-dialogs.tsx @@ -28,6 +28,7 @@ type BaseProps = { export function TransferInDialog({ currency, lotteryMinor, + mainMinor = null, onSuccess, idPrefix = "", triggerClassName, @@ -35,6 +36,7 @@ export function TransferInDialog({ triggerLabel, }: BaseProps & { lotteryMinor: number; + mainMinor?: number | null; triggerClassName?: string; triggerVariant?: "wallet" | "hall"; triggerLabel?: string; @@ -70,6 +72,7 @@ export function TransferInDialog({ variant="dialog" currency={currency} lotteryMinor={lotteryMinor} + mainMinor={mainMinor} idPrefix={idPrefix} onCancel={() => setOpen(false)} onSuccess={async () => { diff --git a/src/features/wallet/wallet-transfer-forms.tsx b/src/features/wallet/wallet-transfer-forms.tsx index d152daa..bf6eeb1 100644 --- a/src/features/wallet/wallet-transfer-forms.tsx +++ b/src/features/wallet/wallet-transfer-forms.tsx @@ -135,12 +135,14 @@ function TransferDialogFooter({ export function TransferInPanel({ currency, lotteryMinor, + mainMinor = null, onSuccess, idPrefix = "", onCancel, variant = "dialog", }: PanelBase & { lotteryMinor: number; + mainMinor?: number | null; onCancel: () => void; variant?: PanelVariant; }) { @@ -226,7 +228,11 @@ export function TransferInPanel({

{t("wallet.mainBalance")}{" "} - {t("wallet.mainPending")} + + {mainMinor != null + ? formatMinorAsCurrency(mainMinor, currency) + : t("wallet.mainPending")} +

{t("wallet.lotteryBalance")}{" "} @@ -413,8 +419,9 @@ export function TransferOutPanel({ export function TransferInPage({ currency, lotteryMinor, + mainMinor = null, onSuccess, -}: PanelBase & { lotteryMinor: number }) { +}: PanelBase & { lotteryMinor: number; mainMinor?: number | null }) { const { t } = useTranslation("player"); return ( @@ -433,6 +440,7 @@ export function TransferInPage({ variant="page" currency={currency} lotteryMinor={lotteryMinor} + mainMinor={mainMinor} idPrefix="page-" onSuccess={onSuccess} onCancel={() => {}} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 9320217..7d98350 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -4,3 +4,4 @@ export { useTokenRefresh } from "./use-token-refresh"; export { useWalletPolling, triggerWalletPollingAfterBet } from "./use-wallet-polling"; export { useWebSocketManager } from "./use-websocket-manager"; export { usePlayerBalanceWs } from "./use-player-balance-ws"; +export { usePlayEffectiveWs } from "./use-play-effective-ws"; diff --git a/src/hooks/use-play-effective-ws.ts b/src/hooks/use-play-effective-ws.ts new file mode 100644 index 0000000..f7a2acd --- /dev/null +++ b/src/hooks/use-play-effective-ws.ts @@ -0,0 +1,42 @@ +"use client"; + +import { useEffect } from "react"; + +import { + dispatchPlayCatalogRefresh, + type PlayCatalogRefreshSource, +} from "@/lib/play-catalog-events"; +import { getLotteryEcho } from "@/lib/lottery-echo"; + +/** + * 订阅大厅频道玩法目录相关 WS,触发 `lottery-play-catalog-refresh`。 + */ +export function usePlayEffectiveWs(): void { + useEffect(() => { + const echo = getLotteryEcho(); + if (!echo) { + return; + } + + const channel = echo.channel("lottery-hall"); + + const onRefresh = (source: PlayCatalogRefreshSource) => (): void => { + dispatchPlayCatalogRefresh(source); + }; + + channel.listen(".play.toggle", onRefresh("play_toggle")); + channel.listen(".odds.update", onRefresh("odds")); + channel.listen(".play.catalog_updated", (payload: { module?: string }) => { + const module = payload?.module; + const source: PlayCatalogRefreshSource = + module === "odds" ? "odds" : module === "risk_cap" ? "risk_cap" : "play_config"; + dispatchPlayCatalogRefresh(source); + }); + + return () => { + channel.stopListening(".play.toggle"); + channel.stopListening(".odds.update"); + channel.stopListening(".play.catalog_updated"); + }; + }, []); +} diff --git a/src/hooks/use-token-refresh.ts b/src/hooks/use-token-refresh.ts index 4cde588..e958651 100644 --- a/src/hooks/use-token-refresh.ts +++ b/src/hooks/use-token-refresh.ts @@ -136,8 +136,11 @@ export function useTokenRefresh(): { const { data } = event; if (!data || typeof data !== "object") return; - // 处理主站发送的新 Token - if (data.type === "LOTTERY_TOKEN_REFRESH_RESPONSE" && data.token) { + // 处理主站发送的新 Token(兼容 MAIN_REFRESH_TOKEN 与 LOTTERY_TOKEN_REFRESH_RESPONSE) + if ( + (data.type === "LOTTERY_TOKEN_REFRESH_RESPONSE" || data.type === "MAIN_REFRESH_TOKEN") && + data.token + ) { console.log("[TokenRefresh] Received new token from parent"); setBearerToken(data.token); retryCountRef.current = 0; // 重置重试计数 diff --git a/src/hooks/use-wallet-polling.ts b/src/hooks/use-wallet-polling.ts index 658e6c1..bf4db9c 100644 --- a/src/hooks/use-wallet-polling.ts +++ b/src/hooks/use-wallet-polling.ts @@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef } from "react"; import { getWalletBalance } from "@/api/wallet"; import { getActivePlayerCurrencyFromStore } from "@/lib/player-currency"; +import { startWalletRefreshBurst } from "@/lib/wallet-refresh-burst"; import { useNetworkConnectionStore } from "@/stores/network-connection-store"; const POLLING_INTERVAL_MS = 30_000; // 30秒轮询间隔 @@ -142,41 +143,10 @@ export function useWalletPolling(): UseWalletPollingReturn { export function triggerWalletPollingAfterBet(): void { const store = useNetworkConnectionStore.getState(); - // 如果是降级模式,立即刷新并启动限时轮询 if (store.mode === "polling" || store.mode === "offline") { - // 立即刷新一次 - void getWalletBalance({ currency: getActivePlayerCurrencyFromStore() }).then(() => { - window.dispatchEvent(new Event("lottery-wallet-refresh")); - }); - - // 清除现有轮询 - if (store.walletPollingIntervalId) { - window.clearInterval(store.walletPollingIntervalId); - } - - // 启动限时轮询 - const intervalId = window.setInterval(() => { - const live = useNetworkConnectionStore.getState(); - if (live.walletPollingExpiryAt && Date.now() > live.walletPollingExpiryAt) { - window.clearInterval(intervalId); - live.setWalletPollingIntervalId(null); - return; - } - void getWalletBalance({ currency: getActivePlayerCurrencyFromStore() }).then(() => { - window.dispatchEvent(new Event("lottery-wallet-refresh")); - }); - }, POLLING_INTERVAL_MS); - - useNetworkConnectionStore.getState().setWalletPollingIntervalId(intervalId); - useNetworkConnectionStore.getState().setWalletPollingExpiryAt(Date.now() + LIMITED_POLLING_DURATION_MS); - - // 2分钟后自动清理 - window.setTimeout(() => { - window.clearInterval(intervalId); - store.setWalletPollingIntervalId(null); - }, LIMITED_POLLING_DURATION_MS + 1000); - } else { - // WebSocket 模式下,只触发一次刷新 - window.dispatchEvent(new Event("lottery-wallet-refresh")); + startWalletRefreshBurst(); + return; } + + window.dispatchEvent(new Event("lottery-wallet-refresh")); } diff --git a/src/hooks/use-websocket-manager.ts b/src/hooks/use-websocket-manager.ts index 503e758..63fb35e 100644 --- a/src/hooks/use-websocket-manager.ts +++ b/src/hooks/use-websocket-manager.ts @@ -38,8 +38,8 @@ export type UseWebSocketManagerReturn = { * 1. 监控 WebSocket 连接状态 * 2. WebSocket 断开时自动切换到轮询模式 * 3. 定期尝试重连 WebSocket - * 4. 管理画作数据轮询(WebSocket断开时) - * 5. 管理钱包余额轮询(下注后/开奖后/降级模式) + * 4. 期号 HTTP 轮询由 {@link useHallDrawLive} 统一管理 + * 5. 可选钱包余额轮询(`startWalletPolling`,下注等场景) */ export function useWebSocketManager(): UseWebSocketManagerReturn { const store = useNetworkConnectionStore(); @@ -48,7 +48,6 @@ export function useWebSocketManager(): UseWebSocketManagerReturn { const { mode, - isWebSocketConnected, reconnectAttempts, setWebSocketConnected, setReconnecting, @@ -77,23 +76,6 @@ export function useWebSocketManager(): UseWebSocketManagerReturn { } }, []); - // 画作轮询:用 getState() 读/写 timer id,避免 callback 依赖 id → effect(含卸载清理)连环重跑导致「Maximum update depth」 - const startDrawPolling = useCallback(() => { - const s = useNetworkConnectionStore.getState(); - const prevId = s.drawPollingIntervalId; - if (prevId !== null) { - window.clearInterval(prevId); - } - - void refreshDraw(); - - const intervalId = window.setInterval(() => { - void refreshDraw(); - }, POLLING_INTERVAL_MS); - - s.setDrawPollingIntervalId(intervalId); - }, [refreshDraw]); - // 钱包轮询 const startWalletPolling = useCallback( (options?: { limitedDuration?: boolean }) => { @@ -147,9 +129,6 @@ export function useWebSocketManager(): UseWebSocketManagerReturn { setLastDisconnectedAt(Date.now()); switchToPollingMode(); - // 启动画作数据轮询(降级模式) - startDrawPolling(); - // 启动重连计时器 if (reconnectTimerRef.current) { window.clearTimeout(reconnectTimerRef.current); @@ -160,7 +139,6 @@ export function useWebSocketManager(): UseWebSocketManagerReturn { setWebSocketConnected, setLastDisconnectedAt, switchToPollingMode, - startDrawPolling, setReconnecting, ]); @@ -249,7 +227,6 @@ export function useWebSocketManager(): UseWebSocketManagerReturn { const echo = getLotteryEcho(); if (!echo) { switchToPollingMode(); - startDrawPolling(); return; } @@ -298,7 +275,6 @@ export function useWebSocketManager(): UseWebSocketManagerReturn { isPusherConnected, handleConnect, handleDisconnect, - startDrawPolling, switchToPollingMode, ]); diff --git a/src/i18n/locales/en/entry.json b/src/i18n/locales/en/entry.json index 2cc4f53..a33e677 100644 --- a/src/i18n/locales/en/entry.json +++ b/src/i18n/locales/en/entry.json @@ -52,6 +52,8 @@ "errors": { "noToken": "No authorization token found", "noTokenDetail": "Please return to the main site and try again.", + "sessionExpired": "Session expired", + "sessionExpiredDetail": "Please return to the main site and open the lottery hall again.", "authFailed": "Authorization failed", "unknown": "Unknown error", "network": "Network error occurred", diff --git a/src/i18n/locales/ne/entry.json b/src/i18n/locales/ne/entry.json index 5501448..e7062c6 100644 --- a/src/i18n/locales/ne/entry.json +++ b/src/i18n/locales/ne/entry.json @@ -52,6 +52,8 @@ "errors": { "noToken": "कुनै प्राधिकरण टोकन फेला परेन", "noTokenDetail": "कृपया मुख्य साइटमा फर्कनुहोस् र फेरि प्रयास गर्नुहोस्।", + "sessionExpired": "लगइन म्याद सकियो", + "sessionExpiredDetail": "कृपया मुख्य साइटमा फर्केर लटरी हल फेरि खोल्नुहोस्।", "authFailed": "प्राधिकरण असफल", "unknown": "अज्ञात त्रुटि", "network": "नेटवर्क त्रुटि भयो", diff --git a/src/i18n/locales/zh/entry.json b/src/i18n/locales/zh/entry.json index 5b12d43..05ca2d1 100644 --- a/src/i18n/locales/zh/entry.json +++ b/src/i18n/locales/zh/entry.json @@ -52,6 +52,8 @@ "errors": { "noToken": "未发现授权令牌", "noTokenDetail": "请返回主站后重试。", + "sessionExpired": "登录已过期", + "sessionExpiredDetail": "请返回主站重新进入彩票大厅。", "authFailed": "授权失败", "unknown": "未知错误", "network": "网络异常", diff --git a/src/lib/csp-config.ts b/src/lib/csp-config.ts index 3a4917b..d032002 100644 --- a/src/lib/csp-config.ts +++ b/src/lib/csp-config.ts @@ -42,6 +42,7 @@ export function generateCSP(): string { "connect-src": [ "'self'", process.env.NEXT_PUBLIC_API_URL || "", + process.env.NEXT_PUBLIC_LOTTERY_API_BASE_URL || "", // WebSocket 连接 "ws:", "wss:", diff --git a/src/lib/lottery-http.ts b/src/lib/lottery-http.ts index b5cb449..d2981f8 100644 --- a/src/lib/lottery-http.ts +++ b/src/lib/lottery-http.ts @@ -4,8 +4,8 @@ import axios, { type AxiosResponse, } from "axios"; -import { setPlayerBearerToken, withPlayerAuthHeader } from "@/lib/lottery-auth"; -import { clearPersistedPlayerBearerToken } from "@/lib/player-session"; +import { withPlayerAuthHeader } from "@/lib/lottery-auth"; +import { usePlayerSessionStore } from "@/stores/player-session-store"; import { withLotteryLocaleHeaders } from "@/lib/lottery-locale"; import { LotteryApiBizError, @@ -35,9 +35,10 @@ lotteryHttp.interceptors.response.use( // 401: 会话过期,清除令牌并重定向 if (status === 401) { - clearPersistedPlayerBearerToken(); - setPlayerBearerToken(null); - if (window.location.pathname !== "/") { + usePlayerSessionStore.getState().clearBearerToken(); + const onEntry = window.location.pathname === "/"; + const alreadyExpired = window.location.search.includes("session=expired"); + if (!onEntry || !alreadyExpired) { window.location.replace("/?session=expired"); } } diff --git a/src/lib/play-catalog-events.ts b/src/lib/play-catalog-events.ts new file mode 100644 index 0000000..b9d68ac --- /dev/null +++ b/src/lib/play-catalog-events.ts @@ -0,0 +1,12 @@ +/** 玩法目录(`play/effective`)需要全量刷新时派发。 */ +export const PLAY_CATALOG_REFRESH_EVENT = "lottery-play-catalog-refresh"; + +export type PlayCatalogRefreshSource = "play_config" | "odds" | "risk_cap" | "play_toggle"; + +export function dispatchPlayCatalogRefresh(source?: PlayCatalogRefreshSource): void { + if (typeof window !== "undefined") { + window.dispatchEvent( + new CustomEvent(PLAY_CATALOG_REFRESH_EVENT, { detail: { source } }), + ); + } +} diff --git a/src/lib/wallet-refresh-burst.ts b/src/lib/wallet-refresh-burst.ts new file mode 100644 index 0000000..7b529e1 --- /dev/null +++ b/src/lib/wallet-refresh-burst.ts @@ -0,0 +1,27 @@ +import { useNetworkConnectionStore } from "@/stores/network-connection-store"; + +const WALLET_REFRESH_BURST_MS = 30_000; +const WALLET_REFRESH_BURST_DURATION_MS = 2 * 60 * 1000; + +/** + * 开奖等场景:立即刷新余额,并在限时内每 30s 派发 `lottery-wallet-refresh`(先清旧定时器,避免泄漏)。 + */ +export function startWalletRefreshBurst(): void { + const store = useNetworkConnectionStore.getState(); + store.clearWalletPolling(); + + window.dispatchEvent(new Event("lottery-wallet-refresh")); + + const expiryAt = Date.now() + WALLET_REFRESH_BURST_DURATION_MS; + store.setWalletPollingExpiryAt(expiryAt); + + const intervalId = window.setInterval(() => { + if (Date.now() > expiryAt) { + useNetworkConnectionStore.getState().clearWalletPolling(); + return; + } + window.dispatchEvent(new Event("lottery-wallet-refresh")); + }, WALLET_REFRESH_BURST_MS); + + store.setWalletPollingIntervalId(intervalId); +} diff --git a/src/types/api/draw-current.ts b/src/types/api/draw-current.ts index e54c74c..ffca969 100644 --- a/src/types/api/draw-current.ts +++ b/src/types/api/draw-current.ts @@ -11,12 +11,7 @@ export type DrawCurrentResultItem = { export type DrawCurrentRiskPoolAlert = { normalized_number: string; - total_cap_amount: number; - locked_amount: number; - remaining_amount: number; - sold_out_status: number; - is_sold_out: boolean; - usage_ratio: number | null; + status: "warning" | "sold_out"; }; export type DrawCurrentPayload = { @@ -37,6 +32,8 @@ export type DrawCurrentPayload = { seconds_to_draw: number; cooling_end_time: string | null; seconds_remaining_in_cooldown: number | null; + /** 与 `jackpot.currency_code` 一致,便于 WS 快照与玩家币种对齐 */ + jackpot_currency_code?: string; jackpot?: { currency_code: string; enabled: boolean; diff --git a/src/types/api/wallet-balance.ts b/src/types/api/wallet-balance.ts index c376343..641ed81 100644 --- a/src/types/api/wallet-balance.ts +++ b/src/types/api/wallet-balance.ts @@ -4,7 +4,7 @@ export type WalletBalanceData = { balance: string | number; /** 可用余额 = balance - frozen_balance(服务端保证 ≥0) */ available_balance: string | number; - main_balance: null; + main_balance: string | number | null; currency_code: string; wallet_type: string; frozen_balance: string | number;