feat: enhance API error handling and UI improvements
Some checks failed
lotteryfront CI / build (push) Has been cancelled
Some checks failed
lotteryfront CI / build (push) Has been cancelled
- 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.
This commit is contained in:
@@ -3,5 +3,7 @@ import type { PublicCurrencyListData } from "@/types/api/currency";
|
|||||||
|
|
||||||
/** `GET /api/v1/currencies`(公开) */
|
/** `GET /api/v1/currencies`(公开) */
|
||||||
export function getPublicCurrencies(): Promise<PublicCurrencyListData> {
|
export function getPublicCurrencies(): Promise<PublicCurrencyListData> {
|
||||||
return lotteryRequest.get<PublicCurrencyListData>(`/currencies`);
|
return lotteryRequest.get<PublicCurrencyListData>(`/currencies`, {
|
||||||
|
skipGlobalServerError: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ export type PlayerAuthLoginData = {
|
|||||||
|
|
||||||
/** `GET /api/v1/player/auth/captcha`(公开) */
|
/** `GET /api/v1/player/auth/captcha`(公开) */
|
||||||
export function getPlayerAuthCaptcha(): Promise<PlayerAuthCaptchaResponse> {
|
export function getPlayerAuthCaptcha(): Promise<PlayerAuthCaptchaResponse> {
|
||||||
return lotteryRequest.get<PlayerAuthCaptchaResponse>(`/player/auth/captcha`);
|
return lotteryRequest.get<PlayerAuthCaptchaResponse>(`/player/auth/captcha`, {
|
||||||
|
skipGlobalServerError: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `POST /api/v1/player/auth/login`(公开) */
|
/** `POST /api/v1/player/auth/login`(公开) */
|
||||||
|
|||||||
@@ -4,87 +4,70 @@ import Link from "next/link";
|
|||||||
import { FileQuestion, Home } from "lucide-react";
|
import { FileQuestion, Home } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
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";
|
import "@/i18n";
|
||||||
|
|
||||||
/**
|
|
||||||
* 全局 404 页面
|
|
||||||
* 当访问不存在的路由时显示
|
|
||||||
* 消息:"页面不存在"
|
|
||||||
* 包含 "返回首页" 按钮
|
|
||||||
* 使用中性颜色 #d9d9d9 作为背景/插图
|
|
||||||
*/
|
|
||||||
export default function NotFoundPage(): React.ReactElement {
|
export default function NotFoundPage(): React.ReactElement {
|
||||||
const { t } = useTranslation("player");
|
const { t } = useTranslation("player");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<PlayerMobileViewport>
|
||||||
className="flex min-h-screen flex-col items-center justify-center p-4"
|
<div
|
||||||
style={{ backgroundColor: ERROR_COLORS.neutral }}
|
className={cn(
|
||||||
>
|
"flex min-h-dvh flex-col items-center justify-center bg-white px-4 py-8 text-slate-900",
|
||||||
<div className="w-full max-w-md rounded-2xl bg-white p-8 shadow-lg">
|
playerViewportColumnClass,
|
||||||
<div className="flex flex-col items-center text-center">
|
)}
|
||||||
{/* 404 数字 */}
|
>
|
||||||
<div
|
<div className="w-full rounded-2xl border border-[#e4eaf4] bg-white p-8 shadow-[0_10px_28px_rgba(15,23,42,0.08)]">
|
||||||
className="mb-6 text-8xl font-bold tracking-tighter"
|
<div className="flex flex-col items-center text-center">
|
||||||
style={{ color: ERROR_COLORS.neutral }}
|
<div className="mb-4 flex size-16 items-center justify-center rounded-full bg-[#fff1f3]">
|
||||||
>
|
<FileQuestion className="size-8 text-[#e5002c]" aria-hidden />
|
||||||
404
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 图标 */}
|
<p className="text-6xl font-black tracking-tight text-[#0b3f96]/20">404</p>
|
||||||
<div
|
|
||||||
className="mb-6 flex size-20 items-center justify-center rounded-full"
|
|
||||||
style={{ backgroundColor: `${ERROR_COLORS.neutral}40` }}
|
|
||||||
>
|
|
||||||
<FileQuestion
|
|
||||||
className="size-10"
|
|
||||||
style={{ color: "#666" }}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 标题 */}
|
<h1 className="mt-2 text-xl font-black text-[#0b3f96]">
|
||||||
<h1 className="mb-3 text-2xl font-bold text-gray-800">
|
{t("notFound.title")}
|
||||||
{t("notFound.title")}
|
</h1>
|
||||||
</h1>
|
|
||||||
|
|
||||||
{/* 描述 */}
|
<p className="mt-2 text-sm leading-relaxed text-slate-500">
|
||||||
<p className="mb-8 text-base text-gray-500">
|
{t("notFound.description")}
|
||||||
{t("notFound.description")}
|
</p>
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* 返回首页按钮 */}
|
<Link
|
||||||
<Link
|
href="/hall"
|
||||||
href="/hall"
|
className="mt-6 inline-flex h-11 w-full items-center justify-center gap-2 rounded-xl bg-[#e5002c] text-sm font-bold text-white shadow-[0_8px_20px_rgba(229,0,44,0.22)] hover:bg-[#d10028]"
|
||||||
className="inline-flex items-center justify-center gap-2 rounded-lg px-8 py-3 text-base font-medium text-white"
|
>
|
||||||
style={{ backgroundColor: "#333" }}
|
<Home className="size-4" aria-hidden />
|
||||||
>
|
{t("notFound.home")}
|
||||||
<Home className="size-5" />
|
|
||||||
{t("notFound.home")}
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{/* 帮助链接 */}
|
|
||||||
<div className="mt-8 flex gap-4 text-sm text-gray-400">
|
|
||||||
<Link href="/hall" className="hover:text-gray-600 hover:underline">
|
|
||||||
{t("notFound.hall")}
|
|
||||||
</Link>
|
|
||||||
<span>|</span>
|
|
||||||
<Link href="/results" className="hover:text-gray-600 hover:underline">
|
|
||||||
{t("notFound.results")}
|
|
||||||
</Link>
|
|
||||||
<span>|</span>
|
|
||||||
<Link href="/wallet" className="hover:text-gray-600 hover:underline">
|
|
||||||
{t("notFound.wallet")}
|
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
<div className="mt-6 flex flex-wrap items-center justify-center gap-x-3 gap-y-1 text-xs font-semibold text-[#32518d]">
|
||||||
|
<Link href="/hall" className="hover:text-[#0b3f96] hover:underline">
|
||||||
|
{t("notFound.hall")}
|
||||||
|
</Link>
|
||||||
|
<span className="text-slate-300" aria-hidden>
|
||||||
|
|
|
||||||
|
</span>
|
||||||
|
<Link href="/results" className="hover:text-[#0b3f96] hover:underline">
|
||||||
|
{t("notFound.results")}
|
||||||
|
</Link>
|
||||||
|
<span className="text-slate-300" aria-hidden>
|
||||||
|
|
|
||||||
|
</span>
|
||||||
|
<Link href="/wallet" className="hover:text-[#0b3f96] hover:underline">
|
||||||
|
{t("notFound.funds")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 品牌信息 */}
|
<p className="mt-8 text-xs text-slate-400">
|
||||||
<p className="mt-8 text-sm text-gray-500">
|
Lottery © {new Date().getFullYear()}
|
||||||
Lottery © {new Date().getFullYear()}
|
</p>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
</PlayerMobileViewport>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Bell } from "lucide-react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { type ReactElement } from "react";
|
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 = {
|
type PlayerNotificationBellProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 顶栏铃铛:待对账划转提醒 */
|
/** 顶栏铃铛:暂隐藏,待接入通用通知中心后再开放 */
|
||||||
export function PlayerNotificationBell({ className }: PlayerNotificationBellProps): ReactElement {
|
export function PlayerNotificationBell(_props: PlayerNotificationBellProps): ReactElement | null {
|
||||||
const { t } = useTranslation("common");
|
return null;
|
||||||
const { hasUnread } = usePendingWalletReconcile();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
href="/notifications"
|
|
||||||
className={cn(
|
|
||||||
playerHeaderControl,
|
|
||||||
"relative size-8 rounded-full text-[#1d57b7] hover:bg-[#f4f7fb]",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
aria-label={t("navigation.notifications")}
|
|
||||||
>
|
|
||||||
<Bell className="size-4" aria-hidden />
|
|
||||||
{hasUnread ? (
|
|
||||||
<span className="absolute right-1.5 top-1.5 size-1.5 rounded-full bg-[#ff143d]" />
|
|
||||||
) : null}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { ChevronLeft } from "lucide-react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||||
import { PlayerNotificationBell } from "@/components/layout/player-notification-bell";
|
import { usePlayerStickyTopStyle } from "@/hooks/use-player-sticky-top-style";
|
||||||
import {
|
import {
|
||||||
playerHeaderControl,
|
playerHeaderControl,
|
||||||
playerPageHeader,
|
playerPageHeader,
|
||||||
@@ -33,12 +33,13 @@ export function PlayerPanel({
|
|||||||
}: PlayerPanelProps) {
|
}: PlayerPanelProps) {
|
||||||
const { t: tp } = useTranslation("player");
|
const { t: tp } = useTranslation("player");
|
||||||
const resolvedBackLabel = backLabel ?? tp("panel.home");
|
const resolvedBackLabel = backLabel ?? tp("panel.home");
|
||||||
|
const stickyTopStyle = usePlayerStickyTopStyle();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("mx-auto w-full max-w-[480px]", containerClassName)}>
|
<div className={cn("mx-auto w-full max-w-[480px]", containerClassName)}>
|
||||||
<section
|
<section
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-white text-slate-900 min-h-screen",
|
"bg-white text-slate-900",
|
||||||
"px-3 pb-6",
|
"px-3 pb-6",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
@@ -46,29 +47,29 @@ export function PlayerPanel({
|
|||||||
<header
|
<header
|
||||||
className={cn(
|
className={cn(
|
||||||
playerPageHeader,
|
playerPageHeader,
|
||||||
"sticky top-0 z-50 bg-white/95 backdrop-blur pt-2 pb-2 -mx-3 px-3",
|
"sticky z-50 grid grid-cols-[minmax(0,38%)_minmax(0,1fr)_minmax(0,48%)] items-center gap-1 bg-white/95 pt-2 pb-2 -mx-3 px-3 backdrop-blur",
|
||||||
)}
|
)}
|
||||||
|
style={stickyTopStyle}
|
||||||
>
|
>
|
||||||
<div className="relative z-[1] flex min-w-0 max-w-[38%] shrink-0 justify-start">
|
<div className="flex min-w-0 justify-start">
|
||||||
<Link
|
<Link
|
||||||
href={backHref}
|
href={backHref}
|
||||||
className={cn(
|
className={cn(
|
||||||
playerHeaderControl,
|
playerHeaderControl,
|
||||||
"gap-0.5 rounded-full border border-[#e4eaf4] bg-[#f8fafc] px-2 text-xs font-bold text-[#0b3f96] hover:bg-[#f1f6ff]",
|
"gap-0.5 rounded-full border border-[#e4eaf4] bg-[#f8fafc] px-2 text-xs font-bold text-[#0b3f96] hover:bg-[#f1f6ff]",
|
||||||
)}
|
)}
|
||||||
|
aria-label={resolvedBackLabel}
|
||||||
>
|
>
|
||||||
<ChevronLeft className="size-4 shrink-0" aria-hidden />
|
<ChevronLeft className="size-4 shrink-0" aria-hidden />
|
||||||
<span className="max-w-[4.5rem] truncate">{resolvedBackLabel}</span>
|
<span className="max-w-[5.5rem] truncate">{resolvedBackLabel}</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1
|
<h1 className="min-w-0 truncate px-1 text-center text-base font-black leading-tight text-[#0b3f96]">
|
||||||
className="pointer-events-none absolute top-1/2 right-[5.25rem] left-[5.25rem] z-0 -translate-y-1/2 truncate text-center text-base font-black leading-tight text-[#0b3f96]"
|
|
||||||
>
|
|
||||||
{title}
|
{title}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div className="relative z-[1] flex min-w-0 max-w-[48%] shrink-0 items-center justify-end gap-0.5">
|
<div className="flex min-w-0 items-center justify-end gap-0.5">
|
||||||
<LanguageSwitcher
|
<LanguageSwitcher
|
||||||
variant="minimal"
|
variant="minimal"
|
||||||
menuAlign="end"
|
menuAlign="end"
|
||||||
@@ -78,7 +79,6 @@ export function PlayerPanel({
|
|||||||
"rounded-full border border-[#e4eaf4] bg-[#f8fafc] [&_button]:h-8 [&_button]:gap-1 [&_button]:px-2 [&_button]:py-0 [&_button]:text-xs",
|
"rounded-full border border-[#e4eaf4] bg-[#f8fafc] [&_button]:h-8 [&_button]:gap-1 [&_button]:px-2 [&_button]:py-0 [&_button]:text-xs",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<PlayerNotificationBell />
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import { WifiOff, RefreshCw, AlertCircle } from "lucide-react";
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { usePlayerBannerHeightRef } from "@/hooks/use-player-banner-height-ref";
|
||||||
import { useWebSocketManager } from "@/hooks/use-websocket-manager";
|
import { useWebSocketManager } from "@/hooks/use-websocket-manager";
|
||||||
|
import { playerViewportColumnClass } from "@/lib/player-viewport";
|
||||||
|
import { useErrorStore } from "@/stores/error-store";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
// 颜色常量(来自用户需求)
|
// 颜色常量(来自用户需求)
|
||||||
@@ -21,32 +24,36 @@ const COLORS = {
|
|||||||
*/
|
*/
|
||||||
export function NetworkStatusBanner(): React.ReactElement | null {
|
export function NetworkStatusBanner(): React.ReactElement | null {
|
||||||
const { showDegradedBanner, mode, reconnect } = useWebSocketManager();
|
const { showDegradedBanner, mode, reconnect } = useWebSocketManager();
|
||||||
|
const browserOffline = useErrorStore((state) => state.isOffline);
|
||||||
|
const bannerRef = usePlayerBannerHeightRef("network");
|
||||||
const { t } = useTranslation("player");
|
const { t } = useTranslation("player");
|
||||||
|
|
||||||
const handleReconnectClick = useCallback(() => {
|
const handleReconnectClick = useCallback(() => {
|
||||||
reconnect();
|
reconnect();
|
||||||
}, [reconnect]);
|
}, [reconnect]);
|
||||||
|
|
||||||
// 只有在降级模式或离线模式下才显示
|
// 浏览器离线时由 OfflineBanner 统一提示,避免重复
|
||||||
if (!showDegradedBanner) {
|
if (!showDegradedBanner || browserOffline) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isOffline = mode === "offline";
|
const isOffline = mode === "offline";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div ref={bannerRef} className="sticky top-0 z-50 w-full">
|
||||||
className={cn(
|
<div
|
||||||
"sticky top-0 z-50 flex items-center justify-center gap-2 px-4 py-2 text-sm",
|
className={cn(
|
||||||
"animate-in slide-in-from-top-2 duration-200",
|
playerViewportColumnClass,
|
||||||
)}
|
"flex items-center justify-center gap-2 px-4 py-2 text-sm",
|
||||||
style={{
|
"animate-in slide-in-from-top-2 duration-200",
|
||||||
backgroundColor: isOffline ? COLORS.error : COLORS.warning,
|
)}
|
||||||
color: "#fff",
|
style={{
|
||||||
}}
|
backgroundColor: isOffline ? COLORS.error : COLORS.warning,
|
||||||
role="status"
|
color: "#fff",
|
||||||
aria-live="polite"
|
}}
|
||||||
>
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
{isOffline ? (
|
{isOffline ? (
|
||||||
<>
|
<>
|
||||||
<WifiOff className="size-4 shrink-0" aria-hidden />
|
<WifiOff className="size-4 shrink-0" aria-hidden />
|
||||||
@@ -74,6 +81,7 @@ export function NetworkStatusBanner(): React.ReactElement | null {
|
|||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { WifiOff, RefreshCw } from "lucide-react";
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { useErrorStore, ERROR_COLORS } from "@/stores/error-store";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -14,6 +16,7 @@ import { cn } from "@/lib/utils";
|
|||||||
*/
|
*/
|
||||||
export function OfflineBanner(): React.ReactElement | null {
|
export function OfflineBanner(): React.ReactElement | null {
|
||||||
const isOffline = useErrorStore((state) => state.isOffline);
|
const isOffline = useErrorStore((state) => state.isOffline);
|
||||||
|
const bannerRef = usePlayerBannerHeightRef("offline");
|
||||||
const { t } = useTranslation("player");
|
const { t } = useTranslation("player");
|
||||||
|
|
||||||
const handleReconnect = useCallback(() => {
|
const handleReconnect = useCallback(() => {
|
||||||
@@ -29,28 +32,31 @@ export function OfflineBanner(): React.ReactElement | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div ref={bannerRef} className="sticky top-0 z-[60] w-full">
|
||||||
className={cn(
|
<div
|
||||||
"sticky top-0 z-[60] flex items-center justify-center gap-2 px-4 py-3 text-sm",
|
className={cn(
|
||||||
"animate-in slide-in-from-top-full duration-300 ease-out",
|
playerViewportColumnClass,
|
||||||
)}
|
"flex items-center justify-center gap-2 px-4 py-3 text-sm",
|
||||||
style={{
|
"animate-in slide-in-from-top-full duration-300 ease-out",
|
||||||
backgroundColor: ERROR_COLORS.error,
|
)}
|
||||||
color: "#fff",
|
style={{
|
||||||
}}
|
backgroundColor: ERROR_COLORS.error,
|
||||||
role="alert"
|
color: "#fff",
|
||||||
aria-live="assertive"
|
}}
|
||||||
>
|
role="alert"
|
||||||
<WifiOff className="size-4 shrink-0" aria-hidden />
|
aria-live="assertive"
|
||||||
<span className="font-medium">{t("network.offline")}</span>
|
|
||||||
<button
|
|
||||||
onClick={handleReconnect}
|
|
||||||
className="ml-3 flex items-center gap-1.5 rounded-md bg-white/20 px-3 py-1.5 text-xs font-medium transition-colors hover:bg-white/30 focus:outline-none focus:ring-2 focus:ring-white/50 active:bg-white/25"
|
|
||||||
aria-label={t("network.reconnect")}
|
|
||||||
>
|
>
|
||||||
<RefreshCw className="size-3.5" aria-hidden />
|
<WifiOff className="size-4 shrink-0" aria-hidden />
|
||||||
{t("network.reconnect")}
|
<span className="font-medium">{t("network.offline")}</span>
|
||||||
</button>
|
<button
|
||||||
|
onClick={handleReconnect}
|
||||||
|
className="ml-3 flex items-center gap-1.5 rounded-md bg-white/20 px-3 py-1.5 text-xs font-medium transition-colors hover:bg-white/30 focus:outline-none focus:ring-2 focus:ring-white/50 active:bg-white/25"
|
||||||
|
aria-label={t("network.reconnect")}
|
||||||
|
>
|
||||||
|
<RefreshCw className="size-3.5" aria-hidden />
|
||||||
|
{t("network.reconnect")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
29
src/components/player-money-display.tsx
Normal file
29
src/components/player-money-display.tsx
Normal file
@@ -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 (
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"font-black leading-none tabular-nums tracking-normal break-all",
|
||||||
|
"text-[clamp(1.125rem,5vw,1.5rem)]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatMinorAsCurrency(amountMinor, currency)}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ type HallBetResultDialogProps = {
|
|||||||
currencyCode: string;
|
currencyCode: string;
|
||||||
data: TicketPlaceData | null;
|
data: TicketPlaceData | null;
|
||||||
jackpotEnabled?: boolean;
|
jackpotEnabled?: boolean;
|
||||||
|
creditMode?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SUCCESS_ITEM_STATUSES = new Set(["pending_draw", "placed"]);
|
const SUCCESS_ITEM_STATUSES = new Set(["pending_draw", "placed"]);
|
||||||
@@ -34,6 +35,7 @@ export function HallBetResultDialog({
|
|||||||
currencyCode,
|
currencyCode,
|
||||||
data,
|
data,
|
||||||
jackpotEnabled = false,
|
jackpotEnabled = false,
|
||||||
|
creditMode = false,
|
||||||
}: HallBetResultDialogProps) {
|
}: HallBetResultDialogProps) {
|
||||||
const { t } = useTranslation("player");
|
const { t } = useTranslation("player");
|
||||||
|
|
||||||
@@ -178,7 +180,10 @@ export function HallBetResultDialog({
|
|||||||
<span className="font-mono font-black text-[#0b3f96]">{data.order_no}</span>
|
<span className="font-mono font-black text-[#0b3f96]">{data.order_no}</span>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
{t("hall.result.balanceAfter")}:{" "}
|
{creditMode
|
||||||
|
? t("hall.result.creditBalanceAfter", { defaultValue: "可用信用" })
|
||||||
|
: t("hall.result.balanceAfter")}
|
||||||
|
:{" "}
|
||||||
<span className="font-semibold text-slate-950">
|
<span className="font-semibold text-slate-950">
|
||||||
{formatMinorAsCurrency(data.balance_after, currencyCode)}
|
{formatMinorAsCurrency(data.balance_after, currencyCode)}
|
||||||
</span>
|
</span>
|
||||||
@@ -200,7 +205,9 @@ export function HallBetResultDialog({
|
|||||||
<table className="min-w-[430px] w-full border-collapse text-xs">
|
<table className="min-w-[430px] w-full border-collapse text-xs">
|
||||||
<thead className="bg-[#f4f7fd] text-[#304f86]">
|
<thead className="bg-[#f4f7fd] text-[#304f86]">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="w-10 border-r border-[#dfe8f6] px-2 py-2.5 text-center font-black">No.</th>
|
<th className="w-10 border-r border-[#dfe8f6] px-2 py-2.5 text-center font-black">
|
||||||
|
{t("hall.table.no")}
|
||||||
|
</th>
|
||||||
<th className="border-r border-[#dfe8f6] px-2 py-2.5 text-center font-black">
|
<th className="border-r border-[#dfe8f6] px-2 py-2.5 text-center font-black">
|
||||||
{t("hall.result.number")}
|
{t("hall.result.number")}
|
||||||
</th>
|
</th>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
import type { HallDrawLiveSnapshot } from "@/features/hall/use-hall-draw-live";
|
import type { HallDrawLiveSnapshot } from "@/features/hall/use-hall-draw-live";
|
||||||
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
||||||
import { triggerWalletPollingAfterBet } from "@/hooks/use-wallet-polling";
|
import { triggerWalletPollingAfterBet } from "@/hooks/use-wallet-polling";
|
||||||
|
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||||
import { getLotteryEcho } from "@/lib/lottery-echo";
|
import { getLotteryEcho } from "@/lib/lottery-echo";
|
||||||
import { formatMinorAsCurrency, parseDecimalInputToMinor } from "@/lib/money";
|
import { formatMinorAsCurrency, parseDecimalInputToMinor } from "@/lib/money";
|
||||||
import { playLabel } from "@/lib/play-labels";
|
import { playLabel } from "@/lib/play-labels";
|
||||||
@@ -33,6 +34,7 @@ import {
|
|||||||
type PlayCatalogRefreshSource,
|
type PlayCatalogRefreshSource,
|
||||||
} from "@/lib/play-catalog-events";
|
} from "@/lib/play-catalog-events";
|
||||||
import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference";
|
import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference";
|
||||||
|
import { isCreditFundingPlayer } from "@/lib/player-funding-mode";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { LotteryApiBizError } from "@/types/api/errors";
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
import type { PlayEffectivePayload, PlayEffectivePlayRow } from "@/types/api/play-effective";
|
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 { display, isBettable, reload: reloadDraw } = drawLive;
|
||||||
const { t } = useTranslation("player");
|
const { t } = useTranslation("player");
|
||||||
const { activeCurrency: currencyParam } = useActivePlayerCurrency();
|
const { activeCurrency: currencyParam } = useActivePlayerCurrency();
|
||||||
|
const creditMode = isCreditFundingPlayer(usePlayerSessionStore((state) => state.profile));
|
||||||
|
|
||||||
const [activeCategory, setActiveCategory] = useState<HallCategory>("D2");
|
const [activeCategory, setActiveCategory] = useState<HallCategory>("D2");
|
||||||
const [rows, setRows] = useState<DraftRow[]>(() => [newDraftRow()]);
|
const [rows, setRows] = useState<DraftRow[]>(() => [newDraftRow()]);
|
||||||
@@ -1241,7 +1244,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
|||||||
<button
|
<button
|
||||||
key={`fav-${number}`}
|
key={`fav-${number}`}
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex h-7 items-center gap-1 rounded-full border border-[#ffd7db] bg-[#fff3f5] px-2.5 text-xs font-semibold text-[#d81435] transition-colors hover:bg-[#ffe9ed]"
|
className="inline-flex h-7 touch-manipulation items-center gap-1 rounded-full border border-[#ffd7db] bg-[#fff3f5] px-2.5 text-xs font-semibold text-[#d81435] transition-colors hover:bg-[#ffe9ed]"
|
||||||
onPointerDown={() => {
|
onPointerDown={() => {
|
||||||
const current = holdFavoriteRef.current;
|
const current = holdFavoriteRef.current;
|
||||||
current.number = number;
|
current.number = number;
|
||||||
@@ -1568,6 +1571,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
|||||||
currencyCode={currencyCode}
|
currencyCode={currencyCode}
|
||||||
data={resultData}
|
data={resultData}
|
||||||
jackpotEnabled={Boolean(jackpot?.enabled)}
|
jackpotEnabled={Boolean(jackpot?.enabled)}
|
||||||
|
creditMode={creditMode}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ export function HallDrawPanel({ drawLive }: { drawLive: HallDrawLiveSnapshot })
|
|||||||
/>
|
/>
|
||||||
<Hourglass
|
<Hourglass
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute right-2 top-1/2 size-5 -translate-y-1/2",
|
"absolute right-0.5 top-1/2 size-4 -translate-y-1/2 opacity-80",
|
||||||
sealedUi ? "text-[#ff143d]" : "text-red-300",
|
sealedUi ? "text-[#ff143d]" : "text-red-300",
|
||||||
)}
|
)}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { BookOpen } from "lucide-react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { CurrencySwitcher } from "@/components/currency-switcher";
|
import { CurrencySwitcher } from "@/components/currency-switcher";
|
||||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||||
import { PlayerNotificationBell } from "@/components/layout/player-notification-bell";
|
|
||||||
import { HallBettingGrid } from "@/features/hall/hall-betting-grid";
|
import { HallBettingGrid } from "@/features/hall/hall-betting-grid";
|
||||||
|
import { usePlayerStickyTopStyle } from "@/hooks/use-player-sticky-top-style";
|
||||||
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
||||||
import { HallDrawPanel } from "@/features/hall/hall-draw-panel";
|
import { HallDrawPanel } from "@/features/hall/hall-draw-panel";
|
||||||
import { HallWalletStrip } from "@/features/hall/hall-wallet-strip";
|
import { HallWalletStrip } from "@/features/hall/hall-wallet-strip";
|
||||||
import { JackpotBurstOverlay } from "@/features/hall/jackpot-burst-overlay";
|
import { JackpotBurstOverlay } from "@/features/hall/jackpot-burst-overlay";
|
||||||
import { useHallDrawLive } from "@/features/hall/use-hall-draw-live";
|
import { useHallDrawLive } from "@/features/hall/use-hall-draw-live";
|
||||||
import { useJackpotBurstLive } from "@/features/hall/use-jackpot-burst-live";
|
import { useJackpotBurstLive } from "@/features/hall/use-jackpot-burst-live";
|
||||||
import { playerHeaderControl, playerPageInset } from "@/lib/player-spacing";
|
import { playerHeaderControl, playerPageHeader, playerPageInset } from "@/lib/player-spacing";
|
||||||
|
import { playerViewportColumnClass } from "@/lib/player-viewport";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -25,22 +27,29 @@ export function HallScreen() {
|
|||||||
const drawLive = useHallDrawLive();
|
const drawLive = useHallDrawLive();
|
||||||
const { activeCurrency } = useActivePlayerCurrency();
|
const { activeCurrency } = useActivePlayerCurrency();
|
||||||
const { burstEvent, clearBurstEvent } = useJackpotBurstLive(tp);
|
const { burstEvent, clearBurstEvent } = useJackpotBurstLive(tp);
|
||||||
|
const stickyTopStyle = usePlayerStickyTopStyle();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-[480px]">
|
<div className={playerViewportColumnClass}>
|
||||||
<section className={cn("bg-white text-slate-900", playerPageInset)}>
|
<section className={cn("bg-white text-slate-900", playerPageInset)}>
|
||||||
<header className="relative z-20 mb-2 flex min-h-9 items-center gap-2 overflow-visible">
|
<header
|
||||||
|
className={cn(
|
||||||
|
playerPageHeader,
|
||||||
|
"sticky z-20 mb-2 flex min-h-9 items-center gap-2 overflow-visible bg-white/95 pt-2 pb-2 -mx-3 px-3 backdrop-blur",
|
||||||
|
)}
|
||||||
|
style={stickyTopStyle}
|
||||||
|
>
|
||||||
<div className="flex min-w-0 flex-1 items-center">
|
<div className="flex min-w-0 flex-1 items-center">
|
||||||
<Image
|
<Image
|
||||||
src="/logo.png"
|
src="/logo.png"
|
||||||
alt="Nlotto"
|
alt="Nlotto"
|
||||||
width={243}
|
width={243}
|
||||||
height={84}
|
height={84}
|
||||||
className="h-8 w-auto max-w-[min(100%,200px)] object-contain object-left"
|
className="h-7 w-auto max-w-[min(100%,160px)] object-contain object-left sm:h-8 sm:max-w-[min(100%,200px)]"
|
||||||
priority
|
priority
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
<div className="flex shrink-0 items-center gap-0.5 sm:gap-1">
|
||||||
<CurrencySwitcher
|
<CurrencySwitcher
|
||||||
variant="minimal"
|
variant="minimal"
|
||||||
menuAlign="end"
|
menuAlign="end"
|
||||||
@@ -65,10 +74,11 @@ export function HallScreen() {
|
|||||||
playerHeaderControl,
|
playerHeaderControl,
|
||||||
"rounded-full border border-[#e4eaf4] bg-[#f8fafc] px-2.5 text-xs font-bold text-[#0b3f96] hover:bg-[#f1f6ff]",
|
"rounded-full border border-[#e4eaf4] bg-[#f8fafc] px-2.5 text-xs font-bold text-[#0b3f96] hover:bg-[#f1f6ff]",
|
||||||
)}
|
)}
|
||||||
|
aria-label={tp("nav.rules")}
|
||||||
>
|
>
|
||||||
{tp("nav.rules")}
|
<BookOpen className="size-3.5 shrink-0 sm:hidden" aria-hidden />
|
||||||
|
<span className="hidden sm:inline">{tp("nav.rules")}</span>
|
||||||
</Link>
|
</Link>
|
||||||
<PlayerNotificationBell />
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { useSWRConfig } from "swr";
|
import { useSWRConfig } from "swr";
|
||||||
|
|
||||||
import { getWalletBalance } from "@/api/wallet";
|
import { getWalletBalance } from "@/api/wallet";
|
||||||
|
import { PlayerMoneyDisplay } from "@/components/player-money-display";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import {
|
import {
|
||||||
TransferInDialog,
|
TransferInDialog,
|
||||||
@@ -65,29 +66,6 @@ export function HallWalletStrip() {
|
|||||||
const balanceMinor = Number(balance?.balance ?? 0);
|
const balanceMinor = Number(balance?.balance ?? 0);
|
||||||
const headlineMinor = isCreditPlayer ? availableMinor : balanceMinor;
|
const headlineMinor = isCreditPlayer ? availableMinor : balanceMinor;
|
||||||
const transferInLotteryMinor = isCreditPlayer ? availableMinor : balanceMinor;
|
const transferInLotteryMinor = isCreditPlayer ? availableMinor : balanceMinor;
|
||||||
// #region agent log
|
|
||||||
if (typeof window !== "undefined" && balance && !loading) {
|
|
||||||
fetch("http://127.0.0.1:7696/ingest/e56128e6-898b-4d61-b06d-2aefe0923744", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json", "X-Debug-Session-Id": "7fd0fc" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
sessionId: "7fd0fc",
|
|
||||||
runId: "hall-wallet-display-post-fix",
|
|
||||||
hypothesisId: "H3",
|
|
||||||
location: "hall-wallet-strip.tsx:render",
|
|
||||||
message: "hall wallet headline amounts",
|
|
||||||
data: {
|
|
||||||
isCreditPlayer,
|
|
||||||
availableMinor,
|
|
||||||
balanceMinor,
|
|
||||||
headlineMinor,
|
|
||||||
mismatchWithWalletPage: !isCreditPlayer && headlineMinor !== balanceMinor,
|
|
||||||
},
|
|
||||||
timestamp: Date.now(),
|
|
||||||
}),
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
// #endregion
|
|
||||||
const mainMinor =
|
const mainMinor =
|
||||||
balance?.main_balance === null || balance?.main_balance === undefined
|
balance?.main_balance === null || balance?.main_balance === undefined
|
||||||
? null
|
? null
|
||||||
@@ -115,7 +93,7 @@ export function HallWalletStrip() {
|
|||||||
aria-hidden
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
<div className="relative flex items-center gap-3">
|
<div className="relative flex items-center gap-3">
|
||||||
<div className="flex size-13 shrink-0 items-center justify-center rounded-full bg-white text-[#d81435] shadow-sm">
|
<div className="flex size-14 shrink-0 items-center justify-center rounded-full bg-white text-[#d81435] shadow-sm">
|
||||||
<Wallet className="size-7" aria-hidden />
|
<Wallet className="size-7" aria-hidden />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
@@ -127,10 +105,21 @@ export function HallWalletStrip() {
|
|||||||
{loading ? (
|
{loading ? (
|
||||||
<Skeleton className="mt-2 h-8 w-44 rounded-md bg-white/25" />
|
<Skeleton className="mt-2 h-8 w-44 rounded-md bg-white/25" />
|
||||||
) : (
|
) : (
|
||||||
<p className="mt-1 text-2xl font-black leading-none tabular-nums tracking-normal">
|
<PlayerMoneyDisplay
|
||||||
{formatMinorAsCurrency(headlineMinor, currency)}
|
amountMinor={headlineMinor}
|
||||||
</p>
|
currency={currency}
|
||||||
|
className="mt-1 text-white"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
{isCreditPlayer && !loading && balance ? (
|
||||||
|
<p className="mt-2 text-xs text-white/75">
|
||||||
|
{t("wallet.creditSummary", {
|
||||||
|
defaultValue: "授信 {{limit}} · 已用 {{used}}",
|
||||||
|
limit: formatMinorAsCurrency(balance.credit_limit ?? 0, currency),
|
||||||
|
used: formatMinorAsCurrency(balance.used_credit ?? 0, currency),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,13 +3,12 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { CalendarRange, ChevronDown, Search } from "lucide-react";
|
import { CalendarRange, Check, ChevronDown, Search } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { getDrawCurrent } from "@/api/draw";
|
import { getDrawCurrent } from "@/api/draw";
|
||||||
import { getTicketItems } from "@/api/ticket-items";
|
import { getTicketItems } from "@/api/ticket-items";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
|
||||||
import { Calendar } from "@/components/ui/calendar";
|
import { Calendar } from "@/components/ui/calendar";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { PlayerPanel } from "@/components/layout/player-panel";
|
import { PlayerPanel } from "@/components/layout/player-panel";
|
||||||
@@ -203,10 +202,7 @@ export function TicketOrdersListScreen() {
|
|||||||
}, [fetchPage, lastPage, loading, loadingMore, page]);
|
}, [fetchPage, lastPage, loading, loadingMore, page]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PlayerPanel
|
<PlayerPanel title={t("orders.title")}>
|
||||||
title={t("orders.title")}
|
|
||||||
containerClassName="max-w-[720px]"
|
|
||||||
>
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="rounded-2xl border border-[#dfe9f8] bg-white p-3 shadow-[0_10px_28px_rgba(15,23,42,0.05)] sm:p-4">
|
<div className="rounded-2xl border border-[#dfe9f8] bg-white p-3 shadow-[0_10px_28px_rgba(15,23,42,0.05)] sm:p-4">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
@@ -381,7 +377,17 @@ export function TicketOrdersListScreen() {
|
|||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Checkbox className="size-3.5" checked={checked} />
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex size-3.5 shrink-0 items-center justify-center rounded border",
|
||||||
|
checked
|
||||||
|
? "border-[#0b56b7] bg-[#0b56b7] text-white"
|
||||||
|
: "border-slate-300 bg-white",
|
||||||
|
)}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
{checked ? <Check className="size-2.5" strokeWidth={3} /> : null}
|
||||||
|
</span>
|
||||||
<span className="truncate">{t(`ticketStatus.${status}`, { defaultValue: status })}</span>
|
<span className="truncate">{t(`ticketStatus.${status}`, { defaultValue: status })}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
Globe,
|
Globe,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
RefreshCw,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
@@ -502,7 +503,7 @@ export function EntryGate() {
|
|||||||
size="lg"
|
size="lg"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Loader2 className="size-4" aria-hidden />
|
<RefreshCw className="size-4" aria-hidden />
|
||||||
{t("failure.reenter")}
|
{t("failure.reenter")}
|
||||||
</Button>
|
</Button>
|
||||||
{MAIN_SITE_URL !== "" ? (
|
{MAIN_SITE_URL !== "" ? (
|
||||||
|
|||||||
@@ -9,14 +9,17 @@ import { PlayerPanel } from "@/components/layout/player-panel";
|
|||||||
import { usePendingWalletReconcile } from "@/hooks/use-pending-wallet-reconcile";
|
import { usePendingWalletReconcile } from "@/hooks/use-pending-wallet-reconcile";
|
||||||
import { formatPlayerInstant } from "@/lib/player-datetime";
|
import { formatPlayerInstant } from "@/lib/player-datetime";
|
||||||
import { formatMinorAsCurrency } from "@/lib/money";
|
import { formatMinorAsCurrency } from "@/lib/money";
|
||||||
|
import { isCreditFundingPlayer } from "@/lib/player-funding-mode";
|
||||||
import {
|
import {
|
||||||
pendingReconcileDescriptionKey,
|
pendingReconcileDescriptionKey,
|
||||||
pendingReconcileTitleKey,
|
pendingReconcileTitleKey,
|
||||||
} from "@/lib/pending-reconcile-notification";
|
} from "@/lib/pending-reconcile-notification";
|
||||||
|
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export function NotificationsScreen() {
|
export function NotificationsScreen() {
|
||||||
const { t } = useTranslation("player");
|
const { t } = useTranslation("player");
|
||||||
|
const creditMode = isCreditFundingPlayer(usePlayerSessionStore((state) => state.profile));
|
||||||
const { pending, unreadPending, unreadCount, loading, markAsRead, markAllAsRead } =
|
const { pending, unreadPending, unreadCount, loading, markAsRead, markAllAsRead } =
|
||||||
usePendingWalletReconcile();
|
usePendingWalletReconcile();
|
||||||
const unreadSet = new Set(unreadPending.map((item) => item.transfer_no));
|
const unreadSet = new Set(unreadPending.map((item) => item.transfer_no));
|
||||||
@@ -24,6 +27,14 @@ export function NotificationsScreen() {
|
|||||||
return (
|
return (
|
||||||
<PlayerPanel title={t("notifications.title")} backHref="/hall">
|
<PlayerPanel title={t("notifications.title")} backHref="/hall">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
{creditMode ? (
|
||||||
|
<div className="rounded-xl border border-[#d6e4ff] bg-[#f5f9ff] px-3 py-3 text-sm text-[#0b3f96]/85">
|
||||||
|
{t("notifications.creditEmptyHint", {
|
||||||
|
defaultValue: "信用盘无主站划转,暂无待对账通知。",
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<div className="flex items-center justify-between rounded-xl border border-[#dce7f7] bg-[#f8fbff] px-3 py-2.5">
|
<div className="flex items-center justify-between rounded-xl border border-[#dce7f7] bg-[#f8fbff] px-3 py-2.5">
|
||||||
<p className="text-sm font-semibold text-[#0b3f96]">
|
<p className="text-sm font-semibold text-[#0b3f96]">
|
||||||
{t("notifications.unreadCount", { count: unreadCount })}
|
{t("notifications.unreadCount", { count: unreadCount })}
|
||||||
@@ -126,6 +137,8 @@ export function NotificationsScreen() {
|
|||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
) : null}
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</PlayerPanel>
|
</PlayerPanel>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -113,19 +113,13 @@ export function PlayerLoginScreen(): React.ReactElement {
|
|||||||
|
|
||||||
const usernameIssue = validatePlayerLoginUsername(username);
|
const usernameIssue = validatePlayerLoginUsername(username);
|
||||||
if (usernameIssue === "invalid_charset") {
|
if (usernameIssue === "invalid_charset") {
|
||||||
toast.error(
|
toast.error(t("login.usernameInvalidCharset"));
|
||||||
t("login.usernameInvalidCharset", {
|
|
||||||
defaultValue: "账号只能使用字母、数字、点(.)、下划线和连字符",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const passwordIssue = validatePlayerLoginPassword(password);
|
const passwordIssue = validatePlayerLoginPassword(password);
|
||||||
if (passwordIssue === "too_short") {
|
if (passwordIssue === "too_short") {
|
||||||
toast.error(
|
toast.error(t("login.passwordMinLength"));
|
||||||
t("login.passwordMinLength", { defaultValue: "密码至少需要 6 个字符" }),
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { formatMinorAsCurrency } from "@/lib/money";
|
|||||||
import { isCreditFundingPlayer } from "@/lib/player-funding-mode";
|
import { isCreditFundingPlayer } from "@/lib/player-funding-mode";
|
||||||
import { norm4d } from "@/lib/norm-4d";
|
import { norm4d } from "@/lib/norm-4d";
|
||||||
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
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 { cn } from "@/lib/utils";
|
||||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||||
import type { DrawResultDetailPayload } from "@/types/api/draw-results";
|
import type { DrawResultDetailPayload } from "@/types/api/draw-results";
|
||||||
@@ -218,18 +219,16 @@ export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps)
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 grid grid-cols-3 gap-2 text-center">
|
<div className="mt-3 grid grid-cols-3 gap-2 text-center">
|
||||||
{[
|
{RESULTS_TOP_PRIZE_KEYS.map((tier) => (
|
||||||
["1st", data.results["1st"]],
|
|
||||||
["2nd", data.results["2nd"]],
|
|
||||||
["3rd", data.results["3rd"]],
|
|
||||||
].map(([label, value]) => (
|
|
||||||
<div
|
<div
|
||||||
key={label}
|
key={tier}
|
||||||
className="rounded-lg border border-[#edf2f8] bg-white py-2 shadow-[0_4px_12px_rgba(15,23,42,0.03)]"
|
className="rounded-lg border border-[#edf2f8] bg-white py-2 shadow-[0_4px_12px_rgba(15,23,42,0.03)]"
|
||||||
>
|
>
|
||||||
<p className="text-[10px] font-bold uppercase text-[#7890b8]">{label}</p>
|
<p className="text-[10px] font-bold text-[#7890b8]">
|
||||||
|
{t(resultsPrizeLabelKey(tier))}
|
||||||
|
</p>
|
||||||
<p className="mt-1 font-mono text-lg font-black tabular-nums text-[#e5002c]">
|
<p className="mt-1 font-mono text-lg font-black tabular-nums text-[#e5002c]">
|
||||||
{value}
|
{data.results[tier]}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { TwentyThreeResultsGrid } from "@/features/results/twenty-three-results-
|
|||||||
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
|
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
|
||||||
import { formatPlayerInstant } from "@/lib/player-datetime";
|
import { formatPlayerInstant } from "@/lib/player-datetime";
|
||||||
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
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";
|
import type { DrawResultListItem } from "@/types/api/draw-results";
|
||||||
|
|
||||||
const RESULTS_PAGE_SIZE = 10;
|
const RESULTS_PAGE_SIZE = 10;
|
||||||
@@ -292,15 +293,13 @@ export function DrawResultsListScreen() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 grid grid-cols-3 gap-2 text-center">
|
<div className="mt-3 grid grid-cols-3 gap-2 text-center">
|
||||||
{[
|
{RESULTS_TOP_PRIZE_KEYS.map((tier) => (
|
||||||
["1st", row.results["1st"]],
|
<div key={tier} className="rounded-lg border border-[#edf2f8] bg-[#f8fbff] py-2">
|
||||||
["2nd", row.results["2nd"]],
|
<p className="text-[10px] font-bold text-[#7890b8]">
|
||||||
["3rd", row.results["3rd"]],
|
{t(resultsPrizeLabelKey(tier))}
|
||||||
].map(([label, value]) => (
|
</p>
|
||||||
<div key={label} className="rounded-lg border border-[#edf2f8] bg-[#f8fbff] py-2">
|
|
||||||
<p className="text-[10px] font-bold uppercase text-[#7890b8]">{label}</p>
|
|
||||||
<p className="mt-1 font-mono text-lg font-black tabular-nums text-[#e5002c]">
|
<p className="mt-1 font-mono text-lg font-black tabular-nums text-[#e5002c]">
|
||||||
{value}
|
{row.results[tier]}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import { useSWRConfig } from "swr";
|
|||||||
|
|
||||||
import { getWalletBalance } from "@/api/wallet";
|
import { getWalletBalance } from "@/api/wallet";
|
||||||
import { TransferInPage } from "@/features/wallet/wallet-transfer-forms";
|
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 { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
||||||
import { useApiQuery } from "@/hooks/use-api-query";
|
import { useApiQuery } from "@/hooks/use-api-query";
|
||||||
import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference";
|
import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference";
|
||||||
@@ -24,7 +25,6 @@ export function TransferInScreen() {
|
|||||||
() => getWalletBalance({ currency }),
|
() => getWalletBalance({ currency }),
|
||||||
);
|
);
|
||||||
|
|
||||||
// 币种切换时 SWR key 自动变化,此处仅处理全局事件触发的刷新
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onRefresh = () => void mutate(BALANCE_KEY(currency));
|
const onRefresh = () => void mutate(BALANCE_KEY(currency));
|
||||||
window.addEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onRefresh);
|
window.addEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onRefresh);
|
||||||
@@ -37,24 +37,21 @@ export function TransferInScreen() {
|
|||||||
}, [mutate, currency, router]);
|
}, [mutate, currency, router]);
|
||||||
|
|
||||||
if (loading && !balance) {
|
if (loading && !balance) {
|
||||||
return (
|
return <WalletTransferLoadingPanel titleKey="wallet.transferInTitle" />;
|
||||||
<div className="space-y-3">
|
|
||||||
<Skeleton className="h-8 w-40" />
|
|
||||||
<Skeleton className="h-48 w-full rounded-xl" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TransferInPage
|
<WalletTransferCreditGuard balance={balance} loading={loading}>
|
||||||
currency={currency}
|
<TransferInPage
|
||||||
lotteryMinor={Number(balance?.balance ?? 0)}
|
currency={currency}
|
||||||
mainMinor={
|
lotteryMinor={Number(balance?.balance ?? 0)}
|
||||||
balance?.main_balance === null || balance?.main_balance === undefined
|
mainMinor={
|
||||||
? null
|
balance?.main_balance === null || balance?.main_balance === undefined
|
||||||
: Number(balance.main_balance)
|
? null
|
||||||
}
|
: Number(balance.main_balance)
|
||||||
onSuccess={onSuccess}
|
}
|
||||||
/>
|
onSuccess={onSuccess}
|
||||||
|
/>
|
||||||
|
</WalletTransferCreditGuard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import { useSWRConfig } from "swr";
|
|||||||
|
|
||||||
import { getWalletBalance } from "@/api/wallet";
|
import { getWalletBalance } from "@/api/wallet";
|
||||||
import { TransferOutPage } from "@/features/wallet/wallet-transfer-forms";
|
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 { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
||||||
import { useApiQuery } from "@/hooks/use-api-query";
|
import { useApiQuery } from "@/hooks/use-api-query";
|
||||||
import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference";
|
import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference";
|
||||||
@@ -24,7 +25,6 @@ export function TransferOutScreen() {
|
|||||||
() => getWalletBalance({ currency }),
|
() => getWalletBalance({ currency }),
|
||||||
);
|
);
|
||||||
|
|
||||||
// 币种切换时 SWR key 自动变化,此处仅处理全局事件触发的刷新
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onRefresh = () => void mutate(BALANCE_KEY(currency));
|
const onRefresh = () => void mutate(BALANCE_KEY(currency));
|
||||||
window.addEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onRefresh);
|
window.addEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onRefresh);
|
||||||
@@ -37,19 +37,16 @@ export function TransferOutScreen() {
|
|||||||
}, [mutate, currency, router]);
|
}, [mutate, currency, router]);
|
||||||
|
|
||||||
if (loading && !balance) {
|
if (loading && !balance) {
|
||||||
return (
|
return <WalletTransferLoadingPanel titleKey="wallet.transferOutTitle" />;
|
||||||
<div className="space-y-3">
|
|
||||||
<Skeleton className="h-8 w-40" />
|
|
||||||
<Skeleton className="h-48 w-full rounded-xl" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TransferOutPage
|
<WalletTransferCreditGuard balance={balance} loading={loading}>
|
||||||
currency={currency}
|
<TransferOutPage
|
||||||
availableMinor={Number(balance?.available_balance ?? 0)}
|
currency={currency}
|
||||||
onSuccess={onSuccess}
|
availableMinor={Number(balance?.available_balance ?? 0)}
|
||||||
/>
|
onSuccess={onSuccess}
|
||||||
|
/>
|
||||||
|
</WalletTransferCreditGuard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ type WalletLogsBlockProps = {
|
|||||||
creditMode?: boolean;
|
creditMode?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 类型筛选 + 列表(待对账见顶栏通知铃铛) */
|
/** 类型筛选 + 列表(钱包盘待对账见钱包页横幅) */
|
||||||
export function WalletLogsBlock({
|
export function WalletLogsBlock({
|
||||||
logs,
|
logs,
|
||||||
logsLoading,
|
logsLoading,
|
||||||
|
|||||||
@@ -142,7 +142,6 @@ export function WalletLogsScreen() {
|
|||||||
onFilterChange={setFilter}
|
onFilterChange={setFilter}
|
||||||
currency={currency}
|
currency={currency}
|
||||||
creditMode={creditMode}
|
creditMode={creditMode}
|
||||||
title={t("wallet.typeFilter")}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</PlayerPanel>
|
</PlayerPanel>
|
||||||
|
|||||||
45
src/features/wallet/wallet-pending-reconcile-banner.tsx
Normal file
45
src/features/wallet/wallet-pending-reconcile-banner.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-3 text-sm text-amber-950">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-600" aria-hidden />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="font-bold text-amber-900">{t("wallet.pendingTitle")}</p>
|
||||||
|
<p className="mt-1 text-xs leading-relaxed text-amber-950/85">
|
||||||
|
{t("wallet.pendingDescription")}
|
||||||
|
</p>
|
||||||
|
{unreadCount > 0 ? (
|
||||||
|
<p className="mt-1.5 text-xs font-semibold text-amber-800">
|
||||||
|
{t("notifications.unreadCount", { count: unreadCount })}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<Link
|
||||||
|
href="/notifications"
|
||||||
|
className="mt-2 inline-flex text-xs font-bold text-[#0b56b7] underline-offset-2 hover:underline"
|
||||||
|
>
|
||||||
|
{t("wallet.viewPendingReconcile", {
|
||||||
|
defaultValue: "查看待对账详情({{count}})",
|
||||||
|
count: pending.length,
|
||||||
|
})}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
TransferInDialog,
|
TransferInDialog,
|
||||||
TransferOutDialog,
|
TransferOutDialog,
|
||||||
} from "@/features/wallet/wallet-transfer-dialogs";
|
} 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 { WalletLogsBlock } from "@/features/wallet/wallet-logs-block";
|
||||||
import { dispatchWalletLogsRefresh } from "@/hooks/use-pending-wallet-reconcile";
|
import { dispatchWalletLogsRefresh } from "@/hooks/use-pending-wallet-reconcile";
|
||||||
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
||||||
@@ -36,8 +38,8 @@ export function WalletScreen() {
|
|||||||
const [loadingMore, setLoadingMore] = useState(false);
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const loadMoreRef = useRef<HTMLDivElement | null>(null);
|
const loadMoreRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const filterInitializedRef = useRef(false);
|
||||||
const fetchPassRef = useRef(true);
|
const prevFilterRef = useRef("");
|
||||||
|
|
||||||
const loadLogs = useCallback(async (targetPage = 1, append = false) => {
|
const loadLogs = useCallback(async (targetPage = 1, append = false) => {
|
||||||
const nextLogs = await getWalletLogs({
|
const nextLogs = await getWalletLogs({
|
||||||
@@ -60,21 +62,21 @@ export function WalletScreen() {
|
|||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
if (fetchPassRef.current) {
|
setLoading(true);
|
||||||
setLoading(true);
|
|
||||||
fetchPassRef.current = false;
|
|
||||||
} else {
|
|
||||||
setLogsLoading(true);
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
// 并行请求余额和日志,避免瀑布式串行等待
|
|
||||||
const [b, nextLogs] = await Promise.all([
|
const [b, nextLogs] = await Promise.all([
|
||||||
getWalletBalance({ currency }),
|
getWalletBalance({ currency }),
|
||||||
loadLogs(1, false),
|
getWalletLogs({
|
||||||
|
page: 1,
|
||||||
|
size: WALLET_LOGS_PAGE_SIZE,
|
||||||
|
type: filter || undefined,
|
||||||
|
currency,
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setBalance(b);
|
setBalance(b);
|
||||||
setLogs(nextLogs);
|
setLogs(nextLogs);
|
||||||
|
dispatchWalletLogsRefresh(nextLogs.pending_reconcile ?? []);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setError(formatWalletClientError(e, t));
|
setError(formatWalletClientError(e, t));
|
||||||
@@ -90,7 +92,50 @@ export function WalletScreen() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
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 () => {
|
const refreshAll = useCallback(async () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -181,7 +226,7 @@ export function WalletScreen() {
|
|||||||
aria-hidden
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
<div className="relative flex items-center gap-3">
|
<div className="relative flex items-center gap-3">
|
||||||
<div className="flex size-13 shrink-0 items-center justify-center rounded-full bg-white text-[#d81435] shadow-sm">
|
<div className="flex size-14 shrink-0 items-center justify-center rounded-full bg-white text-[#d81435] shadow-sm">
|
||||||
<Wallet className="size-7" aria-hidden />
|
<Wallet className="size-7" aria-hidden />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
@@ -193,9 +238,11 @@ export function WalletScreen() {
|
|||||||
{loading ? (
|
{loading ? (
|
||||||
<Skeleton className="mt-2 h-8 w-44 rounded-md bg-white/25" />
|
<Skeleton className="mt-2 h-8 w-44 rounded-md bg-white/25" />
|
||||||
) : (
|
) : (
|
||||||
<p className="mt-1 text-2xl font-black leading-none tabular-nums tracking-normal">
|
<PlayerMoneyDisplay
|
||||||
{formatMinorAsCurrency(displayMinor, currency)}
|
amountMinor={displayMinor}
|
||||||
</p>
|
currency={currency}
|
||||||
|
className="mt-1 text-white"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<p className="mt-2 text-xs text-white/75">
|
<p className="mt-2 text-xs text-white/75">
|
||||||
{isCreditPlayer
|
{isCreditPlayer
|
||||||
@@ -220,7 +267,9 @@ export function WalletScreen() {
|
|||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<>
|
||||||
|
<WalletPendingReconcileBanner />
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<TransferInDialog
|
<TransferInDialog
|
||||||
idPrefix="wallet-"
|
idPrefix="wallet-"
|
||||||
currency={currency}
|
currency={currency}
|
||||||
@@ -245,6 +294,7 @@ export function WalletScreen() {
|
|||||||
triggerClassName="h-14 rounded-2xl text-base font-black"
|
triggerClassName="h-14 rounded-2xl text-base font-black"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<WalletLogsBlock
|
<WalletLogsBlock
|
||||||
|
|||||||
57
src/features/wallet/wallet-transfer-credit-guard.tsx
Normal file
57
src/features/wallet/wallet-transfer-credit-guard.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect, type ReactNode } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { PlayerPanel } from "@/components/layout/player-panel";
|
||||||
|
import { isCreditFundingPlayer } from "@/lib/player-funding-mode";
|
||||||
|
import type { WalletBalanceData } from "@/types/api/wallet-balance";
|
||||||
|
|
||||||
|
type WalletTransferCreditGuardProps = {
|
||||||
|
balance: WalletBalanceData | null | undefined;
|
||||||
|
loading: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 信用盘玩家不可主站划转:加载完成后重定向回钱包页 */
|
||||||
|
export function WalletTransferCreditGuard({
|
||||||
|
balance,
|
||||||
|
loading,
|
||||||
|
children,
|
||||||
|
}: WalletTransferCreditGuardProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useTranslation("player");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading || !balance) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isCreditFundingPlayer(balance)) {
|
||||||
|
router.replace("/wallet");
|
||||||
|
}
|
||||||
|
}, [balance, loading, router]);
|
||||||
|
|
||||||
|
if (loading || !balance) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCreditFundingPlayer(balance)) {
|
||||||
|
return (
|
||||||
|
<PlayerPanel
|
||||||
|
title={t("wallet.creditTitle", { defaultValue: "信用" })}
|
||||||
|
backHref="/wallet"
|
||||||
|
backLabel={t("wallet.creditTitle", { defaultValue: "信用" })}
|
||||||
|
>
|
||||||
|
<p className="rounded-xl border border-[#d6e4ff] bg-[#f5f9ff] px-3 py-3 text-sm text-[#0b3f96]/85">
|
||||||
|
{t("wallet.creditNoTransferHint", {
|
||||||
|
defaultValue:
|
||||||
|
"由代理授信,无需主站转入转出;中奖不即时派彩,盈亏在账期统一结算,额度调整请联系代理。",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</PlayerPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
29
src/features/wallet/wallet-transfer-loading-panel.tsx
Normal file
29
src/features/wallet/wallet-transfer-loading-panel.tsx
Normal file
@@ -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 (
|
||||||
|
<PlayerPanel title={t(titleKey)} backHref="/wallet" backLabel={backLabel}>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Skeleton className="h-8 w-40" />
|
||||||
|
<Skeleton className="h-48 w-full rounded-xl" />
|
||||||
|
</div>
|
||||||
|
</PlayerPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
import { getWalletLogs } from "@/api/wallet";
|
import { getWalletLogs } from "@/api/wallet";
|
||||||
|
import { isCreditFundingPlayer } from "@/lib/player-funding-mode";
|
||||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||||
import type { WalletPendingTransfer } from "@/types/api/wallet-logs";
|
import type { WalletPendingTransfer } from "@/types/api/wallet-logs";
|
||||||
|
|
||||||
@@ -56,6 +57,7 @@ export function usePendingWalletReconcile(): {
|
|||||||
markAllAsRead: () => void;
|
markAllAsRead: () => void;
|
||||||
} {
|
} {
|
||||||
const bearerToken = usePlayerSessionStore((s) => s.bearerToken);
|
const bearerToken = usePlayerSessionStore((s) => s.bearerToken);
|
||||||
|
const creditMode = isCreditFundingPlayer(usePlayerSessionStore((s) => s.profile));
|
||||||
const [pending, setPending] = useState<WalletPendingTransfer[]>([]);
|
const [pending, setPending] = useState<WalletPendingTransfer[]>([]);
|
||||||
const [readTransferNos, setReadTransferNos] = useState<Set<string>>(() => {
|
const [readTransferNos, setReadTransferNos] = useState<Set<string>>(() => {
|
||||||
if (typeof window === "undefined") return new Set();
|
if (typeof window === "undefined") return new Set();
|
||||||
@@ -83,7 +85,7 @@ export function usePendingWalletReconcile(): {
|
|||||||
}, [readTransferNos]);
|
}, [readTransferNos]);
|
||||||
|
|
||||||
const refresh = useCallback(async (): Promise<void> => {
|
const refresh = useCallback(async (): Promise<void> => {
|
||||||
if (!bearerToken?.trim()) {
|
if (!bearerToken?.trim() || creditMode) {
|
||||||
setPending([]);
|
setPending([]);
|
||||||
pendingReconcileCache = null;
|
pendingReconcileCache = null;
|
||||||
pendingReconcileFetchedAtMs = 0;
|
pendingReconcileFetchedAtMs = 0;
|
||||||
@@ -96,7 +98,7 @@ export function usePendingWalletReconcile(): {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [bearerToken]);
|
}, [bearerToken, creditMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
|
|||||||
34
src/hooks/use-player-banner-height-ref.ts
Normal file
34
src/hooks/use-player-banner-height-ref.ts
Normal file
@@ -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<HTMLDivElement>(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;
|
||||||
|
}
|
||||||
16
src/hooks/use-player-sticky-top-style.ts
Normal file
16
src/hooks/use-player-sticky-top-style.ts
Normal file
@@ -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 };
|
||||||
|
}
|
||||||
@@ -64,7 +64,9 @@
|
|||||||
"captchaLoadFailed": "Failed to load captcha. Try again.",
|
"captchaLoadFailed": "Failed to load captcha. Try again.",
|
||||||
"captchaLoading": "Loading…",
|
"captchaLoading": "Loading…",
|
||||||
"captchaRefresh": "Refresh captcha",
|
"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": {
|
"errors": {
|
||||||
"noToken": "No authorization token found",
|
"noToken": "No authorization token found",
|
||||||
|
|||||||
@@ -47,7 +47,8 @@
|
|||||||
"markRead": "Mark as read",
|
"markRead": "Mark as read",
|
||||||
"markAllRead": "Mark all read",
|
"markAllRead": "Mark all read",
|
||||||
"unreadCount_one": "{{count}} unread",
|
"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": {
|
"panel": {
|
||||||
"home": "Home"
|
"home": "Home"
|
||||||
@@ -88,7 +89,24 @@
|
|||||||
"home": "Back home",
|
"home": "Back home",
|
||||||
"hall": "Betting hall",
|
"hall": "Betting hall",
|
||||||
"results": "Results",
|
"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": {
|
"player": {
|
||||||
"fallback": "Player #{{id}}"
|
"fallback": "Player #{{id}}"
|
||||||
@@ -265,7 +283,9 @@
|
|||||||
"processingProgress": "Processing tickets...",
|
"processingProgress": "Processing tickets...",
|
||||||
"noWarnings": "No obvious risk was found in this preview.",
|
"noWarnings": "No obvious risk was found in this preview.",
|
||||||
"warningsTitle": "Payout pool warning",
|
"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": {
|
"result": {
|
||||||
"title": "Bet placed",
|
"title": "Bet placed",
|
||||||
@@ -283,6 +303,7 @@
|
|||||||
"actual": "Actual deduction",
|
"actual": "Actual deduction",
|
||||||
"orderNo": "Order No.",
|
"orderNo": "Order No.",
|
||||||
"balanceAfter": "Remaining balance",
|
"balanceAfter": "Remaining balance",
|
||||||
|
"creditBalanceAfter": "Available credit",
|
||||||
"items": "Successful line details",
|
"items": "Successful line details",
|
||||||
"number": "Number",
|
"number": "Number",
|
||||||
"actualDeduct": "Deducted",
|
"actualDeduct": "Deducted",
|
||||||
@@ -394,6 +415,7 @@
|
|||||||
"outExceeds": "Transfer-out amount cannot exceed available balance.",
|
"outExceeds": "Transfer-out amount cannot exceed available balance.",
|
||||||
"pendingTitle": "Pending reconciliation",
|
"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.",
|
"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",
|
"pendingStatus": "Processing",
|
||||||
"flowsTitle": "Wallet logs",
|
"flowsTitle": "Wallet logs",
|
||||||
"creditFlowsTitle": "Credit activity",
|
"creditFlowsTitle": "Credit activity",
|
||||||
@@ -521,7 +543,20 @@
|
|||||||
"backToGroup": "Back to order detail",
|
"backToGroup": "Back to order detail",
|
||||||
"notFound": "Ticket does not exist or cannot be viewed.",
|
"notFound": "Ticket does not exist or cannot be viewed.",
|
||||||
"noData": "No data",
|
"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": {
|
"results": {
|
||||||
"title": "Results",
|
"title": "Results",
|
||||||
@@ -588,6 +623,8 @@
|
|||||||
"rules": {
|
"rules": {
|
||||||
"title": "Play Rules",
|
"title": "Play Rules",
|
||||||
"subtitle": "2D / 3D / 4D, box plays, rebate, closing and sold-out rules",
|
"subtitle": "2D / 3D / 4D, box plays, rebate, closing and sold-out rules",
|
||||||
|
"empty": "No play rules available",
|
||||||
|
"loadFailed": "Failed to load rules",
|
||||||
"quick": {
|
"quick": {
|
||||||
"title": "Prize Structure",
|
"title": "Prize Structure",
|
||||||
"description": "Each draw publishes 23 four-digit numbers. Settlement uses the odds snapshot locked when the bet was placed.",
|
"description": "Each draw publishes 23 four-digit numbers. Settlement uses the odds snapshot locked when the bet was placed.",
|
||||||
|
|||||||
@@ -64,7 +64,9 @@
|
|||||||
"captchaLoadFailed": "क्याप्चा लोड असफल। फेरि प्रयास गर्नुहोस्।",
|
"captchaLoadFailed": "क्याप्चा लोड असफल। फेरि प्रयास गर्नुहोस्।",
|
||||||
"captchaLoading": "लोड हुँदै…",
|
"captchaLoading": "लोड हुँदै…",
|
||||||
"captchaRefresh": "क्याप्चा रिफ्रेस",
|
"captchaRefresh": "क्याप्चा रिफ्रेस",
|
||||||
"captchaFetch": "लोड गर्न ट्याप गर्नुहोस्"
|
"captchaFetch": "लोड गर्न ट्याप गर्नुहोस्",
|
||||||
|
"usernameInvalidCharset": "प्रयोगकर्ता नाममा अक्षर, अंक, डट (.), अन्डरस्कोर र हाइफन मात्र हुन सक्छ",
|
||||||
|
"passwordMinLength": "पासवर्ड कम्तीमा ६ वर्ण हुनुपर्छ"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"noToken": "कुनै प्राधिकरण टोकन फेला परेन",
|
"noToken": "कुनै प्राधिकरण टोकन फेला परेन",
|
||||||
|
|||||||
@@ -47,7 +47,8 @@
|
|||||||
"markRead": "पढिएको चिन्ह लगाउनुहोस्",
|
"markRead": "पढिएको चिन्ह लगाउनुहोस्",
|
||||||
"markAllRead": "सबै पढिएको",
|
"markAllRead": "सबै पढिएको",
|
||||||
"unreadCount_one": "{{count}} नपढिएको",
|
"unreadCount_one": "{{count}} नपढिएको",
|
||||||
"unreadCount_other": "{{count}} नपढिएको"
|
"unreadCount_other": "{{count}} नपढिएको",
|
||||||
|
"creditEmptyHint": "क्रेडिट खातामा मुख्य साइट ट्रान्सफर हुँदैन; मिलान बाँकी सूचना छैन।"
|
||||||
},
|
},
|
||||||
"panel": {
|
"panel": {
|
||||||
"home": "गृह"
|
"home": "गृह"
|
||||||
@@ -88,7 +89,24 @@
|
|||||||
"home": "गृहमा फर्कनुहोस्",
|
"home": "गृहमा फर्कनुहोस्",
|
||||||
"hall": "बेटिङ हल",
|
"hall": "बेटिङ हल",
|
||||||
"results": "नतिजा",
|
"results": "नतिजा",
|
||||||
"wallet": "मेरो वालेट"
|
"wallet": "मेरो वालेट",
|
||||||
|
"funds": "वालेट / क्रेडिट"
|
||||||
|
},
|
||||||
|
"calendar": {
|
||||||
|
"months": {
|
||||||
|
"1": "जनवरी",
|
||||||
|
"2": "फेब्रुअरी",
|
||||||
|
"3": "मार्च",
|
||||||
|
"4": "अप्रिल",
|
||||||
|
"5": "मे",
|
||||||
|
"6": "जुन",
|
||||||
|
"7": "जुलाई",
|
||||||
|
"8": "अगस्त",
|
||||||
|
"9": "सेप्टेम्बर",
|
||||||
|
"10": "अक्टोबर",
|
||||||
|
"11": "नोभेम्बर",
|
||||||
|
"12": "डिसेम्बर"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"player": {
|
"player": {
|
||||||
"fallback": "खेलाडी #{{id}}"
|
"fallback": "खेलाडी #{{id}}"
|
||||||
@@ -265,7 +283,9 @@
|
|||||||
"processingProgress": "टिकट प्रक्रिया हुँदैछ...",
|
"processingProgress": "टिकट प्रक्रिया हुँदैछ...",
|
||||||
"noWarnings": "यस पूर्वावलोकनमा स्पष्ट जोखिम भेटिएन।",
|
"noWarnings": "यस पूर्वावलोकनमा स्पष्ट जोखिम भेटिएन।",
|
||||||
"warningsTitle": "भुक्तानी पूल चेतावनी",
|
"warningsTitle": "भुक्तानी पूल चेतावनी",
|
||||||
"warningsDescription": "यी नम्बरहरूमा यस इश्यूमा भुक्तानी पूल प्रयोग उच्च छ। बेट अझै गर्न सकिन्छ, तर क्षमता अपुग भए अर्डर sold out हुन सक्छ।"
|
"warningsDescription": "यी नम्बरहरूमा यस इश्यूमा भुक्तानी पूल प्रयोग उच्च छ। बेट अझै गर्न सकिन्छ, तर क्षमता अपुग भए अर्डर sold out हुन सक्छ।",
|
||||||
|
"periodRebate": "अवधि रिबेट",
|
||||||
|
"periodRebateHint": "यो बेटको रिबेट बिलिङ अवधिमा मिलाइन्छ, बेट समयमा कट्दैन।"
|
||||||
},
|
},
|
||||||
"result": {
|
"result": {
|
||||||
"title": "बेट सफल",
|
"title": "बेट सफल",
|
||||||
@@ -283,6 +303,7 @@
|
|||||||
"actual": "वास्तविक कट्टा",
|
"actual": "वास्तविक कट्टा",
|
||||||
"orderNo": "अर्डर नं.",
|
"orderNo": "अर्डर नं.",
|
||||||
"balanceAfter": "बाँकी ब्यालेन्स",
|
"balanceAfter": "बाँकी ब्यालेन्स",
|
||||||
|
"creditBalanceAfter": "उपलब्ध क्रेडिट",
|
||||||
"items": "सफल लाइन विवरण",
|
"items": "सफल लाइन विवरण",
|
||||||
"number": "नम्बर",
|
"number": "नम्बर",
|
||||||
"actualDeduct": "कट्टा",
|
"actualDeduct": "कट्टा",
|
||||||
@@ -386,6 +407,7 @@
|
|||||||
"outExceeds": "ट्रान्सफर आउट रकम उपलब्ध ब्यालेन्सभन्दा बढी हुन सक्दैन।",
|
"outExceeds": "ट्रान्सफर आउट रकम उपलब्ध ब्यालेन्सभन्दा बढी हुन सक्दैन।",
|
||||||
"pendingTitle": "मिलान बाँकी",
|
"pendingTitle": "मिलान बाँकी",
|
||||||
"pendingDescription": "यी ट्रान्सफरहरू मुख्य साइटबाट अन्तिम पुष्टि भएका छैनन्। लामो समयसम्म नआए support सम्पर्क गर्नुहोस्।",
|
"pendingDescription": "यी ट्रान्सफरहरू मुख्य साइटबाट अन्तिम पुष्टि भएका छैनन्। लामो समयसम्म नआए support सम्पर्क गर्नुहोस्।",
|
||||||
|
"viewPendingReconcile": "मिलान बाँकी हेर्नुहोस् ({{count}})",
|
||||||
"pendingStatus": "प्रक्रिया हुँदैछ",
|
"pendingStatus": "प्रक्रिया हुँदैछ",
|
||||||
"flowsTitle": "वालेट लग",
|
"flowsTitle": "वालेट लग",
|
||||||
"creditTitle": "क्रेडिट",
|
"creditTitle": "क्रेडिट",
|
||||||
@@ -520,7 +542,20 @@
|
|||||||
"backToGroup": "अर्डर विवरणमा फर्कनुहोस्",
|
"backToGroup": "अर्डर विवरणमा फर्कनुहोस्",
|
||||||
"notFound": "टिकट छैन वा हेर्न अनुमति छैन।",
|
"notFound": "टिकट छैन वा हेर्न अनुमति छैन।",
|
||||||
"noData": "डेटा छैन",
|
"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": {
|
"results": {
|
||||||
"title": "नतिजा",
|
"title": "नतिजा",
|
||||||
@@ -587,6 +622,8 @@
|
|||||||
"rules": {
|
"rules": {
|
||||||
"title": "प्ले नियम",
|
"title": "प्ले नियम",
|
||||||
"subtitle": "2D / 3D / 4D, box play, rebate, closing र sold-out नियम",
|
"subtitle": "2D / 3D / 4D, box play, rebate, closing र sold-out नियम",
|
||||||
|
"empty": "खेल नियम उपलब्ध छैन",
|
||||||
|
"loadFailed": "नियम लोड असफल",
|
||||||
"quick": {
|
"quick": {
|
||||||
"title": "पुरस्कार संरचना",
|
"title": "पुरस्कार संरचना",
|
||||||
"description": "हरेक ड्रमा 23 वटा 4 अंकका नम्बर प्रकाशित हुन्छन्। सेटलमेन्ट बेट राख्दा लक भएको odds snapshot अनुसार हुन्छ।",
|
"description": "हरेक ड्रमा 23 वटा 4 अंकका नम्बर प्रकाशित हुन्छन्। सेटलमेन्ट बेट राख्दा लक भएको odds snapshot अनुसार हुन्छ।",
|
||||||
|
|||||||
@@ -64,7 +64,9 @@
|
|||||||
"captchaLoadFailed": "验证码加载失败,请重试",
|
"captchaLoadFailed": "验证码加载失败,请重试",
|
||||||
"captchaLoading": "加载中…",
|
"captchaLoading": "加载中…",
|
||||||
"captchaRefresh": "刷新验证码",
|
"captchaRefresh": "刷新验证码",
|
||||||
"captchaFetch": "点击获取"
|
"captchaFetch": "点击获取",
|
||||||
|
"usernameInvalidCharset": "账号只能使用字母、数字、点(.)、下划线和连字符",
|
||||||
|
"passwordMinLength": "密码至少需要 6 个字符"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"noToken": "未发现授权令牌",
|
"noToken": "未发现授权令牌",
|
||||||
|
|||||||
@@ -46,7 +46,8 @@
|
|||||||
"markRead": "标记已读",
|
"markRead": "标记已读",
|
||||||
"markAllRead": "全部已读",
|
"markAllRead": "全部已读",
|
||||||
"unreadCount_one": "未读 {{count}} 条",
|
"unreadCount_one": "未读 {{count}} 条",
|
||||||
"unreadCount_other": "未读 {{count}} 条"
|
"unreadCount_other": "未读 {{count}} 条",
|
||||||
|
"creditEmptyHint": "信用盘无主站划转,暂无待对账通知。"
|
||||||
},
|
},
|
||||||
"panel": {
|
"panel": {
|
||||||
"home": "首页"
|
"home": "首页"
|
||||||
@@ -87,7 +88,24 @@
|
|||||||
"home": "返回首页",
|
"home": "返回首页",
|
||||||
"hall": "投注大厅",
|
"hall": "投注大厅",
|
||||||
"results": "开奖结果",
|
"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": {
|
"player": {
|
||||||
"fallback": "玩家 #{{id}}"
|
"fallback": "玩家 #{{id}}"
|
||||||
@@ -263,7 +281,9 @@
|
|||||||
"processingProgress": "正在处理注单...",
|
"processingProgress": "正在处理注单...",
|
||||||
"noWarnings": "当前预览未发现明显风险。",
|
"noWarnings": "当前预览未发现明显风险。",
|
||||||
"warningsTitle": "赔付池预警",
|
"warningsTitle": "赔付池预警",
|
||||||
"warningsDescription": "以下号码本期赔付池占用较高,仍允许下注;若实际占用不足将售罄拒单。"
|
"warningsDescription": "以下号码本期赔付池占用较高,仍允许下注;若实际占用不足将售罄拒单。",
|
||||||
|
"periodRebate": "账期回水",
|
||||||
|
"periodRebateHint": "本单回水计入账期统一结算,不在下注时即时抵扣。"
|
||||||
},
|
},
|
||||||
"placeAllFailed": "本次 {{failed}} 条注项均未成功",
|
"placeAllFailed": "本次 {{failed}} 条注项均未成功",
|
||||||
"result": {
|
"result": {
|
||||||
@@ -282,6 +302,7 @@
|
|||||||
"actual": "实扣金额",
|
"actual": "实扣金额",
|
||||||
"orderNo": "订单号",
|
"orderNo": "订单号",
|
||||||
"balanceAfter": "剩余余额",
|
"balanceAfter": "剩余余额",
|
||||||
|
"creditBalanceAfter": "可用信用",
|
||||||
"items": "成功注项明细",
|
"items": "成功注项明细",
|
||||||
"number": "号码",
|
"number": "号码",
|
||||||
"actualDeduct": "实扣",
|
"actualDeduct": "实扣",
|
||||||
@@ -393,6 +414,7 @@
|
|||||||
"outExceeds": "转出金额不能超过可用余额。",
|
"outExceeds": "转出金额不能超过可用余额。",
|
||||||
"pendingTitle": "待对账",
|
"pendingTitle": "待对账",
|
||||||
"pendingDescription": "以下划转主站结果未最终确认;若长时间未到账请联系客服。",
|
"pendingDescription": "以下划转主站结果未最终确认;若长时间未到账请联系客服。",
|
||||||
|
"viewPendingReconcile": "查看待对账详情({{count}})",
|
||||||
"pendingStatus": "处理中",
|
"pendingStatus": "处理中",
|
||||||
"flowsTitle": "资金流水",
|
"flowsTitle": "资金流水",
|
||||||
"creditFlowsTitle": "信用流水",
|
"creditFlowsTitle": "信用流水",
|
||||||
@@ -520,7 +542,20 @@
|
|||||||
"backToGroup": "返回订单详情",
|
"backToGroup": "返回订单详情",
|
||||||
"notFound": "注单不存在或无权查看",
|
"notFound": "注单不存在或无权查看",
|
||||||
"noData": "无数据",
|
"noData": "无数据",
|
||||||
"loadFailed": "加载失败"
|
"loadFailed": "加载失败",
|
||||||
|
"failReason": {
|
||||||
|
"2001": "当期已封盘,无法下注",
|
||||||
|
"2003": "余额或可用信用不足",
|
||||||
|
"2004": "号码格式不合法",
|
||||||
|
"2005": "玩法参数无效",
|
||||||
|
"2006": "期号无效或不可下注",
|
||||||
|
"2007": "玩法不存在或已关闭",
|
||||||
|
"2008": "赔率配置已更新,请重新预览",
|
||||||
|
"2009": "订单不可重复提交",
|
||||||
|
"4001": "该号码本期额度已售罄",
|
||||||
|
"draw_no_longer_open": "期号已封盘或不可下注",
|
||||||
|
"draw_cancelled": "本期已取消,注单已退本"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"results": {
|
"results": {
|
||||||
"title": "开奖结果",
|
"title": "开奖结果",
|
||||||
@@ -587,6 +622,8 @@
|
|||||||
"rules": {
|
"rules": {
|
||||||
"title": "玩法规则",
|
"title": "玩法规则",
|
||||||
"subtitle": "2D / 3D / 4D、包号、回水、封盘与售罄说明",
|
"subtitle": "2D / 3D / 4D、包号、回水、封盘与售罄说明",
|
||||||
|
"empty": "暂无玩法规则说明",
|
||||||
|
"loadFailed": "规则加载失败",
|
||||||
"quick": {
|
"quick": {
|
||||||
"title": "开奖结构",
|
"title": "开奖结构",
|
||||||
"description": "每期开奖 23 组 4 位号码,结算以下注时锁定的赔率快照为准。",
|
"description": "每期开奖 23 组 4 位号码,结算以下注时锁定的赔率快照为准。",
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export async function loadIframeAllowedOrigins(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pendingOrigins ??= lotteryHttp
|
pendingOrigins ??= lotteryHttp
|
||||||
.get("/integration/runtime-origins")
|
.get("/integration/runtime-origins", { skipGlobalServerError: true })
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
const data = unwrapData<RuntimeOriginsResponse>(response.data);
|
const data = unwrapData<RuntimeOriginsResponse>(response.data);
|
||||||
runtimeOrigins = data.iframe_allowed_origins
|
runtimeOrigins = data.iframe_allowed_origins
|
||||||
|
|||||||
38
src/lib/lottery-http-server-error.ts
Normal file
38
src/lib/lottery-http-server-error.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -16,7 +16,10 @@ import {
|
|||||||
import { isApiEnvelope } from "@/types/api/envelope";
|
import { isApiEnvelope } from "@/types/api/envelope";
|
||||||
import { useErrorStore } from "@/stores/error-store";
|
import { useErrorStore } from "@/stores/error-store";
|
||||||
import { resolveLotteryApiV1Base } from "@/lib/lottery-api-base";
|
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`)。
|
* **第一层**:`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) {
|
if (status === 500 || status === 502 || status === 503) {
|
||||||
const setServerError = useErrorStore.getState().setServerError;
|
if (!shouldSkipGlobalServerError(error.config)) {
|
||||||
let message = i18n.t("serverError.serverMessage", { ns: "player" });
|
const setServerError = useErrorStore.getState().setServerError;
|
||||||
|
let rawMessage: string | undefined;
|
||||||
|
|
||||||
// 尝试从响应中获取更详细的错误信息
|
const responseData = error.response?.data;
|
||||||
const responseData = error.response?.data;
|
if (
|
||||||
if (
|
typeof responseData === "object" &&
|
||||||
typeof responseData === "object" &&
|
responseData !== null &&
|
||||||
responseData !== null &&
|
"msg" in responseData &&
|
||||||
"msg" in responseData &&
|
typeof responseData.msg === "string"
|
||||||
typeof responseData.msg === "string"
|
) {
|
||||||
) {
|
rawMessage = responseData.msg;
|
||||||
message = responseData.msg;
|
}
|
||||||
|
|
||||||
|
setServerError(true, sanitizePlayerServerErrorMessage(rawMessage));
|
||||||
}
|
}
|
||||||
|
|
||||||
setServerError(true, message);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 网络错误 (无响应): 检查网络状态
|
// 网络错误 (无响应): 检查网络状态
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/** 玩家端统一紧凑间距(页面壳、区块堆叠、区块间距) */
|
/** 玩家端统一紧凑间距(页面壳、区块堆叠、区块间距) */
|
||||||
export const playerPageInset = "px-3 pb-6 pt-2";
|
export const playerPageInset = "px-3 pb-6 pt-2";
|
||||||
/** 顶栏:左右分列 + 标题绝对居中,避免右侧控件挤压/遮挡标题 */
|
/** 顶栏:三列 grid 布局,标题居中;左右控件统一高度 */
|
||||||
export const playerPageHeader =
|
export const playerPageHeader =
|
||||||
"relative mb-2 flex min-h-9 items-center justify-between gap-2";
|
"relative mb-2 flex min-h-9 items-center justify-between gap-2";
|
||||||
/** 顶栏左右控件统一高度 */
|
/** 顶栏左右控件统一高度 */
|
||||||
|
|||||||
19
src/lib/results-prize-labels.ts
Normal file
19
src/lib/results-prize-labels.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/stores/player-top-banner-store.ts
Normal file
20
src/stores/player-top-banner-store.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
export type PlayerTopBannerKind = "offline" | "network";
|
||||||
|
|
||||||
|
type PlayerTopBannerState = {
|
||||||
|
heights: Record<PlayerTopBannerKind, number>;
|
||||||
|
setBannerHeight: (kind: PlayerTopBannerKind, px: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const usePlayerTopBannerStore = create<PlayerTopBannerState>((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;
|
||||||
|
}
|
||||||
8
src/types/axios.d.ts
vendored
Normal file
8
src/types/axios.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import "axios";
|
||||||
|
|
||||||
|
declare module "axios" {
|
||||||
|
export interface AxiosRequestConfig {
|
||||||
|
/** 为 true 时不触发玩家端全屏 500 遮罩(由页面自行处理) */
|
||||||
|
skipGlobalServerError?: boolean;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user