feat: 集成网络连接管理与降级轮询功能
- 在 PlayerAppShell 中引入 NetworkStatusBanner 组件以显示网络状态 - 在 HallBettingGrid 中实现下注后触发钱包轮询 - 在 HallWalletStrip 中添加网络连接状态管理与定期刷新逻辑 - 在 useHallDrawLive 中集成 WebSocket 连接状态与降级轮询机制,确保在断开时自动切换到轮询模式
This commit is contained in:
@@ -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
|
||||
|
||||
96
src/components/network-status-banner.tsx
Normal file
96
src/components/network-status-banner.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
12
src/hooks/index.ts
Normal 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";
|
||||
181
src/hooks/use-wallet-polling.ts
Normal file
181
src/hooks/use-wallet-polling.ts
Normal 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"));
|
||||
}
|
||||
}
|
||||
352
src/hooks/use-websocket-manager.ts
Normal file
352
src/hooks/use-websocket-manager.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
148
src/stores/network-connection-store.ts
Normal file
148
src/stores/network-connection-store.ts
Normal 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 });
|
||||
},
|
||||
}),
|
||||
);
|
||||
Reference in New Issue
Block a user