From ecb032f8cc9f4cbc21e62048b0fcdc9f07c57ffb Mon Sep 17 00:00:00 2001 From: kang Date: Mon, 22 Jun 2026 10:50:52 +0800 Subject: [PATCH] feat: enhance API error handling and UI improvements - Updated `getPublicCurrencies` and `getPlayerAuthCaptcha` functions to include `skipGlobalServerError` option, improving error handling for API requests. - Refactored the NotFoundPage component to enhance mobile responsiveness and UI consistency, integrating `PlayerMobileViewport`. - Improved the NetworkStatusBanner and OfflineBanner components by adding player banner height reference for better layout management. - Updated Wallet components to utilize `PlayerMoneyDisplay` for consistent currency representation and added credit mode handling in various screens. - Enhanced the WalletScreen and Transfer screens with loading panels and credit guard logic for better user experience during wallet operations. --- src/api/currency.ts | 4 +- src/api/player-auth.ts | 4 +- src/app/not-found.tsx | 119 ++++++++---------- .../layout/player-notification-bell.tsx | 31 +---- src/components/layout/player-panel.tsx | 20 +-- src/components/network-status-banner.tsx | 36 +++--- src/components/offline-banner.tsx | 48 +++---- src/components/player-money-display.tsx | 29 +++++ src/features/hall/hall-bet-result-dialog.tsx | 11 +- src/features/hall/hall-betting-grid.tsx | 6 +- src/features/hall/hall-draw-panel.tsx | 2 +- src/features/hall/hall-screen.tsx | 26 ++-- src/features/hall/hall-wallet-strip.tsx | 43 +++---- .../orders/ticket-orders-list-screen.tsx | 20 +-- src/features/player/entry-gate.tsx | 3 +- src/features/player/notifications-screen.tsx | 13 ++ src/features/player/player-login-screen.tsx | 10 +- .../results/draw-result-detail-screen.tsx | 15 ++- .../results/draw-results-list-screen.tsx | 15 ++- src/features/wallet/transfer-in-screen.tsx | 33 +++-- src/features/wallet/transfer-out-screen.tsx | 23 ++-- src/features/wallet/wallet-logs-block.tsx | 2 +- src/features/wallet/wallet-logs-screen.tsx | 1 - .../wallet-pending-reconcile-banner.tsx | 45 +++++++ src/features/wallet/wallet-screen.tsx | 82 +++++++++--- .../wallet/wallet-transfer-credit-guard.tsx | 57 +++++++++ .../wallet/wallet-transfer-loading-panel.tsx | 29 +++++ src/hooks/use-pending-wallet-reconcile.ts | 6 +- src/hooks/use-player-banner-height-ref.ts | 34 +++++ src/hooks/use-player-sticky-top-style.ts | 16 +++ src/i18n/locales/en/entry.json | 4 +- src/i18n/locales/en/player.json | 45 ++++++- src/i18n/locales/ne/entry.json | 4 +- src/i18n/locales/ne/player.json | 45 ++++++- src/i18n/locales/zh/entry.json | 4 +- src/i18n/locales/zh/player.json | 45 ++++++- src/lib/iframe-origins.ts | 2 +- src/lib/lottery-http-server-error.ts | 38 ++++++ src/lib/lottery-http.ts | 34 ++--- src/lib/player-spacing.ts | 2 +- src/lib/results-prize-labels.ts | 19 +++ src/stores/player-top-banner-store.ts | 20 +++ src/types/axios.d.ts | 8 ++ 43 files changed, 756 insertions(+), 297 deletions(-) create mode 100644 src/components/player-money-display.tsx create mode 100644 src/features/wallet/wallet-pending-reconcile-banner.tsx create mode 100644 src/features/wallet/wallet-transfer-credit-guard.tsx create mode 100644 src/features/wallet/wallet-transfer-loading-panel.tsx create mode 100644 src/hooks/use-player-banner-height-ref.ts create mode 100644 src/hooks/use-player-sticky-top-style.ts create mode 100644 src/lib/lottery-http-server-error.ts create mode 100644 src/lib/results-prize-labels.ts create mode 100644 src/stores/player-top-banner-store.ts create mode 100644 src/types/axios.d.ts diff --git a/src/api/currency.ts b/src/api/currency.ts index 0c04d60..64ade0f 100644 --- a/src/api/currency.ts +++ b/src/api/currency.ts @@ -3,5 +3,7 @@ import type { PublicCurrencyListData } from "@/types/api/currency"; /** `GET /api/v1/currencies`(公开) */ export function getPublicCurrencies(): Promise { - return lotteryRequest.get(`/currencies`); + return lotteryRequest.get(`/currencies`, { + skipGlobalServerError: true, + }); } diff --git a/src/api/player-auth.ts b/src/api/player-auth.ts index bf5d953..4fdc0a3 100644 --- a/src/api/player-auth.ts +++ b/src/api/player-auth.ts @@ -29,7 +29,9 @@ export type PlayerAuthLoginData = { /** `GET /api/v1/player/auth/captcha`(公开) */ export function getPlayerAuthCaptcha(): Promise { - return lotteryRequest.get(`/player/auth/captcha`); + return lotteryRequest.get(`/player/auth/captcha`, { + skipGlobalServerError: true, + }); } /** `POST /api/v1/player/auth/login`(公开) */ diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx index 94bd6f8..39c2f2e 100644 --- a/src/app/not-found.tsx +++ b/src/app/not-found.tsx @@ -4,87 +4,70 @@ import Link from "next/link"; import { FileQuestion, Home } from "lucide-react"; import { useTranslation } from "react-i18next"; -import { ERROR_COLORS } from "@/stores/error-store"; +import { PlayerMobileViewport } from "@/components/layout/player-mobile-viewport"; +import { playerViewportColumnClass } from "@/lib/player-viewport"; +import { cn } from "@/lib/utils"; import "@/i18n"; -/** - * 全局 404 页面 - * 当访问不存在的路由时显示 - * 消息:"页面不存在" - * 包含 "返回首页" 按钮 - * 使用中性颜色 #d9d9d9 作为背景/插图 - */ export default function NotFoundPage(): React.ReactElement { const { t } = useTranslation("player"); return ( -
-
-
- {/* 404 数字 */} -
- 404 -
+ +
+
+
+
+ +
- {/* 图标 */} -
- -
+

404

- {/* 标题 */} -

- {t("notFound.title")} -

+

+ {t("notFound.title")} +

- {/* 描述 */} -

- {t("notFound.description")} -

+

+ {t("notFound.description")} +

- {/* 返回首页按钮 */} - - - {t("notFound.home")} - - - {/* 帮助链接 */} -
- - {t("notFound.hall")} - - | - - {t("notFound.results")} - - | - - {t("notFound.wallet")} + + + {t("notFound.home")} + +
+ + {t("notFound.hall")} + + + | + + + {t("notFound.results")} + + + | + + + {t("notFound.funds")} + +
-
- {/* 品牌信息 */} -

- Lottery © {new Date().getFullYear()} -

-
+

+ Lottery © {new Date().getFullYear()} +

+
+ ); } diff --git a/src/components/layout/player-notification-bell.tsx b/src/components/layout/player-notification-bell.tsx index e3a62c5..8b5b9ae 100644 --- a/src/components/layout/player-notification-bell.tsx +++ b/src/components/layout/player-notification-bell.tsx @@ -1,37 +1,12 @@ "use client"; -import { Bell } from "lucide-react"; -import Link from "next/link"; import { type ReactElement } from "react"; -import { useTranslation } from "react-i18next"; - -import { usePendingWalletReconcile } from "@/hooks/use-pending-wallet-reconcile"; -import { playerHeaderControl } from "@/lib/player-spacing"; -import { cn } from "@/lib/utils"; type PlayerNotificationBellProps = { className?: string; }; -/** 顶栏铃铛:待对账划转提醒 */ -export function PlayerNotificationBell({ className }: PlayerNotificationBellProps): ReactElement { - const { t } = useTranslation("common"); - const { hasUnread } = usePendingWalletReconcile(); - - return ( - - - {hasUnread ? ( - - ) : null} - - ); +/** 顶栏铃铛:暂隐藏,待接入通用通知中心后再开放 */ +export function PlayerNotificationBell(_props: PlayerNotificationBellProps): ReactElement | null { + return null; } diff --git a/src/components/layout/player-panel.tsx b/src/components/layout/player-panel.tsx index 39ce2ee..caf28c0 100644 --- a/src/components/layout/player-panel.tsx +++ b/src/components/layout/player-panel.tsx @@ -7,7 +7,7 @@ import { ChevronLeft } from "lucide-react"; import { useTranslation } from "react-i18next"; import { LanguageSwitcher } from "@/components/language-switcher"; -import { PlayerNotificationBell } from "@/components/layout/player-notification-bell"; +import { usePlayerStickyTopStyle } from "@/hooks/use-player-sticky-top-style"; import { playerHeaderControl, playerPageHeader, @@ -33,12 +33,13 @@ export function PlayerPanel({ }: PlayerPanelProps) { const { t: tp } = useTranslation("player"); const resolvedBackLabel = backLabel ?? tp("panel.home"); + const stickyTopStyle = usePlayerStickyTopStyle(); return (
-
+
- {resolvedBackLabel} + {resolvedBackLabel}
-

+

{title}

-
+
-
{children} diff --git a/src/components/network-status-banner.tsx b/src/components/network-status-banner.tsx index 9a896a6..f3e47c6 100644 --- a/src/components/network-status-banner.tsx +++ b/src/components/network-status-banner.tsx @@ -4,7 +4,10 @@ import { WifiOff, RefreshCw, AlertCircle } from "lucide-react"; import { useCallback } from "react"; import { useTranslation } from "react-i18next"; +import { usePlayerBannerHeightRef } from "@/hooks/use-player-banner-height-ref"; import { useWebSocketManager } from "@/hooks/use-websocket-manager"; +import { playerViewportColumnClass } from "@/lib/player-viewport"; +import { useErrorStore } from "@/stores/error-store"; import { cn } from "@/lib/utils"; // 颜色常量(来自用户需求) @@ -21,32 +24,36 @@ const COLORS = { */ export function NetworkStatusBanner(): React.ReactElement | null { const { showDegradedBanner, mode, reconnect } = useWebSocketManager(); + const browserOffline = useErrorStore((state) => state.isOffline); + const bannerRef = usePlayerBannerHeightRef("network"); const { t } = useTranslation("player"); const handleReconnectClick = useCallback(() => { reconnect(); }, [reconnect]); - // 只有在降级模式或离线模式下才显示 - if (!showDegradedBanner) { + // 浏览器离线时由 OfflineBanner 统一提示,避免重复 + if (!showDegradedBanner || browserOffline) { return null; } const isOffline = mode === "offline"; return ( -
+
+
{isOffline ? ( <> @@ -74,6 +81,7 @@ export function NetworkStatusBanner(): React.ReactElement | null { )} +
); } diff --git a/src/components/offline-banner.tsx b/src/components/offline-banner.tsx index 3dd79c5..1169639 100644 --- a/src/components/offline-banner.tsx +++ b/src/components/offline-banner.tsx @@ -4,6 +4,8 @@ import { WifiOff, RefreshCw } from "lucide-react"; import { useCallback } from "react"; import { useTranslation } from "react-i18next"; +import { usePlayerBannerHeightRef } from "@/hooks/use-player-banner-height-ref"; +import { playerViewportColumnClass } from "@/lib/player-viewport"; import { useErrorStore, ERROR_COLORS } from "@/stores/error-store"; import { cn } from "@/lib/utils"; @@ -14,6 +16,7 @@ import { cn } from "@/lib/utils"; */ export function OfflineBanner(): React.ReactElement | null { const isOffline = useErrorStore((state) => state.isOffline); + const bannerRef = usePlayerBannerHeightRef("offline"); const { t } = useTranslation("player"); const handleReconnect = useCallback(() => { @@ -29,28 +32,31 @@ export function OfflineBanner(): React.ReactElement | null { } return ( -
- - {t("network.offline")} - + + {t("network.offline")} + +
); } diff --git a/src/components/player-money-display.tsx b/src/components/player-money-display.tsx new file mode 100644 index 0000000..d802003 --- /dev/null +++ b/src/components/player-money-display.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { formatMinorAsCurrency } from "@/lib/money"; +import { cn } from "@/lib/utils"; + +type PlayerMoneyDisplayProps = { + amountMinor: number; + currency: string; + className?: string; +}; + +/** 玩家端金额展示:自适应字号 + 防大额截断 */ +export function PlayerMoneyDisplay({ + amountMinor, + currency, + className, +}: PlayerMoneyDisplayProps) { + return ( +

+ {formatMinorAsCurrency(amountMinor, currency)} +

+ ); +} diff --git a/src/features/hall/hall-bet-result-dialog.tsx b/src/features/hall/hall-bet-result-dialog.tsx index a38f665..9dbd058 100644 --- a/src/features/hall/hall-bet-result-dialog.tsx +++ b/src/features/hall/hall-bet-result-dialog.tsx @@ -23,6 +23,7 @@ type HallBetResultDialogProps = { currencyCode: string; data: TicketPlaceData | null; jackpotEnabled?: boolean; + creditMode?: boolean; }; const SUCCESS_ITEM_STATUSES = new Set(["pending_draw", "placed"]); @@ -34,6 +35,7 @@ export function HallBetResultDialog({ currencyCode, data, jackpotEnabled = false, + creditMode = false, }: HallBetResultDialogProps) { const { t } = useTranslation("player"); @@ -178,7 +180,10 @@ export function HallBetResultDialog({ {data.order_no}

- {t("hall.result.balanceAfter")}:{" "} + {creditMode + ? t("hall.result.creditBalanceAfter", { defaultValue: "可用信用" }) + : t("hall.result.balanceAfter")} + :{" "} {formatMinorAsCurrency(data.balance_after, currencyCode)} @@ -200,7 +205,9 @@ export function HallBetResultDialog({ - + diff --git a/src/features/hall/hall-betting-grid.tsx b/src/features/hall/hall-betting-grid.tsx index 1768b1b..80cd830 100644 --- a/src/features/hall/hall-betting-grid.tsx +++ b/src/features/hall/hall-betting-grid.tsx @@ -25,6 +25,7 @@ import { import type { HallDrawLiveSnapshot } from "@/features/hall/use-hall-draw-live"; import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency"; import { triggerWalletPollingAfterBet } from "@/hooks/use-wallet-polling"; +import { usePlayerSessionStore } from "@/stores/player-session-store"; import { getLotteryEcho } from "@/lib/lottery-echo"; import { formatMinorAsCurrency, parseDecimalInputToMinor } from "@/lib/money"; import { playLabel } from "@/lib/play-labels"; @@ -33,6 +34,7 @@ import { type PlayCatalogRefreshSource, } from "@/lib/play-catalog-events"; import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference"; +import { isCreditFundingPlayer } from "@/lib/player-funding-mode"; import { cn } from "@/lib/utils"; import { LotteryApiBizError } from "@/types/api/errors"; import type { PlayEffectivePayload, PlayEffectivePlayRow } from "@/types/api/play-effective"; @@ -441,6 +443,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } const { display, isBettable, reload: reloadDraw } = drawLive; const { t } = useTranslation("player"); const { activeCurrency: currencyParam } = useActivePlayerCurrency(); + const creditMode = isCreditFundingPlayer(usePlayerSessionStore((state) => state.profile)); const [activeCategory, setActiveCategory] = useState("D2"); const [rows, setRows] = useState(() => [newDraftRow()]); @@ -1241,7 +1244,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } ); diff --git a/src/features/player/entry-gate.tsx b/src/features/player/entry-gate.tsx index 8c952b6..c82e79a 100644 --- a/src/features/player/entry-gate.tsx +++ b/src/features/player/entry-gate.tsx @@ -8,6 +8,7 @@ import { ChevronRight, Globe, Loader2, + RefreshCw, ShieldCheck, } from "lucide-react"; import Image from "next/image"; @@ -502,7 +503,7 @@ export function EntryGate() { size="lg" type="button" > - + {t("failure.reenter")} {MAIN_SITE_URL !== "" ? ( diff --git a/src/features/player/notifications-screen.tsx b/src/features/player/notifications-screen.tsx index 1745298..1fe90c6 100644 --- a/src/features/player/notifications-screen.tsx +++ b/src/features/player/notifications-screen.tsx @@ -9,14 +9,17 @@ import { PlayerPanel } from "@/components/layout/player-panel"; import { usePendingWalletReconcile } from "@/hooks/use-pending-wallet-reconcile"; import { formatPlayerInstant } from "@/lib/player-datetime"; import { formatMinorAsCurrency } from "@/lib/money"; +import { isCreditFundingPlayer } from "@/lib/player-funding-mode"; import { pendingReconcileDescriptionKey, pendingReconcileTitleKey, } from "@/lib/pending-reconcile-notification"; +import { usePlayerSessionStore } from "@/stores/player-session-store"; import { cn } from "@/lib/utils"; export function NotificationsScreen() { const { t } = useTranslation("player"); + const creditMode = isCreditFundingPlayer(usePlayerSessionStore((state) => state.profile)); const { pending, unreadPending, unreadCount, loading, markAsRead, markAllAsRead } = usePendingWalletReconcile(); const unreadSet = new Set(unreadPending.map((item) => item.transfer_no)); @@ -24,6 +27,14 @@ export function NotificationsScreen() { return (
+ {creditMode ? ( +
+ {t("notifications.creditEmptyHint", { + defaultValue: "信用盘无主站划转,暂无待对账通知。", + })} +
+ ) : ( + <>

{t("notifications.unreadCount", { count: unreadCount })} @@ -126,6 +137,8 @@ export function NotificationsScreen() { })} ) : null} + + )}

); diff --git a/src/features/player/player-login-screen.tsx b/src/features/player/player-login-screen.tsx index 41e21dd..0d59ca3 100644 --- a/src/features/player/player-login-screen.tsx +++ b/src/features/player/player-login-screen.tsx @@ -113,19 +113,13 @@ export function PlayerLoginScreen(): React.ReactElement { const usernameIssue = validatePlayerLoginUsername(username); if (usernameIssue === "invalid_charset") { - toast.error( - t("login.usernameInvalidCharset", { - defaultValue: "账号只能使用字母、数字、点(.)、下划线和连字符", - }), - ); + toast.error(t("login.usernameInvalidCharset")); return; } const passwordIssue = validatePlayerLoginPassword(password); if (passwordIssue === "too_short") { - toast.error( - t("login.passwordMinLength", { defaultValue: "密码至少需要 6 个字符" }), - ); + toast.error(t("login.passwordMinLength")); return; } diff --git a/src/features/results/draw-result-detail-screen.tsx b/src/features/results/draw-result-detail-screen.tsx index c11137b..ef6c358 100644 --- a/src/features/results/draw-result-detail-screen.tsx +++ b/src/features/results/draw-result-detail-screen.tsx @@ -25,6 +25,7 @@ import { formatMinorAsCurrency } from "@/lib/money"; import { isCreditFundingPlayer } from "@/lib/player-funding-mode"; import { norm4d } from "@/lib/norm-4d"; import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency"; +import { resultsPrizeLabelKey, RESULTS_TOP_PRIZE_KEYS } from "@/lib/results-prize-labels"; import { cn } from "@/lib/utils"; import { usePlayerSessionStore } from "@/stores/player-session-store"; import type { DrawResultDetailPayload } from "@/types/api/draw-results"; @@ -218,18 +219,16 @@ export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps)
- {[ - ["1st", data.results["1st"]], - ["2nd", data.results["2nd"]], - ["3rd", data.results["3rd"]], - ].map(([label, value]) => ( + {RESULTS_TOP_PRIZE_KEYS.map((tier) => (
-

{label}

+

+ {t(resultsPrizeLabelKey(tier))} +

- {value} + {data.results[tier]}

))} diff --git a/src/features/results/draw-results-list-screen.tsx b/src/features/results/draw-results-list-screen.tsx index 88f823c..85470b9 100644 --- a/src/features/results/draw-results-list-screen.tsx +++ b/src/features/results/draw-results-list-screen.tsx @@ -23,6 +23,7 @@ import { TwentyThreeResultsGrid } from "@/features/results/twenty-three-results- import { useCurrencyCatalog } from "@/hooks/use-currency-catalog"; import { formatPlayerInstant } from "@/lib/player-datetime"; import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency"; +import { resultsPrizeLabelKey, RESULTS_TOP_PRIZE_KEYS } from "@/lib/results-prize-labels"; import type { DrawResultListItem } from "@/types/api/draw-results"; const RESULTS_PAGE_SIZE = 10; @@ -292,15 +293,13 @@ export function DrawResultsListScreen() {
- {[ - ["1st", row.results["1st"]], - ["2nd", row.results["2nd"]], - ["3rd", row.results["3rd"]], - ].map(([label, value]) => ( -
-

{label}

+ {RESULTS_TOP_PRIZE_KEYS.map((tier) => ( +
+

+ {t(resultsPrizeLabelKey(tier))} +

- {value} + {row.results[tier]}

))} diff --git a/src/features/wallet/transfer-in-screen.tsx b/src/features/wallet/transfer-in-screen.tsx index b2eb936..873dc76 100644 --- a/src/features/wallet/transfer-in-screen.tsx +++ b/src/features/wallet/transfer-in-screen.tsx @@ -6,7 +6,8 @@ import { useSWRConfig } from "swr"; import { getWalletBalance } from "@/api/wallet"; import { TransferInPage } from "@/features/wallet/wallet-transfer-forms"; -import { Skeleton } from "@/components/ui/skeleton"; +import { WalletTransferCreditGuard } from "@/features/wallet/wallet-transfer-credit-guard"; +import { WalletTransferLoadingPanel } from "@/features/wallet/wallet-transfer-loading-panel"; import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency"; import { useApiQuery } from "@/hooks/use-api-query"; import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference"; @@ -24,7 +25,6 @@ export function TransferInScreen() { () => getWalletBalance({ currency }), ); - // 币种切换时 SWR key 自动变化,此处仅处理全局事件触发的刷新 useEffect(() => { const onRefresh = () => void mutate(BALANCE_KEY(currency)); window.addEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onRefresh); @@ -37,24 +37,21 @@ export function TransferInScreen() { }, [mutate, currency, router]); if (loading && !balance) { - return ( -
- - -
- ); + return ; } return ( - + + + ); } diff --git a/src/features/wallet/transfer-out-screen.tsx b/src/features/wallet/transfer-out-screen.tsx index 02c6979..771b35d 100644 --- a/src/features/wallet/transfer-out-screen.tsx +++ b/src/features/wallet/transfer-out-screen.tsx @@ -6,7 +6,8 @@ import { useSWRConfig } from "swr"; import { getWalletBalance } from "@/api/wallet"; import { TransferOutPage } from "@/features/wallet/wallet-transfer-forms"; -import { Skeleton } from "@/components/ui/skeleton"; +import { WalletTransferCreditGuard } from "@/features/wallet/wallet-transfer-credit-guard"; +import { WalletTransferLoadingPanel } from "@/features/wallet/wallet-transfer-loading-panel"; import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency"; import { useApiQuery } from "@/hooks/use-api-query"; import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference"; @@ -24,7 +25,6 @@ export function TransferOutScreen() { () => getWalletBalance({ currency }), ); - // 币种切换时 SWR key 自动变化,此处仅处理全局事件触发的刷新 useEffect(() => { const onRefresh = () => void mutate(BALANCE_KEY(currency)); window.addEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onRefresh); @@ -37,19 +37,16 @@ export function TransferOutScreen() { }, [mutate, currency, router]); if (loading && !balance) { - return ( -
- - -
- ); + return ; } return ( - + + + ); } diff --git a/src/features/wallet/wallet-logs-block.tsx b/src/features/wallet/wallet-logs-block.tsx index ba4f698..4396778 100644 --- a/src/features/wallet/wallet-logs-block.tsx +++ b/src/features/wallet/wallet-logs-block.tsx @@ -78,7 +78,7 @@ type WalletLogsBlockProps = { creditMode?: boolean; }; -/** 类型筛选 + 列表(待对账见顶栏通知铃铛) */ +/** 类型筛选 + 列表(钱包盘待对账见钱包页横幅) */ export function WalletLogsBlock({ logs, logsLoading, diff --git a/src/features/wallet/wallet-logs-screen.tsx b/src/features/wallet/wallet-logs-screen.tsx index 8f7121d..c195f62 100644 --- a/src/features/wallet/wallet-logs-screen.tsx +++ b/src/features/wallet/wallet-logs-screen.tsx @@ -142,7 +142,6 @@ export function WalletLogsScreen() { onFilterChange={setFilter} currency={currency} creditMode={creditMode} - title={t("wallet.typeFilter")} />
diff --git a/src/features/wallet/wallet-pending-reconcile-banner.tsx b/src/features/wallet/wallet-pending-reconcile-banner.tsx new file mode 100644 index 0000000..790a8cd --- /dev/null +++ b/src/features/wallet/wallet-pending-reconcile-banner.tsx @@ -0,0 +1,45 @@ +"use client"; + +import Link from "next/link"; +import { AlertTriangle } from "lucide-react"; +import { useTranslation } from "react-i18next"; + +import { usePendingWalletReconcile } from "@/hooks/use-pending-wallet-reconcile"; + +/** 钱包盘:顶栏铃铛隐藏后,在钱包页展示待对账提醒入口 */ +export function WalletPendingReconcileBanner() { + const { t } = useTranslation("player"); + const { pending, unreadCount, hasPending } = usePendingWalletReconcile(); + + if (!hasPending) { + return null; + } + + return ( +
+
+ +
+

{t("wallet.pendingTitle")}

+

+ {t("wallet.pendingDescription")} +

+ {unreadCount > 0 ? ( +

+ {t("notifications.unreadCount", { count: unreadCount })} +

+ ) : null} + + {t("wallet.viewPendingReconcile", { + defaultValue: "查看待对账详情({{count}})", + count: pending.length, + })} + +
+
+
+ ); +} diff --git a/src/features/wallet/wallet-screen.tsx b/src/features/wallet/wallet-screen.tsx index dbabba3..43bd0b0 100644 --- a/src/features/wallet/wallet-screen.tsx +++ b/src/features/wallet/wallet-screen.tsx @@ -13,6 +13,8 @@ import { TransferInDialog, TransferOutDialog, } from "@/features/wallet/wallet-transfer-dialogs"; +import { WalletPendingReconcileBanner } from "@/features/wallet/wallet-pending-reconcile-banner"; +import { PlayerMoneyDisplay } from "@/components/player-money-display"; import { WalletLogsBlock } from "@/features/wallet/wallet-logs-block"; import { dispatchWalletLogsRefresh } from "@/hooks/use-pending-wallet-reconcile"; import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency"; @@ -36,8 +38,8 @@ export function WalletScreen() { const [loadingMore, setLoadingMore] = useState(false); const [error, setError] = useState(null); const loadMoreRef = useRef(null); - - const fetchPassRef = useRef(true); + const filterInitializedRef = useRef(false); + const prevFilterRef = useRef(""); const loadLogs = useCallback(async (targetPage = 1, append = false) => { const nextLogs = await getWalletLogs({ @@ -60,21 +62,21 @@ export function WalletScreen() { void (async () => { setError(null); - if (fetchPassRef.current) { - setLoading(true); - fetchPassRef.current = false; - } else { - setLogsLoading(true); - } + setLoading(true); try { - // 并行请求余额和日志,避免瀑布式串行等待 const [b, nextLogs] = await Promise.all([ getWalletBalance({ currency }), - loadLogs(1, false), + getWalletLogs({ + page: 1, + size: WALLET_LOGS_PAGE_SIZE, + type: filter || undefined, + currency, + }), ]); if (cancelled) return; setBalance(b); setLogs(nextLogs); + dispatchWalletLogsRefresh(nextLogs.pending_reconcile ?? []); } catch (e) { if (!cancelled) { setError(formatWalletClientError(e, t)); @@ -90,7 +92,50 @@ export function WalletScreen() { return () => { cancelled = true; }; - }, [currency, loadLogs, t]); + }, [currency, t]); // eslint-disable-line react-hooks/exhaustive-deps -- 币种切换整页刷新;流水筛选见下方 effect + + useEffect(() => { + if (!filterInitializedRef.current) { + filterInitializedRef.current = true; + prevFilterRef.current = filter; + return; + } + if (prevFilterRef.current === filter) { + return; + } + prevFilterRef.current = filter; + + let cancelled = false; + setError(null); + setLogsLoading(true); + + void (async () => { + try { + const nextLogs = await getWalletLogs({ + page: 1, + size: WALLET_LOGS_PAGE_SIZE, + type: filter || undefined, + currency, + }); + if (!cancelled) { + setLogs(nextLogs); + dispatchWalletLogsRefresh(nextLogs.pending_reconcile ?? []); + } + } catch (e) { + if (!cancelled) { + setError(formatWalletClientError(e, t)); + } + } finally { + if (!cancelled) { + setLogsLoading(false); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [currency, filter, t]); const refreshAll = useCallback(async () => { setError(null); @@ -181,7 +226,7 @@ export function WalletScreen() { aria-hidden />
-
+
@@ -193,9 +238,11 @@ export function WalletScreen() { {loading ? ( ) : ( -

- {formatMinorAsCurrency(displayMinor, currency)} -

+ )}

{isCreditPlayer @@ -220,7 +267,9 @@ export function WalletScreen() { })}

) : ( -
+ <> + +
+ )} { + if (loading || !balance) { + return; + } + if (isCreditFundingPlayer(balance)) { + router.replace("/wallet"); + } + }, [balance, loading, router]); + + if (loading || !balance) { + return null; + } + + if (isCreditFundingPlayer(balance)) { + return ( + +

+ {t("wallet.creditNoTransferHint", { + defaultValue: + "由代理授信,无需主站转入转出;中奖不即时派彩,盈亏在账期统一结算,额度调整请联系代理。", + })} +

+
+ ); + } + + return <>{children}; +} diff --git a/src/features/wallet/wallet-transfer-loading-panel.tsx b/src/features/wallet/wallet-transfer-loading-panel.tsx new file mode 100644 index 0000000..7a3292e --- /dev/null +++ b/src/features/wallet/wallet-transfer-loading-panel.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { useTranslation } from "react-i18next"; + +import { PlayerPanel } from "@/components/layout/player-panel"; +import { Skeleton } from "@/components/ui/skeleton"; +import { isCreditFundingPlayer } from "@/lib/player-funding-mode"; +import { usePlayerSessionStore } from "@/stores/player-session-store"; + +type WalletTransferLoadingPanelProps = { + titleKey: "wallet.transferInTitle" | "wallet.transferOutTitle"; +}; + +export function WalletTransferLoadingPanel({ titleKey }: WalletTransferLoadingPanelProps) { + const { t } = useTranslation("player"); + const creditMode = isCreditFundingPlayer(usePlayerSessionStore((state) => state.profile)); + const backLabel = creditMode + ? t("wallet.creditTitle", { defaultValue: "信用" }) + : t("wallet.title"); + + return ( + +
+ + +
+
+ ); +} diff --git a/src/hooks/use-pending-wallet-reconcile.ts b/src/hooks/use-pending-wallet-reconcile.ts index 2837b4a..971496f 100644 --- a/src/hooks/use-pending-wallet-reconcile.ts +++ b/src/hooks/use-pending-wallet-reconcile.ts @@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from "react"; import { getWalletLogs } from "@/api/wallet"; +import { isCreditFundingPlayer } from "@/lib/player-funding-mode"; import { usePlayerSessionStore } from "@/stores/player-session-store"; import type { WalletPendingTransfer } from "@/types/api/wallet-logs"; @@ -56,6 +57,7 @@ export function usePendingWalletReconcile(): { markAllAsRead: () => void; } { const bearerToken = usePlayerSessionStore((s) => s.bearerToken); + const creditMode = isCreditFundingPlayer(usePlayerSessionStore((s) => s.profile)); const [pending, setPending] = useState([]); const [readTransferNos, setReadTransferNos] = useState>(() => { if (typeof window === "undefined") return new Set(); @@ -83,7 +85,7 @@ export function usePendingWalletReconcile(): { }, [readTransferNos]); const refresh = useCallback(async (): Promise => { - if (!bearerToken?.trim()) { + if (!bearerToken?.trim() || creditMode) { setPending([]); pendingReconcileCache = null; pendingReconcileFetchedAtMs = 0; @@ -96,7 +98,7 @@ export function usePendingWalletReconcile(): { } finally { setLoading(false); } - }, [bearerToken]); + }, [bearerToken, creditMode]); useEffect(() => { queueMicrotask(() => { diff --git a/src/hooks/use-player-banner-height-ref.ts b/src/hooks/use-player-banner-height-ref.ts new file mode 100644 index 0000000..5858e95 --- /dev/null +++ b/src/hooks/use-player-banner-height-ref.ts @@ -0,0 +1,34 @@ +"use client"; + +import { useLayoutEffect, useRef } from "react"; + +import type { PlayerTopBannerKind } from "@/stores/player-top-banner-store"; +import { usePlayerTopBannerStore } from "@/stores/player-top-banner-store"; + +/** 测量顶栏横幅高度,供 PlayerPanel sticky offset 使用 */ +export function usePlayerBannerHeightRef(kind: PlayerTopBannerKind) { + const setBannerHeight = usePlayerTopBannerStore((state) => state.setBannerHeight); + const ref = useRef(null); + + useLayoutEffect(() => { + const element = ref.current; + if (!element) { + return; + } + + const sync = () => { + setBannerHeight(kind, element.offsetHeight); + }; + + sync(); + const observer = new ResizeObserver(sync); + observer.observe(element); + + return () => { + observer.disconnect(); + setBannerHeight(kind, 0); + }; + }, [kind, setBannerHeight]); + + return ref; +} diff --git a/src/hooks/use-player-sticky-top-style.ts b/src/hooks/use-player-sticky-top-style.ts new file mode 100644 index 0000000..77221e1 --- /dev/null +++ b/src/hooks/use-player-sticky-top-style.ts @@ -0,0 +1,16 @@ +"use client"; + +import type { CSSProperties } from "react"; + +import { + selectPlayerStickyTopOffset, + usePlayerTopBannerStore, +} from "@/stores/player-top-banner-store"; + +export function usePlayerStickyTopStyle(): CSSProperties { + const offsetPx = usePlayerTopBannerStore(selectPlayerStickyTopOffset); + if (offsetPx <= 0) { + return { top: 0 }; + } + return { top: offsetPx }; +} diff --git a/src/i18n/locales/en/entry.json b/src/i18n/locales/en/entry.json index adfa11a..b0accdf 100644 --- a/src/i18n/locales/en/entry.json +++ b/src/i18n/locales/en/entry.json @@ -64,7 +64,9 @@ "captchaLoadFailed": "Failed to load captcha. Try again.", "captchaLoading": "Loading…", "captchaRefresh": "Refresh captcha", - "captchaFetch": "Tap to load" + "captchaFetch": "Tap to load", + "usernameInvalidCharset": "Username may only contain letters, numbers, dots (.), underscores, and hyphens", + "passwordMinLength": "Password must be at least 6 characters" }, "errors": { "noToken": "No authorization token found", diff --git a/src/i18n/locales/en/player.json b/src/i18n/locales/en/player.json index 17f4431..588841c 100644 --- a/src/i18n/locales/en/player.json +++ b/src/i18n/locales/en/player.json @@ -47,7 +47,8 @@ "markRead": "Mark as read", "markAllRead": "Mark all read", "unreadCount_one": "{{count}} unread", - "unreadCount_other": "{{count}} unread" + "unreadCount_other": "{{count}} unread", + "creditEmptyHint": "Credit accounts have no main-site transfers; no pending reconciliation notifications." }, "panel": { "home": "Home" @@ -88,7 +89,24 @@ "home": "Back home", "hall": "Betting hall", "results": "Results", - "wallet": "My wallet" + "wallet": "My wallet", + "funds": "Wallet / Credit" + }, + "calendar": { + "months": { + "1": "January", + "2": "February", + "3": "March", + "4": "April", + "5": "May", + "6": "June", + "7": "July", + "8": "August", + "9": "September", + "10": "October", + "11": "November", + "12": "December" + } }, "player": { "fallback": "Player #{{id}}" @@ -265,7 +283,9 @@ "processingProgress": "Processing tickets...", "noWarnings": "No obvious risk was found in this preview.", "warningsTitle": "Payout pool warning", - "warningsDescription": "The following numbers have high payout pool usage for this issue. Betting is still allowed, but the order may be rejected as sold out if capacity is insufficient." + "warningsDescription": "The following numbers have high payout pool usage for this issue. Betting is still allowed, but the order may be rejected as sold out if capacity is insufficient.", + "periodRebate": "Period rebate", + "periodRebateHint": "Rebate for this bet is settled in the billing period, not deducted at bet time." }, "result": { "title": "Bet placed", @@ -283,6 +303,7 @@ "actual": "Actual deduction", "orderNo": "Order No.", "balanceAfter": "Remaining balance", + "creditBalanceAfter": "Available credit", "items": "Successful line details", "number": "Number", "actualDeduct": "Deducted", @@ -394,6 +415,7 @@ "outExceeds": "Transfer-out amount cannot exceed available balance.", "pendingTitle": "Pending reconciliation", "pendingDescription": "The following transfers have not been finally confirmed by the main site. Contact support if they do not arrive after a long time.", + "viewPendingReconcile": "View pending reconciliation ({{count}})", "pendingStatus": "Processing", "flowsTitle": "Wallet logs", "creditFlowsTitle": "Credit activity", @@ -521,7 +543,20 @@ "backToGroup": "Back to order detail", "notFound": "Ticket does not exist or cannot be viewed.", "noData": "No data", - "loadFailed": "Failed to load" + "loadFailed": "Failed to load", + "failReason": { + "2001": "This draw is closed; betting is not allowed", + "2003": "Insufficient balance or available credit", + "2004": "Invalid number format", + "2005": "Invalid play parameters", + "2006": "Invalid or non-bettable draw", + "2007": "Play not found or disabled", + "2008": "Odds config updated; please preview again", + "2009": "Order cannot be resubmitted", + "4001": "This number is sold out for this draw", + "draw_no_longer_open": "Draw is closed or not bettable", + "draw_cancelled": "Draw cancelled; stake refunded" + } }, "results": { "title": "Results", @@ -588,6 +623,8 @@ "rules": { "title": "Play Rules", "subtitle": "2D / 3D / 4D, box plays, rebate, closing and sold-out rules", + "empty": "No play rules available", + "loadFailed": "Failed to load rules", "quick": { "title": "Prize Structure", "description": "Each draw publishes 23 four-digit numbers. Settlement uses the odds snapshot locked when the bet was placed.", diff --git a/src/i18n/locales/ne/entry.json b/src/i18n/locales/ne/entry.json index 32c52fa..dc2ff6b 100644 --- a/src/i18n/locales/ne/entry.json +++ b/src/i18n/locales/ne/entry.json @@ -64,7 +64,9 @@ "captchaLoadFailed": "क्याप्चा लोड असफल। फेरि प्रयास गर्नुहोस्।", "captchaLoading": "लोड हुँदै…", "captchaRefresh": "क्याप्चा रिफ्रेस", - "captchaFetch": "लोड गर्न ट्याप गर्नुहोस्" + "captchaFetch": "लोड गर्न ट्याप गर्नुहोस्", + "usernameInvalidCharset": "प्रयोगकर्ता नाममा अक्षर, अंक, डट (.), अन्डरस्कोर र हाइफन मात्र हुन सक्छ", + "passwordMinLength": "पासवर्ड कम्तीमा ६ वर्ण हुनुपर्छ" }, "errors": { "noToken": "कुनै प्राधिकरण टोकन फेला परेन", diff --git a/src/i18n/locales/ne/player.json b/src/i18n/locales/ne/player.json index b51c25a..0b40ebb 100644 --- a/src/i18n/locales/ne/player.json +++ b/src/i18n/locales/ne/player.json @@ -47,7 +47,8 @@ "markRead": "पढिएको चिन्ह लगाउनुहोस्", "markAllRead": "सबै पढिएको", "unreadCount_one": "{{count}} नपढिएको", - "unreadCount_other": "{{count}} नपढिएको" + "unreadCount_other": "{{count}} नपढिएको", + "creditEmptyHint": "क्रेडिट खातामा मुख्य साइट ट्रान्सफर हुँदैन; मिलान बाँकी सूचना छैन।" }, "panel": { "home": "गृह" @@ -88,7 +89,24 @@ "home": "गृहमा फर्कनुहोस्", "hall": "बेटिङ हल", "results": "नतिजा", - "wallet": "मेरो वालेट" + "wallet": "मेरो वालेट", + "funds": "वालेट / क्रेडिट" + }, + "calendar": { + "months": { + "1": "जनवरी", + "2": "फेब्रुअरी", + "3": "मार्च", + "4": "अप्रिल", + "5": "मे", + "6": "जुन", + "7": "जुलाई", + "8": "अगस्त", + "9": "सेप्टेम्बर", + "10": "अक्टोबर", + "11": "नोभेम्बर", + "12": "डिसेम्बर" + } }, "player": { "fallback": "खेलाडी #{{id}}" @@ -265,7 +283,9 @@ "processingProgress": "टिकट प्रक्रिया हुँदैछ...", "noWarnings": "यस पूर्वावलोकनमा स्पष्ट जोखिम भेटिएन।", "warningsTitle": "भुक्तानी पूल चेतावनी", - "warningsDescription": "यी नम्बरहरूमा यस इश्यूमा भुक्तानी पूल प्रयोग उच्च छ। बेट अझै गर्न सकिन्छ, तर क्षमता अपुग भए अर्डर sold out हुन सक्छ।" + "warningsDescription": "यी नम्बरहरूमा यस इश्यूमा भुक्तानी पूल प्रयोग उच्च छ। बेट अझै गर्न सकिन्छ, तर क्षमता अपुग भए अर्डर sold out हुन सक्छ।", + "periodRebate": "अवधि रिबेट", + "periodRebateHint": "यो बेटको रिबेट बिलिङ अवधिमा मिलाइन्छ, बेट समयमा कट्दैन।" }, "result": { "title": "बेट सफल", @@ -283,6 +303,7 @@ "actual": "वास्तविक कट्टा", "orderNo": "अर्डर नं.", "balanceAfter": "बाँकी ब्यालेन्स", + "creditBalanceAfter": "उपलब्ध क्रेडिट", "items": "सफल लाइन विवरण", "number": "नम्बर", "actualDeduct": "कट्टा", @@ -386,6 +407,7 @@ "outExceeds": "ट्रान्सफर आउट रकम उपलब्ध ब्यालेन्सभन्दा बढी हुन सक्दैन।", "pendingTitle": "मिलान बाँकी", "pendingDescription": "यी ट्रान्सफरहरू मुख्य साइटबाट अन्तिम पुष्टि भएका छैनन्। लामो समयसम्म नआए support सम्पर्क गर्नुहोस्।", + "viewPendingReconcile": "मिलान बाँकी हेर्नुहोस् ({{count}})", "pendingStatus": "प्रक्रिया हुँदैछ", "flowsTitle": "वालेट लग", "creditTitle": "क्रेडिट", @@ -520,7 +542,20 @@ "backToGroup": "अर्डर विवरणमा फर्कनुहोस्", "notFound": "टिकट छैन वा हेर्न अनुमति छैन।", "noData": "डेटा छैन", - "loadFailed": "लोड असफल" + "loadFailed": "लोड असफल", + "failReason": { + "2001": "यो ड्र बन्द भयो; बेट गर्न मिल्दैन", + "2003": "ब्यालेन्स वा उपलब्ध क्रेडिट अपुग", + "2004": "अवैध नम्बर ढाँचा", + "2005": "अवैध play parameter", + "2006": "अवैध वा non-bettable ड्र", + "2007": "Play फेला परेन वा बन्द", + "2008": "Odds config अपडेट; पुन: preview गर्नुहोस्", + "2009": "अर्डर पुन: पेश गर्न मिल्दैन", + "4001": "यो नम्बर यस ड्रमा sold out", + "draw_no_longer_open": "ड्र बन्द वा बेट योग्य छैन", + "draw_cancelled": "ड्र रद्द; stake फिर्ता" + } }, "results": { "title": "नतिजा", @@ -587,6 +622,8 @@ "rules": { "title": "प्ले नियम", "subtitle": "2D / 3D / 4D, box play, rebate, closing र sold-out नियम", + "empty": "खेल नियम उपलब्ध छैन", + "loadFailed": "नियम लोड असफल", "quick": { "title": "पुरस्कार संरचना", "description": "हरेक ड्रमा 23 वटा 4 अंकका नम्बर प्रकाशित हुन्छन्। सेटलमेन्ट बेट राख्दा लक भएको odds snapshot अनुसार हुन्छ।", diff --git a/src/i18n/locales/zh/entry.json b/src/i18n/locales/zh/entry.json index 473184e..05332dc 100644 --- a/src/i18n/locales/zh/entry.json +++ b/src/i18n/locales/zh/entry.json @@ -64,7 +64,9 @@ "captchaLoadFailed": "验证码加载失败,请重试", "captchaLoading": "加载中…", "captchaRefresh": "刷新验证码", - "captchaFetch": "点击获取" + "captchaFetch": "点击获取", + "usernameInvalidCharset": "账号只能使用字母、数字、点(.)、下划线和连字符", + "passwordMinLength": "密码至少需要 6 个字符" }, "errors": { "noToken": "未发现授权令牌", diff --git a/src/i18n/locales/zh/player.json b/src/i18n/locales/zh/player.json index ce91182..e070736 100644 --- a/src/i18n/locales/zh/player.json +++ b/src/i18n/locales/zh/player.json @@ -46,7 +46,8 @@ "markRead": "标记已读", "markAllRead": "全部已读", "unreadCount_one": "未读 {{count}} 条", - "unreadCount_other": "未读 {{count}} 条" + "unreadCount_other": "未读 {{count}} 条", + "creditEmptyHint": "信用盘无主站划转,暂无待对账通知。" }, "panel": { "home": "首页" @@ -87,7 +88,24 @@ "home": "返回首页", "hall": "投注大厅", "results": "开奖结果", - "wallet": "我的钱包" + "wallet": "我的钱包", + "funds": "钱包 / 信用" + }, + "calendar": { + "months": { + "1": "1月", + "2": "2月", + "3": "3月", + "4": "4月", + "5": "5月", + "6": "6月", + "7": "7月", + "8": "8月", + "9": "9月", + "10": "10月", + "11": "11月", + "12": "12月" + } }, "player": { "fallback": "玩家 #{{id}}" @@ -263,7 +281,9 @@ "processingProgress": "正在处理注单...", "noWarnings": "当前预览未发现明显风险。", "warningsTitle": "赔付池预警", - "warningsDescription": "以下号码本期赔付池占用较高,仍允许下注;若实际占用不足将售罄拒单。" + "warningsDescription": "以下号码本期赔付池占用较高,仍允许下注;若实际占用不足将售罄拒单。", + "periodRebate": "账期回水", + "periodRebateHint": "本单回水计入账期统一结算,不在下注时即时抵扣。" }, "placeAllFailed": "本次 {{failed}} 条注项均未成功", "result": { @@ -282,6 +302,7 @@ "actual": "实扣金额", "orderNo": "订单号", "balanceAfter": "剩余余额", + "creditBalanceAfter": "可用信用", "items": "成功注项明细", "number": "号码", "actualDeduct": "实扣", @@ -393,6 +414,7 @@ "outExceeds": "转出金额不能超过可用余额。", "pendingTitle": "待对账", "pendingDescription": "以下划转主站结果未最终确认;若长时间未到账请联系客服。", + "viewPendingReconcile": "查看待对账详情({{count}})", "pendingStatus": "处理中", "flowsTitle": "资金流水", "creditFlowsTitle": "信用流水", @@ -520,7 +542,20 @@ "backToGroup": "返回订单详情", "notFound": "注单不存在或无权查看", "noData": "无数据", - "loadFailed": "加载失败" + "loadFailed": "加载失败", + "failReason": { + "2001": "当期已封盘,无法下注", + "2003": "余额或可用信用不足", + "2004": "号码格式不合法", + "2005": "玩法参数无效", + "2006": "期号无效或不可下注", + "2007": "玩法不存在或已关闭", + "2008": "赔率配置已更新,请重新预览", + "2009": "订单不可重复提交", + "4001": "该号码本期额度已售罄", + "draw_no_longer_open": "期号已封盘或不可下注", + "draw_cancelled": "本期已取消,注单已退本" + } }, "results": { "title": "开奖结果", @@ -587,6 +622,8 @@ "rules": { "title": "玩法规则", "subtitle": "2D / 3D / 4D、包号、回水、封盘与售罄说明", + "empty": "暂无玩法规则说明", + "loadFailed": "规则加载失败", "quick": { "title": "开奖结构", "description": "每期开奖 23 组 4 位号码,结算以下注时锁定的赔率快照为准。", diff --git a/src/lib/iframe-origins.ts b/src/lib/iframe-origins.ts index a9395d5..2dac69a 100644 --- a/src/lib/iframe-origins.ts +++ b/src/lib/iframe-origins.ts @@ -75,7 +75,7 @@ export async function loadIframeAllowedOrigins( } pendingOrigins ??= lotteryHttp - .get("/integration/runtime-origins") + .get("/integration/runtime-origins", { skipGlobalServerError: true }) .then((response) => { const data = unwrapData(response.data); runtimeOrigins = data.iframe_allowed_origins diff --git a/src/lib/lottery-http-server-error.ts b/src/lib/lottery-http-server-error.ts new file mode 100644 index 0000000..ceb54c8 --- /dev/null +++ b/src/lib/lottery-http-server-error.ts @@ -0,0 +1,38 @@ +import type { AxiosRequestConfig } from "axios"; + +import i18n from "@/i18n"; + +/** 入口/登录页:500 由页面 toast 或步骤条处理,勿全屏遮罩 */ +const PUBLIC_ENTRY_PATHS = new Set(["/", "/login"]); + +/** 后台静默拉取:失败不应阻断当前页 */ +const BACKGROUND_URL_FRAGMENTS = ["/integration/runtime-origins"]; + +const TECHNICAL_ERROR_PATTERN = + /SQLSTATE|pgsql|Connection refused|TCP\/IP|Illuminate\\|vendor\/|\.php:\d+|select\s+.+\s+from/i; + +export function shouldSkipGlobalServerError(config: AxiosRequestConfig | undefined): boolean { + if (config?.skipGlobalServerError) { + return true; + } + + if (typeof window !== "undefined" && PUBLIC_ENTRY_PATHS.has(window.location.pathname)) { + return true; + } + + const url = `${config?.url ?? ""}`; + return BACKGROUND_URL_FRAGMENTS.some((fragment) => url.includes(fragment)); +} + +/** 玩家端全屏 500 仅展示友好文案,不透传 SQL / 堆栈 */ +export function sanitizePlayerServerErrorMessage(raw: string | undefined): string { + const generic = i18n.t("serverError.serverMessage", { ns: "player" }); + const trimmed = raw?.trim(); + if (!trimmed) { + return generic; + } + if (TECHNICAL_ERROR_PATTERN.test(trimmed) || trimmed.length > 120) { + return generic; + } + return trimmed; +} diff --git a/src/lib/lottery-http.ts b/src/lib/lottery-http.ts index 985a6d4..ac1dbef 100644 --- a/src/lib/lottery-http.ts +++ b/src/lib/lottery-http.ts @@ -16,7 +16,10 @@ import { import { isApiEnvelope } from "@/types/api/envelope"; import { useErrorStore } from "@/stores/error-store"; import { resolveLotteryApiV1Base } from "@/lib/lottery-api-base"; -import i18n from "@/i18n"; +import { + sanitizePlayerServerErrorMessage, + shouldSkipGlobalServerError, +} from "@/lib/lottery-http-server-error"; /** * **第一层**:`baseURL` 对齐 Laravel `api/v1`;各 `api/*.ts` 只写业务 path(如 `/currencies`)。 @@ -81,23 +84,24 @@ lotteryHttp.interceptors.response.use( } } - // 500/502/503: 服务器错误,更新全局错误状态 + // 500/502/503: 非入口/后台请求才触发全屏错误;文案脱敏 if (status === 500 || status === 502 || status === 503) { - const setServerError = useErrorStore.getState().setServerError; - let message = i18n.t("serverError.serverMessage", { ns: "player" }); + if (!shouldSkipGlobalServerError(error.config)) { + const setServerError = useErrorStore.getState().setServerError; + let rawMessage: string | undefined; - // 尝试从响应中获取更详细的错误信息 - const responseData = error.response?.data; - if ( - typeof responseData === "object" && - responseData !== null && - "msg" in responseData && - typeof responseData.msg === "string" - ) { - message = responseData.msg; + const responseData = error.response?.data; + if ( + typeof responseData === "object" && + responseData !== null && + "msg" in responseData && + typeof responseData.msg === "string" + ) { + rawMessage = responseData.msg; + } + + setServerError(true, sanitizePlayerServerErrorMessage(rawMessage)); } - - setServerError(true, message); } // 网络错误 (无响应): 检查网络状态 diff --git a/src/lib/player-spacing.ts b/src/lib/player-spacing.ts index 15a4cc3..318e5a8 100644 --- a/src/lib/player-spacing.ts +++ b/src/lib/player-spacing.ts @@ -1,6 +1,6 @@ /** 玩家端统一紧凑间距(页面壳、区块堆叠、区块间距) */ export const playerPageInset = "px-3 pb-6 pt-2"; -/** 顶栏:左右分列 + 标题绝对居中,避免右侧控件挤压/遮挡标题 */ +/** 顶栏:三列 grid 布局,标题居中;左右控件统一高度 */ export const playerPageHeader = "relative mb-2 flex min-h-9 items-center justify-between gap-2"; /** 顶栏左右控件统一高度 */ diff --git a/src/lib/results-prize-labels.ts b/src/lib/results-prize-labels.ts new file mode 100644 index 0000000..15f1b7f --- /dev/null +++ b/src/lib/results-prize-labels.ts @@ -0,0 +1,19 @@ +/** 开奖列表/详情卡片展示用的前三奖 API 字段 */ +export const RESULTS_TOP_PRIZE_KEYS = ["1st", "2nd", "3rd"] as const; + +export type ResultsTopPrizeKey = (typeof RESULTS_TOP_PRIZE_KEYS)[number]; + +export function resultsPrizeLabelKey(tier: ResultsTopPrizeKey): string { + switch (tier) { + case "1st": + return "results.grid.first"; + case "2nd": + return "results.grid.second"; + case "3rd": + return "results.grid.third"; + default: { + const _exhaustive: never = tier; + return _exhaustive; + } + } +} diff --git a/src/stores/player-top-banner-store.ts b/src/stores/player-top-banner-store.ts new file mode 100644 index 0000000..2acb581 --- /dev/null +++ b/src/stores/player-top-banner-store.ts @@ -0,0 +1,20 @@ +import { create } from "zustand"; + +export type PlayerTopBannerKind = "offline" | "network"; + +type PlayerTopBannerState = { + heights: Record; + setBannerHeight: (kind: PlayerTopBannerKind, px: number) => void; +}; + +export const usePlayerTopBannerStore = create((set) => ({ + heights: { offline: 0, network: 0 }, + setBannerHeight: (kind, px) => + set((state) => ({ + heights: { ...state.heights, [kind]: Math.max(0, px) }, + })), +})); + +export function selectPlayerStickyTopOffset(state: PlayerTopBannerState): number { + return state.heights.offline + state.heights.network; +} diff --git a/src/types/axios.d.ts b/src/types/axios.d.ts new file mode 100644 index 0000000..aa4a063 --- /dev/null +++ b/src/types/axios.d.ts @@ -0,0 +1,8 @@ +import "axios"; + +declare module "axios" { + export interface AxiosRequestConfig { + /** 为 true 时不触发玩家端全屏 500 遮罩(由页面自行处理) */ + skipGlobalServerError?: boolean; + } +}
No. + {t("hall.table.no")} + {t("hall.result.number")}