This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { NextResponse, type NextRequest } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
import { lotteryApiOrigin } from "./src/lib/lottery-api-base";
|
import { lotteryApiOrigin } from "./src/lib/lottery-api-base";
|
||||||
import { generateCSP, nonCspSecurityHeaders } from "./src/lib/csp-config";
|
import { generateCSP, nonCspSecurityHeaders } from "./src/lib/csp-config";
|
||||||
@@ -42,7 +42,7 @@ async function loadRuntimeOrigins(): Promise<string[]> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function middleware(_request: NextRequest): Promise<NextResponse> {
|
export async function proxy(): Promise<NextResponse> {
|
||||||
const response = NextResponse.next();
|
const response = NextResponse.next();
|
||||||
const runtimeOrigins = await loadRuntimeOrigins();
|
const runtimeOrigins = await loadRuntimeOrigins();
|
||||||
|
|
||||||
@@ -28,8 +28,6 @@ export const metadata: Metadata = {
|
|||||||
export const viewport = {
|
export const viewport = {
|
||||||
width: "device-width",
|
width: "device-width",
|
||||||
initialScale: 1,
|
initialScale: 1,
|
||||||
maximumScale: 1,
|
|
||||||
userScalable: false,
|
|
||||||
viewportFit: "cover",
|
viewportFit: "cover",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,39 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
|
import {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useSyncExternalStore,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
import { syncPreferredLanguage } from "@/i18n";
|
import { syncPreferredLanguage } from "@/i18n";
|
||||||
|
|
||||||
const I18nHydrationContext = createContext(false);
|
const I18nHydrationContext = createContext(false);
|
||||||
|
|
||||||
|
function subscribeI18nHydration(onStoreChange: () => void): () => void {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
syncPreferredLanguage();
|
||||||
|
onStoreChange();
|
||||||
|
});
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getI18nHydratedSnapshot(): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getI18nHydratedServerSnapshot(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/** hydration 完成前保持 DEFAULT_LANGUAGE,与 SSR 一致;完成后再同步用户偏好语言。 */
|
/** hydration 完成前保持 DEFAULT_LANGUAGE,与 SSR 一致;完成后再同步用户偏好语言。 */
|
||||||
export function I18nHydrationProvider({ children }: { children: ReactNode }) {
|
export function I18nHydrationProvider({ children }: { children: ReactNode }) {
|
||||||
const [hydrated, setHydrated] = useState(false);
|
const hydrated = useSyncExternalStore(
|
||||||
|
subscribeI18nHydration,
|
||||||
useEffect(() => {
|
getI18nHydratedSnapshot,
|
||||||
syncPreferredLanguage();
|
getI18nHydratedServerSnapshot,
|
||||||
setHydrated(true);
|
);
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<I18nHydrationContext.Provider value={hydrated}>{children}</I18nHydrationContext.Provider>
|
<I18nHydrationContext.Provider value={hydrated}>{children}</I18nHydrationContext.Provider>
|
||||||
|
|||||||
@@ -10,6 +10,33 @@ import {
|
|||||||
resolvePostMessageTargetOrigin,
|
resolvePostMessageTargetOrigin,
|
||||||
} from "@/lib/iframe-origins";
|
} from "@/lib/iframe-origins";
|
||||||
|
|
||||||
|
function sanitizeUrlForParent(href: string): string {
|
||||||
|
try {
|
||||||
|
const url = new URL(href);
|
||||||
|
url.searchParams.delete("token");
|
||||||
|
return `${url.pathname}${url.search}${url.hash}`;
|
||||||
|
} catch {
|
||||||
|
return href;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSafeInAppPath(path: string): string | null {
|
||||||
|
if (!path.startsWith("/") || path.startsWith("//")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(path, window.location.origin);
|
||||||
|
if (url.origin !== window.location.origin) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${url.pathname}${url.search}${url.hash}`;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* iframe 通信桥接组件
|
* iframe 通信桥接组件
|
||||||
*
|
*
|
||||||
@@ -49,7 +76,7 @@ export function IframeBridge({ children }: { children: ReactNode }): ReactNode {
|
|||||||
*/
|
*/
|
||||||
const notifyReady = useCallback((): void => {
|
const notifyReady = useCallback((): void => {
|
||||||
sendToParent("READY", {
|
sendToParent("READY", {
|
||||||
url: window.location.href,
|
url: sanitizeUrlForParent(window.location.href),
|
||||||
userAgent: navigator.userAgent,
|
userAgent: navigator.userAgent,
|
||||||
});
|
});
|
||||||
}, [sendToParent]);
|
}, [sendToParent]);
|
||||||
@@ -147,7 +174,10 @@ export function IframeBridge({ children }: { children: ReactNode }): ReactNode {
|
|||||||
// 主站导航请求
|
// 主站导航请求
|
||||||
case "MAIN_NAVIGATE":
|
case "MAIN_NAVIGATE":
|
||||||
if (data.path && typeof data.path === "string") {
|
if (data.path && typeof data.path === "string") {
|
||||||
window.history.pushState({}, "", data.path);
|
const nextPath = resolveSafeInAppPath(data.path);
|
||||||
|
if (nextPath) {
|
||||||
|
window.history.pushState({}, "", nextPath);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ import {
|
|||||||
PLAY_CATALOG_REFRESH_EVENT,
|
PLAY_CATALOG_REFRESH_EVENT,
|
||||||
type PlayCatalogRefreshSource,
|
type PlayCatalogRefreshSource,
|
||||||
} from "@/lib/play-catalog-events";
|
} from "@/lib/play-catalog-events";
|
||||||
import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference";
|
|
||||||
import { isCreditFundingPlayer } from "@/lib/player-funding-mode";
|
import { isCreditFundingPlayer } from "@/lib/player-funding-mode";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { LotteryApiBizError } from "@/types/api/errors";
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
@@ -499,20 +499,28 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
|||||||
const clearPlaceTraceId = useCallback(() => {
|
const clearPlaceTraceId = useCallback(() => {
|
||||||
placeTraceIdRef.current = null;
|
placeTraceIdRef.current = null;
|
||||||
}, []);
|
}, []);
|
||||||
|
const catalogSeqRef = useRef(0);
|
||||||
|
const walletSeqRef = useRef(0);
|
||||||
|
|
||||||
const loadCatalog = useCallback(async () => {
|
const loadCatalog = useCallback(async () => {
|
||||||
setCatalogState((s) => (s.kind === "ok" ? s : { kind: "loading" }));
|
const seq = ++catalogSeqRef.current;
|
||||||
|
setCatalogState({ kind: "loading" });
|
||||||
try {
|
try {
|
||||||
const data = await getPlayEffective({ currency: currencyParam });
|
const data = await getPlayEffective({ currency: currencyParam });
|
||||||
|
if (seq !== catalogSeqRef.current) return;
|
||||||
setCatalogState({ kind: "ok", data });
|
setCatalogState({ kind: "ok", data });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (seq !== catalogSeqRef.current) return;
|
||||||
const msg = e instanceof LotteryApiBizError ? e.message : t("hall.loadingError");
|
const msg = e instanceof LotteryApiBizError ? e.message : t("hall.loadingError");
|
||||||
setCatalogState({ kind: "error", message: msg });
|
setCatalogState({ kind: "error", message: msg });
|
||||||
}
|
}
|
||||||
}, [currencyParam, t]);
|
}, [currencyParam, t]);
|
||||||
|
|
||||||
const refreshWallet = useCallback(async () => {
|
const refreshWallet = useCallback(async () => {
|
||||||
|
const seq = ++walletSeqRef.current;
|
||||||
try {
|
try {
|
||||||
const wallet = await getWalletBalance({ currency: currencyParam });
|
const wallet = await getWalletBalance({ currency: currencyParam });
|
||||||
|
if (seq !== walletSeqRef.current) return;
|
||||||
setAvailableMinor(Number(wallet.available_balance ?? 0));
|
setAvailableMinor(Number(wallet.available_balance ?? 0));
|
||||||
} catch {
|
} catch {
|
||||||
// 保留上次可用余额,避免短暂失败导致误报余额不足
|
// 保留上次可用余额,避免短暂失败导致误报余额不足
|
||||||
@@ -526,15 +534,6 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
|||||||
});
|
});
|
||||||
}, [loadCatalog, refreshWallet]);
|
}, [loadCatalog, refreshWallet]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const onCurrencyChange = () => {
|
|
||||||
void loadCatalog();
|
|
||||||
void refreshWallet();
|
|
||||||
};
|
|
||||||
window.addEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onCurrencyChange);
|
|
||||||
return () => window.removeEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onCurrencyChange);
|
|
||||||
}, [loadCatalog, refreshWallet]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onCatalogRefresh = (ev: Event) => {
|
const onCatalogRefresh = (ev: Event) => {
|
||||||
void loadCatalog();
|
void loadCatalog();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { ChevronRight } from "lucide-react";
|
import { ChevronRight } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@@ -86,23 +86,28 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
|
|||||||
const [siblingItems, setSiblingItems] = useState<TicketItemListRow[]>([]);
|
const [siblingItems, setSiblingItems] = useState<TicketItemListRow[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const requestSeqRef = useRef(0);
|
||||||
const backHref = "/orders";
|
const backHref = "/orders";
|
||||||
const backLabel = t("orders.title");
|
const backLabel = t("orders.title");
|
||||||
|
|
||||||
const load = useCallback(async (options?: { silent?: boolean }) => {
|
const load = useCallback(async (options?: { silent?: boolean }) => {
|
||||||
|
const seq = ++requestSeqRef.current;
|
||||||
if (!options?.silent) {
|
if (!options?.silent) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
}
|
}
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const row = await getTicketItemDetail(ticketNo);
|
const row = await getTicketItemDetail(ticketNo);
|
||||||
|
if (seq !== requestSeqRef.current) return;
|
||||||
setData(row);
|
setData(row);
|
||||||
} catch {
|
} catch {
|
||||||
|
if (seq !== requestSeqRef.current) return;
|
||||||
setData(null);
|
setData(null);
|
||||||
if (!options?.silent) {
|
if (!options?.silent) {
|
||||||
setError(t("orders.notFound"));
|
setError(t("orders.notFound"));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
if (seq !== requestSeqRef.current) return;
|
||||||
if (!options?.silent) {
|
if (!options?.silent) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -117,8 +122,14 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const orderNo = (data?.order_no ?? "").trim();
|
const orderNo = (data?.order_no ?? "").trim();
|
||||||
|
const siblingSeq = requestSeqRef.current;
|
||||||
|
|
||||||
if (!orderNo) {
|
if (!orderNo) {
|
||||||
setSiblingItems([]);
|
queueMicrotask(() => {
|
||||||
|
if (siblingSeq === requestSeqRef.current) {
|
||||||
|
setSiblingItems([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,12 +137,12 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
|
|||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const res = await getTicketItems({ order_no: orderNo, per_page: 50 });
|
const res = await getTicketItems({ order_no: orderNo, per_page: 50 });
|
||||||
if (cancelled) return;
|
if (cancelled || siblingSeq !== requestSeqRef.current) return;
|
||||||
setSiblingItems(
|
setSiblingItems(
|
||||||
res.items.filter((row) => row.ticket_no !== ticketNo),
|
res.items.filter((row) => row.ticket_no !== ticketNo),
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
if (!cancelled) {
|
if (!cancelled && siblingSeq === requestSeqRef.current) {
|
||||||
setSiblingItems([]);
|
setSiblingItems([]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
|
useSyncExternalStore,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@@ -79,6 +80,29 @@ function initialSteps(): EntryStep[] {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let pendingUrlToken = "";
|
||||||
|
|
||||||
|
function subscribeUrlTokenCapture(onStoreChange: () => void): () => void {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
const token = url.searchParams.get("token");
|
||||||
|
if (token) {
|
||||||
|
pendingUrlToken = token;
|
||||||
|
stripSearchParamFromBrowserUrl("token");
|
||||||
|
queueMicrotask(onStoreChange);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUrlTokenSnapshot(): string {
|
||||||
|
return pendingUrlToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getServerUrlTokenSnapshot(): string {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
function stripSearchParamFromBrowserUrl(name: string): void {
|
function stripSearchParamFromBrowserUrl(name: string): void {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
@@ -113,6 +137,11 @@ export function EntryGate() {
|
|||||||
const { t: tc } = useTranslation("common");
|
const { t: tc } = useTranslation("common");
|
||||||
|
|
||||||
const tokenFromUrl = searchParams.get("token") ?? "";
|
const tokenFromUrl = searchParams.get("token") ?? "";
|
||||||
|
const capturedUrlToken = useSyncExternalStore(
|
||||||
|
subscribeUrlTokenCapture,
|
||||||
|
getUrlTokenSnapshot,
|
||||||
|
getServerUrlTokenSnapshot,
|
||||||
|
);
|
||||||
const sessionExpired = searchParams.get("session") === "expired";
|
const sessionExpired = searchParams.get("session") === "expired";
|
||||||
|
|
||||||
const { bearerToken, setBearerToken, setProfile, setCurrencies, clearBearerToken } =
|
const { bearerToken, setBearerToken, setProfile, setCurrencies, clearBearerToken } =
|
||||||
@@ -137,11 +166,11 @@ export function EntryGate() {
|
|||||||
|
|
||||||
if (!isInIframe()) {
|
if (!isInIframe()) {
|
||||||
if (sessionExpired) return false;
|
if (sessionExpired) return false;
|
||||||
return tokenFromUrl !== "";
|
return capturedUrlToken !== "" || tokenFromUrl !== "";
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}, [sessionExpired, tokenFromUrl]);
|
}, [capturedUrlToken, sessionExpired, tokenFromUrl]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (gateReady) return;
|
if (gateReady) return;
|
||||||
@@ -169,14 +198,15 @@ export function EntryGate() {
|
|||||||
const [steps, setSteps] = useState<EntryStep[]>(initialSteps());
|
const [steps, setSteps] = useState<EntryStep[]>(initialSteps());
|
||||||
|
|
||||||
const entryToken = useMemo(() => {
|
const entryToken = useMemo(() => {
|
||||||
|
const urlToken = capturedUrlToken || tokenFromUrl;
|
||||||
if (typeof window === "undefined") {
|
if (typeof window === "undefined") {
|
||||||
return tokenFromUrl || bearerToken;
|
return urlToken || bearerToken;
|
||||||
}
|
}
|
||||||
if (!isInIframe()) {
|
if (!isInIframe()) {
|
||||||
return tokenFromUrl;
|
return urlToken;
|
||||||
}
|
}
|
||||||
return tokenFromUrl || bearerToken;
|
return urlToken || bearerToken;
|
||||||
}, [bearerToken, tokenFromUrl]);
|
}, [bearerToken, capturedUrlToken, tokenFromUrl]);
|
||||||
/** 防止 token 写入 store / URL 剥离后重复触发进场,避免成功/失败页闪一下 */
|
/** 防止 token 写入 store / URL 剥离后重复触发进场,避免成功/失败页闪一下 */
|
||||||
const entryLifecycleRef = useRef<"idle" | "running" | "done">("idle");
|
const entryLifecycleRef = useRef<"idle" | "running" | "done">("idle");
|
||||||
|
|
||||||
@@ -215,8 +245,10 @@ export function EntryGate() {
|
|||||||
setPhase("loading");
|
setPhase("loading");
|
||||||
setFailureDetails([]);
|
setFailureDetails([]);
|
||||||
|
|
||||||
if (tokenFromUrl) {
|
const urlToken = capturedUrlToken || tokenFromUrl;
|
||||||
setBearerToken(tokenFromUrl);
|
if (urlToken) {
|
||||||
|
setBearerToken(urlToken);
|
||||||
|
pendingUrlToken = "";
|
||||||
stripSearchParamFromBrowserUrl("token");
|
stripSearchParamFromBrowserUrl("token");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,6 +343,7 @@ export function EntryGate() {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
}, [
|
}, [
|
||||||
|
capturedUrlToken,
|
||||||
entryToken,
|
entryToken,
|
||||||
tokenFromUrl,
|
tokenFromUrl,
|
||||||
setBearerToken,
|
setBearerToken,
|
||||||
|
|||||||
@@ -63,11 +63,13 @@ export function WalletScreen() {
|
|||||||
|
|
||||||
actionDeepLinkHandledRef.current = true;
|
actionDeepLinkHandledRef.current = true;
|
||||||
if (!isCreditPlayer) {
|
if (!isCreditPlayer) {
|
||||||
if (action === "transfer-in") {
|
queueMicrotask(() => {
|
||||||
setTransferInOpen(true);
|
if (action === "transfer-in") {
|
||||||
} else {
|
setTransferInOpen(true);
|
||||||
setTransferOutOpen(true);
|
} else {
|
||||||
}
|
setTransferOutOpen(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
router.replace("/wallet", { scroll: false });
|
router.replace("/wallet", { scroll: false });
|
||||||
}, [isCreditPlayer, loading, router, searchParams]);
|
}, [isCreditPlayer, loading, router, searchParams]);
|
||||||
@@ -78,7 +80,7 @@ export function WalletScreen() {
|
|||||||
if (section !== "logs" && section !== "pending") return;
|
if (section !== "logs" && section !== "pending") return;
|
||||||
|
|
||||||
sectionDeepLinkHandledRef.current = true;
|
sectionDeepLinkHandledRef.current = true;
|
||||||
const targetId = "wallet-logs";
|
const targetId = section === "pending" ? "wallet-pending" : "wallet-logs";
|
||||||
const timer = window.setTimeout(() => {
|
const timer = window.setTimeout(() => {
|
||||||
scrollToWalletSection(targetId);
|
scrollToWalletSection(targetId);
|
||||||
router.replace("/wallet", { scroll: false });
|
router.replace("/wallet", { scroll: false });
|
||||||
@@ -332,6 +334,42 @@ export function WalletScreen() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{!isCreditPlayer && (logs?.pending_reconcile?.length ?? 0) > 0 ? (
|
||||||
|
<section
|
||||||
|
id="wallet-pending"
|
||||||
|
className="scroll-mt-3 rounded-2xl border border-amber-200 bg-amber-50 px-3 py-3 text-sm text-amber-900"
|
||||||
|
>
|
||||||
|
<h2 className="text-sm font-black text-amber-800">
|
||||||
|
{t("wallet.pendingSectionTitle", { defaultValue: "待对账划转" })}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-xs text-amber-700">
|
||||||
|
{t("wallet.pendingSectionHint", {
|
||||||
|
defaultValue: "以下划转仍在与主站对账,请稍后刷新查看结果。",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<ul className="mt-3 space-y-2">
|
||||||
|
{logs?.pending_reconcile.map((item) => (
|
||||||
|
<li
|
||||||
|
key={item.transfer_no}
|
||||||
|
className="rounded-xl border border-amber-200 bg-white px-3 py-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="font-semibold">
|
||||||
|
{item.direction === "in"
|
||||||
|
? t("wallet.flow.transfer_in", { defaultValue: "转入" })
|
||||||
|
: t("wallet.flow.transfer_out", { defaultValue: "转出" })}
|
||||||
|
</span>
|
||||||
|
<span className="font-black tabular-nums text-amber-800">
|
||||||
|
{formatMinorAsCurrency(item.amount, item.currency_code || currency)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 font-mono text-[11px] text-amber-600">{item.transfer_no}</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div id="wallet-logs" className="scroll-mt-3">
|
<div id="wallet-logs" className="scroll-mt-3">
|
||||||
<WalletLogsBlock
|
<WalletLogsBlock
|
||||||
creditMode={isCreditPlayer}
|
creditMode={isCreditPlayer}
|
||||||
|
|||||||
@@ -1,32 +1,46 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { getPublicCurrencies } from "@/api/currency";
|
import { getPublicCurrencies } from "@/api/currency";
|
||||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||||
|
|
||||||
|
const MAX_CURRENCY_LOAD_ATTEMPTS = 3;
|
||||||
|
const RETRY_DELAY_MS = 2_000;
|
||||||
|
|
||||||
let inflightCurrencyLoad: Promise<void> | null = null;
|
let inflightCurrencyLoad: Promise<void> | null = null;
|
||||||
|
|
||||||
export function useCurrencyCatalog() {
|
export function useCurrencyCatalog() {
|
||||||
const currencies = usePlayerSessionStore((state) => state.currencies);
|
const currencies = usePlayerSessionStore((state) => state.currencies);
|
||||||
const setCurrencies = usePlayerSessionStore((state) => state.setCurrencies);
|
const setCurrencies = usePlayerSessionStore((state) => state.setCurrencies);
|
||||||
|
const [loadGeneration, setLoadGeneration] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currencies.length > 0 || inflightCurrencyLoad !== null) {
|
if (currencies.length > 0 || loadGeneration >= MAX_CURRENCY_LOAD_ATTEMPTS) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inflightCurrencyLoad !== null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
inflightCurrencyLoad = getPublicCurrencies()
|
inflightCurrencyLoad = getPublicCurrencies()
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
setCurrencies(data.items);
|
if (data.items.length > 0) {
|
||||||
|
setCurrencies(data.items);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error("empty_currency_catalog");
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// 币种元数据失败时退回默认 2 位小数,不打断主流程。
|
window.setTimeout(() => {
|
||||||
|
setLoadGeneration((current) => current + 1);
|
||||||
|
}, RETRY_DELAY_MS);
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
inflightCurrencyLoad = null;
|
inflightCurrencyLoad = null;
|
||||||
});
|
});
|
||||||
}, [currencies.length, setCurrencies]);
|
}, [currencies.length, setCurrencies, loadGeneration]);
|
||||||
|
|
||||||
return currencies;
|
return currencies;
|
||||||
}
|
}
|
||||||
@@ -30,14 +30,20 @@ export function usePullToRefresh({
|
|||||||
const pullingRef = useRef(false);
|
const pullingRef = useRef(false);
|
||||||
const pullDistanceRef = useRef(0);
|
const pullDistanceRef = useRef(0);
|
||||||
const onRefreshRef = useRef(onRefresh);
|
const onRefreshRef = useRef(onRefresh);
|
||||||
|
const enabledRef = useRef(enabled);
|
||||||
|
|
||||||
onRefreshRef.current = onRefresh;
|
useEffect(() => {
|
||||||
|
onRefreshRef.current = onRefresh;
|
||||||
|
}, [onRefresh]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
enabledRef.current = enabled;
|
||||||
|
}, [enabled]);
|
||||||
|
|
||||||
const handleTouchStart = useCallback(
|
const handleTouchStart = useCallback(
|
||||||
(e: TouchEvent) => {
|
(e: TouchEvent) => {
|
||||||
if (!enabled || isRefreshing) return;
|
if (!enabledRef.current || isRefreshing) return;
|
||||||
|
|
||||||
// Check if the scroll container is scrolled down
|
|
||||||
const scrollContainer = document.getElementById("player-scroll-container");
|
const scrollContainer = document.getElementById("player-scroll-container");
|
||||||
const currentScrollY = scrollContainer ? scrollContainer.scrollTop : window.scrollY;
|
const currentScrollY = scrollContainer ? scrollContainer.scrollTop : window.scrollY;
|
||||||
|
|
||||||
@@ -45,7 +51,7 @@ export function usePullToRefresh({
|
|||||||
startYRef.current = e.touches[0].clientY;
|
startYRef.current = e.touches[0].clientY;
|
||||||
pullingRef.current = true;
|
pullingRef.current = true;
|
||||||
},
|
},
|
||||||
[enabled, isRefreshing],
|
[isRefreshing],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleTouchMove = useCallback(
|
const handleTouchMove = useCallback(
|
||||||
@@ -53,7 +59,6 @@ export function usePullToRefresh({
|
|||||||
if (!pullingRef.current || isRefreshing) return;
|
if (!pullingRef.current || isRefreshing) return;
|
||||||
const delta = e.touches[0].clientY - startYRef.current;
|
const delta = e.touches[0].clientY - startYRef.current;
|
||||||
if (delta <= 0) {
|
if (delta <= 0) {
|
||||||
// User is scrolling down — abort pull-to-refresh entirely
|
|
||||||
pullingRef.current = false;
|
pullingRef.current = false;
|
||||||
if (pullDistanceRef.current !== 0) {
|
if (pullDistanceRef.current !== 0) {
|
||||||
pullDistanceRef.current = 0;
|
pullDistanceRef.current = 0;
|
||||||
@@ -61,7 +66,6 @@ export function usePullToRefresh({
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Apply rubber-band resistance
|
|
||||||
const resisted = Math.min(delta * 0.4, threshold * 1.5);
|
const resisted = Math.min(delta * 0.4, threshold * 1.5);
|
||||||
if (resisted !== pullDistanceRef.current) {
|
if (resisted !== pullDistanceRef.current) {
|
||||||
pullDistanceRef.current = resisted;
|
pullDistanceRef.current = resisted;
|
||||||
@@ -72,6 +76,15 @@ export function usePullToRefresh({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleTouchEnd = useCallback(async () => {
|
const handleTouchEnd = useCallback(async () => {
|
||||||
|
if (!enabledRef.current) {
|
||||||
|
pullingRef.current = false;
|
||||||
|
if (pullDistanceRef.current !== 0) {
|
||||||
|
pullDistanceRef.current = 0;
|
||||||
|
setPullDistance(0);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!pullingRef.current) {
|
if (!pullingRef.current) {
|
||||||
if (pullDistanceRef.current !== 0) {
|
if (pullDistanceRef.current !== 0) {
|
||||||
pullDistanceRef.current = 0;
|
pullDistanceRef.current = 0;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useRef } from "react";
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
import { parseJwtExp } from "@/lib/jwt-payload";
|
||||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||||
import { useErrorStore } from "@/stores/error-store";
|
import { useErrorStore } from "@/stores/error-store";
|
||||||
import {
|
import {
|
||||||
@@ -14,6 +15,9 @@ const TOKEN_WARNING_THRESHOLD = 60 * 1000; // 1 分钟
|
|||||||
/** 最大重试次数 */
|
/** 最大重试次数 */
|
||||||
const MAX_RETRY = 3;
|
const MAX_RETRY = 3;
|
||||||
|
|
||||||
|
/** 等待主站回传新 Token 的超时(毫秒) */
|
||||||
|
const REFRESH_RESPONSE_TIMEOUT_MS = 5_000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Token 自动续签 Hook
|
* Token 自动续签 Hook
|
||||||
*
|
*
|
||||||
@@ -38,38 +42,19 @@ export function useTokenRefresh(): {
|
|||||||
|
|
||||||
const refreshTimerRef = useRef<NodeJS.Timeout | null>(null);
|
const refreshTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const retryCountRef = useRef(0);
|
const retryCountRef = useRef(0);
|
||||||
|
const pendingRefreshRef = useRef(0);
|
||||||
/**
|
|
||||||
* 解析 JWT 的 exp 字段
|
|
||||||
* @returns exp 时间戳(秒),解析失败返回 null
|
|
||||||
*/
|
|
||||||
const parseTokenExp = useCallback((token: string | null): number | null => {
|
|
||||||
if (!token) return null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// JWT 格式:header.payload.signature
|
|
||||||
const parts = token.split(".");
|
|
||||||
if (parts.length !== 3) return null;
|
|
||||||
|
|
||||||
// Base64 解码 payload
|
|
||||||
const payload = JSON.parse(atob(parts[1]));
|
|
||||||
return payload.exp ?? null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取 Token 剩余有效时间
|
* 获取 Token 剩余有效时间
|
||||||
* @returns 剩余毫秒数,-1 表示未知
|
* @returns 剩余毫秒数,-1 表示未知
|
||||||
*/
|
*/
|
||||||
const getTokenRemainingTime = useCallback((): number => {
|
const getTokenRemainingTime = useCallback((): number => {
|
||||||
const exp = parseTokenExp(bearerToken);
|
const exp = parseJwtExp(bearerToken);
|
||||||
if (!exp) return -1;
|
if (!exp) return -1;
|
||||||
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
return Math.max(0, (exp - now) * 1000);
|
return Math.max(0, (exp - now) * 1000);
|
||||||
}, [bearerToken, parseTokenExp]);
|
}, [bearerToken]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查 Token 是否即将过期(1 分钟内)
|
* 检查 Token 是否即将过期(1 分钟内)
|
||||||
@@ -109,13 +94,17 @@ export function useTokenRefresh(): {
|
|||||||
}
|
}
|
||||||
|
|
||||||
clearServerError();
|
clearServerError();
|
||||||
retryCountRef.current++;
|
const attemptId = Date.now();
|
||||||
|
pendingRefreshRef.current = attemptId;
|
||||||
// 向主站请求新 Token
|
|
||||||
requestParentRefresh();
|
requestParentRefresh();
|
||||||
|
|
||||||
// 等待主站响应(通过 postMessage)
|
window.setTimeout(() => {
|
||||||
// 实际逻辑在下面的 useEffect 中处理
|
if (pendingRefreshRef.current !== attemptId) return;
|
||||||
|
retryCountRef.current += 1;
|
||||||
|
if (retryCountRef.current >= MAX_RETRY) {
|
||||||
|
setServerError(true, "Token 刷新失败,请返回主站重新进入");
|
||||||
|
}
|
||||||
|
}, REFRESH_RESPONSE_TIMEOUT_MS);
|
||||||
}, [clearServerError, requestParentRefresh, setServerError]);
|
}, [clearServerError, requestParentRefresh, setServerError]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -144,8 +133,9 @@ export function useTokenRefresh(): {
|
|||||||
data.token
|
data.token
|
||||||
) {
|
) {
|
||||||
console.log("[TokenRefresh] Received new token from parent");
|
console.log("[TokenRefresh] Received new token from parent");
|
||||||
|
pendingRefreshRef.current = 0;
|
||||||
setBearerToken(data.token);
|
setBearerToken(data.token);
|
||||||
retryCountRef.current = 0; // 重置重试计数
|
retryCountRef.current = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理主站通知 Token 即将过期
|
// 处理主站通知 Token 即将过期
|
||||||
@@ -171,7 +161,7 @@ export function useTokenRefresh(): {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const exp = parseTokenExp(bearerToken);
|
const exp = parseJwtExp(bearerToken);
|
||||||
if (!exp) return;
|
if (!exp) return;
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -203,7 +193,7 @@ export function useTokenRefresh(): {
|
|||||||
clearTimeout(refreshTimerRef.current);
|
clearTimeout(refreshTimerRef.current);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [bearerToken, parseTokenExp, requestParentRefresh]);
|
}, [bearerToken, requestParentRefresh]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
refreshToken,
|
refreshToken,
|
||||||
|
|||||||
@@ -49,13 +49,17 @@ export function generateCSP(extraParentOrigins: string[] = []): string {
|
|||||||
.filter((origin): origin is string => origin !== null),
|
.filter((origin): origin is string => origin !== null),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
const scriptSrc =
|
||||||
|
process.env.NODE_ENV === "production"
|
||||||
|
? ["'self'", "'unsafe-inline'"]
|
||||||
|
: ["'self'", "'unsafe-inline'", "'unsafe-eval'"];
|
||||||
|
|
||||||
const directives: Record<string, string[]> = {
|
const directives: Record<string, string[]> = {
|
||||||
// 默认只允许同源
|
// 默认只允许同源
|
||||||
"default-src": ["'self'"],
|
"default-src": ["'self'"],
|
||||||
|
|
||||||
// 脚本允许同源和内联(Next.js 需要)
|
// 开发环境保留 unsafe-eval 供 Next 调试;生产环境禁用 eval。
|
||||||
"script-src": ["'self'", "'unsafe-inline'", "'unsafe-eval'"],
|
"script-src": scriptSrc,
|
||||||
|
|
||||||
// 样式允许同源和内联
|
// 样式允许同源和内联
|
||||||
"style-src": ["'self'", "'unsafe-inline'"],
|
"style-src": ["'self'", "'unsafe-inline'"],
|
||||||
@@ -107,7 +111,7 @@ export function generateCSP(extraParentOrigins: string[] = []): string {
|
|||||||
export function isAllowedParent(parentOrigin: string): boolean {
|
export function isAllowedParent(parentOrigin: string): boolean {
|
||||||
const origins = staticAllowedParentOrigins();
|
const origins = staticAllowedParentOrigins();
|
||||||
if (origins.length === 0) return false;
|
if (origins.length === 0) return false;
|
||||||
return origins.some((origin) => parentOrigin.startsWith(origin));
|
return origins.includes(parentOrigin);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
23
src/lib/jwt-payload.ts
Normal file
23
src/lib/jwt-payload.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/** 解析 JWT payload(RFC 7519 base64url)。 */
|
||||||
|
export function parseJwtPayload(token: string): Record<string, unknown> | null {
|
||||||
|
const parts = token.split(".");
|
||||||
|
if (parts.length !== 3) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
|
||||||
|
const json = atob(padded);
|
||||||
|
const payload = JSON.parse(json) as Record<string, unknown>;
|
||||||
|
return payload;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 返回 JWT `exp`(秒级 Unix 时间戳),解析失败返回 null。 */
|
||||||
|
export function parseJwtExp(token: string | null): number | null {
|
||||||
|
if (!token) return null;
|
||||||
|
const payload = parseJwtPayload(token);
|
||||||
|
const exp = payload?.exp;
|
||||||
|
return typeof exp === "number" ? exp : null;
|
||||||
|
}
|
||||||
@@ -50,7 +50,6 @@ export async function proxyLotteryApi(
|
|||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
msg: "Upstream Laravel unreachable",
|
msg: "Upstream Laravel unreachable",
|
||||||
target,
|
|
||||||
error: error instanceof Error ? error.message : "Unknown error",
|
error: error instanceof Error ? error.message : "Unknown error",
|
||||||
},
|
},
|
||||||
{ status: 502 },
|
{ status: 502 },
|
||||||
|
|||||||
@@ -61,7 +61,12 @@ function shouldRedirectPlayerSessionExpired(error: AxiosError): boolean {
|
|||||||
/** 站内接口 401:清本地会话并回入口,与 {@link EntryGate} `session=expired` 衔接 */
|
/** 站内接口 401:清本地会话并回入口,与 {@link EntryGate} `session=expired` 衔接 */
|
||||||
/** 500 错误:更新全局服务器错误状态 */
|
/** 500 错误:更新全局服务器错误状态 */
|
||||||
lotteryHttp.interceptors.response.use(
|
lotteryHttp.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
useErrorStore.getState().setIsOffline(false);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
},
|
||||||
(error: unknown) => {
|
(error: unknown) => {
|
||||||
if (isAxiosError(error) && typeof window !== "undefined") {
|
if (isAxiosError(error) && typeof window !== "undefined") {
|
||||||
const status = error.response?.status;
|
const status = error.response?.status;
|
||||||
|
|||||||
Reference in New Issue
Block a user