feat: 集成网络连接管理与降级轮询功能

- 在 PlayerAppShell 中引入 NetworkStatusBanner 组件以显示网络状态
- 在 HallBettingGrid 中实现下注后触发钱包轮询
- 在 HallWalletStrip 中添加网络连接状态管理与定期刷新逻辑
- 在 useHallDrawLive 中集成 WebSocket 连接状态与降级轮询机制,确保在断开时自动切换到轮询模式
This commit is contained in:
2026-05-13 14:44:58 +08:00
parent 377e03e167
commit 1e7a06dc86
9 changed files with 974 additions and 16 deletions

View File

@@ -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 (
<div className="flex min-h-dvh flex-col bg-background text-foreground">
<NetworkStatusBanner />
<header className="sticky top-0 z-40 shrink-0 border-b border-border bg-background/95 backdrop-blur-sm supports-[backdrop-filter]:bg-background/80">
<div className="mx-auto flex h-12 max-w-lg items-center gap-2 px-4">
<Link

View File

@@ -0,0 +1,96 @@
"use client";
import { WifiOff, RefreshCw, AlertCircle } from "lucide-react";
import { useCallback } from "react";
import { useWebSocketManager } from "@/hooks/use-websocket-manager";
import { cn } from "@/lib/utils";
// 颜色常量(来自用户需求)
const COLORS = {
success: "#52c41a",
error: "#ff4d4f",
warning: "#faad14",
neutral: "#d9d9d9",
};
/**
* 网络状态横幅组件
* 当 WebSocket 断开时显示降级模式提示
*/
export function NetworkStatusBanner(): React.ReactElement | null {
const { showDegradedBanner, mode, reconnect } = useWebSocketManager();
const handleReconnectClick = useCallback(() => {
reconnect();
}, [reconnect]);
// 只有在降级模式或离线模式下才显示
if (!showDegradedBanner) {
return null;
}
const isOffline = mode === "offline";
return (
<div
className={cn(
"sticky top-0 z-50 flex items-center justify-center gap-2 px-4 py-2 text-sm",
"animate-in slide-in-from-top-2 duration-200",
)}
style={{
backgroundColor: isOffline ? COLORS.error : COLORS.warning,
color: "#fff",
}}
role="status"
aria-live="polite"
>
{isOffline ? (
<>
<WifiOff className="size-4 shrink-0" aria-hidden />
<span className="font-medium"></span>
<button
onClick={handleReconnectClick}
className="ml-2 flex items-center gap-1 rounded px-2 py-0.5 text-xs font-medium hover:bg-white/20 focus:outline-none focus:ring-2 focus:ring-white/50"
aria-label="尝试重连"
>
<RefreshCw className="size-3" aria-hidden />
</button>
</>
) : (
<>
<AlertCircle className="size-4 shrink-0" aria-hidden />
<span className="font-medium">
...
</span>
<button
onClick={handleReconnectClick}
className="ml-2 flex items-center gap-1 rounded px-2 py-0.5 text-xs font-medium hover:bg-white/20 focus:outline-none focus:ring-2 focus:ring-white/50"
aria-label="尝试恢复 WebSocket 连接"
>
<RefreshCw className="size-3" aria-hidden />
</button>
</>
)}
</div>
);
}
/**
* 带网络状态横幅的页面包装器
* 将横幅插入到页面顶部
*/
export function WithNetworkStatusBanner({
children,
}: {
children: React.ReactNode;
}): React.ReactElement {
return (
<>
<NetworkStatusBanner />
{children}
</>
);
}

View File

@@ -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;

View File

@@ -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<WalletBalanceData | null>(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);

View File

@@ -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<string | null>(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);

12
src/hooks/index.ts Normal file
View File

@@ -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";

View File

@@ -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<void>;
/** 是否正在轮询中 */
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<number | null>(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"));
}
}

View File

@@ -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<void>;
/** 手动刷新钱包余额 */
refreshWallet: () => Promise<void>;
};
/**
* WebSocket 连接管理器 Hook
* 负责:
* 1. 监控 WebSocket 连接状态
* 2. WebSocket 断开时自动切换到轮询模式
* 3. 定期尝试重连 WebSocket
* 4. 管理画作数据轮询WebSocket断开时
* 5. 管理钱包余额轮询(下注后/开奖后/降级模式)
*/
export function useWebSocketManager(): UseWebSocketManagerReturn {
const store = useNetworkConnectionStore();
const reconnectTimerRef = useRef<number | null>(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,
};
}

View File

@@ -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<NetworkConnectionState>(
(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 });
},
}),
);