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