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

@@ -6,10 +6,18 @@ import type {
DrawResultsListPayload, DrawResultsListPayload,
} from "@/types/api/draw-results"; } from "@/types/api/draw-results";
export type GetDrawCurrentParams = {
/** 与 `play/effective` 一致,决定嵌入快照中的 jackpot 币种 */
currency?: string;
};
/** `GET /api/v1/draw/current`(无需登录;无当前期时 `data` 为 `null` */ /** `GET /api/v1/draw/current`(无需登录;无当前期时 `data` 为 `null` */
export function getDrawCurrent(): Promise<DrawCurrentResponse> { export function getDrawCurrent(
params?: GetDrawCurrentParams,
): Promise<DrawCurrentResponse> {
return lotteryRequest.get<DrawCurrentResponse>( return lotteryRequest.get<DrawCurrentResponse>(
`${API_V1_PREFIX}/draw/current`, `${API_V1_PREFIX}/draw/current`,
{ params: params?.currency ? { currency: params.currency } : undefined },
); );
} }
@@ -19,6 +27,7 @@ export type GetDrawResultsParams = {
size?: number; size?: number;
/** `YYYY-MM-DD`,按业务日过滤 */ /** `YYYY-MM-DD`,按业务日过滤 */
business_date?: string; business_date?: string;
currency?: string;
}; };
/** `GET /api/v1/draw/results` */ /** `GET /api/v1/draw/results` */
@@ -27,16 +36,29 @@ export function getDrawResults(
): Promise<DrawResultsListPayload> { ): Promise<DrawResultsListPayload> {
return lotteryRequest.get<DrawResultsListPayload>( return lotteryRequest.get<DrawResultsListPayload>(
`${API_V1_PREFIX}/draw/results`, `${API_V1_PREFIX}/draw/results`,
{ params: { page: params?.page, size: params?.size, business_date: params?.business_date } }, {
params: {
page: params?.page,
size: params?.size,
business_date: params?.business_date,
currency: params?.currency,
},
},
); );
} }
export type GetDrawResultByNoParams = {
currency?: string;
};
/** `GET /api/v1/draw/results/{draw_no}` */ /** `GET /api/v1/draw/results/{draw_no}` */
export function getDrawResultByNo( export function getDrawResultByNo(
drawNo: string, drawNo: string,
params?: GetDrawResultByNoParams,
): Promise<DrawResultDetailPayload> { ): Promise<DrawResultDetailPayload> {
const encoded = encodeURIComponent(drawNo); const encoded = encodeURIComponent(drawNo);
return lotteryRequest.get<DrawResultDetailPayload>( return lotteryRequest.get<DrawResultDetailPayload>(
`${API_V1_PREFIX}/draw/results/${encoded}`, `${API_V1_PREFIX}/draw/results/${encoded}`,
{ params: params?.currency ? { currency: params.currency } : undefined },
); );
} }

View File

@@ -7,7 +7,7 @@
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--font-sans: var(--font-sans); --font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono); --font-mono: var(--font-geist-mono);
--font-heading: var(--font-sans); --font-heading: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
@@ -127,6 +127,13 @@
html { html {
@apply font-sans; @apply font-sans;
} }
html:lang(ne) {
font-family:
var(--font-noto-sans-devanagari),
var(--font-geist-sans),
system-ui,
sans-serif;
}
} }
/* 玩家端 Toast顶部居中、紧凑尺寸位置见 components/ui/sonner.tsx */ /* 玩家端 Toast顶部居中、紧凑尺寸位置见 components/ui/sonner.tsx */

View File

@@ -1,5 +1,5 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono, Noto_Sans_Devanagari } from "next/font/google";
import { Providers } from "@/components/providers"; import { Providers } from "@/components/providers";
import { DEFAULT_LANGUAGE } from "@/i18n/language"; import { DEFAULT_LANGUAGE } from "@/i18n/language";
@@ -15,6 +15,11 @@ const geistMono = Geist_Mono({
subsets: ["latin"], subsets: ["latin"],
}); });
const notoSansDevanagari = Noto_Sans_Devanagari({
variable: "--font-noto-sans-devanagari",
subsets: ["devanagari", "latin"],
});
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Lottery", title: "Lottery",
description: "Lottery player", description: "Lottery player",
@@ -34,7 +39,7 @@ export default function RootLayout({
<html <html
lang={DEFAULT_LANGUAGE} lang={DEFAULT_LANGUAGE}
suppressHydrationWarning suppressHydrationWarning
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} className={`${geistSans.variable} ${geistMono.variable} ${notoSansDevanagari.variable} h-full antialiased`}
> >
<body className="min-h-full flex flex-col"> <body className="min-h-full flex flex-col">
<Providers>{children}</Providers> <Providers>{children}</Providers>

View File

@@ -116,7 +116,7 @@ export function IframeBridge({ children }: { children: ReactNode }): ReactNode {
console.log("[IframeBridge] Received initial token"); console.log("[IframeBridge] Received initial token");
setBearerToken(data.token); setBearerToken(data.token);
setPlayerBearerToken(data.token); setPlayerBearerToken(data.token);
notifyReady(); // 勿再 notifyReady(),否则主站会重复 MAIN_INIT_TOKEN 导致消息刷屏
} }
break; break;

View File

@@ -0,0 +1,12 @@
"use client";
import type { ReactNode } from "react";
import { usePlayEffectiveWs } from "@/hooks/use-play-effective-ws";
/** 全局挂载:后台发布玩法/赔率/封顶后刷新 `play/effective` 订阅方。 */
export function PlayEffectiveWsListener(): ReactNode {
usePlayEffectiveWs();
return null;
}

View File

@@ -5,8 +5,9 @@ import { useEffect, type ReactNode } from "react";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import { ErrorProvider } from "@/components/error-provider"; import { ErrorProvider } from "@/components/error-provider";
import { IframeBridge } from "@/components/iframe-bridge"; import { IframeBridge } from "@/components/iframe-bridge";
import { PlayEffectiveWsListener } from "@/components/play-effective-ws-listener";
import { PlayerBalanceWsListener } from "@/components/player-balance-ws-listener"; import { PlayerBalanceWsListener } from "@/components/player-balance-ws-listener";
import { TokenRefreshIndicator } from "@/components/token-refresh-indicator"; import { TokenSilentRefresh } from "@/components/token-silent-refresh";
import "@/i18n"; import "@/i18n";
import { syncPreferredLanguage } from "@/i18n"; import { syncPreferredLanguage } from "@/i18n";
@@ -26,8 +27,9 @@ export function Providers({ children }: ProvidersProps): ReactNode {
<IframeBridge> <IframeBridge>
{children} {children}
<PlayerBalanceWsListener /> <PlayerBalanceWsListener />
{/* Token 续签指示器 - 显示在右下角 */} <PlayEffectiveWsListener />
<TokenRefreshIndicator /> {/* Token 静默续签(无 UI */}
<TokenSilentRefresh />
</IframeBridge> </IframeBridge>
</ErrorProvider> </ErrorProvider>
<Toaster /> <Toaster />

View File

@@ -1,123 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { RefreshCw, AlertCircle } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useTokenRefresh } from "@/hooks/use-token-refresh";
import { Button } from "@/components/ui/button";
import { ERROR_COLORS } from "@/stores/error-store";
import { cn } from "@/lib/utils";
/**
* Token 续签状态指示器组件
*
* 当 Token 即将过期或正在刷新时显示提示
*/
export function TokenRefreshIndicator(): React.ReactElement | null {
const { isTokenExpiringSoon, getTokenRemainingTime, refreshToken } =
useTokenRefresh();
const { t } = useTranslation("player");
const [isRefreshing, setIsRefreshing] = useState(false);
const [showWarning, setShowWarning] = useState(false);
const [remainingSeconds, setRemainingSeconds] = useState<number | null>(null);
// 检测 Token 状态
useEffect(() => {
const checkToken = (): void => {
const remaining = getTokenRemainingTime();
if (remaining > 0 && remaining < 120000) {
// 2 分钟内显示
setShowWarning(true);
setRemainingSeconds(Math.floor(remaining / 1000));
} else {
setShowWarning(false);
setRemainingSeconds(null);
}
};
checkToken();
const interval = setInterval(checkToken, 10000); // 每 10 秒检查
return () => clearInterval(interval);
}, [getTokenRemainingTime, isTokenExpiringSoon]);
// 手动刷新
const handleRefresh = async (): Promise<void> => {
setIsRefreshing(true);
try {
await refreshToken();
} finally {
setIsRefreshing(false);
}
};
if (!showWarning) {
return null;
}
const isCritical = remainingSeconds !== null && remainingSeconds < 60;
return (
<div
className={cn(
"fixed bottom-4 right-4 z-50 flex items-center gap-3 rounded-lg px-4 py-3 shadow-lg",
"animate-in slide-in-from-bottom-4 duration-300",
)}
style={{
backgroundColor: isCritical
? `${ERROR_COLORS.error}15`
: `${ERROR_COLORS.warning}15`,
border: `1px solid ${isCritical ? ERROR_COLORS.error : ERROR_COLORS.warning}`,
}}
>
<AlertCircle
className="size-5 shrink-0"
style={{
color: isCritical ? ERROR_COLORS.error : ERROR_COLORS.warning,
}}
/>
<div className="flex flex-col">
<span
className="text-sm font-medium"
style={{
color: isCritical ? ERROR_COLORS.error : ERROR_COLORS.warning,
}}
>
{isCritical ? t("token.critical") : t("token.warning")}
</span>
<span className="text-xs text-muted-foreground">
{remainingSeconds !== null && (
<>
{t("token.remaining", {
time: `${Math.floor(remainingSeconds / 60)}:${String(
remainingSeconds % 60,
).padStart(2, "0")}`,
})}{" "}
</>
)}
{t("token.autoRenewing")}
</span>
</div>
<Button
size="sm"
variant="outline"
className={cn("ml-2 h-8 gap-1 border-current")}
style={{
color: isCritical ? ERROR_COLORS.error : ERROR_COLORS.warning,
borderColor: isCritical ? ERROR_COLORS.error : ERROR_COLORS.warning,
}}
onClick={handleRefresh}
disabled={isRefreshing}
>
<RefreshCw
className={cn("size-3.5", isRefreshing && "animate-spin")}
/>
{t("token.renewNow")}
</Button>
</div>
);
}

View File

@@ -0,0 +1,11 @@
"use client";
import { useTokenRefresh } from "@/hooks/use-token-refresh";
/**
* 挂载 Token 自动续签逻辑,不展示任何 UI产品要求静默续签
*/
export function TokenSilentRefresh(): null {
useTokenRefresh();
return null;
}

View File

@@ -38,9 +38,13 @@ export function HallBetResultDialog({
const { t } = useTranslation("player"); const { t } = useTranslation("player");
const successItems = 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 = 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 = const pendingItems =
data?.items.filter((item) => item.status === "pending_confirm") ?? []; 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 { getLotteryRequestLocale } from "@/lib/lottery-locale";
import { formatMinorAsCurrency, parseDecimalInputToMinor } from "@/lib/money"; import { formatMinorAsCurrency, parseDecimalInputToMinor } from "@/lib/money";
import { playLabel } from "@/lib/play-labels"; 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 { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { LotteryApiBizError } from "@/types/api/errors"; 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 { TicketLineInput, TicketPlaceData, TicketPreviewData } from "@/types/api/ticket";
import type { DrawCurrentRiskPoolAlert } from "@/types/api/draw-current"; import type { DrawCurrentRiskPoolAlert } from "@/types/api/draw-current";
const DEFAULT_POLL_MS = 120_000;
const MAX_ROWS = 20; const MAX_ROWS = 20;
type HallCategory = "D2" | "D3" | "D4" | "JACKPOT"; type HallCategory = "D2" | "D3" | "D4" | "JACKPOT";
@@ -421,7 +424,7 @@ function cellRiskState(
for (const alert of alerts) { for (const alert of alerts) {
if (matchesRiskAlert(alert.normalized_number, play.play_code, normalizedRow, category, digitSlot)) { 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]); }, [loadCatalog, refreshWallet]);
useEffect(() => { useEffect(() => {
const id = window.setInterval(() => { const onCatalogRefresh = (ev: Event) => {
void loadCatalog(); void loadCatalog();
void refreshWallet(); const source = (ev as CustomEvent<{ source?: PlayCatalogRefreshSource }>).detail
}, DEFAULT_POLL_MS); ?.source;
return () => window.clearInterval(id); if (source === "play_config" || source === "risk_cap") {
}, [loadCatalog, refreshWallet]); 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(() => { const openPlays = useMemo(() => {
if (catalogState.kind !== "ok") return []; if (catalogState.kind !== "ok") return [];
@@ -731,7 +742,6 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
const channel = echo.channel("lottery-hall"); const channel = echo.channel("lottery-hall");
const onPlayToggle = (evt: PlayToggleWsEvent) => { const onPlayToggle = (evt: PlayToggleWsEvent) => {
void loadCatalog();
if (evt.enabled === false && typeof evt.play_code === "string") { if (evt.enabled === false && typeof evt.play_code === "string") {
const removed = clearAmountsForPlay(evt.play_code); const removed = clearAmountsForPlay(evt.play_code);
setPreviewOpen(false); setPreviewOpen(false);
@@ -746,13 +756,10 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
playCode: evt.play_code, playCode: evt.play_code,
}), }),
); );
} else {
toast.message(t("hall.playConfig.updated"));
} }
}; };
const onOddsUpdate = (evt: OddsUpdateWsEvent) => { const onOddsUpdate = (evt: OddsUpdateWsEvent) => {
void loadCatalog();
setPreviewOpen(false); setPreviewOpen(false);
setPreviewData(null); setPreviewData(null);
clearPlaceTraceId(); clearPlaceTraceId();

View File

@@ -23,6 +23,7 @@ import {
} from "@/components/ui/table"; } from "@/components/ui/table";
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency"; import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
import { getLotteryRequestLocale } from "@/lib/lottery-locale"; 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 { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { LotteryApiBizError } from "@/types/api/errors"; import { LotteryApiBizError } from "@/types/api/errors";
@@ -117,6 +118,12 @@ export function HallPlayCatalogPanel() {
return () => window.removeEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onCurrencyChange); return () => window.removeEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onCurrencyChange);
}, [load]); }, [load]);
useEffect(() => {
const onCatalogRefresh = () => void load();
window.addEventListener(PLAY_CATALOG_REFRESH_EVENT, onCatalogRefresh);
return () => window.removeEventListener(PLAY_CATALOG_REFRESH_EVENT, onCatalogRefresh);
}, [load]);
const body = (() => { const body = (() => {
if (state.kind === "loading") { if (state.kind === "loading") {
return ( return (

View File

@@ -84,6 +84,10 @@ export function HallWalletStrip() {
}, [mode, refresh]); }, [mode, refresh]);
const availableMinor = Number(balance?.available_balance ?? balance?.balance ?? 0); 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 ( return (
<section className="mb-3 space-y-2.5" aria-label={t("wallet.balance")}> <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" triggerClassName="h-12 rounded-lg text-base font-bold"
currency={currency} currency={currency}
lotteryMinor={availableMinor} lotteryMinor={availableMinor}
mainMinor={mainMinor}
onSuccess={refresh} onSuccess={refresh}
/> />
<TransferOutDialog <TransferOutDialog

View File

@@ -4,9 +4,12 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { getDrawCurrent } from "@/api/draw"; import { getDrawCurrent } from "@/api/draw";
import { isHallAwaitingDrawProcessing, isHallSealedCountdownUi } from "@/features/draw/draw-status-meta"; import { isHallAwaitingDrawProcessing, isHallSealedCountdownUi } from "@/features/draw/draw-status-meta";
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
import { getLotteryEcho } from "@/lib/lottery-echo"; 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 { 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} 产出,供期号条与下注表共用)。 */ /** 大厅共享的当期快照(由 {@link useHallDrawLive} 产出,供期号条与下注表共用)。 */
export type HallDrawLiveSnapshot = { export type HallDrawLiveSnapshot = {
@@ -40,6 +43,33 @@ function secondsUntilIso(iso: string | null | undefined, effectiveNowMs: number)
/** /**
* 以服务端 `server_now_ms` 为锚、本地时钟仅负责推进,倒计时与 ISO 时刻一致。 * 以服务端 `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( function applySnapshotDrift(
payload: DrawCurrentPayload, payload: DrawCurrentPayload,
emittedAtMs: number, emittedAtMs: number,
@@ -64,6 +94,7 @@ function applySnapshotDrift(
* 已集成网络连接管理WebSocket断开时自动切换到轮询模式。 * 已集成网络连接管理WebSocket断开时自动切换到轮询模式。
*/ */
export function useHallDrawLive(): HallDrawLiveSnapshot { export function useHallDrawLive(): HallDrawLiveSnapshot {
const { activeCurrency } = useActivePlayerCurrency();
const [raw, setRaw] = useState<DrawCurrentPayload | null | undefined>(undefined); const [raw, setRaw] = useState<DrawCurrentPayload | null | undefined>(undefined);
const [serverNowMs, setServerNowMs] = useState(() => Date.now()); const [serverNowMs, setServerNowMs] = useState(() => Date.now());
const [emittedAtMs, setEmittedAtMs] = useState(() => Date.now()); const [emittedAtMs, setEmittedAtMs] = useState(() => Date.now());
@@ -78,40 +109,44 @@ export function useHallDrawLive(): HallDrawLiveSnapshot {
const setDrawPollingIntervalId = useNetworkConnectionStore( const setDrawPollingIntervalId = useNetworkConnectionStore(
(s) => s.setDrawPollingIntervalId, (s) => s.setDrawPollingIntervalId,
); );
const setWalletPollingIntervalId = useNetworkConnectionStore( const clearDrawPolling = useNetworkConnectionStore((s) => s.clearDrawPolling);
(s) => s.setWalletPollingIntervalId,
);
const setWalletPollingExpiryAt = useNetworkConnectionStore(
(s) => s.setWalletPollingExpiryAt,
);
const latestSnapshotMsRef = useRef(0); const latestSnapshotMsRef = useRef(0);
const applySnapshot = useCallback((anchorMs: number, data: DrawCurrentPayload | null) => { const applySnapshot = useCallback(
if (anchorMs < latestSnapshotMsRef.current) { (anchorMs: number, data: DrawCurrentPayload | null) => {
return; if (anchorMs < latestSnapshotMsRef.current) {
} return;
latestSnapshotMsRef.current = anchorMs; }
setServerNowMs(anchorMs); latestSnapshotMsRef.current = anchorMs;
setRaw(data); setServerNowMs(anchorMs);
setEmittedAtMs(anchorMs); setRaw((prev) => mergeJackpotForCurrency(data, prev, activeCurrency));
}, []); setEmittedAtMs(anchorMs);
},
[activeCurrency],
);
const mergeFromWs = useCallback((evt: HallWsEnvelope) => { const mergeFromWs = useCallback(
const anchor = evt.emitted_at_ms ?? Date.now(); (evt: HallWsEnvelope) => {
applySnapshot(anchor, evt.data); const anchor = evt.emitted_at_ms ?? Date.now();
}, [applySnapshot]); applySnapshot(anchor, evt.data);
},
[applySnapshot],
);
const mergeCountdownFromWs = useCallback((evt: HallWsEnvelope) => { const mergeCountdownFromWs = useCallback(
if (evt.data === null) return; (evt: HallWsEnvelope) => {
const anchor = evt.emitted_at_ms ?? Date.now(); if (evt.data === null) return;
applySnapshot(anchor, evt.data); const anchor = evt.emitted_at_ms ?? Date.now();
}, [applySnapshot]); applySnapshot(anchor, evt.data);
},
[applySnapshot],
);
const load = useCallback(async (options?: { force?: boolean }) => { const load = useCallback(async (options?: { force?: boolean }) => {
try { try {
setError(null); setError(null);
const d = await getDrawCurrent(); const d = await getDrawCurrent({ currency: activeCurrency });
const wsConnected = useNetworkConnectionStore.getState().isWebSocketConnected; const wsConnected = useNetworkConnectionStore.getState().isWebSocketConnected;
if (!options?.force && wsConnected && d.server_now_ms < latestSnapshotMsRef.current) { if (!options?.force && wsConnected && d.server_now_ms < latestSnapshotMsRef.current) {
return; return;
@@ -121,7 +156,15 @@ export function useHallDrawLive(): HallDrawLiveSnapshot {
setError("draw.loadFailedRefresh"); setError("draw.loadFailedRefresh");
setRaw(undefined); 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(() => { useEffect(() => {
@@ -155,124 +198,26 @@ export function useHallDrawLive(): HallDrawLiveSnapshot {
}; };
}, []); }, []);
// WebSocket 订阅 + 降级轮询逻辑 // WebSocket 订阅(期号 HTTP 轮询由下方单一 effect 负责)
useEffect(() => { useEffect(() => {
const echo = getLotteryEcho(); const echo = getLotteryEcho();
if (!echo) return; if (!echo) return;
// 监听 WebSocket 事件
const channel = echo.channel("lottery-hall"); const channel = echo.channel("lottery-hall");
// 设置事件监听
channel.listen(".draw.countdown", mergeCountdownFromWs); channel.listen(".draw.countdown", mergeCountdownFromWs);
channel.listen(".draw.status_change", mergeFromWs); channel.listen(".draw.status_change", mergeFromWs);
channel.listen(".result.published", (evt: HallWsEnvelope) => { channel.listen(".result.published", (evt: HallWsEnvelope) => {
// 开奖结果发布时触发钱包轮询
mergeFromWs(evt); mergeFromWs(evt);
startWalletRefreshBurst();
// 触发钱包余额轮询(开奖后需要更新余额)
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 () => { return () => {
channel.stopListening(".draw.countdown"); channel.stopListening(".draw.countdown");
channel.stopListening(".draw.status_change"); channel.stopListening(".draw.status_change");
channel.stopListening(".result.published"); channel.stopListening(".result.published");
if (echo.connector?.pusher) {
echo.connector.pusher.connection.unbind("connected", handleConnected);
echo.connector.pusher.connection.unbind(
"disconnected",
handleDisconnected,
);
}
}; };
}, [ }, [mergeCountdownFromWs, mergeFromWs]);
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]);
const display: DrawCurrentPayload | null | undefined = const display: DrawCurrentPayload | null | undefined =
raw === undefined || raw === null raw === undefined || raw === null
@@ -339,28 +284,32 @@ export function useHallDrawLive(): HallDrawLiveSnapshot {
nowMs, nowMs,
); );
// 封盘/待开奖/开奖中:调度推进状态前每 3 秒拉一次,避免 0:00 卡几十秒 // 单一期号 HTTP 轮询:降级 30s / 待开奖 3s / 常态保险 45s避免多套 interval 叠加)
useEffect(() => { useEffect(() => {
if (!needsFastDrawPoll) { const wsOk = isWebSocketConnected && mode === "websocket";
return; const intervalMs = !wsOk ? 30_000 : needsFastDrawPoll ? 3_000 : 45_000;
}
const intervalId = window.setInterval(() => { const initialTimer = window.setTimeout(() => {
void load(); void load();
}, 3000); }, 0);
return () => window.clearInterval(intervalId);
}, [needsFastDrawPoll, load]);
// WebSocket 已连接时的兜底轮询tick 延迟时的保险;常态下由 WS 推送,避免 HTTP 覆盖新快照)
useEffect(() => {
if (isWebSocketConnected && !needsFastDrawPoll) {
return;
}
const intervalMs = needsFastDrawPoll ? 15_000 : 45_000;
const intervalId = window.setInterval(() => { const intervalId = window.setInterval(() => {
void load(); void load();
}, intervalMs); }, intervalMs);
return () => window.clearInterval(intervalId); setDrawPollingIntervalId(intervalId);
}, [load, needsFastDrawPoll, isWebSocketConnected]);
return () => {
window.clearTimeout(initialTimer);
clearDrawPolling();
};
}, [
isWebSocketConnected,
mode,
needsFastDrawPoll,
load,
setDrawPollingIntervalId,
clearDrawPolling,
]);
return { raw, display, serverNowMs, nowMs, error, reload: load, isBettable }; return { raw, display, serverNowMs, nowMs, error, reload: load, isBettable };
} }

View File

@@ -68,6 +68,15 @@ function initialSteps(): EntryStep[] {
]; ];
} }
function stripSearchParamFromBrowserUrl(name: string): void {
if (typeof window === "undefined") return;
const url = new URL(window.location.href);
if (!url.searchParams.has(name)) return;
url.searchParams.delete(name);
const next = `${url.pathname}${url.search}${url.hash}`;
window.history.replaceState(null, "", next);
}
function shouldRetryEntryRequest(error: unknown): boolean { function shouldRetryEntryRequest(error: unknown): boolean {
if (error instanceof LotteryApiBizError) { if (error instanceof LotteryApiBizError) {
return false; return false;
@@ -93,12 +102,22 @@ export function EntryGate() {
const { t: tc } = useTranslation("common"); const { t: tc } = useTranslation("common");
const tokenFromUrl = searchParams.get("token") ?? ""; const tokenFromUrl = searchParams.get("token") ?? "";
const sessionExpired = searchParams.get("session") === "expired";
const { bearerToken, setBearerToken, setProfile, setCurrencies, clearBearerToken } = const { bearerToken, setBearerToken, setProfile, setCurrencies, clearBearerToken } =
usePlayerSessionStore(); usePlayerSessionStore();
const [phase, setPhase] = useState<Phase>("loading"); const [phase, setPhase] = useState<Phase>(sessionExpired ? "failed" : "loading");
const [failureDetails, setFailureDetails] = useState<FailureRow[]>([]); const [failureDetails, setFailureDetails] = useState<FailureRow[]>(() =>
sessionExpired
? [
{
code: "SESSION_EXPIRED",
detailKey: "errors.sessionExpiredDetail",
},
]
: [],
);
const [steps, setSteps] = useState<EntryStep[]>(initialSteps()); const [steps, setSteps] = useState<EntryStep[]>(initialSteps());
const effectiveToken = tokenFromUrl || bearerToken; const effectiveToken = tokenFromUrl || bearerToken;
@@ -113,12 +132,6 @@ export function EntryGate() {
return Math.round(((doneCount + inProgressCount * 0.5) / steps.length) * 100); return Math.round(((doneCount + inProgressCount * 0.5) / steps.length) * 100);
}, [steps]); }, [steps]);
const handleRetry = useCallback(() => {
setPhase("loading");
setFailureDetails([]);
setSteps(initialSteps());
}, []);
const doEntry = useCallback(async () => { const doEntry = useCallback(async () => {
if (!effectiveToken) { if (!effectiveToken) {
setPhase("failed"); setPhase("failed");
@@ -128,6 +141,7 @@ export function EntryGate() {
if (tokenFromUrl) { if (tokenFromUrl) {
setBearerToken(tokenFromUrl); setBearerToken(tokenFromUrl);
stripSearchParamFromBrowserUrl("token");
} }
setSteps((prev) => setSteps((prev) =>
@@ -231,12 +245,26 @@ export function EntryGate() {
t, t,
]); ]);
const handleRetry = useCallback(() => {
setPhase("loading");
setFailureDetails([]);
setSteps(initialSteps());
void doEntry();
}, [doEntry]);
useEffect(() => { useEffect(() => {
if (!sessionExpired) return;
clearBearerToken();
stripSearchParamFromBrowserUrl("session");
}, [sessionExpired, clearBearerToken]);
useEffect(() => {
if (sessionExpired) return;
const tmr = window.setTimeout(() => { const tmr = window.setTimeout(() => {
void doEntry(); void doEntry();
}, 300); }, 300);
return () => window.clearTimeout(tmr); return () => window.clearTimeout(tmr);
}, [doEntry]); }, [doEntry, sessionExpired]);
return ( return (
<div className="relative flex min-h-dvh flex-col bg-white"> <div className="relative flex min-h-dvh flex-col bg-white">

View File

@@ -50,7 +50,7 @@ export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps)
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const row = await getDrawResultByNo(drawNo); const row = await getDrawResultByNo(drawNo, { currency: activeCurrency });
setData(row); setData(row);
} catch { } catch {
setData(null); setData(null);
@@ -58,7 +58,7 @@ export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps)
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [drawNo, t]); }, [activeCurrency, drawNo, t]);
useEffect(() => { useEffect(() => {
queueMicrotask(() => { queueMicrotask(() => {

View File

@@ -65,6 +65,7 @@ export function DrawResultsListScreen() {
page: targetPage, page: targetPage,
size: RESULTS_PAGE_SIZE, size: RESULTS_PAGE_SIZE,
business_date: businessDate, business_date: businessDate,
currency: activeCurrency,
}); });
setItems((current) => (append && current ? [...current, ...res.items] : res.items)); setItems((current) => (append && current ? [...current, ...res.items] : res.items));
setPage(res.page); setPage(res.page);
@@ -81,7 +82,7 @@ export function DrawResultsListScreen() {
setLoading(false); setLoading(false);
} }
} }
}, [businessDate, t]); }, [activeCurrency, businessDate, t]);
useEffect(() => { useEffect(() => {
queueMicrotask(() => { queueMicrotask(() => {

View File

@@ -60,6 +60,11 @@ export function TransferInScreen() {
<TransferInPage <TransferInPage
currency={currency} currency={currency}
lotteryMinor={Number(balance?.balance ?? 0)} lotteryMinor={Number(balance?.balance ?? 0)}
mainMinor={
balance?.main_balance === null || balance?.main_balance === undefined
? null
: Number(balance.main_balance)
}
onSuccess={onSuccess} onSuccess={onSuccess}
/> />
); );

View File

@@ -192,6 +192,11 @@ export function WalletScreen() {
idPrefix="wallet-" idPrefix="wallet-"
currency={currency} currency={currency}
lotteryMinor={Number(balance?.balance ?? 0)} lotteryMinor={Number(balance?.balance ?? 0)}
mainMinor={
balance?.main_balance === null || balance?.main_balance === undefined
? null
: Number(balance.main_balance)
}
onSuccess={refreshAll} onSuccess={refreshAll}
triggerVariant="hall" triggerVariant="hall"
triggerLabel={t("wallet.transferIn", { defaultValue: "Transfer In" })} triggerLabel={t("wallet.transferIn", { defaultValue: "Transfer In" })}

View File

@@ -28,6 +28,7 @@ type BaseProps = {
export function TransferInDialog({ export function TransferInDialog({
currency, currency,
lotteryMinor, lotteryMinor,
mainMinor = null,
onSuccess, onSuccess,
idPrefix = "", idPrefix = "",
triggerClassName, triggerClassName,
@@ -35,6 +36,7 @@ export function TransferInDialog({
triggerLabel, triggerLabel,
}: BaseProps & { }: BaseProps & {
lotteryMinor: number; lotteryMinor: number;
mainMinor?: number | null;
triggerClassName?: string; triggerClassName?: string;
triggerVariant?: "wallet" | "hall"; triggerVariant?: "wallet" | "hall";
triggerLabel?: string; triggerLabel?: string;
@@ -70,6 +72,7 @@ export function TransferInDialog({
variant="dialog" variant="dialog"
currency={currency} currency={currency}
lotteryMinor={lotteryMinor} lotteryMinor={lotteryMinor}
mainMinor={mainMinor}
idPrefix={idPrefix} idPrefix={idPrefix}
onCancel={() => setOpen(false)} onCancel={() => setOpen(false)}
onSuccess={async () => { onSuccess={async () => {

View File

@@ -135,12 +135,14 @@ function TransferDialogFooter({
export function TransferInPanel({ export function TransferInPanel({
currency, currency,
lotteryMinor, lotteryMinor,
mainMinor = null,
onSuccess, onSuccess,
idPrefix = "", idPrefix = "",
onCancel, onCancel,
variant = "dialog", variant = "dialog",
}: PanelBase & { }: PanelBase & {
lotteryMinor: number; lotteryMinor: number;
mainMinor?: number | null;
onCancel: () => void; onCancel: () => void;
variant?: PanelVariant; variant?: PanelVariant;
}) { }) {
@@ -226,7 +228,11 @@ export function TransferInPanel({
<TransferInfoBlock> <TransferInfoBlock>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{t("wallet.mainBalance")}{" "} {t("wallet.mainBalance")}{" "}
<span className="font-medium text-foreground">{t("wallet.mainPending")}</span> <span className="font-medium tabular-nums text-foreground">
{mainMinor != null
? formatMinorAsCurrency(mainMinor, currency)
: t("wallet.mainPending")}
</span>
</p> </p>
<p className="mt-2 text-muted-foreground"> <p className="mt-2 text-muted-foreground">
{t("wallet.lotteryBalance")}{" "} {t("wallet.lotteryBalance")}{" "}
@@ -413,8 +419,9 @@ export function TransferOutPanel({
export function TransferInPage({ export function TransferInPage({
currency, currency,
lotteryMinor, lotteryMinor,
mainMinor = null,
onSuccess, onSuccess,
}: PanelBase & { lotteryMinor: number }) { }: PanelBase & { lotteryMinor: number; mainMinor?: number | null }) {
const { t } = useTranslation("player"); const { t } = useTranslation("player");
return ( return (
@@ -433,6 +440,7 @@ export function TransferInPage({
variant="page" variant="page"
currency={currency} currency={currency}
lotteryMinor={lotteryMinor} lotteryMinor={lotteryMinor}
mainMinor={mainMinor}
idPrefix="page-" idPrefix="page-"
onSuccess={onSuccess} onSuccess={onSuccess}
onCancel={() => {}} onCancel={() => {}}

View File

@@ -4,3 +4,4 @@ export { useTokenRefresh } from "./use-token-refresh";
export { useWalletPolling, triggerWalletPollingAfterBet } from "./use-wallet-polling"; export { useWalletPolling, triggerWalletPollingAfterBet } from "./use-wallet-polling";
export { useWebSocketManager } from "./use-websocket-manager"; export { useWebSocketManager } from "./use-websocket-manager";
export { usePlayerBalanceWs } from "./use-player-balance-ws"; export { usePlayerBalanceWs } from "./use-player-balance-ws";
export { usePlayEffectiveWs } from "./use-play-effective-ws";

View File

@@ -0,0 +1,42 @@
"use client";
import { useEffect } from "react";
import {
dispatchPlayCatalogRefresh,
type PlayCatalogRefreshSource,
} from "@/lib/play-catalog-events";
import { getLotteryEcho } from "@/lib/lottery-echo";
/**
* 订阅大厅频道玩法目录相关 WS触发 `lottery-play-catalog-refresh`。
*/
export function usePlayEffectiveWs(): void {
useEffect(() => {
const echo = getLotteryEcho();
if (!echo) {
return;
}
const channel = echo.channel("lottery-hall");
const onRefresh = (source: PlayCatalogRefreshSource) => (): void => {
dispatchPlayCatalogRefresh(source);
};
channel.listen(".play.toggle", onRefresh("play_toggle"));
channel.listen(".odds.update", onRefresh("odds"));
channel.listen(".play.catalog_updated", (payload: { module?: string }) => {
const module = payload?.module;
const source: PlayCatalogRefreshSource =
module === "odds" ? "odds" : module === "risk_cap" ? "risk_cap" : "play_config";
dispatchPlayCatalogRefresh(source);
});
return () => {
channel.stopListening(".play.toggle");
channel.stopListening(".odds.update");
channel.stopListening(".play.catalog_updated");
};
}, []);
}

View File

@@ -136,8 +136,11 @@ export function useTokenRefresh(): {
const { data } = event; const { data } = event;
if (!data || typeof data !== "object") return; if (!data || typeof data !== "object") return;
// 处理主站发送的新 Token // 处理主站发送的新 Token(兼容 MAIN_REFRESH_TOKEN 与 LOTTERY_TOKEN_REFRESH_RESPONSE
if (data.type === "LOTTERY_TOKEN_REFRESH_RESPONSE" && data.token) { if (
(data.type === "LOTTERY_TOKEN_REFRESH_RESPONSE" || data.type === "MAIN_REFRESH_TOKEN") &&
data.token
) {
console.log("[TokenRefresh] Received new token from parent"); console.log("[TokenRefresh] Received new token from parent");
setBearerToken(data.token); setBearerToken(data.token);
retryCountRef.current = 0; // 重置重试计数 retryCountRef.current = 0; // 重置重试计数

View File

@@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef } from "react";
import { getWalletBalance } from "@/api/wallet"; import { getWalletBalance } from "@/api/wallet";
import { getActivePlayerCurrencyFromStore } from "@/lib/player-currency"; import { getActivePlayerCurrencyFromStore } from "@/lib/player-currency";
import { startWalletRefreshBurst } from "@/lib/wallet-refresh-burst";
import { useNetworkConnectionStore } from "@/stores/network-connection-store"; import { useNetworkConnectionStore } from "@/stores/network-connection-store";
const POLLING_INTERVAL_MS = 30_000; // 30秒轮询间隔 const POLLING_INTERVAL_MS = 30_000; // 30秒轮询间隔
@@ -142,41 +143,10 @@ export function useWalletPolling(): UseWalletPollingReturn {
export function triggerWalletPollingAfterBet(): void { export function triggerWalletPollingAfterBet(): void {
const store = useNetworkConnectionStore.getState(); const store = useNetworkConnectionStore.getState();
// 如果是降级模式,立即刷新并启动限时轮询
if (store.mode === "polling" || store.mode === "offline") { if (store.mode === "polling" || store.mode === "offline") {
// 立即刷新一次 startWalletRefreshBurst();
void getWalletBalance({ currency: getActivePlayerCurrencyFromStore() }).then(() => { return;
window.dispatchEvent(new Event("lottery-wallet-refresh"));
});
// 清除现有轮询
if (store.walletPollingIntervalId) {
window.clearInterval(store.walletPollingIntervalId);
}
// 启动限时轮询
const intervalId = window.setInterval(() => {
const live = useNetworkConnectionStore.getState();
if (live.walletPollingExpiryAt && Date.now() > live.walletPollingExpiryAt) {
window.clearInterval(intervalId);
live.setWalletPollingIntervalId(null);
return;
}
void getWalletBalance({ currency: getActivePlayerCurrencyFromStore() }).then(() => {
window.dispatchEvent(new Event("lottery-wallet-refresh"));
});
}, POLLING_INTERVAL_MS);
useNetworkConnectionStore.getState().setWalletPollingIntervalId(intervalId);
useNetworkConnectionStore.getState().setWalletPollingExpiryAt(Date.now() + LIMITED_POLLING_DURATION_MS);
// 2分钟后自动清理
window.setTimeout(() => {
window.clearInterval(intervalId);
store.setWalletPollingIntervalId(null);
}, LIMITED_POLLING_DURATION_MS + 1000);
} else {
// WebSocket 模式下,只触发一次刷新
window.dispatchEvent(new Event("lottery-wallet-refresh"));
} }
window.dispatchEvent(new Event("lottery-wallet-refresh"));
} }

View File

@@ -38,8 +38,8 @@ export type UseWebSocketManagerReturn = {
* 1. 监控 WebSocket 连接状态 * 1. 监控 WebSocket 连接状态
* 2. WebSocket 断开时自动切换到轮询模式 * 2. WebSocket 断开时自动切换到轮询模式
* 3. 定期尝试重连 WebSocket * 3. 定期尝试重连 WebSocket
* 4. 管理画作数据轮询WebSocket断开时 * 4. 期号 HTTP 轮询由 {@link useHallDrawLive} 统一管理
* 5. 管理钱包余额轮询(下注后/开奖后/降级模式 * 5. 可选钱包余额轮询(`startWalletPolling`,下注等场景
*/ */
export function useWebSocketManager(): UseWebSocketManagerReturn { export function useWebSocketManager(): UseWebSocketManagerReturn {
const store = useNetworkConnectionStore(); const store = useNetworkConnectionStore();
@@ -48,7 +48,6 @@ export function useWebSocketManager(): UseWebSocketManagerReturn {
const { const {
mode, mode,
isWebSocketConnected,
reconnectAttempts, reconnectAttempts,
setWebSocketConnected, setWebSocketConnected,
setReconnecting, setReconnecting,
@@ -77,23 +76,6 @@ export function useWebSocketManager(): UseWebSocketManagerReturn {
} }
}, []); }, []);
// 画作轮询:用 getState() 读/写 timer id避免 callback 依赖 id → effect含卸载清理连环重跑导致「Maximum update depth」
const startDrawPolling = useCallback(() => {
const s = useNetworkConnectionStore.getState();
const prevId = s.drawPollingIntervalId;
if (prevId !== null) {
window.clearInterval(prevId);
}
void refreshDraw();
const intervalId = window.setInterval(() => {
void refreshDraw();
}, POLLING_INTERVAL_MS);
s.setDrawPollingIntervalId(intervalId);
}, [refreshDraw]);
// 钱包轮询 // 钱包轮询
const startWalletPolling = useCallback( const startWalletPolling = useCallback(
(options?: { limitedDuration?: boolean }) => { (options?: { limitedDuration?: boolean }) => {
@@ -147,9 +129,6 @@ export function useWebSocketManager(): UseWebSocketManagerReturn {
setLastDisconnectedAt(Date.now()); setLastDisconnectedAt(Date.now());
switchToPollingMode(); switchToPollingMode();
// 启动画作数据轮询(降级模式)
startDrawPolling();
// 启动重连计时器 // 启动重连计时器
if (reconnectTimerRef.current) { if (reconnectTimerRef.current) {
window.clearTimeout(reconnectTimerRef.current); window.clearTimeout(reconnectTimerRef.current);
@@ -160,7 +139,6 @@ export function useWebSocketManager(): UseWebSocketManagerReturn {
setWebSocketConnected, setWebSocketConnected,
setLastDisconnectedAt, setLastDisconnectedAt,
switchToPollingMode, switchToPollingMode,
startDrawPolling,
setReconnecting, setReconnecting,
]); ]);
@@ -249,7 +227,6 @@ export function useWebSocketManager(): UseWebSocketManagerReturn {
const echo = getLotteryEcho(); const echo = getLotteryEcho();
if (!echo) { if (!echo) {
switchToPollingMode(); switchToPollingMode();
startDrawPolling();
return; return;
} }
@@ -298,7 +275,6 @@ export function useWebSocketManager(): UseWebSocketManagerReturn {
isPusherConnected, isPusherConnected,
handleConnect, handleConnect,
handleDisconnect, handleDisconnect,
startDrawPolling,
switchToPollingMode, switchToPollingMode,
]); ]);

View File

@@ -52,6 +52,8 @@
"errors": { "errors": {
"noToken": "No authorization token found", "noToken": "No authorization token found",
"noTokenDetail": "Please return to the main site and try again.", "noTokenDetail": "Please return to the main site and try again.",
"sessionExpired": "Session expired",
"sessionExpiredDetail": "Please return to the main site and open the lottery hall again.",
"authFailed": "Authorization failed", "authFailed": "Authorization failed",
"unknown": "Unknown error", "unknown": "Unknown error",
"network": "Network error occurred", "network": "Network error occurred",

View File

@@ -52,6 +52,8 @@
"errors": { "errors": {
"noToken": "कुनै प्राधिकरण टोकन फेला परेन", "noToken": "कुनै प्राधिकरण टोकन फेला परेन",
"noTokenDetail": "कृपया मुख्य साइटमा फर्कनुहोस् र फेरि प्रयास गर्नुहोस्।", "noTokenDetail": "कृपया मुख्य साइटमा फर्कनुहोस् र फेरि प्रयास गर्नुहोस्।",
"sessionExpired": "लगइन म्याद सकियो",
"sessionExpiredDetail": "कृपया मुख्य साइटमा फर्केर लटरी हल फेरि खोल्नुहोस्।",
"authFailed": "प्राधिकरण असफल", "authFailed": "प्राधिकरण असफल",
"unknown": "अज्ञात त्रुटि", "unknown": "अज्ञात त्रुटि",
"network": "नेटवर्क त्रुटि भयो", "network": "नेटवर्क त्रुटि भयो",

View File

@@ -52,6 +52,8 @@
"errors": { "errors": {
"noToken": "未发现授权令牌", "noToken": "未发现授权令牌",
"noTokenDetail": "请返回主站后重试。", "noTokenDetail": "请返回主站后重试。",
"sessionExpired": "登录已过期",
"sessionExpiredDetail": "请返回主站重新进入彩票大厅。",
"authFailed": "授权失败", "authFailed": "授权失败",
"unknown": "未知错误", "unknown": "未知错误",
"network": "网络异常", "network": "网络异常",

View File

@@ -42,6 +42,7 @@ export function generateCSP(): string {
"connect-src": [ "connect-src": [
"'self'", "'self'",
process.env.NEXT_PUBLIC_API_URL || "", process.env.NEXT_PUBLIC_API_URL || "",
process.env.NEXT_PUBLIC_LOTTERY_API_BASE_URL || "",
// WebSocket 连接 // WebSocket 连接
"ws:", "ws:",
"wss:", "wss:",

View File

@@ -4,8 +4,8 @@ import axios, {
type AxiosResponse, type AxiosResponse,
} from "axios"; } from "axios";
import { setPlayerBearerToken, withPlayerAuthHeader } from "@/lib/lottery-auth"; import { withPlayerAuthHeader } from "@/lib/lottery-auth";
import { clearPersistedPlayerBearerToken } from "@/lib/player-session"; import { usePlayerSessionStore } from "@/stores/player-session-store";
import { withLotteryLocaleHeaders } from "@/lib/lottery-locale"; import { withLotteryLocaleHeaders } from "@/lib/lottery-locale";
import { import {
LotteryApiBizError, LotteryApiBizError,
@@ -35,9 +35,10 @@ lotteryHttp.interceptors.response.use(
// 401: 会话过期,清除令牌并重定向 // 401: 会话过期,清除令牌并重定向
if (status === 401) { if (status === 401) {
clearPersistedPlayerBearerToken(); usePlayerSessionStore.getState().clearBearerToken();
setPlayerBearerToken(null); const onEntry = window.location.pathname === "/";
if (window.location.pathname !== "/") { const alreadyExpired = window.location.search.includes("session=expired");
if (!onEntry || !alreadyExpired) {
window.location.replace("/?session=expired"); window.location.replace("/?session=expired");
} }
} }

View File

@@ -0,0 +1,12 @@
/** 玩法目录(`play/effective`)需要全量刷新时派发。 */
export const PLAY_CATALOG_REFRESH_EVENT = "lottery-play-catalog-refresh";
export type PlayCatalogRefreshSource = "play_config" | "odds" | "risk_cap" | "play_toggle";
export function dispatchPlayCatalogRefresh(source?: PlayCatalogRefreshSource): void {
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent(PLAY_CATALOG_REFRESH_EVENT, { detail: { source } }),
);
}
}

View File

@@ -0,0 +1,27 @@
import { useNetworkConnectionStore } from "@/stores/network-connection-store";
const WALLET_REFRESH_BURST_MS = 30_000;
const WALLET_REFRESH_BURST_DURATION_MS = 2 * 60 * 1000;
/**
* 开奖等场景:立即刷新余额,并在限时内每 30s 派发 `lottery-wallet-refresh`(先清旧定时器,避免泄漏)。
*/
export function startWalletRefreshBurst(): void {
const store = useNetworkConnectionStore.getState();
store.clearWalletPolling();
window.dispatchEvent(new Event("lottery-wallet-refresh"));
const expiryAt = Date.now() + WALLET_REFRESH_BURST_DURATION_MS;
store.setWalletPollingExpiryAt(expiryAt);
const intervalId = window.setInterval(() => {
if (Date.now() > expiryAt) {
useNetworkConnectionStore.getState().clearWalletPolling();
return;
}
window.dispatchEvent(new Event("lottery-wallet-refresh"));
}, WALLET_REFRESH_BURST_MS);
store.setWalletPollingIntervalId(intervalId);
}

View File

@@ -11,12 +11,7 @@ export type DrawCurrentResultItem = {
export type DrawCurrentRiskPoolAlert = { export type DrawCurrentRiskPoolAlert = {
normalized_number: string; normalized_number: string;
total_cap_amount: number; status: "warning" | "sold_out";
locked_amount: number;
remaining_amount: number;
sold_out_status: number;
is_sold_out: boolean;
usage_ratio: number | null;
}; };
export type DrawCurrentPayload = { export type DrawCurrentPayload = {
@@ -37,6 +32,8 @@ export type DrawCurrentPayload = {
seconds_to_draw: number; seconds_to_draw: number;
cooling_end_time: string | null; cooling_end_time: string | null;
seconds_remaining_in_cooldown: number | null; seconds_remaining_in_cooldown: number | null;
/** 与 `jackpot.currency_code` 一致,便于 WS 快照与玩家币种对齐 */
jackpot_currency_code?: string;
jackpot?: { jackpot?: {
currency_code: string; currency_code: string;
enabled: boolean; enabled: boolean;

View File

@@ -4,7 +4,7 @@ export type WalletBalanceData = {
balance: string | number; balance: string | number;
/** 可用余额 = balance - frozen_balance服务端保证 ≥0 */ /** 可用余额 = balance - frozen_balance服务端保证 ≥0 */
available_balance: string | number; available_balance: string | number;
main_balance: null; main_balance: string | number | null;
currency_code: string; currency_code: string;
wallet_type: string; wallet_type: string;
frozen_balance: string | number; frozen_balance: string | number;