实现 resolvePostMessageTargetOrigin,优化 iframe 消息通信中的目标来源(origin)解析与校验。 更新 IframeBridge:支持定期刷新允许的来源列表,并优化消息事件管理机制。 重构 usePendingWalletReconcile:优化待对账通知的获取与缓存逻辑,提升性能与用户体验。 增强 NotificationsScreen:新增待对账通知内容,并优化界面展示效果。 更新英文、尼泊尔语与中文语言包,新增待对账通知相关翻译文案。
308 lines
9.7 KiB
TypeScript
308 lines
9.7 KiB
TypeScript
"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 };
|
||
}
|