feat: 增强开奖 API 的币种支持并优化钱包处理逻辑
更新 getDrawCurrent、getDrawResults 与 getDrawResultByNo 方法,新增币种参数支持,以适配玩家币种偏好。 优化 HallBettingGrid 及相关组件:支持币种切换时自动刷新钱包数据。 重构钱包处理逻辑,简化余额更新流程并提升用户体验。 新增会话过期相关多语言提示文案,并优化现有翻译内容,提升多语言环境下的提示清晰度。
This commit is contained in:
@@ -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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) */
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
12
src/components/play-effective-ws-listener.tsx
Normal file
12
src/components/play-effective-ws-listener.tsx
Normal 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;
|
||||||
|
}
|
||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
11
src/components/token-silent-refresh.tsx
Normal file
11
src/components/token-silent-refresh.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTokenRefresh } from "@/hooks/use-token-refresh";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 挂载 Token 自动续签逻辑,不展示任何 UI(产品要求静默续签)。
|
||||||
|
*/
|
||||||
|
export function TokenSilentRefresh(): null {
|
||||||
|
useTokenRefresh();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -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") ?? [];
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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" })}
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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={() => {}}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
42
src/hooks/use-play-effective-ws.ts
Normal file
42
src/hooks/use-play-effective-ws.ts
Normal 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");
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
@@ -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; // 重置重试计数
|
||||||
|
|||||||
@@ -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"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -52,6 +52,8 @@
|
|||||||
"errors": {
|
"errors": {
|
||||||
"noToken": "कुनै प्राधिकरण टोकन फेला परेन",
|
"noToken": "कुनै प्राधिकरण टोकन फेला परेन",
|
||||||
"noTokenDetail": "कृपया मुख्य साइटमा फर्कनुहोस् र फेरि प्रयास गर्नुहोस्।",
|
"noTokenDetail": "कृपया मुख्य साइटमा फर्कनुहोस् र फेरि प्रयास गर्नुहोस्।",
|
||||||
|
"sessionExpired": "लगइन म्याद सकियो",
|
||||||
|
"sessionExpiredDetail": "कृपया मुख्य साइटमा फर्केर लटरी हल फेरि खोल्नुहोस्।",
|
||||||
"authFailed": "प्राधिकरण असफल",
|
"authFailed": "प्राधिकरण असफल",
|
||||||
"unknown": "अज्ञात त्रुटि",
|
"unknown": "अज्ञात त्रुटि",
|
||||||
"network": "नेटवर्क त्रुटि भयो",
|
"network": "नेटवर्क त्रुटि भयो",
|
||||||
|
|||||||
@@ -52,6 +52,8 @@
|
|||||||
"errors": {
|
"errors": {
|
||||||
"noToken": "未发现授权令牌",
|
"noToken": "未发现授权令牌",
|
||||||
"noTokenDetail": "请返回主站后重试。",
|
"noTokenDetail": "请返回主站后重试。",
|
||||||
|
"sessionExpired": "登录已过期",
|
||||||
|
"sessionExpiredDetail": "请返回主站重新进入彩票大厅。",
|
||||||
"authFailed": "授权失败",
|
"authFailed": "授权失败",
|
||||||
"unknown": "未知错误",
|
"unknown": "未知错误",
|
||||||
"network": "网络异常",
|
"network": "网络异常",
|
||||||
|
|||||||
@@ -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:",
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
12
src/lib/play-catalog-events.ts
Normal file
12
src/lib/play-catalog-events.ts
Normal 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 } }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/lib/wallet-refresh-burst.ts
Normal file
27
src/lib/wallet-refresh-burst.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user