feat: 增强开奖 API 的币种支持并优化钱包处理逻辑
更新 getDrawCurrent、getDrawResults 与 getDrawResultByNo 方法,新增币种参数支持,以适配玩家币种偏好。 优化 HallBettingGrid 及相关组件:支持币种切换时自动刷新钱包数据。 重构钱包处理逻辑,简化余额更新流程并提升用户体验。 新增会话过期相关多语言提示文案,并优化现有翻译内容,提升多语言环境下的提示清晰度。
This commit is contained in:
@@ -38,9 +38,13 @@ export function HallBetResultDialog({
|
||||
const { t } = useTranslation("player");
|
||||
|
||||
const successItems =
|
||||
data?.items.filter((item) => SUCCESS_ITEM_STATUSES.has(item.status)) ?? [];
|
||||
data?.items.filter(
|
||||
(item) => item.status != null && SUCCESS_ITEM_STATUSES.has(item.status),
|
||||
) ?? [];
|
||||
const failedItems =
|
||||
data?.items.filter((item) => FAILURE_ITEM_STATUSES.has(item.status)) ?? [];
|
||||
data?.items.filter(
|
||||
(item) => item.status != null && FAILURE_ITEM_STATUSES.has(item.status),
|
||||
) ?? [];
|
||||
const pendingItems =
|
||||
data?.items.filter((item) => item.status === "pending_confirm") ?? [];
|
||||
|
||||
|
||||
@@ -29,6 +29,10 @@ import { getLotteryEcho } from "@/lib/lottery-echo";
|
||||
import { getLotteryRequestLocale } from "@/lib/lottery-locale";
|
||||
import { formatMinorAsCurrency, parseDecimalInputToMinor } from "@/lib/money";
|
||||
import { playLabel } from "@/lib/play-labels";
|
||||
import {
|
||||
PLAY_CATALOG_REFRESH_EVENT,
|
||||
type PlayCatalogRefreshSource,
|
||||
} from "@/lib/play-catalog-events";
|
||||
import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
@@ -36,7 +40,6 @@ import type { PlayEffectivePayload, PlayEffectivePlayRow } from "@/types/api/pla
|
||||
import type { TicketLineInput, TicketPlaceData, TicketPreviewData } from "@/types/api/ticket";
|
||||
import type { DrawCurrentRiskPoolAlert } from "@/types/api/draw-current";
|
||||
|
||||
const DEFAULT_POLL_MS = 120_000;
|
||||
const MAX_ROWS = 20;
|
||||
|
||||
type HallCategory = "D2" | "D3" | "D4" | "JACKPOT";
|
||||
@@ -421,7 +424,7 @@ function cellRiskState(
|
||||
|
||||
for (const alert of alerts) {
|
||||
if (matchesRiskAlert(alert.normalized_number, play.play_code, normalizedRow, category, digitSlot)) {
|
||||
return alert.is_sold_out ? "sold_out" : "warning";
|
||||
return alert.status === "sold_out" ? "sold_out" : "warning";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -512,12 +515,20 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
}, [loadCatalog, refreshWallet]);
|
||||
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => {
|
||||
const onCatalogRefresh = (ev: Event) => {
|
||||
void loadCatalog();
|
||||
void refreshWallet();
|
||||
}, DEFAULT_POLL_MS);
|
||||
return () => window.clearInterval(id);
|
||||
}, [loadCatalog, refreshWallet]);
|
||||
const source = (ev as CustomEvent<{ source?: PlayCatalogRefreshSource }>).detail
|
||||
?.source;
|
||||
if (source === "play_config" || source === "risk_cap") {
|
||||
setPreviewOpen(false);
|
||||
setPreviewData(null);
|
||||
clearPlaceTraceId();
|
||||
toast.message(t("hall.playConfig.updated"));
|
||||
}
|
||||
};
|
||||
window.addEventListener(PLAY_CATALOG_REFRESH_EVENT, onCatalogRefresh);
|
||||
return () => window.removeEventListener(PLAY_CATALOG_REFRESH_EVENT, onCatalogRefresh);
|
||||
}, [clearPlaceTraceId, loadCatalog, t]);
|
||||
|
||||
const openPlays = useMemo(() => {
|
||||
if (catalogState.kind !== "ok") return [];
|
||||
@@ -731,7 +742,6 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
const channel = echo.channel("lottery-hall");
|
||||
|
||||
const onPlayToggle = (evt: PlayToggleWsEvent) => {
|
||||
void loadCatalog();
|
||||
if (evt.enabled === false && typeof evt.play_code === "string") {
|
||||
const removed = clearAmountsForPlay(evt.play_code);
|
||||
setPreviewOpen(false);
|
||||
@@ -746,13 +756,10 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
playCode: evt.play_code,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
toast.message(t("hall.playConfig.updated"));
|
||||
}
|
||||
};
|
||||
|
||||
const onOddsUpdate = (evt: OddsUpdateWsEvent) => {
|
||||
void loadCatalog();
|
||||
setPreviewOpen(false);
|
||||
setPreviewData(null);
|
||||
clearPlaceTraceId();
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
||||
import { getLotteryRequestLocale } from "@/lib/lottery-locale";
|
||||
import { PLAY_CATALOG_REFRESH_EVENT } from "@/lib/play-catalog-events";
|
||||
import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
@@ -117,6 +118,12 @@ export function HallPlayCatalogPanel() {
|
||||
return () => window.removeEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onCurrencyChange);
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
const onCatalogRefresh = () => void load();
|
||||
window.addEventListener(PLAY_CATALOG_REFRESH_EVENT, onCatalogRefresh);
|
||||
return () => window.removeEventListener(PLAY_CATALOG_REFRESH_EVENT, onCatalogRefresh);
|
||||
}, [load]);
|
||||
|
||||
const body = (() => {
|
||||
if (state.kind === "loading") {
|
||||
return (
|
||||
|
||||
@@ -84,6 +84,10 @@ export function HallWalletStrip() {
|
||||
}, [mode, refresh]);
|
||||
|
||||
const availableMinor = Number(balance?.available_balance ?? balance?.balance ?? 0);
|
||||
const mainMinor =
|
||||
balance?.main_balance === null || balance?.main_balance === undefined
|
||||
? null
|
||||
: Number(balance.main_balance);
|
||||
|
||||
return (
|
||||
<section className="mb-3 space-y-2.5" aria-label={t("wallet.balance")}>
|
||||
@@ -124,6 +128,7 @@ export function HallWalletStrip() {
|
||||
triggerClassName="h-12 rounded-lg text-base font-bold"
|
||||
currency={currency}
|
||||
lotteryMinor={availableMinor}
|
||||
mainMinor={mainMinor}
|
||||
onSuccess={refresh}
|
||||
/>
|
||||
<TransferOutDialog
|
||||
|
||||
@@ -4,9 +4,12 @@ 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, DrawCurrentResponse } from "@/types/api/draw-current";
|
||||
import type { DrawCurrentPayload } from "@/types/api/draw-current";
|
||||
|
||||
/** 大厅共享的当期快照(由 {@link useHallDrawLive} 产出,供期号条与下注表共用)。 */
|
||||
export type HallDrawLiveSnapshot = {
|
||||
@@ -40,6 +43,33 @@ function secondsUntilIso(iso: string | null | undefined, effectiveNowMs: number)
|
||||
/**
|
||||
* 以服务端 `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,
|
||||
@@ -64,6 +94,7 @@ function applySnapshotDrift(
|
||||
* 已集成网络连接管理,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());
|
||||
@@ -78,40 +109,44 @@ export function useHallDrawLive(): HallDrawLiveSnapshot {
|
||||
const setDrawPollingIntervalId = useNetworkConnectionStore(
|
||||
(s) => s.setDrawPollingIntervalId,
|
||||
);
|
||||
const setWalletPollingIntervalId = useNetworkConnectionStore(
|
||||
(s) => s.setWalletPollingIntervalId,
|
||||
);
|
||||
const setWalletPollingExpiryAt = useNetworkConnectionStore(
|
||||
(s) => s.setWalletPollingExpiryAt,
|
||||
);
|
||||
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(data);
|
||||
setEmittedAtMs(anchorMs);
|
||||
}, []);
|
||||
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 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 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();
|
||||
const d = await getDrawCurrent({ currency: activeCurrency });
|
||||
const wsConnected = useNetworkConnectionStore.getState().isWebSocketConnected;
|
||||
if (!options?.force && wsConnected && d.server_now_ms < latestSnapshotMsRef.current) {
|
||||
return;
|
||||
@@ -121,7 +156,15 @@ export function useHallDrawLive(): HallDrawLiveSnapshot {
|
||||
setError("draw.loadFailedRefresh");
|
||||
setRaw(undefined);
|
||||
}
|
||||
}, [applySnapshot]);
|
||||
}, [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(() => {
|
||||
@@ -155,124 +198,26 @@ export function useHallDrawLive(): HallDrawLiveSnapshot {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// WebSocket 订阅 + 降级轮询逻辑
|
||||
// WebSocket 订阅(期号 HTTP 轮询由下方单一 effect 负责)
|
||||
useEffect(() => {
|
||||
const echo = getLotteryEcho();
|
||||
if (!echo) return;
|
||||
|
||||
// 监听 WebSocket 事件
|
||||
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);
|
||||
|
||||
// 触发钱包余额轮询(开奖后需要更新余额)
|
||||
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);
|
||||
startWalletRefreshBurst();
|
||||
});
|
||||
|
||||
// 连接状态变化处理
|
||||
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 () => {
|
||||
channel.stopListening(".draw.countdown");
|
||||
channel.stopListening(".draw.status_change");
|
||||
channel.stopListening(".result.published");
|
||||
if (echo.connector?.pusher) {
|
||||
echo.connector.pusher.connection.unbind("connected", handleConnected);
|
||||
echo.connector.pusher.connection.unbind(
|
||||
"disconnected",
|
||||
handleDisconnected,
|
||||
);
|
||||
}
|
||||
};
|
||||
}, [
|
||||
mergeCountdownFromWs,
|
||||
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) {
|
||||
const initialLoadId = window.setTimeout(() => {
|
||||
void load();
|
||||
}, 0);
|
||||
|
||||
// 设置30秒轮询
|
||||
intervalId = window.setInterval(() => {
|
||||
void load();
|
||||
}, 30_000);
|
||||
setDrawPollingIntervalId(intervalId);
|
||||
return () => {
|
||||
window.clearTimeout(initialLoadId);
|
||||
if (intervalId) {
|
||||
window.clearInterval(intervalId);
|
||||
setDrawPollingIntervalId(null);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (intervalId) {
|
||||
window.clearInterval(intervalId);
|
||||
setDrawPollingIntervalId(null);
|
||||
}
|
||||
};
|
||||
}, [isWebSocketConnected, mode, load, setDrawPollingIntervalId]);
|
||||
}, [mergeCountdownFromWs, mergeFromWs]);
|
||||
|
||||
const display: DrawCurrentPayload | null | undefined =
|
||||
raw === undefined || raw === null
|
||||
@@ -339,28 +284,32 @@ export function useHallDrawLive(): HallDrawLiveSnapshot {
|
||||
nowMs,
|
||||
);
|
||||
|
||||
// 封盘/待开奖/开奖中:调度推进状态前每 3 秒拉一次,避免 0:00 卡几十秒
|
||||
// 单一期号 HTTP 轮询:降级 30s / 待开奖 3s / 常态保险 45s(避免多套 interval 叠加)
|
||||
useEffect(() => {
|
||||
if (!needsFastDrawPoll) {
|
||||
return;
|
||||
}
|
||||
const intervalId = window.setInterval(() => {
|
||||
void load();
|
||||
}, 3000);
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [needsFastDrawPoll, load]);
|
||||
const wsOk = isWebSocketConnected && mode === "websocket";
|
||||
const intervalMs = !wsOk ? 30_000 : needsFastDrawPoll ? 3_000 : 45_000;
|
||||
|
||||
const initialTimer = window.setTimeout(() => {
|
||||
void load();
|
||||
}, 0);
|
||||
|
||||
// WebSocket 已连接时的兜底轮询(tick 延迟时的保险;常态下由 WS 推送,避免 HTTP 覆盖新快照)
|
||||
useEffect(() => {
|
||||
if (isWebSocketConnected && !needsFastDrawPoll) {
|
||||
return;
|
||||
}
|
||||
const intervalMs = needsFastDrawPoll ? 15_000 : 45_000;
|
||||
const intervalId = window.setInterval(() => {
|
||||
void load();
|
||||
}, intervalMs);
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [load, needsFastDrawPoll, isWebSocketConnected]);
|
||||
setDrawPollingIntervalId(intervalId);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(initialTimer);
|
||||
clearDrawPolling();
|
||||
};
|
||||
}, [
|
||||
isWebSocketConnected,
|
||||
mode,
|
||||
needsFastDrawPoll,
|
||||
load,
|
||||
setDrawPollingIntervalId,
|
||||
clearDrawPolling,
|
||||
]);
|
||||
|
||||
return { raw, display, serverNowMs, nowMs, error, reload: load, isBettable };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user