feat: 增强开奖 API 的币种支持并优化钱包处理逻辑

更新 getDrawCurrent、getDrawResults 与 getDrawResultByNo 方法,新增币种参数支持,以适配玩家币种偏好。
优化 HallBettingGrid 及相关组件:支持币种切换时自动刷新钱包数据。
重构钱包处理逻辑,简化余额更新流程并提升用户体验。
新增会话过期相关多语言提示文案,并优化现有翻译内容,提升多语言环境下的提示清晰度。
This commit is contained in:
2026-05-27 16:52:12 +08:00
parent adae4a0be1
commit 58afa8e844
34 changed files with 373 additions and 379 deletions

View File

@@ -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") ?? [];

View File

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

View File

@@ -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 (

View File

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

View File

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