From 1e7a06dc86458367499d86007986588f292b487f Mon Sep 17 00:00:00 2001 From: kang Date: Wed, 13 May 2026 14:44:58 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=9B=86=E6=88=90=E7=BD=91=E7=BB=9C?= =?UTF-8?q?=E8=BF=9E=E6=8E=A5=E7=AE=A1=E7=90=86=E4=B8=8E=E9=99=8D=E7=BA=A7?= =?UTF-8?q?=E8=BD=AE=E8=AF=A2=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 PlayerAppShell 中引入 NetworkStatusBanner 组件以显示网络状态 - 在 HallBettingGrid 中实现下注后触发钱包轮询 - 在 HallWalletStrip 中添加网络连接状态管理与定期刷新逻辑 - 在 useHallDrawLive 中集成 WebSocket 连接状态与降级轮询机制,确保在断开时自动切换到轮询模式 --- src/components/layout/player-app-shell.tsx | 2 + src/components/network-status-banner.tsx | 96 ++++++ src/features/hall/hall-betting-grid.tsx | 4 +- src/features/hall/hall-wallet-strip.tsx | 59 ++++ src/features/hall/use-hall-draw-live.ts | 136 +++++++- src/hooks/index.ts | 12 + src/hooks/use-wallet-polling.ts | 181 +++++++++++ src/hooks/use-websocket-manager.ts | 352 +++++++++++++++++++++ src/stores/network-connection-store.ts | 148 +++++++++ 9 files changed, 974 insertions(+), 16 deletions(-) create mode 100644 src/components/network-status-banner.tsx create mode 100644 src/hooks/index.ts create mode 100644 src/hooks/use-wallet-polling.ts create mode 100644 src/hooks/use-websocket-manager.ts create mode 100644 src/stores/network-connection-store.ts diff --git a/src/components/layout/player-app-shell.tsx b/src/components/layout/player-app-shell.tsx index 7c59eac..977cb7f 100644 --- a/src/components/layout/player-app-shell.tsx +++ b/src/components/layout/player-app-shell.tsx @@ -1,6 +1,7 @@ import Link from "next/link"; import type { ReactNode } from "react"; +import { NetworkStatusBanner } from "@/components/network-status-banner"; import { PlayerBottomNav } from "@/components/layout/player-bottom-nav"; import { PlayerSessionBar } from "@/features/player/player-session-bar"; @@ -15,6 +16,7 @@ type PlayerAppShellProps = { export function PlayerAppShell({ children }: PlayerAppShellProps): ReactNode { return (
+
{ + reconnect(); + }, [reconnect]); + + // 只有在降级模式或离线模式下才显示 + if (!showDegradedBanner) { + return null; + } + + const isOffline = mode === "offline"; + + return ( +
+ {isOffline ? ( + <> + + 网络已断开,请检查网络连接 + + + ) : ( + <> + + + 网络不稳定,已切换至降级模式(轮询中...) + + + + )} +
+ ); +} + +/** + * 带网络状态横幅的页面包装器 + * 将横幅插入到页面顶部 + */ +export function WithNetworkStatusBanner({ + children, +}: { + children: React.ReactNode; +}): React.ReactElement { + return ( + <> + + {children} + + ); +} diff --git a/src/features/hall/hall-betting-grid.tsx b/src/features/hall/hall-betting-grid.tsx index 82f8908..c9bdd32 100644 --- a/src/features/hall/hall-betting-grid.tsx +++ b/src/features/hall/hall-betting-grid.tsx @@ -19,6 +19,7 @@ import { HallBetAmountInput } from "@/features/hall/hall-bet-amount-input"; import { HallBetPreviewDialog } from "@/features/hall/hall-bet-preview-dialog"; import { HallBetNumberInput } from "@/features/hall/hall-bet-number-input"; import { isHallSealedCountdownUi } from "@/features/draw/draw-status-meta"; +import { triggerWalletPollingAfterBet } from "@/hooks/use-wallet-polling"; import { playNeedsDigitSlot, playNeedsDimension, @@ -301,7 +302,8 @@ export function HallBettingGrid() { setPreviewData(null); setAmountStr(""); setNumber(""); - window.dispatchEvent(new Event("lottery-wallet-refresh")); + // 触发钱包轮询(降级模式下启动2分钟限时轮询) + triggerWalletPollingAfterBet(); void reloadDraw(); } catch (e) { const code = e instanceof LotteryApiBizError ? e.code : 0; diff --git a/src/features/hall/hall-wallet-strip.tsx b/src/features/hall/hall-wallet-strip.tsx index 8e85f09..afcb358 100644 --- a/src/features/hall/hall-wallet-strip.tsx +++ b/src/features/hall/hall-wallet-strip.tsx @@ -14,10 +14,12 @@ import { import { formatMinorAsCurrency } from "@/lib/money"; import { cn } from "@/lib/utils"; import { usePlayerSessionStore } from "@/stores/player-session-store"; +import { useNetworkConnectionStore } from "@/stores/network-connection-store"; import type { WalletBalanceData } from "@/types/api/wallet-balance"; /** * 高保真稿:大厅顶部红卡 + Transfer In(蓝)/ Transfer Out(白底红边),§4.2 + * 已集成网络降级模式下的轮询刷新 */ export function HallWalletStrip() { const profile = usePlayerSessionStore((s) => s.profile); @@ -25,6 +27,19 @@ export function HallWalletStrip() { const [balance, setBalance] = useState(null); const [loading, setLoading] = useState(true); + // 网络连接状态(用于降级模式下的轮询) + const mode = useNetworkConnectionStore((s) => s.mode); + const walletPollingIntervalId = useNetworkConnectionStore( + (s) => s.walletPollingIntervalId, + ); + const setWalletPollingIntervalId = useNetworkConnectionStore( + (s) => s.setWalletPollingIntervalId, + ); + const setWalletPollingExpiryAt = useNetworkConnectionStore( + (s) => s.setWalletPollingExpiryAt, + ); + const clearWalletPolling = useNetworkConnectionStore((s) => s.clearWalletPolling); + const currency = useMemo( () => (balance?.currency_code ?? profile?.default_currency ?? "NPR").toUpperCase(), @@ -57,6 +72,50 @@ export function HallWalletStrip() { return () => window.removeEventListener("lottery-wallet-refresh", onRefresh); }, [refresh]); + // 监听钱包轮询状态变化(由下注或开奖结果触发) + useEffect(() => { + // 如果有活跃的轮询计时器,监听它并执行刷新 + if (walletPollingIntervalId) { + // 轮询已在全局管理中设置,这里只需监听轮询触发的事件 + const handlePollingRefresh = () => void refresh(); + window.addEventListener("lottery-wallet-refresh", handlePollingRefresh); + return () => { + window.removeEventListener("lottery-wallet-refresh", handlePollingRefresh); + }; + } + }, [walletPollingIntervalId, refresh]); + + // 降级模式下的定期刷新(作为兜底) + useEffect(() => { + // 只有在降级模式下才启动兜底轮询 + if (mode !== "polling" && mode !== "offline") { + return; + } + + // 如果已经有活跃的轮询计时器,不重复设置 + if (walletPollingIntervalId) { + return; + } + + // 设置兜底轮询(60秒一次,避免过于频繁) + const intervalId = window.setInterval(() => { + void refresh(); + }, 60_000); + + setWalletPollingIntervalId(intervalId); + + return () => { + window.clearInterval(intervalId); + clearWalletPolling(); + }; + }, [ + mode, + walletPollingIntervalId, + refresh, + setWalletPollingIntervalId, + clearWalletPolling, + ]); + const lotteryMinor = Number(balance?.balance ?? 0); const availableMinor = Number(balance?.available_balance ?? 0); diff --git a/src/features/hall/use-hall-draw-live.ts b/src/features/hall/use-hall-draw-live.ts index e4c89ca..2903849 100644 --- a/src/features/hall/use-hall-draw-live.ts +++ b/src/features/hall/use-hall-draw-live.ts @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { getDrawCurrent } from "@/api/draw"; import { getLotteryEcho } from "@/lib/lottery-echo"; +import { useNetworkConnectionStore } from "@/stores/network-connection-store"; import type { DrawCurrentPayload } from "@/types/api/draw-current"; /** 界面文档 §2.1:`draw.countdown` / `draw.status_change` / `result.published` 载荷 */ @@ -34,6 +35,7 @@ function applySnapshotDrift( /** * 大厅期号:WebSocket `lottery-hall` + 轮询降级(与 {@link HallDrawPanel} 同源逻辑)。 + * 已集成网络连接管理,WebSocket断开时自动切换到轮询模式。 */ export function useHallDrawLive(): { raw: DrawCurrentPayload | null | undefined; @@ -47,6 +49,21 @@ export function useHallDrawLive(): { const [nowMs, setNowMs] = useState(() => Date.now()); const [error, setError] = useState(null); + // 网络连接状态 + const mode = useNetworkConnectionStore((s) => s.mode); + const isWebSocketConnected = useNetworkConnectionStore( + (s) => s.isWebSocketConnected, + ); + const setDrawPollingIntervalId = useNetworkConnectionStore( + (s) => s.setDrawPollingIntervalId, + ); + const setWalletPollingIntervalId = useNetworkConnectionStore( + (s) => s.setWalletPollingIntervalId, + ); + const setWalletPollingExpiryAt = useNetworkConnectionStore( + (s) => s.setWalletPollingExpiryAt, + ); + const mergeFromWs = useCallback((evt: HallWsEnvelope) => { setRaw(evt.data); setEmittedAtMs(evt.emitted_at_ms ?? Date.now()); @@ -64,11 +81,13 @@ export function useHallDrawLive(): { } }, []); + // WebSocket 正常时的轮询间隔(作为备用) const refreshMs = useMemo(() => { - if (raw === undefined) return 10_000; - return raw ? 30_000 : 12_000; + if (raw === undefined) return 30_000; + return raw ? 60_000 : 30_000; // WebSocket正常时减少轮询频率 }, [raw]); + // 初始加载 useEffect(() => { const timer = window.setTimeout(() => { void load(); @@ -76,13 +95,7 @@ export function useHallDrawLive(): { return () => window.clearTimeout(timer); }, [load]); - useEffect(() => { - const id = window.setInterval(() => { - void load(); - }, refreshMs); - return () => window.clearInterval(id); - }, [load, refreshMs]); - + // 本地倒计时计时器(用于 UI 更新) useEffect(() => { const bump = () => setNowMs(Date.now()); bump(); @@ -97,20 +110,113 @@ export function useHallDrawLive(): { }; }, []); + // WebSocket 订阅 + 降级轮询逻辑 useEffect(() => { const echo = getLotteryEcho(); if (!echo) return; - echo - .channel("lottery-hall") - .listen(".draw.countdown", mergeFromWs) - .listen(".draw.status_change", mergeFromWs) - .listen(".result.published", mergeFromWs); + // 监听 WebSocket 事件 + const channel = echo.channel("lottery-hall"); + + // 设置事件监听 + channel.listen(".draw.countdown", mergeFromWs); + 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); + }); + + // 连接状态变化处理 + 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 () => { echo.leave("lottery-hall"); + if (echo.connector?.pusher) { + echo.connector.pusher.connection.unbind("connected", handleConnected); + echo.connector.pusher.connection.unbind( + "disconnected", + handleDisconnected, + ); + } }; - }, [mergeFromWs]); + }, [ + 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) { + // 立即执行一次 + void load(); + + // 设置30秒轮询 + intervalId = window.setInterval(() => { + void load(); + }, 30_000); + setDrawPollingIntervalId(intervalId); + } + } + + return () => { + if (intervalId) { + window.clearInterval(intervalId); + setDrawPollingIntervalId(null); + } + }; + }, [isWebSocketConnected, mode, load, setDrawPollingIntervalId]); const display: DrawCurrentPayload | null | undefined = raw === undefined || raw === null ? raw : applySnapshotDrift(raw, emittedAtMs, nowMs); diff --git a/src/hooks/index.ts b/src/hooks/index.ts new file mode 100644 index 0000000..c3ae4c5 --- /dev/null +++ b/src/hooks/index.ts @@ -0,0 +1,12 @@ +// WebSocket Connection Management +export { + useWebSocketManager, + type UseWebSocketManagerReturn, +} from "./use-websocket-manager"; + +// Wallet Polling +export { + useWalletPolling, + triggerWalletPollingAfterBet, + type UseWalletPollingReturn, +} from "./use-wallet-polling"; diff --git a/src/hooks/use-wallet-polling.ts b/src/hooks/use-wallet-polling.ts new file mode 100644 index 0000000..5ed2bc9 --- /dev/null +++ b/src/hooks/use-wallet-polling.ts @@ -0,0 +1,181 @@ +"use client"; + +import { useCallback, useEffect, useRef } from "react"; + +import { getWalletBalance } from "@/api/wallet"; +import { useNetworkConnectionStore } from "@/stores/network-connection-store"; + +const POLLING_INTERVAL_MS = 30_000; // 30秒轮询间隔 +const LIMITED_POLLING_DURATION_MS = 2 * 60 * 1000; // 2分钟限时轮询 + +export type UseWalletPollingReturn = { + /** 开始钱包轮询(持续或限时) */ + startWalletPolling: (options?: { limitedDuration?: boolean }) => void; + /** 停止钱包轮询 */ + stopWalletPolling: () => void; + /** 立即刷新钱包余额 */ + refreshWallet: () => Promise; + /** 是否正在轮询中 */ + isPolling: boolean; +}; + +/** + * 钱包余额轮询 Hook + * 用于: + * 1. WebSocket 降级模式下的余额同步 + * 2. 下注后的限时轮询(2分钟) + * 3. 开奖结果后的限时轮询(2分钟) + */ +export function useWalletPolling(): UseWalletPollingReturn { + const store = useNetworkConnectionStore(); + const { + walletPollingIntervalId, + walletPollingExpiryAt, + setWalletPollingIntervalId, + setWalletPollingExpiryAt, + clearWalletPolling, + } = store; + + const intervalIdRef = useRef(walletPollingIntervalId); + + // 同步 ref 和 store 状态 + useEffect(() => { + intervalIdRef.current = walletPollingIntervalId; + }, [walletPollingIntervalId]); + + // 刷新钱包余额 + const refreshWallet = useCallback(async () => { + try { + await getWalletBalance(); + // 触发全局刷新事件,让所有监听组件更新 + window.dispatchEvent(new Event("lottery-wallet-refresh")); + } catch { + // 静默处理错误,避免频繁报错 + } + }, []); + + // 开始钱包轮询 + const startWalletPolling = useCallback( + (options?: { limitedDuration?: boolean }) => { + const { limitedDuration = false } = options ?? {}; + + // 先停止现有的轮询 + if (intervalIdRef.current) { + window.clearInterval(intervalIdRef.current); + intervalIdRef.current = null; + } + + // 立即执行一次刷新 + void refreshWallet(); + + // 设置轮询间隔 + const intervalId = window.setInterval(() => { + // 检查限时轮询是否过期 + if (limitedDuration && walletPollingExpiryAt) { + if (Date.now() > walletPollingExpiryAt) { + // 过期,停止轮询 + clearWalletPolling(); + intervalIdRef.current = null; + return; + } + } + void refreshWallet(); + }, POLLING_INTERVAL_MS); + + intervalIdRef.current = intervalId; + setWalletPollingIntervalId(intervalId); + + // 设置限时轮询的过期时间 + if (limitedDuration) { + const expiryAt = Date.now() + LIMITED_POLLING_DURATION_MS; + setWalletPollingExpiryAt(expiryAt); + + // 设置一个定时器在过期时清理 + window.setTimeout(() => { + if (intervalIdRef.current === intervalId) { + clearWalletPolling(); + intervalIdRef.current = null; + } + }, LIMITED_POLLING_DURATION_MS + 1000); // 稍微延迟确保interval先执行检查 + } + }, + [ + walletPollingExpiryAt, + refreshWallet, + setWalletPollingIntervalId, + setWalletPollingExpiryAt, + clearWalletPolling, + ], + ); + + // 停止钱包轮询 + const stopWalletPolling = useCallback(() => { + if (intervalIdRef.current) { + window.clearInterval(intervalIdRef.current); + intervalIdRef.current = null; + } + clearWalletPolling(); + }, [clearWalletPolling]); + + // 组件卸载时清理 + useEffect(() => { + return () => { + if (intervalIdRef.current) { + window.clearInterval(intervalIdRef.current); + } + }; + }, []); + + return { + startWalletPolling, + stopWalletPolling, + refreshWallet, + isPolling: walletPollingIntervalId !== null, + }; +} + +/** + * 全局钱包轮询触发器 + * 用于在非 React 上下文(如事件监听)中触发钱包轮询 + */ +export function triggerWalletPollingAfterBet(): void { + const store = useNetworkConnectionStore.getState(); + + // 如果是降级模式,立即刷新并启动限时轮询 + if (store.mode === "polling" || store.mode === "offline") { + // 立即刷新一次 + void getWalletBalance().then(() => { + window.dispatchEvent(new Event("lottery-wallet-refresh")); + }); + + // 清除现有轮询 + if (store.walletPollingIntervalId) { + window.clearInterval(store.walletPollingIntervalId); + } + + // 启动限时轮询 + const intervalId = window.setInterval(() => { + // 检查是否过期 + if (store.walletPollingExpiryAt && Date.now() > store.walletPollingExpiryAt) { + window.clearInterval(intervalId); + store.setWalletPollingIntervalId(null); + return; + } + void getWalletBalance().then(() => { + window.dispatchEvent(new Event("lottery-wallet-refresh")); + }); + }, POLLING_INTERVAL_MS); + + store.setWalletPollingIntervalId(intervalId); + store.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")); + } +} diff --git a/src/hooks/use-websocket-manager.ts b/src/hooks/use-websocket-manager.ts new file mode 100644 index 0000000..6be99ec --- /dev/null +++ b/src/hooks/use-websocket-manager.ts @@ -0,0 +1,352 @@ +"use client"; + +import { useCallback, useEffect, useRef } from "react"; + +import { getDrawCurrent } from "@/api/draw"; +import { getWalletBalance } from "@/api/wallet"; +import { getLotteryEcho, disconnectLotteryEcho } from "@/lib/lottery-echo"; +import { + useNetworkConnectionStore, + type NetworkMode, +} from "@/stores/network-connection-store"; + +const POLLING_INTERVAL_MS = 30_000; // 30秒轮询间隔 +const WALLET_POLLING_DURATION_MS = 2 * 60 * 1000; // 2分钟限时轮询 +const RECONNECT_INTERVAL_MS = 5_000; // 5秒重连间隔 +const MAX_RECONNECT_ATTEMPTS = 10; // 最大重连次数 + +export type UseWebSocketManagerReturn = { + /** 当前网络模式 */ + mode: NetworkMode; + /** 是否显示降级模式横幅 */ + showDegradedBanner: boolean; + /** 手动触发重连 */ + reconnect: () => void; + /** 开始钱包轮询(用于下注后或开奖后) */ + startWalletPolling: (options?: { limitedDuration?: boolean }) => void; + /** 停止钱包轮询 */ + stopWalletPolling: () => void; + /** 手动刷新画作数据 */ + refreshDraw: () => Promise; + /** 手动刷新钱包余额 */ + refreshWallet: () => Promise; +}; + +/** + * WebSocket 连接管理器 Hook + * 负责: + * 1. 监控 WebSocket 连接状态 + * 2. WebSocket 断开时自动切换到轮询模式 + * 3. 定期尝试重连 WebSocket + * 4. 管理画作数据轮询(WebSocket断开时) + * 5. 管理钱包余额轮询(下注后/开奖后/降级模式) + */ +export function useWebSocketManager(): UseWebSocketManagerReturn { + const store = useNetworkConnectionStore(); + const reconnectTimerRef = useRef(null); + + const { + mode, + isWebSocketConnected, + isReconnecting, + reconnectAttempts, + drawPollingIntervalId, + walletPollingIntervalId, + walletPollingExpiryAt, + setWebSocketConnected, + setReconnecting, + incrementReconnectAttempts, + resetReconnectAttempts, + setLastDisconnectedAt, + switchToPollingMode, + switchToWebSocketMode, + setDrawPollingIntervalId, + setWalletPollingIntervalId, + setWalletPollingExpiryAt, + clearWalletPolling, + } = store; + + // 刷新画作数据 + const refreshDraw = useCallback(async () => { + try { + await getDrawCurrent(); + } catch { + // 静默处理错误,避免频繁报错 + } + }, []); + + // 刷新钱包余额 + const refreshWallet = useCallback(async () => { + try { + await getWalletBalance(); + // 触发全局刷新事件,让组件更新 + window.dispatchEvent(new Event("lottery-wallet-refresh")); + } catch { + // 静默处理错误 + } + }, []); + + // 开始画作数据轮询(30秒间隔) + const startDrawPolling = useCallback(() => { + // 先停止现有的轮询 + if (drawPollingIntervalId) { + window.clearInterval(drawPollingIntervalId); + } + + // 立即执行一次 + void refreshDraw(); + + // 设置轮询 + const intervalId = window.setInterval(() => { + void refreshDraw(); + }, POLLING_INTERVAL_MS); + + setDrawPollingIntervalId(intervalId); + }, [drawPollingIntervalId, refreshDraw, setDrawPollingIntervalId]); + + // 停止画作数据轮询 + const stopDrawPolling = useCallback(() => { + if (drawPollingIntervalId) { + window.clearInterval(drawPollingIntervalId); + setDrawPollingIntervalId(null); + } + }, [drawPollingIntervalId, setDrawPollingIntervalId]); + + // 开始钱包轮询 + const startWalletPolling = useCallback( + (options?: { limitedDuration?: boolean }) => { + const { limitedDuration = false } = options ?? {}; + + // 先停止现有的轮询 + if (walletPollingIntervalId) { + window.clearInterval(walletPollingIntervalId); + } + + // 立即执行一次 + void refreshWallet(); + + // 设置轮询 + const intervalId = window.setInterval(() => { + // 检查限时轮询是否过期 + if (limitedDuration && walletPollingExpiryAt) { + if (Date.now() > walletPollingExpiryAt) { + clearWalletPolling(); + return; + } + } + void refreshWallet(); + }, POLLING_INTERVAL_MS); + + setWalletPollingIntervalId(intervalId); + + // 设置限时轮询的过期时间 + if (limitedDuration) { + setWalletPollingExpiryAt(Date.now() + WALLET_POLLING_DURATION_MS); + } + }, + [ + walletPollingIntervalId, + walletPollingExpiryAt, + refreshWallet, + setWalletPollingIntervalId, + setWalletPollingExpiryAt, + clearWalletPolling, + ], + ); + + // 停止钱包轮询 + const stopWalletPolling = useCallback(() => { + clearWalletPolling(); + }, [clearWalletPolling]); + + // 尝试连接 WebSocket + const connectWebSocket = useCallback(() => { + const echo = getLotteryEcho(); + if (!echo) { + // 配置不完整,无法连接 + return false; + } + + try { + // 连接到 lottery-hall 频道以测试连接 + echo.channel("lottery-hall"); + return true; + } catch { + return false; + } + }, []); + + // 处理连接断开 + const handleDisconnect = useCallback(() => { + setWebSocketConnected(false); + setLastDisconnectedAt(Date.now()); + switchToPollingMode(); + + // 启动画作数据轮询(降级模式) + startDrawPolling(); + + // 启动重连计时器 + if (reconnectTimerRef.current) { + window.clearTimeout(reconnectTimerRef.current); + } + + setReconnecting(true); + }, [ + setWebSocketConnected, + setLastDisconnectedAt, + switchToPollingMode, + startDrawPolling, + setReconnecting, + ]); + + // 处理连接成功 + const handleConnect = useCallback(() => { + setWebSocketConnected(true); + resetReconnectAttempts(); + switchToWebSocketMode(); + setReconnecting(false); + + // 清除重连计时器 + if (reconnectTimerRef.current) { + window.clearTimeout(reconnectTimerRef.current); + reconnectTimerRef.current = null; + } + }, [ + setWebSocketConnected, + resetReconnectAttempts, + switchToWebSocketMode, + setReconnecting, + ]); + + // 重连逻辑 + const attemptReconnect = useCallback(() => { + if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { + // 达到最大重连次数,停止尝试 + setReconnecting(false); + return; + } + + incrementReconnectAttempts(); + + const success = connectWebSocket(); + if (success) { + handleConnect(); + } else { + // 继续重连 + reconnectTimerRef.current = window.setTimeout( + attemptReconnect, + RECONNECT_INTERVAL_MS, + ); + } + }, [ + reconnectAttempts, + incrementReconnectAttempts, + connectWebSocket, + handleConnect, + setReconnecting, + ]); + + // 手动重连 + const reconnect = useCallback(() => { + resetReconnectAttempts(); + setReconnecting(true); + attemptReconnect(); + }, [resetReconnectAttempts, setReconnecting, attemptReconnect]); + + // 监听网络状态变化 + useEffect(() => { + const handleOnline = () => { + // 网络恢复时尝试重连 WebSocket + if (mode === "polling" || mode === "offline") { + reconnect(); + } + }; + + const handleOffline = () => { + // 网络断开 + store.setMode("offline"); + }; + + window.addEventListener("online", handleOnline); + window.addEventListener("offline", handleOffline); + + return () => { + window.removeEventListener("online", handleOnline); + window.removeEventListener("offline", handleOffline); + }; + }, [mode, reconnect, store]); + + // 初始化和 WebSocket 监控 + useEffect(() => { + const echo = getLotteryEcho(); + if (!echo) { + // 没有 Echo 配置,直接启用轮询模式 + switchToPollingMode(); + startDrawPolling(); + return; + } + + // 尝试初始连接 + const success = connectWebSocket(); + if (success) { + handleConnect(); + } else { + handleDisconnect(); + } + + // 设置连接状态监控(通过定期订阅状态) + const checkConnection = () => { + try { + // 尝试访问频道来测试连接 + const channel = echo.channel("lottery-hall"); + if (channel) { + if (!isWebSocketConnected) { + handleConnect(); + } + } + } catch { + if (isWebSocketConnected) { + handleDisconnect(); + } + } + }; + + // 每 5 秒检查一次连接状态 + const checkInterval = window.setInterval(checkConnection, 5000); + + return () => { + window.clearInterval(checkInterval); + if (reconnectTimerRef.current) { + window.clearTimeout(reconnectTimerRef.current); + } + }; + }, [ + connectWebSocket, + handleConnect, + handleDisconnect, + isWebSocketConnected, + startDrawPolling, + switchToPollingMode, + ]); + + // 清理函数 + useEffect(() => { + return () => { + stopDrawPolling(); + stopWalletPolling(); + if (reconnectTimerRef.current) { + window.clearTimeout(reconnectTimerRef.current); + } + }; + }, [stopDrawPolling, stopWalletPolling]); + + return { + mode, + showDegradedBanner: mode === "polling" || mode === "offline", + reconnect, + startWalletPolling, + stopWalletPolling, + refreshDraw, + refreshWallet, + }; +} diff --git a/src/stores/network-connection-store.ts b/src/stores/network-connection-store.ts new file mode 100644 index 0000000..3260baf --- /dev/null +++ b/src/stores/network-connection-store.ts @@ -0,0 +1,148 @@ +"use client"; + +import { create } from "zustand"; + +export type NetworkMode = "websocket" | "polling" | "offline"; + +export type NetworkConnectionState = { + /** 当前网络模式:WebSocket 正常 / 降级轮询 / 离线 */ + mode: NetworkMode; + /** WebSocket 是否已连接 */ + isWebSocketConnected: boolean; + /** 是否正在尝试重连 */ + isReconnecting: boolean; + /** 重连尝试次数 */ + reconnectAttempts: number; + /** 上次连接成功时间 */ + lastConnectedAt: number | null; + /** 上次断开连接时间 */ + lastDisconnectedAt: number | null; + /** 当前活跃的画作数据轮询计时器ID */ + drawPollingIntervalId: number | null; + /** 当前活跃的钱包余额轮询计时器ID */ + walletPollingIntervalId: number | null; + /** 钱包余额轮询过期时间戳(用于bet后的限时轮询) */ + walletPollingExpiryAt: number | null; + + // Actions + setMode: (mode: NetworkMode) => void; + setWebSocketConnected: (connected: boolean) => void; + setReconnecting: (reconnecting: boolean) => void; + incrementReconnectAttempts: () => void; + resetReconnectAttempts: () => void; + setLastConnectedAt: (timestamp: number | null) => void; + setLastDisconnectedAt: (timestamp: number | null) => void; + setDrawPollingIntervalId: (id: number | null) => void; + setWalletPollingIntervalId: (id: number | null) => void; + setWalletPollingExpiryAt: (timestamp: number | null) => void; + /** 切换到降级模式(WebSocket断开时调用) */ + switchToPollingMode: () => void; + /** 切换回WebSocket模式(WebSocket重连成功时调用) */ + switchToWebSocketMode: () => void; + /** 清除所有轮询计时器 */ + clearAllPollingIntervals: () => void; + /** 停止钱包轮询 */ + clearWalletPolling: () => void; + /** 停止画作数据轮询 */ + clearDrawPolling: () => void; +}; + +export const useNetworkConnectionStore = create( + (set, get) => ({ + mode: "websocket", + isWebSocketConnected: false, + isReconnecting: false, + reconnectAttempts: 0, + lastConnectedAt: null, + lastDisconnectedAt: null, + drawPollingIntervalId: null, + walletPollingIntervalId: null, + walletPollingExpiryAt: null, + + setMode: (mode) => set({ mode }), + + setWebSocketConnected: (connected) => + set({ isWebSocketConnected: connected }), + + setReconnecting: (reconnecting) => set({ isReconnecting: reconnecting }), + + incrementReconnectAttempts: () => + set((state) => ({ reconnectAttempts: state.reconnectAttempts + 1 })), + + resetReconnectAttempts: () => set({ reconnectAttempts: 0 }), + + setLastConnectedAt: (timestamp) => set({ lastConnectedAt: timestamp }), + + setLastDisconnectedAt: (timestamp) => + set({ lastDisconnectedAt: timestamp }), + + setDrawPollingIntervalId: (id) => set({ drawPollingIntervalId: id }), + + setWalletPollingIntervalId: (id) => set({ walletPollingIntervalId: id }), + + setWalletPollingExpiryAt: (timestamp) => + set({ walletPollingExpiryAt: timestamp }), + + switchToPollingMode: () => + set({ + mode: "polling", + isWebSocketConnected: false, + lastDisconnectedAt: Date.now(), + }), + + switchToWebSocketMode: () => { + const { drawPollingIntervalId, walletPollingIntervalId } = get(); + // 清除轮询计时器 + if (drawPollingIntervalId) { + window.clearInterval(drawPollingIntervalId); + } + if (walletPollingIntervalId) { + window.clearInterval(walletPollingIntervalId); + } + set({ + mode: "websocket", + isWebSocketConnected: true, + lastConnectedAt: Date.now(), + drawPollingIntervalId: null, + walletPollingIntervalId: null, + walletPollingExpiryAt: null, + reconnectAttempts: 0, + isReconnecting: false, + }); + }, + + clearAllPollingIntervals: () => { + const { drawPollingIntervalId, walletPollingIntervalId } = get(); + if (drawPollingIntervalId) { + window.clearInterval(drawPollingIntervalId); + } + if (walletPollingIntervalId) { + window.clearInterval(walletPollingIntervalId); + } + set({ + drawPollingIntervalId: null, + walletPollingIntervalId: null, + walletPollingExpiryAt: null, + }); + }, + + clearWalletPolling: () => { + const { walletPollingIntervalId } = get(); + if (walletPollingIntervalId) { + window.clearInterval(walletPollingIntervalId); + } + set({ + walletPollingIntervalId: null, + walletPollingExpiryAt: null, + }); + }, + + clearDrawPolling: () => { + const { drawPollingIntervalId } = get(); + if (drawPollingIntervalId) { + window.clearInterval(drawPollingIntervalId); + } + set({ drawPollingIntervalId: null }); + }, + }), +);