feat: 集成网络连接管理与降级轮询功能
- 在 PlayerAppShell 中引入 NetworkStatusBanner 组件以显示网络状态 - 在 HallBettingGrid 中实现下注后触发钱包轮询 - 在 HallWalletStrip 中添加网络连接状态管理与定期刷新逻辑 - 在 useHallDrawLive 中集成 WebSocket 连接状态与降级轮询机制,确保在断开时自动切换到轮询模式
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import { NetworkStatusBanner } from "@/components/network-status-banner";
|
||||||
import { PlayerBottomNav } from "@/components/layout/player-bottom-nav";
|
import { PlayerBottomNav } from "@/components/layout/player-bottom-nav";
|
||||||
import { PlayerSessionBar } from "@/features/player/player-session-bar";
|
import { PlayerSessionBar } from "@/features/player/player-session-bar";
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ type PlayerAppShellProps = {
|
|||||||
export function PlayerAppShell({ children }: PlayerAppShellProps): ReactNode {
|
export function PlayerAppShell({ children }: PlayerAppShellProps): ReactNode {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-dvh flex-col bg-background text-foreground">
|
<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">
|
<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">
|
<div className="mx-auto flex h-12 max-w-lg items-center gap-2 px-4">
|
||||||
<Link
|
<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 { HallBetPreviewDialog } from "@/features/hall/hall-bet-preview-dialog";
|
||||||
import { HallBetNumberInput } from "@/features/hall/hall-bet-number-input";
|
import { HallBetNumberInput } from "@/features/hall/hall-bet-number-input";
|
||||||
import { isHallSealedCountdownUi } from "@/features/draw/draw-status-meta";
|
import { isHallSealedCountdownUi } from "@/features/draw/draw-status-meta";
|
||||||
|
import { triggerWalletPollingAfterBet } from "@/hooks/use-wallet-polling";
|
||||||
import {
|
import {
|
||||||
playNeedsDigitSlot,
|
playNeedsDigitSlot,
|
||||||
playNeedsDimension,
|
playNeedsDimension,
|
||||||
@@ -301,7 +302,8 @@ export function HallBettingGrid() {
|
|||||||
setPreviewData(null);
|
setPreviewData(null);
|
||||||
setAmountStr("");
|
setAmountStr("");
|
||||||
setNumber("");
|
setNumber("");
|
||||||
window.dispatchEvent(new Event("lottery-wallet-refresh"));
|
// 触发钱包轮询(降级模式下启动2分钟限时轮询)
|
||||||
|
triggerWalletPollingAfterBet();
|
||||||
void reloadDraw();
|
void reloadDraw();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const code = e instanceof LotteryApiBizError ? e.code : 0;
|
const code = e instanceof LotteryApiBizError ? e.code : 0;
|
||||||
|
|||||||
@@ -14,10 +14,12 @@ import {
|
|||||||
import { formatMinorAsCurrency } from "@/lib/money";
|
import { formatMinorAsCurrency } from "@/lib/money";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||||
|
import { useNetworkConnectionStore } from "@/stores/network-connection-store";
|
||||||
import type { WalletBalanceData } from "@/types/api/wallet-balance";
|
import type { WalletBalanceData } from "@/types/api/wallet-balance";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 高保真稿:大厅顶部红卡 + Transfer In(蓝)/ Transfer Out(白底红边),§4.2
|
* 高保真稿:大厅顶部红卡 + Transfer In(蓝)/ Transfer Out(白底红边),§4.2
|
||||||
|
* 已集成网络降级模式下的轮询刷新
|
||||||
*/
|
*/
|
||||||
export function HallWalletStrip() {
|
export function HallWalletStrip() {
|
||||||
const profile = usePlayerSessionStore((s) => s.profile);
|
const profile = usePlayerSessionStore((s) => s.profile);
|
||||||
@@ -25,6 +27,19 @@ export function HallWalletStrip() {
|
|||||||
const [balance, setBalance] = useState<WalletBalanceData | null>(null);
|
const [balance, setBalance] = useState<WalletBalanceData | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
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(
|
const currency = useMemo(
|
||||||
() =>
|
() =>
|
||||||
(balance?.currency_code ?? profile?.default_currency ?? "NPR").toUpperCase(),
|
(balance?.currency_code ?? profile?.default_currency ?? "NPR").toUpperCase(),
|
||||||
@@ -57,6 +72,50 @@ export function HallWalletStrip() {
|
|||||||
return () => window.removeEventListener("lottery-wallet-refresh", onRefresh);
|
return () => window.removeEventListener("lottery-wallet-refresh", onRefresh);
|
||||||
}, [refresh]);
|
}, [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 lotteryMinor = Number(balance?.balance ?? 0);
|
||||||
const availableMinor = Number(balance?.available_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 { getDrawCurrent } from "@/api/draw";
|
||||||
import { getLotteryEcho } from "@/lib/lottery-echo";
|
import { getLotteryEcho } from "@/lib/lottery-echo";
|
||||||
|
import { useNetworkConnectionStore } from "@/stores/network-connection-store";
|
||||||
import type { DrawCurrentPayload } from "@/types/api/draw-current";
|
import type { DrawCurrentPayload } from "@/types/api/draw-current";
|
||||||
|
|
||||||
/** 界面文档 §2.1:`draw.countdown` / `draw.status_change` / `result.published` 载荷 */
|
/** 界面文档 §2.1:`draw.countdown` / `draw.status_change` / `result.published` 载荷 */
|
||||||
@@ -34,6 +35,7 @@ function applySnapshotDrift(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 大厅期号:WebSocket `lottery-hall` + 轮询降级(与 {@link HallDrawPanel} 同源逻辑)。
|
* 大厅期号:WebSocket `lottery-hall` + 轮询降级(与 {@link HallDrawPanel} 同源逻辑)。
|
||||||
|
* 已集成网络连接管理,WebSocket断开时自动切换到轮询模式。
|
||||||
*/
|
*/
|
||||||
export function useHallDrawLive(): {
|
export function useHallDrawLive(): {
|
||||||
raw: DrawCurrentPayload | null | undefined;
|
raw: DrawCurrentPayload | null | undefined;
|
||||||
@@ -47,6 +49,21 @@ export function useHallDrawLive(): {
|
|||||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||||
const [error, setError] = useState<string | null>(null);
|
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) => {
|
const mergeFromWs = useCallback((evt: HallWsEnvelope) => {
|
||||||
setRaw(evt.data);
|
setRaw(evt.data);
|
||||||
setEmittedAtMs(evt.emitted_at_ms ?? Date.now());
|
setEmittedAtMs(evt.emitted_at_ms ?? Date.now());
|
||||||
@@ -64,11 +81,13 @@ export function useHallDrawLive(): {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// WebSocket 正常时的轮询间隔(作为备用)
|
||||||
const refreshMs = useMemo(() => {
|
const refreshMs = useMemo(() => {
|
||||||
if (raw === undefined) return 10_000;
|
if (raw === undefined) return 30_000;
|
||||||
return raw ? 30_000 : 12_000;
|
return raw ? 60_000 : 30_000; // WebSocket正常时减少轮询频率
|
||||||
}, [raw]);
|
}, [raw]);
|
||||||
|
|
||||||
|
// 初始加载
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = window.setTimeout(() => {
|
const timer = window.setTimeout(() => {
|
||||||
void load();
|
void load();
|
||||||
@@ -76,13 +95,7 @@ export function useHallDrawLive(): {
|
|||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
useEffect(() => {
|
// 本地倒计时计时器(用于 UI 更新)
|
||||||
const id = window.setInterval(() => {
|
|
||||||
void load();
|
|
||||||
}, refreshMs);
|
|
||||||
return () => window.clearInterval(id);
|
|
||||||
}, [load, refreshMs]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const bump = () => setNowMs(Date.now());
|
const bump = () => setNowMs(Date.now());
|
||||||
bump();
|
bump();
|
||||||
@@ -97,20 +110,113 @@ export function useHallDrawLive(): {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// WebSocket 订阅 + 降级轮询逻辑
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const echo = getLotteryEcho();
|
const echo = getLotteryEcho();
|
||||||
if (!echo) return;
|
if (!echo) return;
|
||||||
|
|
||||||
echo
|
// 监听 WebSocket 事件
|
||||||
.channel("lottery-hall")
|
const channel = echo.channel("lottery-hall");
|
||||||
.listen(".draw.countdown", mergeFromWs)
|
|
||||||
.listen(".draw.status_change", mergeFromWs)
|
// 设置事件监听
|
||||||
.listen(".result.published", mergeFromWs);
|
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 () => {
|
return () => {
|
||||||
echo.leave("lottery-hall");
|
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 =
|
const display: DrawCurrentPayload | null | undefined =
|
||||||
raw === undefined || raw === null ? raw : applySnapshotDrift(raw, emittedAtMs, nowMs);
|
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