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

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