Files
lotteryFront/src/features/hall/use-hall-draw-live.ts
kang b819894e75 feat: 增强 iframe 通信机制与通知处理功能
实现 resolvePostMessageTargetOrigin,优化 iframe 消息通信中的目标来源(origin)解析与校验。
更新 IframeBridge:支持定期刷新允许的来源列表,并优化消息事件管理机制。
重构 usePendingWalletReconcile:优化待对账通知的获取与缓存逻辑,提升性能与用户体验。
增强 NotificationsScreen:新增待对账通知内容,并优化界面展示效果。
更新英文、尼泊尔语与中文语言包,新增待对账通知相关翻译文案。
2026-06-01 13:38:30 +08:00

308 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { getDrawCurrent } from "@/api/draw";
import { isHallAwaitingDrawProcessing, isHallSealedCountdownUi } from "@/features/draw/draw-status-meta";
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
import { getLotteryEcho } from "@/lib/lottery-echo";
import { startWalletRefreshBurst } from "@/lib/wallet-refresh-burst";
import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference";
import { useNetworkConnectionStore } from "@/stores/network-connection-store";
import type { DrawCurrentPayload } from "@/types/api/draw-current";
/** 大厅共享的当期快照(由 {@link useHallDrawLive} 产出,供期号条与下注表共用)。 */
export type HallDrawLiveSnapshot = {
raw: DrawCurrentPayload | null | undefined;
display: DrawCurrentPayload | null | undefined;
serverNowMs: number;
/** 与 display 漂移推演一致的本地「当前」毫秒时间戳 */
nowMs: number;
error: string | null;
reload: () => Promise<void>;
isBettable: boolean;
};
/** 界面文档 §2.1`draw.countdown` / `draw.status_change` / `result.published` 载荷 */
export type HallWsEnvelope = {
data: DrawCurrentPayload | null;
emitted_at_ms?: number;
};
function secondsUntilIso(iso: string | null | undefined, effectiveNowMs: number): number {
if (iso == null || iso === "") {
return 0;
}
const targetMs = Date.parse(iso);
if (Number.isNaN(targetMs)) {
return 0;
}
return Math.max(0, Math.ceil((targetMs - effectiveNowMs) / 1000));
}
/**
* 以服务端 `server_now_ms` 为锚、本地时钟仅负责推进,倒计时与 ISO 时刻一致。
*/
function mergeJackpotForCurrency(
incoming: DrawCurrentPayload | null,
previous: DrawCurrentPayload | null | undefined,
activeCurrency: string,
): DrawCurrentPayload | null {
if (incoming === null) {
return null;
}
const incomingCode = (
incoming.jackpot_currency_code ?? incoming.jackpot?.currency_code ?? ""
)
.trim()
.toUpperCase();
const wanted = activeCurrency.trim().toUpperCase();
if (incomingCode !== "" && incomingCode !== wanted && previous?.jackpot) {
return {
...incoming,
jackpot_currency_code: wanted,
jackpot: previous.jackpot,
};
}
return incoming;
}
function applySnapshotDrift(
payload: DrawCurrentPayload,
emittedAtMs: number,
clientNowMs: number,
serverNowMs: number,
): DrawCurrentPayload {
const effectiveNowMs = serverNowMs + (clientNowMs - emittedAtMs);
return {
...payload,
seconds_to_close: secondsUntilIso(payload.close_time, effectiveNowMs),
seconds_to_start: secondsUntilIso(payload.start_time, effectiveNowMs),
seconds_to_draw: secondsUntilIso(payload.draw_time, effectiveNowMs),
seconds_remaining_in_cooldown:
payload.cooling_end_time == null
? null
: secondsUntilIso(payload.cooling_end_time, effectiveNowMs),
};
}
/**
* 大厅期号WebSocket `lottery-hall` + 轮询降级;由 {@link HallScreen} 调用一次,注入 {@link HallDrawPanel} 与 {@link HallBettingGrid}。
* 已集成网络连接管理WebSocket断开时自动切换到轮询模式。
*/
export function useHallDrawLive(): HallDrawLiveSnapshot {
const { activeCurrency } = useActivePlayerCurrency();
const [raw, setRaw] = useState<DrawCurrentPayload | null | undefined>(undefined);
const [serverNowMs, setServerNowMs] = useState(() => Date.now());
const [emittedAtMs, setEmittedAtMs] = useState(() => Date.now());
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 clearDrawPolling = useNetworkConnectionStore((s) => s.clearDrawPolling);
const latestSnapshotMsRef = useRef(0);
const applySnapshot = useCallback(
(anchorMs: number, data: DrawCurrentPayload | null) => {
if (anchorMs < latestSnapshotMsRef.current) {
return;
}
latestSnapshotMsRef.current = anchorMs;
setServerNowMs(anchorMs);
setRaw((prev) => mergeJackpotForCurrency(data, prev, activeCurrency));
setEmittedAtMs(anchorMs);
},
[activeCurrency],
);
const mergeFromWs = useCallback(
(evt: HallWsEnvelope) => {
const anchor = evt.emitted_at_ms ?? Date.now();
applySnapshot(anchor, evt.data);
},
[applySnapshot],
);
const mergeCountdownFromWs = useCallback(
(evt: HallWsEnvelope) => {
if (evt.data === null) return;
const anchor = evt.emitted_at_ms ?? Date.now();
applySnapshot(anchor, evt.data);
},
[applySnapshot],
);
const load = useCallback(async (options?: { force?: boolean }) => {
try {
setError(null);
const d = await getDrawCurrent({ currency: activeCurrency });
const wsConnected = useNetworkConnectionStore.getState().isWebSocketConnected;
if (!options?.force && wsConnected && d.server_now_ms < latestSnapshotMsRef.current) {
return;
}
applySnapshot(d.server_now_ms, d.data);
} catch {
setError("draw.loadFailedRefresh");
setRaw(undefined);
}
}, [activeCurrency, applySnapshot]);
useEffect(() => {
const onCurrencyChange = () => {
void load({ force: true });
};
window.addEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onCurrencyChange);
return () => window.removeEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onCurrencyChange);
}, [load]);
// 爆池等场景:刷新大厅快照(含奖池余额)
useEffect(() => {
const onHallRefresh = () => {
void load();
};
window.addEventListener("lottery-hall-refresh", onHallRefresh);
return () => window.removeEventListener("lottery-hall-refresh", onHallRefresh);
}, [load]);
// 本地倒计时计时器(用于 UI 更新)
useEffect(() => {
const bump = () => setNowMs(Date.now());
bump();
const sid = window.setInterval(bump, 1000);
const onVisibility = () => {
if (document.visibilityState === "visible") bump();
};
document.addEventListener("visibilitychange", onVisibility);
return () => {
window.clearInterval(sid);
document.removeEventListener("visibilitychange", onVisibility);
};
}, []);
// WebSocket 订阅(期号 HTTP 轮询由下方单一 effect 负责)
useEffect(() => {
const echo = getLotteryEcho();
if (!echo) return;
const channel = echo.channel("lottery-hall");
channel.listen(".draw.countdown", mergeCountdownFromWs);
channel.listen(".draw.status_change", mergeFromWs);
channel.listen(".result.published", (evt: HallWsEnvelope) => {
mergeFromWs(evt);
startWalletRefreshBurst();
});
return () => {
channel.stopListening(".draw.countdown");
channel.stopListening(".draw.status_change");
channel.stopListening(".result.published");
};
}, [mergeCountdownFromWs, mergeFromWs]);
const display: DrawCurrentPayload | null | undefined =
raw === undefined || raw === null
? raw
: applySnapshotDrift(raw, emittedAtMs, nowMs, serverNowMs);
const isBettable = display != null && display.status === "open";
const zeroRefreshKeyRef = useRef<string | null>(null);
// 本地倒计时归零时主动拉取(冷静期结束、封盘切下一期等;避免只靠手动刷新)
useEffect(() => {
if (!display) return;
const coolingEndMs = display.cooling_end_time
? Date.parse(display.cooling_end_time)
: null;
const coolingDone =
display.status === "cooldown" &&
((display.seconds_remaining_in_cooldown ?? 1) === 0 ||
(coolingEndMs !== null && !Number.isNaN(coolingEndMs) && coolingEndMs <= nowMs));
const awaitingDraw = isHallAwaitingDrawProcessing(
display.status,
display.draw_time,
display.seconds_to_draw,
nowMs,
);
const sealedDone =
isHallSealedCountdownUi(display.status) && (display.seconds_to_draw ?? 1) === 0;
const closeDone =
display.status === "open" && (display.seconds_to_close ?? 1) === 0;
const trigger = coolingDone
? `${display.draw_no}:cooldown-end`
: awaitingDraw
? `${display.draw_no}:awaiting-draw`
: sealedDone
? `${display.draw_no}:sealed-end`
: closeDone
? `${display.draw_no}:close-end`
: null;
if (trigger && zeroRefreshKeyRef.current !== trigger) {
zeroRefreshKeyRef.current = trigger;
void load({ force: true });
}
}, [display, nowMs, load]);
useEffect(() => {
if (display?.draw_no) {
zeroRefreshKeyRef.current = null;
}
}, [display?.draw_no, display?.status]);
const needsFastDrawPoll =
display != null
&& isHallAwaitingDrawProcessing(
display.status,
display.draw_time,
display.seconds_to_draw,
nowMs,
);
// 单一期号 HTTP 轮询:降级 30s / 待开奖 3s / 常态保险 45s避免多套 interval 叠加)
useEffect(() => {
const wsOk = isWebSocketConnected && mode === "websocket";
const intervalMs = !wsOk ? 30_000 : needsFastDrawPoll ? 3_000 : 45_000;
const initialTimer = window.setTimeout(() => {
void load();
}, 0);
const intervalId = window.setInterval(() => {
void load();
}, intervalMs);
setDrawPollingIntervalId(intervalId);
return () => {
window.clearTimeout(initialTimer);
clearDrawPolling();
};
}, [
isWebSocketConnected,
mode,
needsFastDrawPoll,
load,
setDrawPollingIntervalId,
clearDrawPolling,
]);
return { raw, display, serverNowMs, nowMs, error, reload: load, isBettable };
}