diff --git a/src/api/ticket-items.ts b/src/api/ticket-items.ts index 8f05cdb..bb4630a 100644 --- a/src/api/ticket-items.ts +++ b/src/api/ticket-items.ts @@ -9,6 +9,7 @@ export type GetTicketItemsParams = { page?: number; per_page?: number; draw_no?: string; + ticket_no?: string; order_no?: string; number?: string; status?: string[]; @@ -27,6 +28,7 @@ export function getTicketItems( page: params?.page, per_page: params?.per_page, draw_no: params?.draw_no, + ticket_no: params?.ticket_no, order_no: params?.order_no, number: params?.number, status: params?.status, diff --git a/src/app/(player)/(main)/layout.tsx b/src/app/(player)/(main)/layout.tsx index 942eb6f..2ce2f33 100644 --- a/src/app/(player)/(main)/layout.tsx +++ b/src/app/(player)/(main)/layout.tsx @@ -1,4 +1,8 @@ import { PlayerAppShell } from "@/components/layout/player-app-shell"; +import { PlayEffectiveWsListener } from "@/components/play-effective-ws-listener"; +import { PlayerBalanceWsListener } from "@/components/player-balance-ws-listener"; +import { TokenSilentRefresh } from "@/components/token-silent-refresh"; +import { PlayerAuthGate } from "@/features/player/player-auth-gate"; import { HydratePlayerAuth } from "@/features/player/hydrate-player-auth"; export default function PlayerMainLayout({ @@ -7,9 +11,12 @@ export default function PlayerMainLayout({ children: React.ReactNode; }>) { return ( - <> + + + + {children} - + ); } diff --git a/src/app/globals.css b/src/app/globals.css index 6044a82..bbdb08b 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -185,6 +185,16 @@ font-size: 0.875rem; line-height: 1.25rem; } + + .hall-summary-grid { + grid-template-columns: repeat(var(--hall-summary-columns), minmax(0, 1fr)); + } +} + +@media (min-width: 1500px) { + .hall-summary-grid { + grid-template-columns: repeat(var(--hall-summary-columns-wide), minmax(0, 1fr)); + } } /* 玩家端 Toast:顶部居中、紧凑尺寸(位置见 components/ui/sonner.tsx) */ @@ -335,3 +345,19 @@ transform: rotate(360deg); } } + +@keyframes jackpot-strip-sweep { + 0%, + 20% { + transform: translateX(0) rotate(12deg); + opacity: 0; + } + 35% { + opacity: 0.75; + } + 70%, + 100% { + transform: translateX(500%) rotate(12deg); + opacity: 0; + } +} diff --git a/src/components/i18n-hydration-provider.tsx b/src/components/i18n-hydration-provider.tsx index a422fc2..d96b619 100644 --- a/src/components/i18n-hydration-provider.tsx +++ b/src/components/i18n-hydration-provider.tsx @@ -10,17 +10,29 @@ import { import { syncPreferredLanguage } from "@/i18n"; const I18nHydrationContext = createContext(false); +const hydrationListeners = new Set<() => void>(); +let hydrationReady = false; +let hydrationSyncPromise: Promise | null = null; + +function ensurePreferredLanguageSynced(): void { + if (hydrationSyncPromise) return; + + hydrationSyncPromise = syncPreferredLanguage() + .catch(() => undefined) + .then(() => { + hydrationReady = true; + hydrationListeners.forEach((listener) => listener()); + }); +} function subscribeI18nHydration(onStoreChange: () => void): () => void { - queueMicrotask(() => { - syncPreferredLanguage(); - onStoreChange(); - }); - return () => {}; + hydrationListeners.add(onStoreChange); + ensurePreferredLanguageSynced(); + return () => hydrationListeners.delete(onStoreChange); } function getI18nHydratedSnapshot(): boolean { - return true; + return hydrationReady; } function getI18nHydratedServerSnapshot(): boolean { @@ -42,4 +54,4 @@ export function I18nHydrationProvider({ children }: { children: ReactNode }) { export function useI18nHydrated(): boolean { return useContext(I18nHydrationContext); -} \ No newline at end of file +} diff --git a/src/components/layout/player-bottom-nav.tsx b/src/components/layout/player-bottom-nav.tsx index d0fa448..86b4cf0 100644 --- a/src/components/layout/player-bottom-nav.tsx +++ b/src/components/layout/player-bottom-nav.tsx @@ -37,7 +37,7 @@ const tabs = [ labelKey: "nav.wallet", creditLabelKey: "nav.credit", labelDefault: "钱包", - creditLabelDefault: "信用", + creditLabelDefault: "额度", icon: Wallet, match: (p: string) => p === "/wallet" || p.startsWith("/wallet/"), }, diff --git a/src/components/layout/player-sidebar.tsx b/src/components/layout/player-sidebar.tsx index 9938eb0..714559b 100644 --- a/src/components/layout/player-sidebar.tsx +++ b/src/components/layout/player-sidebar.tsx @@ -43,7 +43,7 @@ const navItems = [ labelKey: "nav.wallet", creditLabelKey: "nav.credit", labelDefault: "钱包", - creditLabelDefault: "信用", + creditLabelDefault: "额度", icon: Wallet, match: (p: string) => p === "/wallet" || p.startsWith("/wallet/"), }, diff --git a/src/components/player-money-display.tsx b/src/components/player-money-display.tsx index d802003..103831c 100644 --- a/src/components/player-money-display.tsx +++ b/src/components/player-money-display.tsx @@ -1,12 +1,13 @@ "use client"; -import { formatMinorAsCurrency } from "@/lib/money"; +import { formatMinorAmount, formatMinorAsCurrency } from "@/lib/money"; import { cn } from "@/lib/utils"; type PlayerMoneyDisplayProps = { amountMinor: number; currency: string; className?: string; + showCurrencyCode?: boolean; }; /** 玩家端金额展示:自适应字号 + 防大额截断 */ @@ -14,6 +15,7 @@ export function PlayerMoneyDisplay({ amountMinor, currency, className, + showCurrencyCode = true, }: PlayerMoneyDisplayProps) { return (

- {formatMinorAsCurrency(amountMinor, currency)} + {showCurrencyCode + ? formatMinorAsCurrency(amountMinor, currency) + : formatMinorAmount(amountMinor)}

); } diff --git a/src/components/providers.tsx b/src/components/providers.tsx index 049f557..0d35e82 100644 --- a/src/components/providers.tsx +++ b/src/components/providers.tsx @@ -6,9 +6,6 @@ import { I18nHydrationProvider } from "@/components/i18n-hydration-provider"; import { Toaster } from "@/components/ui/sonner"; import { ErrorProvider } from "@/components/error-provider"; import { IframeBridge } from "@/components/iframe-bridge"; -import { PlayEffectiveWsListener } from "@/components/play-effective-ws-listener"; -import { PlayerBalanceWsListener } from "@/components/player-balance-ws-listener"; -import { TokenSilentRefresh } from "@/components/token-silent-refresh"; import "@/i18n"; type ProvidersProps = { @@ -21,13 +18,7 @@ export function Providers({ children }: ProvidersProps): ReactNode { {/* iframe 通信桥接 - 支持主站嵌入 */} - - {children} - - - {/* Token 静默续签(无 UI) */} - - + {children} diff --git a/src/features/hall/hall-bet-preview-dialog.tsx b/src/features/hall/hall-bet-preview-dialog.tsx index 35cffa4..33f3b9c 100644 --- a/src/features/hall/hall-bet-preview-dialog.tsx +++ b/src/features/hall/hall-bet-preview-dialog.tsx @@ -257,10 +257,9 @@ export function HallBetPreviewDialog({ {ln.number} - {playLabel(ln.play_code, t)} - - {ln.play_code} + + {playLabel(ln.play_code, t)} diff --git a/src/features/hall/hall-betting-grid-model.ts b/src/features/hall/hall-betting-grid-model.ts index 88306a5..6d948ae 100644 --- a/src/features/hall/hall-betting-grid-model.ts +++ b/src/features/hall/hall-betting-grid-model.ts @@ -55,13 +55,16 @@ export function digitSlotOptions(category: PlayHallCategory): number[] { return [0, 1, 2, 3]; } -function digitSlotLabel(category: PlayHallCategory, slot: number): string { - const labels: Record> = { - D2: { 2: "十", 3: "个" }, - D3: { 1: "百", 2: "十", 3: "个" }, - D4: { 0: "千", 1: "百", 2: "十", 3: "个" }, - }; - return labels[category][slot] ?? String(slot + 1); +function digitSlotLabel( + category: PlayHallCategory, + slot: number, + t: HallTranslate, +): string { + if (!digitSlotOptions(category).includes(slot)) return String(slot + 1); + + return t(`hall.table.digitPosition.${slot}`, { + defaultValue: String(10 ** (3 - slot)), + }); } export function playColumnHeaderLabel( @@ -72,7 +75,7 @@ export function playColumnHeaderLabel( ): string { if (digitSlot !== undefined) { const kind = play.play_code === "digit_big" ? "big" : "small"; - return `${t(`hall.table.digitShort.${kind}`)}·${digitSlotLabel(category, digitSlot)}`; + return `${t(`hall.table.digitShort.${kind}`)}·${digitSlotLabel(category, digitSlot, t)}`; } return playLabel(play.play_code, t); } diff --git a/src/features/hall/hall-betting-grid.tsx b/src/features/hall/hall-betting-grid.tsx index 6488f29..c92ff72 100644 --- a/src/features/hall/hall-betting-grid.tsx +++ b/src/features/hall/hall-betting-grid.tsx @@ -55,6 +55,7 @@ import { } from "@/features/hall/selection-type"; import type { HallDrawLiveSnapshot } from "@/features/hall/use-hall-draw-live"; import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency"; +import { useAsyncEffect } from "@/hooks/use-async-effect"; import { triggerWalletPollingAfterBet } from "@/hooks/use-wallet-polling"; import { usePlayerSessionStore } from "@/stores/player-session-store"; import { getLotteryEcho } from "@/lib/lottery-echo"; @@ -436,11 +437,9 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } } }, [currencyParam]); - useEffect(() => { - queueMicrotask(() => { - void loadCatalog(); - void refreshWallet(); - }); + useAsyncEffect(() => { + void loadCatalog(); + void refreshWallet(); }, [loadCatalog, refreshWallet]); useEffect(() => { @@ -1500,18 +1499,22 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
{jackpot?.enabled ? ( -
-
-
-

+

+
+
+
+ +
+
+

{t("results.jackpotLabel", { defaultValue: "Jackpot" })}

-

- {formatMinorAsCurrency(jackpot.current_amount_minor, jackpot.currency_code)} +

+ {formatMinorAmount(jackpot.current_amount_minor)}

{jackpot.draws_since_last_burst !== null ? ( -

+

{t("results.jackpotGap", { count: jackpot.draws_since_last_burst, })} diff --git a/src/features/hall/hall-play-catalog-panel.tsx b/src/features/hall/hall-play-catalog-panel.tsx index ec26582..948e715 100644 --- a/src/features/hall/hall-play-catalog-panel.tsx +++ b/src/features/hall/hall-play-catalog-panel.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; +import { useAsyncEffect } from "@/hooks/use-async-effect"; import { getPlayEffective } from "@/api/play"; import { Badge } from "@/components/ui/badge"; @@ -99,10 +100,8 @@ export function HallPlayCatalogPanel() { } }, [currencyParam, t]); - useEffect(() => { - queueMicrotask(() => { - void load(); - }); + useAsyncEffect(() => { + void load(); }, [load]); useEffect(() => { diff --git a/src/features/hall/hall-play-summary-grid.tsx b/src/features/hall/hall-play-summary-grid.tsx index ce54c89..3ef6909 100644 --- a/src/features/hall/hall-play-summary-grid.tsx +++ b/src/features/hall/hall-play-summary-grid.tsx @@ -1,3 +1,5 @@ +import type { CSSProperties } from "react"; + import { formatMinorAmount } from "@/lib/money"; import { cn } from "@/lib/utils"; @@ -7,6 +9,11 @@ export type HallSummaryItem = { totalMinor: number; }; +function balancedColumnCount(itemCount: number, maxColumns: number) { + const rowCount = Math.max(1, Math.ceil(itemCount / maxColumns)); + return Math.max(1, Math.ceil(itemCount / rowCount)); +} + export function HallPlaySummaryGrid({ items, className, @@ -14,12 +21,18 @@ export function HallPlaySummaryGrid({ items: HallSummaryItem[]; className?: string; }) { + const gridStyle = { + "--hall-summary-columns": balancedColumnCount(items.length, 10), + "--hall-summary-columns-wide": balancedColumnCount(items.length, 16), + } as CSSProperties; + return (

{items.map((item) => { const hasValue = item.totalMinor > 0; diff --git a/src/features/hall/hall-wallet-strip.tsx b/src/features/hall/hall-wallet-strip.tsx index fa5558b..a76cbe0 100644 --- a/src/features/hall/hall-wallet-strip.tsx +++ b/src/features/hall/hall-wallet-strip.tsx @@ -16,7 +16,6 @@ import { import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency"; import { useApiQuery } from "@/hooks/use-api-query"; import { useIsMobile } from "@/hooks/use-mobile"; -import { formatMinorAsCurrency } from "@/lib/money"; import { isCreditFundingPlayer } from "@/lib/player-funding-mode"; import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference"; import { cn } from "@/lib/utils"; @@ -97,6 +96,7 @@ export function HallWalletStrip() { alt="" fill sizes="(max-width: 1023px) 100vw, 1400px" + loading="eager" className="pointer-events-none object-cover object-center" aria-hidden /> @@ -111,20 +111,9 @@ export function HallWalletStrip() { >
-
-
-

+

+
+

{label}

{loading ? ( @@ -138,27 +127,14 @@ export function HallWalletStrip() { )}
- {isCreditPlayer && !loading && balance ? ( -

- {t("wallet.creditSummary", { - defaultValue: "授信 {{limit}} · 已用 {{used}}", - limit: formatMinorAsCurrency(balance.credit_limit ?? 0, currency), - used: formatMinorAsCurrency(balance.used_credit ?? 0, currency), - })} -

- ) : null}
diff --git a/src/features/orders/ticket-order-detail-screen.tsx b/src/features/orders/ticket-order-detail-screen.tsx index a926bd7..a3c4154 100644 --- a/src/features/orders/ticket-order-detail-screen.tsx +++ b/src/features/orders/ticket-order-detail-screen.tsx @@ -18,6 +18,7 @@ import { import { Skeleton } from "@/components/ui/skeleton"; import { TwentyThreeResultsGrid } from "@/features/results/twenty-three-results-grid"; import { useCurrencyCatalog } from "@/hooks/use-currency-catalog"; +import { useAsyncEffect } from "@/hooks/use-async-effect"; import { ticketDetailHref } from "@/features/orders/group-ticket-items"; import { StatusDot, ticketStatusDisplay } from "@/features/orders/ticket-item-status"; import { formatPlayerInstant } from "@/lib/player-datetime"; @@ -118,10 +119,8 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) { } }, [ticketNo, t]); - useEffect(() => { - queueMicrotask(() => { - void load(); - }); + useAsyncEffect(() => { + void load(); }, [load]); useEffect(() => { diff --git a/src/features/orders/ticket-orders-list-screen.tsx b/src/features/orders/ticket-orders-list-screen.tsx index 6ce4d6f..2d66614 100644 --- a/src/features/orders/ticket-orders-list-screen.tsx +++ b/src/features/orders/ticket-orders-list-screen.tsx @@ -28,6 +28,7 @@ import { OrderMetaLine } from "@/features/orders/order-meta-line"; import { StatusDot, ticketStatusDisplay } from "@/features/orders/ticket-item-status"; import { useCurrencyCatalog } from "@/hooks/use-currency-catalog"; import { useIsMobile } from "@/hooks/use-mobile"; +import { useAsyncEffect } from "@/hooks/use-async-effect"; import { LOTTERY_SCHEDULE_TIMEZONE } from "@/lib/lottery-schedule-timezone"; import { formatMinorAsCurrency } from "@/lib/money"; @@ -61,6 +62,12 @@ function providerLabel(providerName?: string | null, providerCode?: string | nul return providerName || providerCode || "-"; } +function groupProviderLabel(items: TicketItemListRow[]): string { + return Array.from( + new Set(items.map((item) => providerLabel(item.provider_name, item.provider_code))), + ).join(", "); +} + export function TicketOrdersListScreen() { const router = useRouter(); const searchParams = useSearchParams(); @@ -170,7 +177,7 @@ export function TicketOrdersListScreen() { [fromDate, queryDrawNo, queryNumber, queryStatuses, t, toDate], ); - useEffect(() => { + useAsyncEffect(() => { void getDrawCurrent() .then((res) => { const tz = res.data?.schedule_timezone?.trim(); @@ -186,7 +193,7 @@ export function TicketOrdersListScreen() { [scheduleTimezone], ); - useEffect(() => { + useAsyncEffect(() => { if (!initialLoadDone.current) { initialLoadDone.current = true; void fetchPage(1, false); @@ -606,6 +613,8 @@ export function TicketOrdersListScreen() {

{t("orders.betItems")} · {group.items.length} + {" · "} + {groupProviderLabel(group.items)}

{group.status === "partial_failed" ? (

@@ -750,7 +759,14 @@ export function TicketOrdersListScreen() { ) : null}

- + +

+ {groupProviderLabel(group.items)} +

+

{formatMinorAsCurrency(group.total_bet_amount, cur)} diff --git a/src/features/player/hydrate-player-auth.tsx b/src/features/player/hydrate-player-auth.tsx index 134fffe..3007fea 100644 --- a/src/features/player/hydrate-player-auth.tsx +++ b/src/features/player/hydrate-player-auth.tsx @@ -1,9 +1,8 @@ "use client"; -import { useEffect } from "react"; - -import { getPublicCurrencies } from "@/api/currency"; import { getPlayerMe } from "@/api/player"; +import { useAsyncEffect } from "@/hooks/use-async-effect"; +import { ensureCurrencyCatalogLoaded } from "@/hooks/use-currency-catalog"; import { loadCurrencyDisplayFormat } from "@/lib/currency-display-settings"; import { usePlayerSessionStore } from "@/stores/player-session-store"; @@ -16,9 +15,8 @@ export function HydratePlayerAuth(): null { (state) => state.restoreBearerToken, ); const setProfile = usePlayerSessionStore((state) => state.setProfile); - const setCurrencies = usePlayerSessionStore((state) => state.setCurrencies); - useEffect(() => { + useAsyncEffect(() => { usePlayerSessionStore.getState().reconcileSelectedCurrency(); void loadCurrencyDisplayFormat(); const refreshFormat = setInterval(() => { @@ -30,8 +28,7 @@ export function HydratePlayerAuth(): null { try { if (usePlayerSessionStore.getState().currencies.length === 0) { try { - const currencies = await getPublicCurrencies(); - setCurrencies(currencies.items); + await ensureCurrencyCatalogLoaded(); } catch { // 币种元数据失败时不影响登录态恢复。 } @@ -50,7 +47,7 @@ export function HydratePlayerAuth(): null { return () => { clearInterval(refreshFormat); }; - }, [restoreBearerToken, setCurrencies, setProfile]); + }, [restoreBearerToken, setProfile]); return null; } diff --git a/src/features/player/player-auth-gate.tsx b/src/features/player/player-auth-gate.tsx new file mode 100644 index 0000000..c98dc66 --- /dev/null +++ b/src/features/player/player-auth-gate.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { Loader2 } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useEffect, useRef, useSyncExternalStore, type ReactNode } from "react"; + +import { usePlayerSessionStore } from "@/stores/player-session-store"; + +function subscribeHydration(onStoreChange: () => void): () => void { + let cancelled = false; + queueMicrotask(() => { + if (!cancelled) onStoreChange(); + }); + return () => { + cancelled = true; + }; +} + +function getHydratedSnapshot(): boolean { + return true; +} + +function getServerHydratedSnapshot(): boolean { + return false; +} + +export function PlayerAuthGate({ children }: { children: ReactNode }): ReactNode { + const router = useRouter(); + const bearerToken = usePlayerSessionStore((state) => state.bearerToken); + const hasHydrated = useSyncExternalStore( + subscribeHydration, + getHydratedSnapshot, + getServerHydratedSnapshot, + ); + const redirectStarted = useRef(false); + + useEffect(() => { + if (!hasHydrated || bearerToken || redirectStarted.current) return; + redirectStarted.current = true; + router.replace("/login"); + }, [bearerToken, hasHydrated, router]); + + if (!hasHydrated || !bearerToken) { + return ( +

+ +
+ ); + } + + return children; +} diff --git a/src/features/player/player-login-form.tsx b/src/features/player/player-login-form.tsx index b77b1d6..9b3f572 100644 --- a/src/features/player/player-login-form.tsx +++ b/src/features/player/player-login-form.tsx @@ -42,6 +42,10 @@ export function PlayerLoginForm({ }: PlayerLoginFormProps) { const [showPassword, setShowPassword] = useState(false); const isDesktop = variant === "desktop"; + const fieldIdPrefix = isDesktop ? "login-desktop" : "login-mobile"; + const usernameId = `${fieldIdPrefix}-user`; + const passwordId = `${fieldIdPrefix}-pass`; + const captchaId = `${fieldIdPrefix}-captcha`; const inputClass = "h-10 rounded-xl border-gray-200 bg-white text-base shadow-none"; @@ -55,7 +59,7 @@ export function PlayerLoginForm({ onSubmit={onSubmit} >
-
); @@ -267,7 +263,7 @@ export function WalletScreen() { return nextLogs; }, [currency, filter]); - useEffect(() => { + useAsyncEffect(() => { let cancelled = false; void (async () => { @@ -307,7 +303,7 @@ export function WalletScreen() { return () => { cancelled = true; }; - }, [currency, t]); // eslint-disable-line react-hooks/exhaustive-deps -- 币种切换整页刷新;流水筛选见下方 effect + }, [currency, t]); useEffect(() => { if (!filterInitializedRef.current) { @@ -395,10 +391,12 @@ export function WalletScreen() { const displayMinor = isCreditPlayer ? Number(balance?.available_balance ?? 0) : Number(balance?.balance ?? 0); + const creditLimitMinor = Number(balance?.credit_limit ?? 0); + const creditInUseMinor = Math.max(0, creditLimitMinor - displayMinor); const panelTitle = !fundingModeKnown ? t("wallet.loadingTitle", { defaultValue: "账户资金" }) : isCreditPlayer - ? t("wallet.creditTitle", { defaultValue: "信用" }) + ? t("wallet.creditTitle", { defaultValue: "额度" }) : t("wallet.title"); const loadMore = useCallback(() => { @@ -470,6 +468,7 @@ export function WalletScreen() { src="/entry/image5.png" alt="" fill + loading="eager" className="pointer-events-none object-cover object-center" aria-hidden /> @@ -535,6 +534,7 @@ export function WalletScreen() { src="/entry/image5.png" alt="" fill + loading="eager" className="pointer-events-none object-cover object-center" aria-hidden /> @@ -545,7 +545,7 @@ export function WalletScreen() {

{fundingModeKnown - ? t("wallet.creditAvailable", { defaultValue: "可用信用" }) + ? t("wallet.creditAvailable", { defaultValue: "可用额度" }) : t("wallet.loadingBalance", { defaultValue: "正在加载" })}

{loading || !fundingModeKnown ? ( @@ -554,40 +554,43 @@ export function WalletScreen() { )}

{!fundingModeKnown ? t("wallet.loadingAccountMode", { defaultValue: "正在确认资金模式…" }) - : t("wallet.creditSummary", { - defaultValue: "授信 {{limit}} · 已用 {{used}}", - limit: formatMinorAsCurrency(balance?.credit_limit ?? 0, currency), - used: formatMinorAsCurrency(balance?.used_credit ?? 0, currency), - })} + : t("wallet.creditAvailableHint", { defaultValue: "当前可用于下注" })}

{fundingModeKnown ? ( -
-
-

- {t("wallet.creditLimit", { defaultValue: "授信额度" })} -

-

- {formatMinorAsCurrency(balance?.credit_limit ?? 0, currency)} -

+
+ + {t("wallet.creditDetails", { defaultValue: "额度详情" })} + + +
+
+

+ {t("wallet.creditLimit", { defaultValue: "总额度" })} +

+

+ {formatMinorAmount(creditLimitMinor)} +

+
+
+

+ {t("wallet.usedCredit", { defaultValue: "使用中" })} +

+

+ {formatMinorAmount(creditInUseMinor)} +

+
-
-

- {t("wallet.usedCredit", { defaultValue: "已用信用" })} -

-

- {formatMinorAsCurrency(balance?.used_credit ?? 0, currency)} -

-
-
+ ) : null} diff --git a/src/hooks/use-async-effect.ts b/src/hooks/use-async-effect.ts new file mode 100644 index 0000000..41e04a7 --- /dev/null +++ b/src/hooks/use-async-effect.ts @@ -0,0 +1,40 @@ +"use client"; + +import { useEffect, useRef, type DependencyList } from "react"; + +/** + * Defer asynchronous side effects until the current effect instance survives + * React Strict Mode's development-only setup/cleanup probe. + */ +export function useAsyncEffect( + factory: () => void | (() => void) | Promise, + deps: DependencyList, +): void { + const factoryRef = useRef(factory); + useEffect(() => { + factoryRef.current = factory; + }, [factory]); + + useEffect(() => { + let cleanup: void | (() => void); + let cancelled = false; + + queueMicrotask(() => { + if (cancelled) return; + + void Promise.resolve(factoryRef.current()).then((result) => { + if (cancelled) { + result?.(); + return; + } + cleanup = result; + }); + }); + + return () => { + cancelled = true; + cleanup?.(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps); +} diff --git a/src/hooks/use-currency-catalog.ts b/src/hooks/use-currency-catalog.ts index 000723d..22dba20 100644 --- a/src/hooks/use-currency-catalog.ts +++ b/src/hooks/use-currency-catalog.ts @@ -10,9 +10,31 @@ const RETRY_DELAY_MS = 2_000; let inflightCurrencyLoad: Promise | null = null; +export function ensureCurrencyCatalogLoaded(): Promise { + if (usePlayerSessionStore.getState().currencies.length > 0) { + return Promise.resolve(); + } + + if (inflightCurrencyLoad !== null) { + return inflightCurrencyLoad; + } + + inflightCurrencyLoad = getPublicCurrencies() + .then((data) => { + if (data.items.length === 0) { + throw new Error("empty_currency_catalog"); + } + usePlayerSessionStore.getState().setCurrencies(data.items); + }) + .finally(() => { + inflightCurrencyLoad = null; + }); + + return inflightCurrencyLoad; +} + export function useCurrencyCatalog() { const currencies = usePlayerSessionStore((state) => state.currencies); - const setCurrencies = usePlayerSessionStore((state) => state.setCurrencies); const [loadGeneration, setLoadGeneration] = useState(0); useEffect(() => { @@ -20,27 +42,13 @@ export function useCurrencyCatalog() { return; } - if (inflightCurrencyLoad !== null) { - return; - } - - inflightCurrencyLoad = getPublicCurrencies() - .then((data) => { - if (data.items.length > 0) { - setCurrencies(data.items); - return; - } - throw new Error("empty_currency_catalog"); - }) + void ensureCurrencyCatalogLoaded() .catch(() => { window.setTimeout(() => { setLoadGeneration((current) => current + 1); }, RETRY_DELAY_MS); - }) - .finally(() => { - inflightCurrencyLoad = null; }); - }, [currencies.length, setCurrencies, loadGeneration]); + }, [currencies.length, loadGeneration]); return currencies; -} \ No newline at end of file +} diff --git a/src/hooks/use-player-balance-ws.ts b/src/hooks/use-player-balance-ws.ts index ee2b0d7..ebe3897 100644 --- a/src/hooks/use-player-balance-ws.ts +++ b/src/hooks/use-player-balance-ws.ts @@ -4,7 +4,11 @@ import { useEffect } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; -import { getLotteryEcho } from "@/lib/lottery-echo"; +import { + disconnectLotteryEcho, + getLotteryEcho, +} from "@/lib/lottery-echo"; +import { parseJwtSessionVersion } from "@/lib/jwt-payload"; import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency"; import { usePlayerSessionStore } from "@/stores/player-session-store"; @@ -17,6 +21,11 @@ type BalanceUpdateWsEvent = { reason?: string; }; +type SessionReplacedWsEvent = { + player_id?: number; + session_version?: number; +}; + const REASON_I18N_KEY: Record = { transfer_in: "wallet.wsReason.transferIn", transfer_out: "wallet.wsReason.transferOut", @@ -71,10 +80,33 @@ export function usePlayerBalanceWs(): void { toast.message(t("wallet.wsBalanceUpdated", { change: changeLabel, reason: reasonLabel })); }; + const onSessionReplaced = (evt: SessionReplacedWsEvent): void => { + const nextVersion = evt.session_version; + if ( + evt.player_id !== playerId || + typeof nextVersion !== "number" || + !Number.isInteger(nextVersion) + ) { + return; + } + + const state = usePlayerSessionStore.getState(); + const currentVersion = parseJwtSessionVersion(state.bearerToken) ?? 0; + if (nextVersion <= currentVersion) { + return; + } + + disconnectLotteryEcho(); + state.clearBearerToken(); + window.location.replace("/login?session=replaced"); + }; + channel.listen(".balance.update", onBalanceUpdate); + channel.listen(".session.replaced", onSessionReplaced); return () => { channel.stopListening(".balance.update"); + channel.stopListening(".session.replaced"); echo.leave(channelName); }; }, [activeCurrency, bearerToken, playerId, t]); diff --git a/src/hooks/use-token-refresh.ts b/src/hooks/use-token-refresh.ts index 0d67e00..7aabc0e 100644 --- a/src/hooks/use-token-refresh.ts +++ b/src/hooks/use-token-refresh.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef } from "react"; +import { useCallback, useRef } from "react"; import { useTranslation } from "react-i18next"; import { parseJwtExp } from "@/lib/jwt-payload"; @@ -6,6 +6,7 @@ import { useErrorStore } from "@/stores/error-store"; import { resolvePostMessageTargetOrigin } from "@/lib/iframe-origins"; import { subscribeIframeTokenRefresh } from "@/lib/iframe-token-refresh-events"; import { usePlayerSessionStore } from "@/stores/player-session-store"; +import { useAsyncEffect } from "@/hooks/use-async-effect"; /** Token 过期前警告阈值(毫秒) */ const TOKEN_WARNING_THRESHOLD = 60 * 1000; // 1 分钟 @@ -106,7 +107,7 @@ export function useTokenRefresh(): { }, [clearServerError, requestParentRefresh, setServerError, t]); /** IframeBridge 统一校验父窗口消息;这里只接收已验证的续签结果。 */ - useEffect(() => { + useAsyncEffect(() => { return subscribeIframeTokenRefresh(() => { pendingRefreshRef.current = 0; retryCountRef.current = 0; @@ -116,7 +117,7 @@ export function useTokenRefresh(): { /** * 自动刷新逻辑 */ - useEffect(() => { + useAsyncEffect(() => { if (!bearerToken) { if (refreshTimerRef.current) { clearTimeout(refreshTimerRef.current); diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 3ae6724..b87f002 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -59,7 +59,7 @@ export function syncDocumentLanguage(lang: AppLanguage): void { document.documentElement.lang = lang; } -export function syncPreferredLanguage(): void { +export async function syncPreferredLanguage(): Promise { if (typeof window === "undefined") return; const stored = window.localStorage.getItem("i18nextLng"); @@ -67,7 +67,7 @@ export function syncPreferredLanguage(): void { const current = normalizeLanguage(i18n.resolvedLanguage ?? i18n.language); if (preferred !== current) { - void i18n.changeLanguage(preferred); + await i18n.changeLanguage(preferred); } } diff --git a/src/i18n/locales/en/entry.json b/src/i18n/locales/en/entry.json index fadcdb8..b8b7279 100644 --- a/src/i18n/locales/en/entry.json +++ b/src/i18n/locales/en/entry.json @@ -65,6 +65,7 @@ "captcha": "Captcha", "captchaPlaceholder": "Enter captcha", "captchaRequired": "Please load the captcha first", + "captchaCodeRequired": "Please enter the captcha", "captchaLoadFailed": "Failed to load captcha. Try again.", "captchaLoading": "Loading…", "captchaRefresh": "Refresh captcha", @@ -78,6 +79,7 @@ "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.", + "sessionReplaced": "This account was signed in on another device. Please sign in again.", "authFailed": "Authorization failed", "unknown": "Unknown error", "network": "Network error occurred", diff --git a/src/i18n/locales/en/player.json b/src/i18n/locales/en/player.json index 528a05d..4181d3b 100644 --- a/src/i18n/locales/en/player.json +++ b/src/i18n/locales/en/player.json @@ -26,7 +26,7 @@ "orders": "My Bets", "rules": "Rules", "wallet": "Wallet", - "credit": "Credit" + "credit": "Limit" }, "notifications": { "title": "Pending reconciliation", @@ -162,7 +162,7 @@ "closesIn": "Closes In", "drawsIn": "Draws In", "drawProcessing": "Drawing…", - "coolDown": "Cool Down", + "coolDown": "Until automatic settlement", "loadFailedRefresh": "Failed to load. Pull down to refresh.", "issueNo": "Issue No.", "hall": "Betting Hall", @@ -177,7 +177,7 @@ "closed": "Awaiting draw", "drawing": "Drawing", "review": "Under review", - "cooldown": "Cool down", + "cooldown": "Result confirmation", "settling": "Settling", "settled": "Settled", "cancelled": "Cancelled" @@ -288,6 +288,7 @@ "no": "No.", "number": "Number", "selectionType": "Type", + "total": "Total", "rowTotal": "Row Total", "selectAllTypes": "Select all types", "setAllTypes": "Set the type for every row", @@ -338,6 +339,12 @@ "big": "B", "small": "S" }, + "digitPosition": { + "0": "1000s", + "1": "100s", + "2": "10s", + "3": "1s" + }, "filledPlayCount": "{{count}} plays filled", "tapToFill": "Tap to enter amounts", "rowActual": "Actual", @@ -483,11 +490,15 @@ "wallet": { "title": "Wallet", "subtitle": "Balance and transfer records", - "creditTitle": "Credit", - "creditSubtitle": "Credit usage and billing-period activity", + "creditTitle": "Limit", + "creditSubtitle": "Available limit and period settlement", "balance": "Wallet Balance", - "creditAvailable": "Available credit", + "creditAvailable": "Available limit", + "creditAvailableHint": "Available to bet now", + "creditDetails": "Limit details", "creditSummary": "Limit {{limit}} · Used {{used}}", + "creditLimit": "Total limit", + "usedCredit": "In use", "creditNoTransferHint": "Credit accounts are funded by your agent; no main-site transfers. Wins are not paid out instantly—profit and loss are settled in billing periods. Contact your agent to change your limit.", "available": "Available {{amount}}", "transferIn": "Transfer In", @@ -497,9 +508,13 @@ "logs": "Logs", "logsTitle": "Wallet Logs", "logsSubtitle": "Transfer and betting records", - "creditLogsTitle": "Credit activity", - "creditLogsSubtitle": "Holds, releases, and billing-period payments", + "creditLogsTitle": "Limit and settlement activity", + "creditLogsSubtitle": "Bets, draw results, and period settlement", "typeFilter": "Type Filter", + "logType": "Type", + "logTime": "Time", + "logRef": "Reference", + "logAmount": "Amount", "transferInTitle": "Transfer In", "transferOutTitle": "Transfer Out", "transferInSubtitle": "Add funds to lottery wallet ({{currency}})", @@ -532,17 +547,17 @@ "viewPendingReconcile": "View pending reconciliation ({{count}})", "pendingStatus": "Processing", "flowsTitle": "Wallet logs", - "creditFlowsTitle": "Credit activity", + "creditFlowsTitle": "Limit and settlement activity", "playerChannel": { "credit": "Credit line: bet holds, draw settlement, and period payments (not wallet payouts)", "wallet": "Main-site wallet player (transfers & wallet ledger)" }, - "creditAvailableAfter": "Available credit after", + "creditAvailableAfter": "Available limit after", "creditReleasedAmount": "Released credit {{amount}}", "creditPaymentRecordOnly": "Billing payment record only; not counted in available credit", "totalRecords": "{{total}} records", "emptyLogs": "No wallet logs", - "emptyCreditLogs": "No credit activity", + "emptyCreditLogs": "No limit or settlement activity", "noMoreLogs": "No more transactions", "balanceAfter": "Balance after", "wsBalanceUpdated": "Balance {{change}} ({{reason}})", @@ -565,11 +580,11 @@ }, "creditFlow": { "all": "All", - "bet": "Bet hold", - "game_settlement": "Draw settlement", + "bet": "Bet", + "game_settlement": "Draw results", "rebate": "Period rebate", "credit_release": "Credit release", - "bill_settlement": "Period payment", + "bill_settlement": "Period settlement", "win_credit": "Win release", "refund": "Period confirm release", "reversal": "Refund release" @@ -612,8 +627,29 @@ "1009": "The main site could not complete this transfer. Please try again later.", "1010": "Do not use the same idempotency key for transfers with different amounts." }, - "settlementTitle": "My Billing Period Bills", - "settlementHint": "Showing only unsettled bills. Winnings exceeding your credit limit are settled here.", + "activity": { + "bet": "Bet", + "settledLoss": "Not a winner", + "settledWin": "Winner", + "result": "Draw result", + "refunded": "Refunded", + "periodPaid": "Paid", + "periodReceived": "Received", + "awaitingDraw": "Awaiting draw", + "processing": "Processing", + "reversed": "Reversed", + "details": "View details", + "stake": "Bet amount", + "win": "Win amount", + "rebate": "Rebate", + "ticketCount": "Bet lines", + "ticketCountValue": "{{count}} item(s)", + "viewOrder": "View bet" + }, + "settlementTitle": "Period settlement", + "settlementHint": "See the amount currently due to you or from you.", + "settlementClear": "Nothing to settle right now", + "settlementDetails": "View period details", "settlementPendingCount": "{{count}} bill(s)", "pendingReceivable": "Receivable", "pendingPayable": "Payable", diff --git a/src/i18n/locales/ne/entry.json b/src/i18n/locales/ne/entry.json index 65fb231..35a0f6d 100644 --- a/src/i18n/locales/ne/entry.json +++ b/src/i18n/locales/ne/entry.json @@ -65,6 +65,7 @@ "captcha": "क्याप्चा", "captchaPlaceholder": "क्याप्चा प्रविष्ट गर्नुहोस्", "captchaRequired": "कृपया पहिले क्याप्चा लोड गर्नुहोस्", + "captchaCodeRequired": "कृपया क्याप्चा प्रविष्ट गर्नुहोस्", "captchaLoadFailed": "क्याप्चा लोड असफल। फेरि प्रयास गर्नुहोस्।", "captchaLoading": "लोड हुँदै…", "captchaRefresh": "क्याप्चा रिफ्रेस", @@ -78,6 +79,7 @@ "noTokenDetail": "कृपया मुख्य साइटमा फर्कनुहोस् र फेरि प्रयास गर्नुहोस्।", "sessionExpired": "लगइन म्याद सकियो", "sessionExpiredDetail": "कृपया मुख्य साइटमा फर्केर लटरी हल फेरि खोल्नुहोस्।", + "sessionReplaced": "यो खाता अर्को उपकरणमा लगइन गरिएको छ। कृपया फेरि लगइन गर्नुहोस्।", "authFailed": "प्राधिकरण असफल", "unknown": "अज्ञात त्रुटि", "network": "नेटवर्क त्रुटि भयो", diff --git a/src/i18n/locales/ne/player.json b/src/i18n/locales/ne/player.json index 78af81d..c740f91 100644 --- a/src/i18n/locales/ne/player.json +++ b/src/i18n/locales/ne/player.json @@ -26,7 +26,7 @@ "orders": "मेरा बेट", "rules": "नियम", "wallet": "वालेट", - "credit": "क्रेडिट" + "credit": "सीमा" }, "notifications": { "title": "मिलान बाँकी सूचना", @@ -162,7 +162,7 @@ "closesIn": "बन्द हुन बाँकी", "drawsIn": "ड्र हुन बाँकी", "drawProcessing": "ड्रअ प्रक्रियामा", - "coolDown": "कुल डाउन", + "coolDown": "स्वचालित सेटलमेन्टसम्म", "loadFailedRefresh": "लोड असफल भयो। तल तानेर रिफ्रेस गर्नुहोस्।", "issueNo": "इश्यू नं.", "hall": "बेटिङ हल", @@ -177,7 +177,7 @@ "closed": "ड्र पर्खँदै", "drawing": "ड्र हुँदैछ", "review": "समीक्षामा", - "cooldown": "कुल डाउन", + "cooldown": "नतिजा पुष्टि हुँदैछ", "settling": "सेटल हुँदैछ", "settled": "सेटल भयो", "cancelled": "रद्द" @@ -288,6 +288,7 @@ "no": "नं.", "number": "नम्बर", "selectionType": "प्रकार", + "total": "जम्मा", "rowTotal": "पंक्ति जम्मा", "selectAllTypes": "सबै प्रकार छान्नुहोस्", "setAllTypes": "सबै पंक्तिको प्रकार सेट गर्नुहोस्", @@ -338,6 +339,12 @@ "big": "ठू", "small": "सा" }, + "digitPosition": { + "0": "हजार", + "1": "सय", + "2": "दश", + "3": "एकाइ" + }, "filledPlayCount": "{{count}} प्ले भरियो", "tapToFill": "रकम लेख्न ट्याप गर्नुहोस्", "rowActual": "वास्तविक", @@ -493,6 +500,10 @@ "logsTitle": "वालेट लग", "logsSubtitle": "ट्रान्सफर र बेट रेकर्ड", "typeFilter": "प्रकार फिल्टर", + "logType": "प्रकार", + "logTime": "समय", + "logRef": "सम्बन्धित नम्बर", + "logAmount": "रकम", "transferInTitle": "फन्ड ट्रान्सफर इन", "transferOutTitle": "फन्ड ट्रान्सफर आउट", "transferInSubtitle": "लटरी वालेटमा रकम थप्नुहोस् ({{currency}})", @@ -525,29 +536,33 @@ "viewPendingReconcile": "मिलान बाँकी हेर्नुहोस् ({{count}})", "pendingStatus": "प्रक्रिया हुँदैछ", "flowsTitle": "वालेट लग", - "creditTitle": "क्रेडिट", - "creditSubtitle": "क्रेडिट प्रयोग र बिलिङ अवधि लेनदेन", - "creditAvailable": "उपलब्ध क्रेडिट", + "creditTitle": "सीमा", + "creditSubtitle": "उपलब्ध सीमा र अवधि सेटलमेन्ट", + "creditAvailable": "उपलब्ध सीमा", + "creditAvailableHint": "अहिले बेट गर्न उपलब्ध", + "creditDetails": "सीमा विवरण", "creditSummary": "सीमा {{limit}} · प्रयोग {{used}}", + "creditLimit": "कुल सीमा", + "usedCredit": "प्रयोगमा", "creditNoTransferHint": "क्रेडिट खाता एजेन्टले दिन्छ; मुख्य साइट ट्रान्सफर छैन। जित तुरुन्त भुक्तानी हुँदैन—नाफा/नोक्सान बिलिङ अवधिमा मिल्छ।", - "creditFlowsTitle": "क्रेडिट लेनदेन", - "creditLogsTitle": "क्रेडिट लेनदेन", - "creditLogsSubtitle": "ओगट, मुक्ति र बिलिङ भुक्तानी", + "creditFlowsTitle": "सीमा र सेटलमेन्ट रेकर्ड", + "creditLogsTitle": "सीमा र सेटलमेन्ट रेकर्ड", + "creditLogsSubtitle": "बेट, ड्र नतिजा र अवधि सेटलमेन्ट", "playerChannel": { "credit": "क्रेडिट लाइन: बेट होल्ड, ड्र सेटलमेन्ट र अवधि भुक्तानी (वालेट भुक्तानी होइन)", "wallet": "मुख्य साइट वालेट खेलाडी" }, - "creditAvailableAfter": "पछि उपलब्ध क्रेडिट", + "creditAvailableAfter": "परिवर्तनपछि उपलब्ध सीमा", "creditReleasedAmount": "मुक्त क्रेडिट {{amount}}", "creditPaymentRecordOnly": "बिलिङ भुक्तानी रेकर्ड मात्र; उपलब्ध क्रेडिटमा गणना हुँदैन", - "emptyCreditLogs": "क्रेडिट लेनदेन छैन", + "emptyCreditLogs": "सीमा वा सेटलमेन्ट रेकर्ड छैन", "creditFlow": { "all": "सबै", - "bet": "बेट होल्ड", - "game_settlement": "ड्र सेटलमेन्ट", + "bet": "बेट", + "game_settlement": "ड्र नतिजा", "rebate": "अवधि रिबेट", "credit_release": "मुक्ति", - "bill_settlement": "अवधि भुक्तानी", + "bill_settlement": "अवधि सेटलमेन्ट", "win_credit": "जित मुक्ति", "refund": "अवधि पुष्टि मुक्ति", "reversal": "फिर्ता मुक्ति" @@ -612,8 +627,29 @@ "timeout": "अनुरोध timeout भयो। कृपया पछि प्रयास गर्नुहोस्।", "fallback": "अनुरोध असफल। कृपया पछि प्रयास गर्नुहोस्।" }, - "settlementTitle": "मेरो बिलिङ अवधि बिलहरू", - "settlementHint": "केवल बाँकी बिलहरू देखाउँदै। क्रेडिट सीमाभन्दा बढी जित रकम यहाँ सेटल हुन्छ।", + "activity": { + "bet": "बेट", + "settledLoss": "जितेन", + "settledWin": "जित", + "result": "ड्र नतिजा", + "refunded": "फिर्ता भयो", + "periodPaid": "भुक्तानी भयो", + "periodReceived": "प्राप्त भयो", + "awaitingDraw": "ड्र पर्खँदै", + "processing": "प्रक्रियामा", + "reversed": "रिभर्स भयो", + "details": "विवरण हेर्नुहोस्", + "stake": "बेट रकम", + "win": "जित रकम", + "rebate": "रिबेट", + "ticketCount": "बेट संख्या", + "ticketCountValue": "{{count}} आइटम", + "viewOrder": "बेट हेर्नुहोस्" + }, + "settlementTitle": "अवधि सेटलमेन्ट", + "settlementHint": "अहिले प्राप्त वा भुक्तानी गर्नुपर्ने रकम हेर्नुहोस्।", + "settlementClear": "अहिले सेटल गर्नुपर्ने केही छैन", + "settlementDetails": "अवधि विवरण हेर्नुहोस्", "settlementPendingCount": "{{count}} बिल(हरू)", "pendingReceivable": "प्राप्त हुन बाँकी", "pendingPayable": "भुक्तानी हुन बाँकी", diff --git a/src/i18n/locales/zh/entry.json b/src/i18n/locales/zh/entry.json index ab077d6..687000e 100644 --- a/src/i18n/locales/zh/entry.json +++ b/src/i18n/locales/zh/entry.json @@ -65,6 +65,7 @@ "captcha": "验证码", "captchaPlaceholder": "请输入验证码", "captchaRequired": "请先加载验证码", + "captchaCodeRequired": "请输入验证码", "captchaLoadFailed": "验证码加载失败,请重试", "captchaLoading": "加载中…", "captchaRefresh": "刷新验证码", @@ -78,6 +79,7 @@ "noTokenDetail": "请返回主站后重试。", "sessionExpired": "登录已过期", "sessionExpiredDetail": "请返回主站重新进入彩票大厅。", + "sessionReplaced": "该账号已在其他设备登录,请重新登录", "authFailed": "授权失败", "unknown": "未知错误", "network": "网络异常", diff --git a/src/i18n/locales/zh/player.json b/src/i18n/locales/zh/player.json index 625635f..2f042c2 100644 --- a/src/i18n/locales/zh/player.json +++ b/src/i18n/locales/zh/player.json @@ -26,7 +26,7 @@ "orders": "我的注单", "rules": "规则", "wallet": "钱包", - "credit": "信用" + "credit": "额度" }, "notifications": { "title": "待对账提醒", @@ -162,7 +162,7 @@ "closesIn": "距封盘", "drawsIn": "距开奖", "drawProcessing": "开奖处理中", - "coolDown": "冷静期", + "coolDown": "距自动结算", "loadFailedRefresh": "加载失败,请下拉刷新", "issueNo": "期号", "hall": "下注大厅", @@ -177,7 +177,7 @@ "closed": "待开奖", "drawing": "开奖中", "review": "待审核", - "cooldown": "冷静期", + "cooldown": "开奖结果确认中", "settling": "结算中", "settled": "已结算", "cancelled": "已取消" @@ -287,6 +287,7 @@ "no": "序号", "number": "号码", "selectionType": "种类", + "total": "合计", "rowTotal": "本行合计", "selectAllTypes": "全选种类", "setAllTypes": "统一设置所有行的种类", @@ -337,6 +338,12 @@ "big": "大", "small": "小" }, + "digitPosition": { + "0": "千", + "1": "百", + "2": "十", + "3": "个" + }, "filledPlayCount": "已填写 {{count}} 个玩法", "tapToFill": "点击填写玩法金额", "rowActual": "实扣", @@ -483,11 +490,15 @@ "wallet": { "title": "钱包", "subtitle": "余额与划转记录", - "creditTitle": "信用", - "creditSubtitle": "额度占用与账期流水", + "creditTitle": "额度", + "creditSubtitle": "可用额度与账期结算", "balance": "钱包余额", - "creditAvailable": "可用信用", + "creditAvailable": "可用额度", + "creditAvailableHint": "当前可用于下注", + "creditDetails": "额度详情", "creditSummary": "授信 {{limit}} · 已用 {{used}}", + "creditLimit": "总额度", + "usedCredit": "使用中", "creditNoTransferHint": "由代理授信,无需主站转入转出;中奖不即时派彩,盈亏在账期统一结算,额度调整请联系代理。", "available": "可用 {{amount}}", "transferIn": "转入", @@ -497,9 +508,13 @@ "logs": "流水", "logsTitle": "钱包流水", "logsSubtitle": "划转与下注记录", - "creditLogsTitle": "信用流水", - "creditLogsSubtitle": "占用、释额与账期收付", + "creditLogsTitle": "额度与结算记录", + "creditLogsSubtitle": "下注、开奖结果与账期结算", "typeFilter": "类型筛选", + "logType": "类型", + "logTime": "时间", + "logRef": "关联单号", + "logAmount": "金额", "transferInTitle": "转入资金", "transferOutTitle": "转出资金", "transferInSubtitle": "转入彩票钱包({{currency}})", @@ -532,17 +547,17 @@ "viewPendingReconcile": "查看待对账详情({{count}})", "pendingStatus": "处理中", "flowsTitle": "资金流水", - "creditFlowsTitle": "信用流水", + "creditFlowsTitle": "额度与结算记录", "playerChannel": { "credit": "信用盘:展示下注冻结、开奖结算与账期收付(非钱包派彩)", "wallet": "主站钱包玩家(划转与钱包余额流水)" }, - "creditAvailableAfter": "变更后可用信用", + "creditAvailableAfter": "变更后可用额度", "creditReleasedAmount": "释额 {{amount}}", "creditPaymentRecordOnly": "账期收付记账,不计入可用信用", "totalRecords": "共 {{total}} 条记录", "emptyLogs": "暂无流水", - "emptyCreditLogs": "暂无信用流水", + "emptyCreditLogs": "暂无额度与结算记录", "noMoreLogs": "没有更多流水", "balanceAfter": "变更后余额", "wsBalanceUpdated": "余额 {{change}}({{reason}})", @@ -565,11 +580,11 @@ }, "creditFlow": { "all": "全部", - "bet": "下注冻结", - "game_settlement": "开奖结算", + "bet": "下注", + "game_settlement": "开奖结果", "rebate": "账期回水", "credit_release": "释额", - "bill_settlement": "账期收付", + "bill_settlement": "账期结算", "win_credit": "中奖释额", "refund": "账期确认释额", "reversal": "退本释额" @@ -612,8 +627,29 @@ "1009": "主站未能完成本次划转,请稍后重试。", "1010": "请勿用同一幂等键发起不同金额的转账。" }, - "settlementTitle": "我的账期账单", - "settlementHint": "只显示未结清账单,中奖超出授信的部分在这里结算。", + "activity": { + "bet": "下注", + "settledLoss": "未中奖", + "settledWin": "中奖", + "result": "开奖结果", + "refunded": "已退款", + "periodPaid": "已付款", + "periodReceived": "已收款", + "awaitingDraw": "待开奖", + "processing": "处理中", + "reversed": "已冲正", + "details": "查看明细", + "stake": "下注金额", + "win": "中奖金额", + "rebate": "回水", + "ticketCount": "注单数量", + "ticketCountValue": "{{count}}项", + "viewOrder": "查看注单" + }, + "settlementTitle": "账期结算", + "settlementHint": "查看当前需要收取或支付的金额。", + "settlementClear": "当前无需结算", + "settlementDetails": "查看账期明细", "settlementPendingCount": "{{count}}笔", "pendingReceivable": "待收", "pendingPayable": "待付", diff --git a/src/lib/jwt-payload.ts b/src/lib/jwt-payload.ts index bc54e47..bebb640 100644 --- a/src/lib/jwt-payload.ts +++ b/src/lib/jwt-payload.ts @@ -20,4 +20,14 @@ export function parseJwtExp(token: string | null): number | null { const payload = parseJwtPayload(token); const exp = payload?.exp; return typeof exp === "number" ? exp : null; -} \ No newline at end of file +} + +/** 返回原生玩家 JWT 的单设备会话版本;非原生或解析失败返回 null。 */ +export function parseJwtSessionVersion(token: string | null): number | null { + if (!token) return null; + const payload = parseJwtPayload(token); + const sessionVersion = payload?.session_version; + return typeof sessionVersion === "number" && Number.isInteger(sessionVersion) + ? sessionVersion + : null; +} diff --git a/src/lib/lottery-http.ts b/src/lib/lottery-http.ts index ad5638c..16e7ecd 100644 --- a/src/lib/lottery-http.ts +++ b/src/lib/lottery-http.ts @@ -32,6 +32,7 @@ export const lotteryHttp = axios.create({ /** 凭据类 401(非 token 失效),不应触发「会话过期」跳转 */ const PLAYER_CREDENTIAL_BIZ_CODES = new Set([8006]); +const PLAYER_SESSION_REPLACED_BIZ_CODE = 8010; function isPlayerAuthLoginRequest(config: AxiosRequestConfig | undefined): boolean { if (!config || (config.method ?? "get").toLowerCase() !== "post") { @@ -73,16 +74,26 @@ lotteryHttp.interceptors.response.use( // 401: 已登录态 token 失效 → 清会话并回入口;登录凭据错误等由页面自行提示 if (status === 401) { + const body = error.response?.data; + const sessionReplaced = + isApiEnvelope(body) && body.code === PLAYER_SESSION_REPLACED_BIZ_CODE; const redirectSessionExpired = shouldRedirectPlayerSessionExpired(error); if (redirectSessionExpired || window.location.pathname === "/login") { usePlayerSessionStore.getState().clearBearerToken(); } if (redirectSessionExpired) { const pathname = window.location.pathname; - const alreadyExpired = window.location.search.includes("session=expired"); - const expiredTarget = isInIframe() ? "/?session=expired" : "/login?session=expired"; + const sessionStatus = sessionReplaced ? "replaced" : "expired"; + const alreadyOnTarget = window.location.search.includes( + `session=${sessionStatus}`, + ); + const expiredTarget = sessionReplaced + ? "/login?session=replaced" + : isInIframe() + ? "/?session=expired" + : "/login?session=expired"; const onExpiredPage = - (pathname === "/" || pathname === "/login") && alreadyExpired; + (pathname === "/" || pathname === "/login") && alreadyOnTarget; if (!onExpiredPage) { window.location.replace(expiredTarget); } @@ -185,4 +196,4 @@ export const lotteryRequest = { data?: unknown, config?: Omit, ) => request({ ...config, url, method: "PATCH", data }), -}; \ No newline at end of file +}; diff --git a/src/types/api/wallet-logs.ts b/src/types/api/wallet-logs.ts index f112e15..1452ce9 100644 --- a/src/types/api/wallet-logs.ts +++ b/src/types/api/wallet-logs.ts @@ -15,6 +15,18 @@ export type WalletLogItem = { balance_after: number | null; /** 信用盘:false 表示账期收付/账期回水展示记录,不改变可用信用 */ affects_available_credit?: boolean; + /** 信用盘玩家视图:按业务事件聚合后的活动类型 */ + activity_kind?: "bet" | "draw_result" | "period_settlement" | "refund"; + activity_status?: "pending" | "completed" | "reversed"; + order_no?: string | null; + draw_no?: string | null; + ticket_no?: string | null; + ticket_count?: number; + settlement_bill_id?: number | null; + stake_amount?: number; + win_amount?: number; + rebate_amount?: number; + net_amount?: number; ref_id: string | null; idempotent_key: string | null; external_ref_no: string | null;