refactor: 完成全站国际化改造,统一多语言支持
此提交完成了全项目的国际化适配: 1. 新增多语言翻译文件与基础配置 2. 替换所有硬编码文本为i18n调用 3. 优化语言切换与文档语言同步逻辑 4. 重构部分业务逻辑以支持动态翻译 5. 移除过时代码与硬编码配置
This commit is contained in:
@@ -10,10 +10,6 @@ export default async function DrawResultByNoPage(props: PageProps) {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold tracking-tight">开奖结果</h1>
|
||||
<p className="text-xs text-muted-foreground">当期明细 · 23 组分区</p>
|
||||
</div>
|
||||
<DrawResultDetailScreen drawNo={drawNo} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,13 +3,8 @@ import { DrawResultsListScreen } from "@/features/results/draw-results-list-scre
|
||||
export default function DrawResultsHistoryPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold tracking-tight">开奖结果</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
往期列表与时间以服务器 GMT 为准(界面文档 §4.6)
|
||||
</p>
|
||||
</div>
|
||||
<DrawResultsListScreen />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { EntryGate } from "@/features/player/entry-gate";
|
||||
function EntryFallback(): ReactNode {
|
||||
return (
|
||||
<div className="flex min-h-dvh flex-col items-center justify-center bg-gradient-to-b from-red-800 to-red-950 px-4 text-sm text-white/90">
|
||||
<p>加载入口页…</p>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
import { useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { AlertTriangle, Home, RotateCcw } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ERROR_COLORS } from "@/stores/error-store";
|
||||
import "@/i18n";
|
||||
|
||||
/**
|
||||
* Next.js 错误边界组件
|
||||
@@ -18,6 +20,8 @@ export default function ErrorBoundary({
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation("player");
|
||||
|
||||
useEffect(() => {
|
||||
// 记录错误日志
|
||||
console.error("[ErrorBoundary] Application error:", error);
|
||||
@@ -52,26 +56,28 @@ export default function ErrorBoundary({
|
||||
|
||||
{/* 错误标题 */}
|
||||
<h1 className="mb-2 text-2xl font-bold text-foreground">
|
||||
{isServerError ? "服务器暂时不可用" : "应用发生错误"}
|
||||
{isServerError ? t("serverError.unavailable") : t("serverError.appTitle")}
|
||||
</h1>
|
||||
|
||||
{/* 错误消息 */}
|
||||
<p className="mb-2 text-base text-muted-foreground">
|
||||
{isServerError
|
||||
? "服务器暂时不可用,请稍后重试"
|
||||
: "抱歉,应用遇到了问题,请尝试刷新页面"}
|
||||
? t("serverError.serverMessage")
|
||||
: t("serverError.appMessage")}
|
||||
</p>
|
||||
|
||||
{/* 技术详情(开发模式) */}
|
||||
{process.env.NODE_ENV === "development" && (
|
||||
<div className="mb-6 w-full rounded-lg bg-muted p-3 text-left">
|
||||
<p className="mb-1 text-xs font-medium text-muted-foreground">错误详情:</p>
|
||||
<p className="mb-1 text-xs font-medium text-muted-foreground">
|
||||
{t("serverError.details")}
|
||||
</p>
|
||||
<code className="block max-h-32 overflow-auto whitespace-pre-wrap break-words text-xs text-destructive">
|
||||
{error.message}
|
||||
</code>
|
||||
{error.digest && (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
错误 ID: {error.digest}
|
||||
{t("serverError.errorId", { id: error.digest })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -85,7 +91,7 @@ export default function ErrorBoundary({
|
||||
style={{ borderColor: isServerError ? ERROR_COLORS.error : ERROR_COLORS.warning, color: isServerError ? ERROR_COLORS.error : ERROR_COLORS.warning }}
|
||||
>
|
||||
<RotateCcw className="size-4" />
|
||||
重试
|
||||
{t("actions.retry")}
|
||||
</button>
|
||||
|
||||
<Link
|
||||
@@ -96,7 +102,7 @@ export default function ErrorBoundary({
|
||||
}}
|
||||
>
|
||||
<Home className="size-4" />
|
||||
返回首页
|
||||
{t("serverError.home")}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -106,21 +112,21 @@ export default function ErrorBoundary({
|
||||
onClick={() => window.location.reload()}
|
||||
className="hover:text-foreground hover:underline"
|
||||
>
|
||||
刷新页面
|
||||
{t("serverError.refreshPage")}
|
||||
</button>
|
||||
<span>|</span>
|
||||
<Link
|
||||
href="/hall"
|
||||
className="hover:text-foreground hover:underline"
|
||||
>
|
||||
投注大厅
|
||||
{t("serverError.hall")}
|
||||
</Link>
|
||||
<span>|</span>
|
||||
<Link
|
||||
href="/wallet"
|
||||
className="hover:text-foreground hover:underline"
|
||||
>
|
||||
我的钱包
|
||||
{t("serverError.wallet")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
|
||||
import { Providers } from "@/components/providers";
|
||||
import { DEFAULT_LANGUAGE } from "@/i18n";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
@@ -26,7 +27,7 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
lang={DEFAULT_LANGUAGE}
|
||||
suppressHydrationWarning
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
>
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { FileQuestion, Home } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ERROR_COLORS } from "@/stores/error-store";
|
||||
import "@/i18n";
|
||||
|
||||
/**
|
||||
* 全局 404 页面
|
||||
@@ -13,6 +15,8 @@ import { ERROR_COLORS } from "@/stores/error-store";
|
||||
* 使用中性颜色 #d9d9d9 作为背景/插图
|
||||
*/
|
||||
export default function NotFoundPage(): React.ReactElement {
|
||||
const { t } = useTranslation("player");
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-screen flex-col items-center justify-center p-4"
|
||||
@@ -42,12 +46,12 @@ export default function NotFoundPage(): React.ReactElement {
|
||||
|
||||
{/* 标题 */}
|
||||
<h1 className="mb-3 text-2xl font-bold text-gray-800">
|
||||
页面不存在
|
||||
{t("notFound.title")}
|
||||
</h1>
|
||||
|
||||
{/* 描述 */}
|
||||
<p className="mb-8 text-base text-gray-500">
|
||||
抱歉,您访问的页面可能已被删除、移动或从未存在过。
|
||||
{t("notFound.description")}
|
||||
</p>
|
||||
|
||||
{/* 返回首页按钮 */}
|
||||
@@ -57,21 +61,21 @@ export default function NotFoundPage(): React.ReactElement {
|
||||
style={{ backgroundColor: "#333" }}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -172,13 +172,12 @@ export function IframeBridge({ children }: { children: ReactNode }): ReactNode {
|
||||
window.removeEventListener("message", handleMessage);
|
||||
clearInterval(heartbeat);
|
||||
};
|
||||
}, [notifyReady, sendToParent, setBearerToken]);
|
||||
}, [notifyReady, notifyTokenRefreshed, sendToParent, setBearerToken]);
|
||||
|
||||
// 暴露全局方法供调试
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(window as unknown as Record<string, unknown>).lotteryIframeBridge = {
|
||||
notifyReady,
|
||||
notifyTokenNeeded,
|
||||
|
||||
@@ -35,7 +35,7 @@ export function LanguageSwitcher({
|
||||
label: t(`language.${item.code}`),
|
||||
short: t(`languageShort.${item.code}`),
|
||||
})),
|
||||
[t, i18n.language],
|
||||
[t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -18,9 +18,9 @@ type PlayerAppShellProps = {
|
||||
*/
|
||||
export function PlayerAppShell({ children }: PlayerAppShellProps): ReactNode {
|
||||
return (
|
||||
<div className="min-h-dvh bg-[#f3f7fd] text-foreground">
|
||||
<div className="min-h-dvh bg-white text-foreground">
|
||||
<NetworkStatusBanner />
|
||||
<main className="mx-auto flex w-full max-w-lg flex-col px-3 pb-[calc(4.25rem+env(safe-area-inset-bottom,0px)+0.75rem)] pt-3 sm:px-4">
|
||||
<main className="mx-auto flex w-full max-w-lg flex-col pb-[calc(4rem+env(safe-area-inset-bottom,0px)+1.5rem)]">
|
||||
{children}
|
||||
</main>
|
||||
<PlayerBottomNav />
|
||||
|
||||
@@ -4,26 +4,27 @@ import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { BarChart3, ClipboardList, Home, Wallet } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const tabs = [
|
||||
{ href: "/hall", label: "Home", icon: Home, match: (p: string) => p === "/hall" },
|
||||
{ href: "/hall", labelKey: "nav.home", icon: Home, match: (p: string) => p === "/hall" },
|
||||
{
|
||||
href: "/results",
|
||||
label: "Results",
|
||||
labelKey: "nav.results",
|
||||
icon: BarChart3,
|
||||
match: (p: string) => p === "/results" || p.startsWith("/results/"),
|
||||
},
|
||||
{
|
||||
href: "/orders",
|
||||
label: "My Bets",
|
||||
labelKey: "nav.orders",
|
||||
icon: ClipboardList,
|
||||
match: (p: string) => p === "/orders" || p.startsWith("/orders/"),
|
||||
},
|
||||
{
|
||||
href: "/wallet",
|
||||
label: "Wallet",
|
||||
labelKey: "nav.wallet",
|
||||
icon: Wallet,
|
||||
match: (p: string) => p === "/wallet" || p.startsWith("/wallet/"),
|
||||
},
|
||||
@@ -34,15 +35,17 @@ const tabs = [
|
||||
*/
|
||||
export function PlayerBottomNav() {
|
||||
const pathname = usePathname() ?? "";
|
||||
const { t } = useTranslation("player");
|
||||
|
||||
return (
|
||||
<nav
|
||||
className="fixed bottom-0 left-0 right-0 z-50 border-t border-[#e4ebf5] bg-white/96 pb-[env(safe-area-inset-bottom,0px)] shadow-[0_-10px_30px_rgba(15,23,42,0.08)] backdrop-blur-md"
|
||||
aria-label="主导航"
|
||||
aria-label={t("nav.aria")}
|
||||
>
|
||||
<div className="mx-auto grid h-16 w-full max-w-lg grid-rows-1 [grid-template-columns:repeat(4,minmax(0,1fr))]">
|
||||
{tabs.map(({ href, label, icon: Icon, match }) => {
|
||||
{tabs.map(({ href, labelKey, icon: Icon, match }) => {
|
||||
const active = match(pathname);
|
||||
const label = t(labelKey);
|
||||
return (
|
||||
<Link
|
||||
key={href}
|
||||
|
||||
@@ -4,6 +4,7 @@ import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { Bell, ChevronLeft } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -24,24 +25,28 @@ export function PlayerPanel({
|
||||
eyebrow,
|
||||
children,
|
||||
backHref = "/hall",
|
||||
backLabel = "Home",
|
||||
backLabel,
|
||||
className,
|
||||
}: PlayerPanelProps) {
|
||||
const { t } = useTranslation("common");
|
||||
const { t: tp } = useTranslation("player");
|
||||
const resolvedBackLabel = backLabel ?? tp("panel.home");
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[480px]">
|
||||
<section
|
||||
className={cn(
|
||||
"overflow-hidden rounded-[18px] border border-[#dce7f7] bg-white px-2.5 py-3 text-slate-900 shadow-[0_18px_50px_rgba(15,44,92,0.12)]",
|
||||
"overflow-hidden bg-white px-4 pb-8 pt-4 text-slate-900",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="mb-3 flex items-center gap-2 px-1">
|
||||
<div className="mb-3 flex items-center gap-2 px-1 pt-3">
|
||||
<Link
|
||||
href={backHref}
|
||||
className="flex h-9 shrink-0 items-center gap-1 rounded-full border border-[#e4eaf4] bg-[#f8fafc] px-2.5 text-xs font-bold text-[#0b3f96] hover:bg-[#f1f6ff]"
|
||||
>
|
||||
<ChevronLeft className="size-4" aria-hidden />
|
||||
{backLabel}
|
||||
{resolvedBackLabel}
|
||||
</Link>
|
||||
<div className="min-w-0 flex-1 text-center">
|
||||
{eyebrow ? (
|
||||
@@ -66,7 +71,7 @@ export function PlayerPanel({
|
||||
<button
|
||||
type="button"
|
||||
className="relative flex size-9 shrink-0 items-center justify-center rounded-full text-[#1d57b7] hover:bg-[#f4f7fb]"
|
||||
aria-label="Notifications"
|
||||
aria-label={t("navigation.notifications")}
|
||||
>
|
||||
<Bell className="size-5" aria-hidden />
|
||||
<span className="absolute right-2 top-2 size-2 rounded-full bg-[#ff143d]" />
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { WifiOff, RefreshCw, AlertCircle } from "lucide-react";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useWebSocketManager } from "@/hooks/use-websocket-manager";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -20,6 +21,7 @@ const COLORS = {
|
||||
*/
|
||||
export function NetworkStatusBanner(): React.ReactElement | null {
|
||||
const { showDegradedBanner, mode, reconnect } = useWebSocketManager();
|
||||
const { t } = useTranslation("player");
|
||||
|
||||
const handleReconnectClick = useCallback(() => {
|
||||
reconnect();
|
||||
@@ -48,29 +50,27 @@ export function NetworkStatusBanner(): React.ReactElement | null {
|
||||
{isOffline ? (
|
||||
<>
|
||||
<WifiOff className="size-4 shrink-0" aria-hidden />
|
||||
<span className="font-medium">网络已断开,请检查网络连接</span>
|
||||
<span className="font-medium">{t("network.offline")}</span>
|
||||
<button
|
||||
onClick={handleReconnectClick}
|
||||
className="ml-2 flex items-center gap-1 rounded px-2 py-0.5 text-xs font-medium hover:bg-white/20 focus:outline-none focus:ring-2 focus:ring-white/50"
|
||||
aria-label="尝试重连"
|
||||
aria-label={t("network.retryReconnect")}
|
||||
>
|
||||
<RefreshCw className="size-3" aria-hidden />
|
||||
重试
|
||||
{t("network.retry")}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AlertCircle className="size-4 shrink-0" aria-hidden />
|
||||
<span className="font-medium">
|
||||
网络不稳定,已切换至降级模式(轮询中...)
|
||||
</span>
|
||||
<span className="font-medium">{t("network.degraded")}</span>
|
||||
<button
|
||||
onClick={handleReconnectClick}
|
||||
className="ml-2 flex items-center gap-1 rounded px-2 py-0.5 text-xs font-medium hover:bg-white/20 focus:outline-none focus:ring-2 focus:ring-white/50"
|
||||
aria-label="尝试恢复 WebSocket 连接"
|
||||
aria-label={t("network.recoverWebsocket")}
|
||||
>
|
||||
<RefreshCw className="size-3" aria-hidden />
|
||||
恢复
|
||||
{t("network.recover")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { WifiOff, RefreshCw } from "lucide-react";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useErrorStore, ERROR_COLORS } from "@/stores/error-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -13,6 +14,7 @@ import { cn } from "@/lib/utils";
|
||||
*/
|
||||
export function OfflineBanner(): React.ReactElement | null {
|
||||
const isOffline = useErrorStore((state) => state.isOffline);
|
||||
const { t } = useTranslation("player");
|
||||
|
||||
const handleReconnect = useCallback(() => {
|
||||
// 尝试重新加载页面
|
||||
@@ -40,14 +42,14 @@ export function OfflineBanner(): React.ReactElement | null {
|
||||
aria-live="assertive"
|
||||
>
|
||||
<WifiOff className="size-4 shrink-0" aria-hidden />
|
||||
<span className="font-medium">网络已断开,请检查网络连接</span>
|
||||
<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="重新连接"
|
||||
aria-label={t("network.reconnect")}
|
||||
>
|
||||
<RefreshCw className="size-3.5" aria-hidden />
|
||||
重新连接
|
||||
{t("network.reconnect")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { ServerCrash, Home, RefreshCw } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useErrorStore, ERROR_COLORS } from "@/stores/error-store";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -15,6 +16,7 @@ import { cn } from "@/lib/utils";
|
||||
*/
|
||||
export function ServerError(): React.ReactElement | null {
|
||||
const { isServerError, serverErrorMessage, clearServerError } = useErrorStore();
|
||||
const { t } = useTranslation("player");
|
||||
|
||||
const handleRetry = (): void => {
|
||||
clearServerError();
|
||||
@@ -49,7 +51,7 @@ export function ServerError(): React.ReactElement | null {
|
||||
className="mb-2 text-2xl font-bold"
|
||||
style={{ color: ERROR_COLORS.error }}
|
||||
>
|
||||
服务器错误
|
||||
{t("serverError.title")}
|
||||
</h1>
|
||||
|
||||
{/* 错误消息 */}
|
||||
@@ -68,7 +70,7 @@ export function ServerError(): React.ReactElement | null {
|
||||
style={{ borderColor: ERROR_COLORS.error, color: ERROR_COLORS.error }}
|
||||
>
|
||||
<RefreshCw className="size-4" />
|
||||
重试
|
||||
{t("actions.retry")}
|
||||
</Button>
|
||||
|
||||
<Link
|
||||
@@ -77,13 +79,13 @@ export function ServerError(): React.ReactElement | null {
|
||||
style={{ backgroundColor: ERROR_COLORS.error }}
|
||||
>
|
||||
<Home className="size-4" />
|
||||
返回首页
|
||||
{t("serverError.home")}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* 状态码提示 */}
|
||||
<p className="mt-8 text-xs text-muted-foreground/60">
|
||||
错误代码: 500 Internal Server Error
|
||||
{t("serverError.code")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -95,6 +97,7 @@ export function ServerError(): React.ReactElement | null {
|
||||
*/
|
||||
export function ServerErrorModal(): React.ReactElement | null {
|
||||
const { isServerError, serverErrorMessage, clearServerError } = useErrorStore();
|
||||
const { t } = useTranslation("player");
|
||||
|
||||
const handleRetry = (): void => {
|
||||
clearServerError();
|
||||
@@ -122,7 +125,7 @@ export function ServerErrorModal(): React.ReactElement | null {
|
||||
|
||||
{/* 错误消息 */}
|
||||
<h2 className="mb-2 text-lg font-semibold text-foreground">
|
||||
服务器暂时不可用
|
||||
{t("serverError.unavailable")}
|
||||
</h2>
|
||||
<p className="mb-6 text-sm text-muted-foreground">
|
||||
{serverErrorMessage}
|
||||
@@ -136,7 +139,7 @@ export function ServerErrorModal(): React.ReactElement | null {
|
||||
onClick={handleRetry}
|
||||
>
|
||||
<RefreshCw className="mr-2 size-4" />
|
||||
重试
|
||||
{t("actions.retry")}
|
||||
</Button>
|
||||
<Link
|
||||
href="/hall"
|
||||
@@ -144,7 +147,7 @@ export function ServerErrorModal(): React.ReactElement | null {
|
||||
style={{ backgroundColor: ERROR_COLORS.error }}
|
||||
>
|
||||
<Home className="size-4" />
|
||||
返回首页
|
||||
{t("serverError.home")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { RefreshCw, AlertCircle } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useTokenRefresh } from "@/hooks/use-token-refresh";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -16,6 +17,7 @@ import { cn } from "@/lib/utils";
|
||||
export function TokenRefreshIndicator(): React.ReactElement | null {
|
||||
const { isTokenExpiringSoon, getTokenRemainingTime, refreshToken } =
|
||||
useTokenRefresh();
|
||||
const { t } = useTranslation("player");
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [showWarning, setShowWarning] = useState(false);
|
||||
const [remainingSeconds, setRemainingSeconds] = useState<number | null>(null);
|
||||
@@ -24,7 +26,6 @@ export function TokenRefreshIndicator(): React.ReactElement | null {
|
||||
useEffect(() => {
|
||||
const checkToken = (): void => {
|
||||
const remaining = getTokenRemainingTime();
|
||||
const expiringSoon = isTokenExpiringSoon();
|
||||
|
||||
if (remaining > 0 && remaining < 120000) {
|
||||
// 2 分钟内显示
|
||||
@@ -85,16 +86,19 @@ export function TokenRefreshIndicator(): React.ReactElement | null {
|
||||
color: isCritical ? ERROR_COLORS.error : ERROR_COLORS.warning,
|
||||
}}
|
||||
>
|
||||
{isCritical ? "登录即将失效" : "登录即将过期"}
|
||||
{isCritical ? t("token.critical") : t("token.warning")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{remainingSeconds !== null && (
|
||||
<>
|
||||
剩余 {Math.floor(remainingSeconds / 60)}:
|
||||
{String(remainingSeconds % 60).padStart(2, "0")} {" "}
|
||||
{t("token.remaining", {
|
||||
time: `${Math.floor(remainingSeconds / 60)}:${String(
|
||||
remainingSeconds % 60,
|
||||
).padStart(2, "0")}`,
|
||||
})}{" "}
|
||||
</>
|
||||
)}
|
||||
正在自动续签...
|
||||
{t("token.autoRenewing")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -112,7 +116,7 @@ export function TokenRefreshIndicator(): React.ReactElement | null {
|
||||
<RefreshCw
|
||||
className={cn("size-3.5", isRefreshing && "animate-spin")}
|
||||
/>
|
||||
立即续签
|
||||
{t("token.renewNow")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export type DrawStatusHud = {
|
||||
label: string;
|
||||
labelKey: string;
|
||||
/** Tailwind 颜色类:状态圆点 */
|
||||
dotClass: string;
|
||||
/** 文案条(如「距封盘」) */
|
||||
@@ -18,26 +18,26 @@ export function isHallSealedCountdownUi(status: string): boolean {
|
||||
export function drawStatusHud(status: string): DrawStatusHud {
|
||||
switch (status) {
|
||||
case "pending":
|
||||
return { label: "未开始", dotClass: "bg-muted-foreground", countdownKind: "none" };
|
||||
return { labelKey: "draw.status.pending", dotClass: "bg-muted-foreground", countdownKind: "none" };
|
||||
case "open":
|
||||
return { label: "可下注", dotClass: "bg-emerald-500", countdownKind: "close" };
|
||||
return { labelKey: "draw.status.open", dotClass: "bg-emerald-500", countdownKind: "close" };
|
||||
case "closing":
|
||||
return { label: "已封盘", dotClass: "bg-rose-500", countdownKind: "draw" };
|
||||
return { labelKey: "draw.status.closing", dotClass: "bg-rose-500", countdownKind: "draw" };
|
||||
case "closed":
|
||||
return { label: "待开奖", dotClass: "bg-amber-500", countdownKind: "draw" };
|
||||
return { labelKey: "draw.status.closed", dotClass: "bg-amber-500", countdownKind: "draw" };
|
||||
case "drawing":
|
||||
return { label: "开奖中", dotClass: "bg-sky-500", countdownKind: "none" };
|
||||
return { labelKey: "draw.status.drawing", dotClass: "bg-sky-500", countdownKind: "none" };
|
||||
case "review":
|
||||
return { label: "待审核", dotClass: "bg-violet-500", countdownKind: "none" };
|
||||
return { labelKey: "draw.status.review", dotClass: "bg-violet-500", countdownKind: "none" };
|
||||
case "cooldown":
|
||||
return { label: "冷静期", dotClass: "bg-cyan-500", countdownKind: "cooldown" };
|
||||
return { labelKey: "draw.status.cooldown", dotClass: "bg-cyan-500", countdownKind: "cooldown" };
|
||||
case "settling":
|
||||
return { label: "结算中", dotClass: "bg-blue-600", countdownKind: "none" };
|
||||
return { labelKey: "draw.status.settling", dotClass: "bg-blue-600", countdownKind: "none" };
|
||||
case "settled":
|
||||
return { label: "已结算", dotClass: "bg-muted-foreground", countdownKind: "none" };
|
||||
return { labelKey: "draw.status.settled", dotClass: "bg-muted-foreground", countdownKind: "none" };
|
||||
case "cancelled":
|
||||
return { label: "已取消", dotClass: "bg-muted-foreground", countdownKind: "none" };
|
||||
return { labelKey: "draw.status.cancelled", dotClass: "bg-muted-foreground", countdownKind: "none" };
|
||||
default:
|
||||
return { label: status, dotClass: "bg-muted-foreground", countdownKind: "none" };
|
||||
return { labelKey: status, dotClass: "bg-muted-foreground", countdownKind: "none" };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { formatMinorAsCurrency } from "@/lib/money";
|
||||
@@ -31,13 +33,16 @@ export function HallBetAmountInput({
|
||||
disabled,
|
||||
hint,
|
||||
}: HallBetAmountInputProps) {
|
||||
const { t } = useTranslation("player");
|
||||
const min = formatMinorAsCurrency(minBetMinor, currencyCode);
|
||||
const max = formatMinorAsCurrency(maxBetMinor, currencyCode);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-end justify-between gap-2">
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
限额 {formatMinorAsCurrency(minBetMinor, currencyCode)} —{" "}
|
||||
{formatMinorAsCurrency(maxBetMinor, currencyCode)}
|
||||
{t("hall.amountInput.limit", { min, max })}
|
||||
</p>
|
||||
</div>
|
||||
<Input
|
||||
@@ -48,7 +53,7 @@ export function HallBetAmountInput({
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={cn("tabular-nums")}
|
||||
placeholder="例如 100.00"
|
||||
placeholder={t("hall.amountInput.placeholder")}
|
||||
/>
|
||||
{hint ? <p className="text-xs text-muted-foreground">{hint}</p> : null}
|
||||
</div>
|
||||
|
||||
@@ -1,30 +1,44 @@
|
||||
/**
|
||||
* 下注业务码与玩家可见说明(对齐 Laravel `ErrorCode` 与产品文档 §6.3 / §6.4)。
|
||||
*/
|
||||
export function mapTicketBetError(code: number, fallbackMsg: string): string {
|
||||
export function mapTicketBetError(
|
||||
code: number,
|
||||
fallbackMsg: string,
|
||||
t?: (key: string) => string,
|
||||
): string {
|
||||
const msg = (key: string, fallback: string) => t?.(key) ?? fallback;
|
||||
switch (code) {
|
||||
case 4001:
|
||||
return "该号码本期赔付池不足,已售罄。请更换号码、金额或玩法后重试。";
|
||||
return msg(
|
||||
"hall.ticketError.4001",
|
||||
"该号码本期赔付池不足,已售罄。请更换号码、金额或玩法后重试。",
|
||||
);
|
||||
case 2003:
|
||||
case 1001:
|
||||
return "余额不足,请先转入后再下注。";
|
||||
return msg("hall.ticketError.1001", "余额不足,请先转入后再下注。");
|
||||
case 2001:
|
||||
return "本期已封盘,无法继续下注。";
|
||||
return msg("hall.ticketError.2001", "本期已封盘,无法继续下注。");
|
||||
case 2002:
|
||||
return "该玩法已关闭,请选择其他玩法。";
|
||||
return msg("hall.ticketError.2002", "该玩法已关闭,请选择其他玩法。");
|
||||
case 2004:
|
||||
return "号码格式或长度不符合该玩法要求。";
|
||||
return msg("hall.ticketError.2004", "号码格式或长度不符合该玩法要求。");
|
||||
case 2005:
|
||||
return "玩法参数不完整(如单双大小需选择位数与维度)。";
|
||||
return msg(
|
||||
"hall.ticketError.2005",
|
||||
"玩法参数不完整(如单双大小需选择位数与维度)。",
|
||||
);
|
||||
case 2006:
|
||||
return "当前期号不可下注。";
|
||||
return msg("hall.ticketError.2006", "当前期号不可下注。");
|
||||
case 2007:
|
||||
return "该玩法暂不支持或缺少赔率配置。";
|
||||
return msg("hall.ticketError.2007", "该玩法暂不支持或缺少赔率配置。");
|
||||
case 2008:
|
||||
return "赔率或玩法配置已更新,请关闭预览后重新操作。";
|
||||
return msg(
|
||||
"hall.ticketError.2008",
|
||||
"赔率或玩法配置已更新,请关闭预览后重新操作。",
|
||||
);
|
||||
case 1003:
|
||||
return "下注金额超出该玩法允许范围。";
|
||||
return msg("hall.ticketError.1003", "下注金额超出该玩法允许范围。");
|
||||
default:
|
||||
return fallbackMsg || "下注失败,请稍后重试。";
|
||||
return fallbackMsg || msg("hall.ticketError.fallback", "下注失败,请稍后重试。");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -36,6 +38,7 @@ export function HallBetNumberInput({
|
||||
disabled,
|
||||
helper,
|
||||
}: HallBetNumberInputProps) {
|
||||
const { t } = useTranslation("player");
|
||||
const handle = (raw: string) => {
|
||||
if (spec.mode === "roll") {
|
||||
onChange(sanitizeRoll(raw, spec.maxChars));
|
||||
@@ -56,7 +59,11 @@ export function HallBetNumberInput({
|
||||
value={value}
|
||||
onChange={(e) => handle(e.target.value)}
|
||||
className={cn("font-mono text-base tracking-widest")}
|
||||
placeholder={spec.mode === "roll" ? "如 12R4" : "0-9"}
|
||||
placeholder={
|
||||
spec.mode === "roll"
|
||||
? t("hall.numberInput.rollPlaceholder")
|
||||
: t("hall.numberInput.digitPlaceholder")
|
||||
}
|
||||
maxLength={spec.maxChars}
|
||||
/>
|
||||
{helper ? <p className="text-xs text-muted-foreground">{helper}</p> : null}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { AlertTriangleIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -28,14 +29,16 @@ type HallBetPreviewDialogProps = {
|
||||
};
|
||||
|
||||
function WarningsBlock({ warnings }: { warnings: TicketPreviewWarning[] }) {
|
||||
const { t } = useTranslation("player");
|
||||
|
||||
if (warnings.length === 0) return null;
|
||||
return (
|
||||
<Alert className="border-amber-500/40 bg-amber-500/5 text-amber-950 dark:text-amber-100">
|
||||
<AlertTriangleIcon />
|
||||
<AlertTitle>赔付池预警</AlertTitle>
|
||||
<AlertTitle>{t("hall.preview.warningsTitle")}</AlertTitle>
|
||||
<AlertDescription className="space-y-1">
|
||||
<p className="text-xs leading-relaxed">
|
||||
产品文档 §6.4:以下号码本期赔付池占用较高,仍允许下注;若实际占用不足将售罄拒单。
|
||||
{t("hall.preview.warningsDescription")}
|
||||
</p>
|
||||
<ul className="list-inside list-disc text-xs">
|
||||
{warnings.map((w, i) => (
|
||||
@@ -61,6 +64,7 @@ export function HallBetPreviewDialog({
|
||||
allowSubmit = true,
|
||||
onConfirmPlace,
|
||||
}: HallBetPreviewDialogProps) {
|
||||
const { t } = useTranslation("player");
|
||||
const summary = data?.summary;
|
||||
const lines = data?.lines ?? [];
|
||||
|
||||
@@ -69,17 +73,17 @@ export function HallBetPreviewDialog({
|
||||
<DialogContent className="max-h-[min(90vh,560px)] gap-0 overflow-hidden p-0 sm:max-w-md">
|
||||
<div className="p-4 pb-2">
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认下注</DialogTitle>
|
||||
<DialogTitle>{t("hall.preview.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
请核对号码、玩法与实扣金额;确认后将扣减彩票钱包且不可撤单(产品文档 §6.3)。
|
||||
{t("hall.preview.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{!allowSubmit ? (
|
||||
<Alert className="mt-3 border-[#ff4d4f]/35 bg-[#ff4d4f]/8 text-[#ff4d4f] dark:bg-[#ff4d4f]/12">
|
||||
<AlertTriangleIcon />
|
||||
<AlertTitle>已封盘</AlertTitle>
|
||||
<AlertTitle>{t("hall.preview.sealedTitle")}</AlertTitle>
|
||||
<AlertDescription className="text-xs leading-relaxed">
|
||||
当前期已停止接收注单,无法提交。请选择下一期(界面文档 §4.2)。
|
||||
{t("hall.preview.sealedDescription")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
@@ -88,37 +92,38 @@ export function HallBetPreviewDialog({
|
||||
<ScrollArea className="max-h-[min(52vh,360px)] border-y px-4">
|
||||
<div className="space-y-4 py-3 pr-3">
|
||||
{!data ? (
|
||||
<p className="text-sm text-muted-foreground">暂无预览数据</p>
|
||||
<p className="text-sm text-muted-foreground">{t("hall.preview.empty")}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-lg border bg-muted/30 p-3 text-xs">
|
||||
<p>
|
||||
期号{" "}
|
||||
<span className="font-mono font-semibold">{data.draw.draw_id}</span> · 状态{" "}
|
||||
{t("hall.preview.draw")}{" "}
|
||||
<span className="font-mono font-semibold">{data.draw.draw_id}</span> ·{" "}
|
||||
{t("hall.preview.status")}{" "}
|
||||
<span className="font-medium">{data.draw.status}</span>
|
||||
</p>
|
||||
{summary ? (
|
||||
<ul className="mt-2 space-y-1 tabular-nums">
|
||||
<li>
|
||||
总下注{" "}
|
||||
{t("hall.preview.totalBet")}{" "}
|
||||
<span className="font-medium">
|
||||
{formatMinorAsCurrency(summary.total_bet_amount, currencyCode)}
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
回水抵扣{" "}
|
||||
{t("hall.preview.rebateDeduct")}{" "}
|
||||
<span className="font-medium">
|
||||
{formatMinorAsCurrency(summary.total_rebate_amount, currencyCode)}
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
实扣金额{" "}
|
||||
{t("hall.preview.actualDeduct")}{" "}
|
||||
<span className="font-semibold text-primary">
|
||||
{formatMinorAsCurrency(summary.total_actual_deduct, currencyCode)}
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
预估最高赔付{" "}
|
||||
{t("hall.preview.estimatedPayout")}{" "}
|
||||
<span className="font-medium">
|
||||
{formatMinorAsCurrency(summary.total_estimated_payout, currencyCode)}
|
||||
</span>
|
||||
@@ -130,7 +135,9 @@ export function HallBetPreviewDialog({
|
||||
<WarningsBlock warnings={data.warnings} />
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">注项明细</p>
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
{t("hall.preview.lines")}
|
||||
</p>
|
||||
<ul className="space-y-2 text-sm">
|
||||
{lines.map((ln) => (
|
||||
<li
|
||||
@@ -146,15 +153,23 @@ export function HallBetPreviewDialog({
|
||||
<p className="mt-1 font-mono text-base">{ln.number}</p>
|
||||
<Separator className="my-2" />
|
||||
<div className="grid grid-cols-2 gap-x-2 gap-y-1 text-xs tabular-nums">
|
||||
<span className="text-muted-foreground">归一号码</span>
|
||||
<span className="text-muted-foreground">
|
||||
{t("hall.preview.normalizedNumber")}
|
||||
</span>
|
||||
<span className="text-right font-mono">{ln.normalized_number}</span>
|
||||
<span className="text-muted-foreground">组合数</span>
|
||||
<span className="text-muted-foreground">
|
||||
{t("hall.preview.combinationCount")}
|
||||
</span>
|
||||
<span className="text-right">{ln.combination_count}</span>
|
||||
<span className="text-muted-foreground">实扣</span>
|
||||
<span className="text-muted-foreground">
|
||||
{t("hall.preview.actual")}
|
||||
</span>
|
||||
<span className="text-right">
|
||||
{formatMinorAsCurrency(ln.actual_deduct_amount, currencyCode)}
|
||||
</span>
|
||||
<span className="text-muted-foreground">预估最高赔</span>
|
||||
<span className="text-muted-foreground">
|
||||
{t("hall.preview.estimatedMax")}
|
||||
</span>
|
||||
<span className="text-right">
|
||||
{formatMinorAsCurrency(ln.estimated_max_payout, currencyCode)}
|
||||
</span>
|
||||
@@ -170,10 +185,14 @@ export function HallBetPreviewDialog({
|
||||
|
||||
<div className="flex flex-col-reverse gap-2 border-t bg-muted/30 p-4 sm:flex-row sm:justify-between">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={placing}>
|
||||
返回修改
|
||||
{t("hall.preview.backEdit")}
|
||||
</Button>
|
||||
<Button type="button" onClick={onConfirmPlace} disabled={!data || placing || !allowSubmit}>
|
||||
{placing ? "提交中…" : allowSubmit ? "确认提交" : "已封盘"}
|
||||
{placing
|
||||
? t("hall.preview.submitting")
|
||||
: allowSubmit
|
||||
? t("hall.preview.confirmSubmit")
|
||||
: t("hall.preview.sealedTitle")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
@@ -47,12 +47,24 @@ export function playNeedsDigitSlot(playCode: string): boolean {
|
||||
}
|
||||
|
||||
/** 产品文档:iBox/Roll 单注金额;mBox 总金额摊分 */
|
||||
export function ticketAmountHint(playCode: string): string {
|
||||
export function ticketAmountHint(
|
||||
playCode: string,
|
||||
t?: (key: string) => string,
|
||||
): string {
|
||||
if (playCode === "ibox" || playCode === "roll") {
|
||||
return "本玩法金额为「单注金额」,系统按展开组合数计算总下注与实扣。";
|
||||
return (
|
||||
t?.("hall.amountHint.iboxRoll") ??
|
||||
"本玩法金额为「单注金额」,系统按展开组合数计算总下注与实扣。"
|
||||
);
|
||||
}
|
||||
if (playCode === "mbox") {
|
||||
return "本玩法金额为「总输入金额」,将均摊到各排列组合(向下取整到最小单位)。";
|
||||
return (
|
||||
t?.("hall.amountHint.mbox") ??
|
||||
"本玩法金额为「总输入金额」,将均摊到各排列组合(向下取整到最小单位)。"
|
||||
);
|
||||
}
|
||||
return "金额为该笔注单的下注额(最小货币单位整数,与钱包一致)。";
|
||||
return (
|
||||
t?.("hall.amountHint.default") ??
|
||||
"金额为该笔注单的下注额(最小货币单位整数,与钱包一致)。"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { CirclePlus, Cuboid, PackageOpen, Ticket, Trash2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { getPlayEffective } from "@/api/play";
|
||||
@@ -205,6 +206,7 @@ function pickSimplePlay(
|
||||
|
||||
export function HallBettingGrid() {
|
||||
const { display, isBettable, reload: reloadDraw } = useHallDrawLive();
|
||||
const { t } = useTranslation("player");
|
||||
const [activeCategory, setActiveCategory] = useState<HallCategory>("D2");
|
||||
const [boxMode, setBoxMode] = useState<BoxMode>("ibox");
|
||||
const [rows, setRows] = useState<DraftRow[]>(() => [
|
||||
@@ -239,10 +241,10 @@ export function HallBettingGrid() {
|
||||
setCatalogState({ kind: "ok", data });
|
||||
} catch (e) {
|
||||
const msg =
|
||||
e instanceof LotteryApiBizError ? e.message : "加载玩法失败,请稍后重试。";
|
||||
e instanceof LotteryApiBizError ? e.message : t("hall.loadingError");
|
||||
setCatalogState({ kind: "error", message: msg });
|
||||
}
|
||||
}, [currencyParam]);
|
||||
}, [currencyParam, t]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
@@ -373,21 +375,21 @@ export function HallBettingGrid() {
|
||||
|
||||
const handlePreview = async () => {
|
||||
if (!display) {
|
||||
toast.error("暂无当期期号,无法提交。");
|
||||
toast.error(t("hall.noDraw"));
|
||||
return;
|
||||
}
|
||||
if (!isBettable) {
|
||||
toast.error("当前已封盘或不可下注。");
|
||||
toast.error(t("hall.notBettable"));
|
||||
return;
|
||||
}
|
||||
if (catalogState.kind !== "ok") {
|
||||
toast.error("玩法配置尚未加载完成。");
|
||||
toast.error(t("hall.catalogNotReady"));
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = buildLines();
|
||||
if (lines.length === 0) {
|
||||
toast.error("请至少填写一组有效号码和下注金额。");
|
||||
toast.error(t("hall.emptyLines"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -403,8 +405,8 @@ export function HallBettingGrid() {
|
||||
setPreviewOpen(true);
|
||||
} catch (e) {
|
||||
const code = e instanceof LotteryApiBizError ? e.code : 0;
|
||||
const msg = e instanceof LotteryApiBizError ? e.message : "预览失败";
|
||||
toast.error(mapTicketBetError(code, msg));
|
||||
const msg = e instanceof LotteryApiBizError ? e.message : t("hall.previewFailed");
|
||||
toast.error(mapTicketBetError(code, msg, t));
|
||||
} finally {
|
||||
setPreviewLoading(false);
|
||||
}
|
||||
@@ -413,13 +415,13 @@ export function HallBettingGrid() {
|
||||
const handlePlace = async () => {
|
||||
if (!display || !previewData) return;
|
||||
if (!isBettable) {
|
||||
toast.error("已封盘,无法提交。");
|
||||
toast.error(t("hall.closedSubmit"));
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = buildLines();
|
||||
if (lines.length === 0) {
|
||||
toast.error("提交前数据已变化,请关闭预览后重试。");
|
||||
toast.error(t("hall.changedBeforeSubmit"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -436,7 +438,10 @@ export function HallBettingGrid() {
|
||||
expected_config_versions: previewData.config_versions,
|
||||
});
|
||||
toast.success(
|
||||
`下注成功,订单号 ${data.order_no},实扣 ${formatMinorAsCurrency(data.summary.total_actual_deduct, currencyCode)}。`,
|
||||
t("hall.placeSuccess", {
|
||||
orderNo: data.order_no,
|
||||
amount: formatMinorAsCurrency(data.summary.total_actual_deduct, currencyCode),
|
||||
}),
|
||||
);
|
||||
setPreviewOpen(false);
|
||||
setPreviewData(null);
|
||||
@@ -445,8 +450,8 @@ export function HallBettingGrid() {
|
||||
void reloadDraw();
|
||||
} catch (e) {
|
||||
const code = e instanceof LotteryApiBizError ? e.code : 0;
|
||||
const msg = e instanceof LotteryApiBizError ? e.message : "提交失败";
|
||||
toast.error(mapTicketBetError(code, msg));
|
||||
const msg = e instanceof LotteryApiBizError ? e.message : t("hall.placeFailed");
|
||||
toast.error(mapTicketBetError(code, msg, t));
|
||||
} finally {
|
||||
setPlaceLoading(false);
|
||||
}
|
||||
@@ -454,7 +459,7 @@ export function HallBettingGrid() {
|
||||
|
||||
if (catalogState.kind === "loading") {
|
||||
return (
|
||||
<section className="space-y-3" aria-label="Betting table">
|
||||
<section className="space-y-3" aria-label={t("hall.aria")}>
|
||||
<Skeleton className="h-12 rounded-xl" />
|
||||
<Skeleton className="h-72 rounded-xl" />
|
||||
<Skeleton className="h-14 rounded-xl" />
|
||||
@@ -473,7 +478,7 @@ export function HallBettingGrid() {
|
||||
className="mt-3 border-red-200 bg-white text-red-700 hover:bg-red-50"
|
||||
onClick={() => void loadCatalog()}
|
||||
>
|
||||
重试
|
||||
{t("actions.retry")}
|
||||
</Button>
|
||||
</section>
|
||||
);
|
||||
@@ -485,7 +490,7 @@ export function HallBettingGrid() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="space-y-4" aria-label="Betting table">
|
||||
<section className="space-y-4" aria-label={t("hall.aria")}>
|
||||
<div className="grid grid-cols-4 rounded-xl border border-[#e8eef7] bg-white p-1 shadow-[0_6px_18px_rgba(30,64,175,0.06)]">
|
||||
{categoryTabs.map((tab) => {
|
||||
const active = activeCategory === tab.value;
|
||||
@@ -539,9 +544,11 @@ export function HallBettingGrid() {
|
||||
<Cuboid className="size-4" aria-hidden />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm font-bold">iBox</span>
|
||||
<span className="block truncate text-sm font-bold">
|
||||
{t("hall.boxMode.iboxTitle")}
|
||||
</span>
|
||||
<span className="block truncate text-[10px] text-slate-500">
|
||||
Divide all by amount
|
||||
{t("hall.boxMode.iboxDesc")}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
@@ -561,9 +568,11 @@ export function HallBettingGrid() {
|
||||
<PackageOpen className="size-4" aria-hidden />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm font-bold">Box</span>
|
||||
<span className="block truncate text-sm font-bold">
|
||||
{t("hall.boxMode.boxTitle")}
|
||||
</span>
|
||||
<span className="block truncate text-[10px] text-slate-500">
|
||||
Multiply all by amount
|
||||
{t("hall.boxMode.boxDesc")}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
@@ -575,10 +584,12 @@ export function HallBettingGrid() {
|
||||
<div className="mx-auto flex size-14 items-center justify-center rounded-full bg-slate-200 text-slate-600">
|
||||
<Ticket className="size-7" aria-hidden />
|
||||
</div>
|
||||
<p className="mt-4 text-lg font-bold text-slate-900">Closed</p>
|
||||
<p className="mt-1 text-xs">This issue is now closed.</p>
|
||||
<p className="mt-4 text-lg font-bold text-slate-900">
|
||||
{t("hall.closed.title")}
|
||||
</p>
|
||||
<p className="mt-1 text-xs">{t("hall.closed.subtitle")}</p>
|
||||
<div className="mt-5 rounded-lg border border-[#cbdcf7] bg-white px-3 py-3 text-left text-xs text-[#315a9f]">
|
||||
The betting window has closed. Please wait for the next issue to place your bets.
|
||||
{t("hall.closed.description")}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -593,10 +604,10 @@ export function HallBettingGrid() {
|
||||
<thead>
|
||||
<tr className="border-b border-[#e8eef7] bg-[#f5f8fd] text-[11px] font-semibold text-[#32518d]">
|
||||
<th className="sticky left-0 z-20 w-12 bg-[#f5f8fd] px-2 py-3 text-center">
|
||||
No.
|
||||
{t("hall.table.no")}
|
||||
</th>
|
||||
<th className="sticky left-12 z-20 w-24 bg-[#f5f8fd] px-2 py-3 text-center">
|
||||
Number
|
||||
{t("hall.table.number")}
|
||||
<span className="block text-[10px] font-normal text-[#6b7896]">
|
||||
({numberPlaceholder})
|
||||
</span>
|
||||
@@ -609,12 +620,18 @@ export function HallBettingGrid() {
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
<th className="min-w-28 px-2 py-3 text-center">Stake Amount</th>
|
||||
<th className="min-w-28 px-2 py-3 text-center">Commission / Rebate</th>
|
||||
<th className="min-w-28 px-2 py-3 text-center">Actual Deduction</th>
|
||||
<th className="min-w-28 px-2 py-3 text-center">
|
||||
{t("hall.table.stake")}
|
||||
</th>
|
||||
<th className="min-w-28 px-2 py-3 text-center">
|
||||
{t("hall.table.rebate")}
|
||||
</th>
|
||||
<th className="min-w-28 px-2 py-3 text-center">
|
||||
{t("hall.table.actual")}
|
||||
</th>
|
||||
</>
|
||||
)}
|
||||
<th className="w-10 px-2 py-3" aria-label="Delete" />
|
||||
<th className="w-10 px-2 py-3" aria-label={t("hall.table.delete")} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -695,7 +712,7 @@ export function HallBettingGrid() {
|
||||
disabled={tableDisabled || rows.length <= 1}
|
||||
onClick={() => removeRow(row.id)}
|
||||
className="inline-flex size-8 items-center justify-center rounded-full text-[#ff4d4f] hover:bg-red-50 disabled:text-slate-300 disabled:hover:bg-transparent"
|
||||
aria-label={`删除第 ${index + 1} 行`}
|
||||
aria-label={t("actions.deleteRow", { row: index + 1 })}
|
||||
>
|
||||
<Trash2 className="size-4" aria-hidden />
|
||||
</button>
|
||||
@@ -713,13 +730,13 @@ export function HallBettingGrid() {
|
||||
className="flex h-11 w-full items-center justify-center gap-1.5 border-t border-[#edf2f9] text-sm font-semibold text-[#1d57b7] hover:bg-[#f7faff] disabled:text-slate-300"
|
||||
>
|
||||
<CirclePlus className="size-4" aria-hidden />
|
||||
Add Row
|
||||
{t("hall.table.addRow")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between rounded-xl border border-[#e9eef7] bg-[#f8fbff] px-4 py-3 text-sm shadow-[0_6px_20px_rgba(15,23,42,0.04)]">
|
||||
<span className="font-medium text-slate-800">Draft Total</span>
|
||||
<span className="font-medium text-slate-800">{t("hall.table.draftTotal")}</span>
|
||||
<span className="text-lg font-bold tabular-nums text-[#0b3f96]">
|
||||
{formatMinorAsCurrency(draftSummary.actual, currencyCode)}
|
||||
</span>
|
||||
@@ -727,7 +744,7 @@ export function HallBettingGrid() {
|
||||
|
||||
{sealedBetUi ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-600">
|
||||
已封盘:当前表格不可编辑,请等待下一期。
|
||||
{t("hall.table.sealedHint")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -738,7 +755,11 @@ export function HallBettingGrid() {
|
||||
className="h-12 w-full rounded-xl border-0 bg-[#e5002c] text-base font-bold text-white shadow-[0_8px_20px_rgba(229,0,44,0.26)] hover:bg-[#d10028]"
|
||||
>
|
||||
<Ticket className="size-5" aria-hidden />
|
||||
{previewLoading ? "Previewing..." : activeCategory === "JACKPOT" ? "Closed" : "Submit Bet"}
|
||||
{previewLoading
|
||||
? t("hall.table.previewing")
|
||||
: activeCategory === "JACKPOT"
|
||||
? t("hall.closed.title")
|
||||
: t("hall.table.submitBet")}
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Hourglass, Landmark, TimerReset, WalletCards } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
@@ -12,13 +13,14 @@ import { cn } from "@/lib/utils";
|
||||
import type { DrawCurrentPayload } from "@/types/api/draw-current";
|
||||
|
||||
function CurrentTime({ payload }: { payload: DrawCurrentPayload }) {
|
||||
const { t } = useTranslation("player");
|
||||
const source = payload.close_time ?? payload.draw_time ?? payload.start_time;
|
||||
const formatted = source ? formatLotteryInstant(source) : null;
|
||||
if (!formatted) {
|
||||
return (
|
||||
<>
|
||||
<span className="text-lg font-black tabular-nums text-[#0b3f96]">--:--:--</span>
|
||||
<span className="mt-1 text-[11px] text-slate-500">Current Time</span>
|
||||
<span className="mt-1 text-[11px] text-slate-500">{t("draw.currentTime")}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -42,18 +44,19 @@ function CloseTime({
|
||||
hud: ReturnType<typeof drawStatusHud>;
|
||||
payload: DrawCurrentPayload;
|
||||
}) {
|
||||
const { t } = useTranslation("player");
|
||||
const sealedCountdown = isHallSealedCountdownUi(payload.status);
|
||||
let seconds = 0;
|
||||
let label = "Closes In";
|
||||
let label = t("draw.closesIn");
|
||||
|
||||
if (hud.countdownKind === "close") {
|
||||
seconds = Math.max(0, payload.seconds_to_close);
|
||||
} else if (hud.countdownKind === "draw") {
|
||||
seconds = Math.max(0, payload.seconds_to_draw);
|
||||
label = sealedCountdown ? "Draws In" : "Closes In";
|
||||
label = sealedCountdown ? t("draw.drawsIn") : t("draw.closesIn");
|
||||
} else if (hud.countdownKind === "cooldown") {
|
||||
seconds = Math.max(0, payload.seconds_remaining_in_cooldown ?? 0);
|
||||
label = "Cool Down";
|
||||
label = t("draw.coolDown");
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -68,11 +71,12 @@ function CloseTime({
|
||||
|
||||
export function HallDrawPanel() {
|
||||
const { raw, display, error, reload } = useHallDrawLive();
|
||||
const { t } = useTranslation("player");
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<section className="mb-4 rounded-xl border border-red-200 bg-red-50 px-3 py-3 text-sm text-red-700">
|
||||
<p>{error}</p>
|
||||
<p>{t(error, { defaultValue: error })}</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -80,7 +84,7 @@ export function HallDrawPanel() {
|
||||
className="mt-2 border-red-200 bg-white text-red-700"
|
||||
onClick={() => void reload()}
|
||||
>
|
||||
重试
|
||||
{t("actions.retry")}
|
||||
</Button>
|
||||
</section>
|
||||
);
|
||||
@@ -101,7 +105,7 @@ export function HallDrawPanel() {
|
||||
if (raw === null || display === null) {
|
||||
return (
|
||||
<section className="mb-4 rounded-xl border border-[#e3ebf6] bg-white px-3 py-4 text-center text-sm text-slate-500 shadow-sm">
|
||||
暂无可用期号,请稍后再试
|
||||
{t("draw.noIssue")}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -115,7 +119,7 @@ export function HallDrawPanel() {
|
||||
"mb-4 overflow-hidden rounded-xl border border-[#e1e9f5] bg-white shadow-[0_6px_20px_rgba(15,23,42,0.06)]",
|
||||
sealedUi && "border-red-200 bg-red-50/30",
|
||||
)}
|
||||
aria-label="Current issue"
|
||||
aria-label={t("draw.currentIssue")}
|
||||
>
|
||||
<div className="grid grid-cols-[1fr_1.05fr_1fr] divide-x divide-[#e7edf6]">
|
||||
<div className="flex min-w-0 items-center justify-center gap-2 px-2 py-3 text-center">
|
||||
@@ -123,7 +127,7 @@ export function HallDrawPanel() {
|
||||
<WalletCards className="size-4" aria-hidden />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] font-semibold text-slate-500">Issue No.</p>
|
||||
<p className="text-[11px] font-semibold text-slate-500">{t("draw.issueNo")}</p>
|
||||
<p className="truncate text-sm font-black tabular-nums text-[#ff143d]">
|
||||
{display.draw_no}
|
||||
</p>
|
||||
@@ -146,17 +150,17 @@ export function HallDrawPanel() {
|
||||
{sealedUi ? (
|
||||
<div className="flex items-center gap-2 border-t border-red-100 bg-red-50 px-3 py-2 text-xs font-medium text-red-600">
|
||||
<TimerReset className="size-4" aria-hidden />
|
||||
已封盘,请等待下一期。
|
||||
{t("draw.sealedNotice")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between border-t border-[#eef3f9] bg-[#fbfdff] px-3 py-1.5 text-[11px] text-slate-500">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className={cn("size-2 rounded-full", hud.dotClass)} />
|
||||
{hud.label}
|
||||
{t(hud.labelKey, { defaultValue: hud.labelKey })}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Landmark className="size-3.5" aria-hidden />
|
||||
Betting Hall
|
||||
{t("draw.hall")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { getPlayEffective } from "@/api/play";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -76,6 +77,7 @@ function formatMoneyAmount(n: number): string {
|
||||
}
|
||||
|
||||
export function HallPlayCatalogPanel() {
|
||||
const { t } = useTranslation("player");
|
||||
const [state, setState] = useState<LoadState>({ kind: "loading" });
|
||||
const currencyParam = useMemo(() => {
|
||||
const fromEnv = process.env.NEXT_PUBLIC_LOTTERY_PLAY_CURRENCY?.trim();
|
||||
@@ -93,8 +95,7 @@ export function HallPlayCatalogPanel() {
|
||||
if (e instanceof LotteryApiBizError && e.code === 9004) {
|
||||
setState({
|
||||
kind: "error",
|
||||
message:
|
||||
"玩法配置尚未初始化。请在 Laravel 执行含 OperationalConfigV1Seeder 的 seed。",
|
||||
message: t("hall.playCatalog.notReady"),
|
||||
notReady: true,
|
||||
});
|
||||
return;
|
||||
@@ -102,10 +103,10 @@ export function HallPlayCatalogPanel() {
|
||||
const msg =
|
||||
e instanceof LotteryApiBizError
|
||||
? e.message
|
||||
: "加载玩法配置失败,请稍后重试。";
|
||||
: t("hall.playCatalog.loadFailed");
|
||||
setState({ kind: "error", message: msg });
|
||||
}
|
||||
}, [currencyParam]);
|
||||
}, [currencyParam, t]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
@@ -123,7 +124,7 @@ export function HallPlayCatalogPanel() {
|
||||
const body = (() => {
|
||||
if (state.kind === "loading") {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">加载玩法与赔率…</p>
|
||||
<p className="text-sm text-muted-foreground">{t("hall.playCatalog.loading")}</p>
|
||||
);
|
||||
}
|
||||
if (state.kind === "error") {
|
||||
@@ -131,7 +132,7 @@ export function HallPlayCatalogPanel() {
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-destructive">{state.message}</p>
|
||||
<Button type="button" size="sm" variant="secondary" onClick={() => void load()}>
|
||||
重试
|
||||
{t("actions.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
@@ -145,19 +146,28 @@ export function HallPlayCatalogPanel() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
币种 {data.currency_code} · 配置版本 play#{data.effective_versions.play_config.version_no}
|
||||
/odds#{data.effective_versions.odds.version_no} · 限额单位为最小货币单位(与钱包一致)。
|
||||
{t("hall.playCatalog.meta", {
|
||||
currency: data.currency_code,
|
||||
playVersion: data.effective_versions.play_config.version_no,
|
||||
oddsVersion: data.effective_versions.odds.version_no,
|
||||
})}
|
||||
</p>
|
||||
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="min-w-[140px]">玩法</TableHead>
|
||||
<TableHead className="w-[88px] text-center">状态</TableHead>
|
||||
<TableHead className="min-w-[160px] whitespace-nowrap">下注限额</TableHead>
|
||||
<TableHead className="min-w-[100px]">赔率×</TableHead>
|
||||
<TableHead className="min-w-[200px]">说明</TableHead>
|
||||
<TableHead className="min-w-[140px]">{t("hall.playCatalog.play")}</TableHead>
|
||||
<TableHead className="w-[88px] text-center">
|
||||
{t("hall.playCatalog.status")}
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[160px] whitespace-nowrap">
|
||||
{t("hall.playCatalog.limit")}
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[100px]">{t("hall.playCatalog.odds")}</TableHead>
|
||||
<TableHead className="min-w-[200px]">
|
||||
{t("hall.playCatalog.description")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -188,11 +198,11 @@ export function HallPlayCatalogPanel() {
|
||||
<TableCell className="text-center">
|
||||
{open ? (
|
||||
<Badge variant="default" className="font-normal">
|
||||
开放
|
||||
{t("hall.playCatalog.open")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
关闭
|
||||
{t("hall.playCatalog.closed")}
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
@@ -222,14 +232,16 @@ export function HallPlayCatalogPanel() {
|
||||
|
||||
{data.risk_cap_items.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium text-foreground">风控封顶(示例号码)</h3>
|
||||
<h3 className="text-sm font-medium text-foreground">
|
||||
{t("hall.playCatalog.riskTitle")}
|
||||
</h3>
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>号码</TableHead>
|
||||
<TableHead>封顶额</TableHead>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>{t("hall.playCatalog.number")}</TableHead>
|
||||
<TableHead>{t("hall.playCatalog.capAmount")}</TableHead>
|
||||
<TableHead>{t("hall.playCatalog.type")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -257,10 +269,11 @@ export function HallPlayCatalogPanel() {
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-base">玩法与赔率</CardTitle>
|
||||
<CardTitle className="text-base">{t("hall.playCatalog.title")}</CardTitle>
|
||||
<CardDescription>
|
||||
数据来自 <code className="text-xs">GET /api/v1/play/effective</code>
|
||||
;后台修改并发布后,最长约 {DEFAULT_POLL_MS / 1000}s 内自动刷新(亦可手动刷新)。
|
||||
{t("hall.playCatalog.descriptionText", {
|
||||
seconds: DEFAULT_POLL_MS / 1000,
|
||||
})}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
@@ -270,7 +283,7 @@ export function HallPlayCatalogPanel() {
|
||||
className="shrink-0"
|
||||
onClick={() => void load()}
|
||||
>
|
||||
刷新配置
|
||||
{t("hall.playCatalog.refresh")}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>{body}</CardContent>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type PlayChip = {
|
||||
@@ -23,15 +25,17 @@ export function HallPlaySwitcher({
|
||||
onChange,
|
||||
disabled,
|
||||
}: HallPlaySwitcherProps) {
|
||||
const { t } = useTranslation("player");
|
||||
|
||||
if (plays.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">当前币种下没有可下注的开放玩法。</p>
|
||||
<p className="text-sm text-muted-foreground">{t("hall.playSwitcher.empty")}</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">玩法</p>
|
||||
<p className="text-xs font-medium text-muted-foreground">{t("hall.playSwitcher.label")}</p>
|
||||
<div className="-mx-1 flex gap-1.5 overflow-x-auto pb-1">
|
||||
{plays.map((p) => {
|
||||
const active = p.play_code === value;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Bell } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||
import { HallBettingGrid } from "@/features/hall/hall-betting-grid";
|
||||
@@ -11,10 +12,12 @@ import { HallWalletStrip } from "@/features/hall/hall-wallet-strip";
|
||||
* 下注大厅:钱包条 §4 + 当期期号 §4.2(封盘置灰 / 倒计时错误色 / WS+轮询);玩法目录 §12.3;下注表格 §13.3。
|
||||
*/
|
||||
export function HallScreen() {
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[480px]">
|
||||
<section className="overflow-hidden rounded-[18px] border border-[#dce7f7] bg-white px-2.5 py-3 text-slate-900 shadow-[0_18px_50px_rgba(15,44,92,0.12)]">
|
||||
<div className="mb-3 flex items-center gap-2 px-1">
|
||||
<section className="overflow-hidden bg-white px-4 pb-8 pt-4 text-slate-900">
|
||||
<div className="mb-3 flex items-center gap-2 px-1 pt-3">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<div className="relative flex size-10 shrink-0 rotate-[-10deg] items-center justify-center rounded-lg bg-[#e60023] text-white shadow-[0_7px_14px_rgba(230,0,35,0.22)]">
|
||||
<span className="absolute -left-1.5 top-1 flex size-6 rotate-[18deg] items-center justify-center rounded-sm bg-[#0b56b7] text-xs font-black">
|
||||
@@ -35,14 +38,14 @@ export function HallScreen() {
|
||||
<button
|
||||
type="button"
|
||||
className="relative flex size-9 shrink-0 items-center justify-center rounded-full text-[#1d57b7] hover:bg-[#f4f7fb]"
|
||||
aria-label="Notifications"
|
||||
aria-label={t("navigation.notifications")}
|
||||
>
|
||||
<Bell className="size-5" aria-hidden />
|
||||
<span className="absolute right-2 top-2 size-2 rounded-full bg-[#ff143d]" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<HallDrawPanel />
|
||||
<HallDrawPanel />
|
||||
|
||||
<HallWalletStrip />
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { Wallet } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { getWalletBalance } from "@/api/wallet";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
@@ -18,6 +19,7 @@ import type { WalletBalanceData } from "@/types/api/wallet-balance";
|
||||
export function HallWalletStrip() {
|
||||
const profile = usePlayerSessionStore((s) => s.profile);
|
||||
const mode = useNetworkConnectionStore((s) => s.mode);
|
||||
const { t } = useTranslation("player");
|
||||
const [balance, setBalance] = useState<WalletBalanceData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const degradedWalletPollRef = useRef<number | null>(null);
|
||||
@@ -83,7 +85,7 @@ export function HallWalletStrip() {
|
||||
const availableMinor = Number(balance?.available_balance ?? 0);
|
||||
|
||||
return (
|
||||
<section className="mb-4 space-y-3" aria-label="Wallet balance">
|
||||
<section className="mb-4 space-y-3" aria-label={t("wallet.balance")}>
|
||||
<div
|
||||
className={cn(
|
||||
"relative overflow-hidden rounded-xl bg-[#e5002c] px-4 py-4 text-white shadow-[0_10px_28px_rgba(229,0,44,0.25)]",
|
||||
@@ -95,7 +97,7 @@ export function HallWalletStrip() {
|
||||
<Wallet className="size-7" aria-hidden />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold text-white/90">Wallet Balance</p>
|
||||
<p className="text-sm font-semibold text-white/90">{t("wallet.balance")}</p>
|
||||
{loading ? (
|
||||
<Skeleton className="mt-2 h-8 w-44 rounded-md bg-white/25" />
|
||||
) : (
|
||||
@@ -111,7 +113,7 @@ export function HallWalletStrip() {
|
||||
<TransferInDialog
|
||||
idPrefix="hall-"
|
||||
triggerVariant="hall"
|
||||
triggerLabel="Transfer In"
|
||||
triggerLabel={t("wallet.transferIn")}
|
||||
triggerClassName="h-12 rounded-lg text-base font-bold"
|
||||
currency={currency}
|
||||
lotteryMinor={lotteryMinor}
|
||||
@@ -120,7 +122,7 @@ export function HallWalletStrip() {
|
||||
<TransferOutDialog
|
||||
idPrefix="hall-"
|
||||
triggerVariant="hall"
|
||||
triggerLabel="Transfer Out"
|
||||
triggerLabel={t("wallet.transferOut")}
|
||||
triggerClassName="h-12 rounded-lg text-base font-bold"
|
||||
currency={currency}
|
||||
availableMinor={availableMinor}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { getDrawCurrent } from "@/api/draw";
|
||||
import { getLotteryEcho } from "@/lib/lottery-echo";
|
||||
@@ -76,17 +76,11 @@ export function useHallDrawLive(): {
|
||||
setRaw(d);
|
||||
setEmittedAtMs(Date.now());
|
||||
} catch {
|
||||
setError("加载失败,请下拉刷新");
|
||||
setError("draw.loadFailedRefresh");
|
||||
setRaw(undefined);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// WebSocket 正常时的轮询间隔(作为备用)
|
||||
const refreshMs = useMemo(() => {
|
||||
if (raw === undefined) return 30_000;
|
||||
return raw ? 60_000 : 30_000; // WebSocket正常时减少轮询频率
|
||||
}, [raw]);
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
@@ -199,14 +193,22 @@ export function useHallDrawLive(): {
|
||||
if (!isWebSocketConnected && mode !== "websocket") {
|
||||
const currentPollingId = useNetworkConnectionStore.getState().drawPollingIntervalId;
|
||||
if (!currentPollingId) {
|
||||
// 立即执行一次
|
||||
void load();
|
||||
const initialLoadId = window.setTimeout(() => {
|
||||
void load();
|
||||
}, 0);
|
||||
|
||||
// 设置30秒轮询
|
||||
intervalId = window.setInterval(() => {
|
||||
void load();
|
||||
}, 30_000);
|
||||
setDrawPollingIntervalId(intervalId);
|
||||
return () => {
|
||||
window.clearTimeout(initialLoadId);
|
||||
if (intervalId) {
|
||||
window.clearInterval(intervalId);
|
||||
setDrawPollingIntervalId(null);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,22 +4,26 @@ export function ticketStatusDisplay(
|
||||
status: string,
|
||||
winMinor: number,
|
||||
jackpotMinor: number,
|
||||
t?: (key: string, options?: { defaultValue?: string; status?: string }) => string,
|
||||
): { label: string; dotClass: string; ring?: boolean } {
|
||||
const total = winMinor + jackpotMinor;
|
||||
if (status === "success") {
|
||||
return { label: "待开奖", dotClass: "bg-sky-500" };
|
||||
return { label: t?.("ticketStatus.success") ?? "待开奖", dotClass: "bg-sky-500" };
|
||||
}
|
||||
if (status === "settled_win" && total > 0) {
|
||||
return { label: "已派彩", dotClass: "bg-emerald-500" };
|
||||
return { label: t?.("ticketStatus.settled_win") ?? "已派彩", dotClass: "bg-emerald-500" };
|
||||
}
|
||||
if (status === "settled_lose" || status === "settled_win") {
|
||||
return {
|
||||
label: "未中奖",
|
||||
label: t?.("ticketStatus.settled_lose") ?? "未中奖",
|
||||
dotClass: "bg-background",
|
||||
ring: true,
|
||||
};
|
||||
}
|
||||
return { label: status, dotClass: "bg-red-500" };
|
||||
return {
|
||||
label: t?.("ticketStatus.unknown", { status, defaultValue: status }) ?? status,
|
||||
dotClass: "bg-red-500",
|
||||
};
|
||||
}
|
||||
|
||||
export function StatusDot({
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { getTicketItemDetail } from "@/api/ticket-items";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { PlayerPanel } from "@/components/layout/player-panel";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -18,46 +20,31 @@ import { StatusDot, ticketStatusDisplay } from "@/features/orders/ticket-item-st
|
||||
import { formatLotteryInstant } from "@/lib/player-datetime";
|
||||
import { formatMinorAsCurrency } from "@/lib/money";
|
||||
import { norm4d } from "@/lib/norm-4d";
|
||||
import { playLabelZh } from "@/lib/play-labels";
|
||||
import { playLabel } from "@/lib/play-labels";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { TicketItemDetailPayload } from "@/types/api/ticket-items";
|
||||
|
||||
type OddsSnapRow = { prize_scope?: string; odds_value?: number };
|
||||
|
||||
function formatOddsSnapshot(json: unknown): string {
|
||||
function formatOddsSnapshot(
|
||||
json: unknown,
|
||||
t: (key: string, options?: { defaultValue?: string }) => string,
|
||||
): string {
|
||||
if (!Array.isArray(json)) return "—";
|
||||
const parts = (json as OddsSnapRow[])
|
||||
.filter((r) => r.prize_scope && r.odds_value != null)
|
||||
.map((r) => {
|
||||
const scope = String(r.prize_scope);
|
||||
const label =
|
||||
scope === "first"
|
||||
? "头奖"
|
||||
: scope === "second"
|
||||
? "二奖"
|
||||
: scope === "third"
|
||||
? "三奖"
|
||||
: scope === "starter"
|
||||
? "特别奖"
|
||||
: scope === "consolation"
|
||||
? "安慰奖"
|
||||
: scope;
|
||||
const label = t(`prizeTier.${scope}`, { defaultValue: scope });
|
||||
const mult = Number(r.odds_value) / 10_000;
|
||||
return `${label} ${mult}x`;
|
||||
});
|
||||
return parts.length ? parts.join(" · ") : "—";
|
||||
}
|
||||
|
||||
const TIER_ZH: Record<string, string> = {
|
||||
first: "头奖",
|
||||
second: "二奖",
|
||||
third: "三奖",
|
||||
starter: "特别奖",
|
||||
consolation: "安慰奖",
|
||||
};
|
||||
|
||||
/** 界面文档 §4.8 注单详情 */
|
||||
export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
|
||||
const { t } = useTranslation("player");
|
||||
const [data, setData] = useState<TicketItemDetailPayload | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -70,11 +57,11 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
|
||||
setData(row);
|
||||
} catch {
|
||||
setData(null);
|
||||
setError("注单不存在或无权查看");
|
||||
setError(t("orders.notFound"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [ticketNo]);
|
||||
}, [ticketNo, t]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
@@ -84,39 +71,42 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-40" />
|
||||
<Skeleton className="h-4 w-56" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<PlayerPanel
|
||||
title={t("orders.betDetail")}
|
||||
subtitle={ticketNo}
|
||||
eyebrow={t("orders.title")}
|
||||
backHref="/orders"
|
||||
backLabel={t("orders.title")}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-12 rounded-xl" />
|
||||
<Skeleton className="h-56 rounded-xl" />
|
||||
</div>
|
||||
</PlayerPanel>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<Card className="border-destructive/40">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">注单详情</CardTitle>
|
||||
<CardDescription>{error ?? "无数据"}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap gap-2">
|
||||
<PlayerPanel
|
||||
title={t("orders.betDetail")}
|
||||
subtitle={ticketNo}
|
||||
eyebrow={t("orders.title")}
|
||||
backHref="/orders"
|
||||
backLabel={t("orders.title")}
|
||||
>
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-4 text-sm text-red-700">
|
||||
<p>{error ?? t("orders.noData")}</p>
|
||||
<Button type="button" size="sm" variant="secondary" onClick={() => void load()}>
|
||||
重试
|
||||
{t("actions.retry")}
|
||||
</Button>
|
||||
<Link href="/orders" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||||
返回列表
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</PlayerPanel>
|
||||
);
|
||||
}
|
||||
|
||||
const cur = data.currency_code ?? "NPR";
|
||||
const st = ticketStatusDisplay(data.status, data.win_amount, data.jackpot_win_amount);
|
||||
const st = ticketStatusDisplay(data.status, data.win_amount, data.jackpot_win_amount, t);
|
||||
const totalWin = data.win_amount + data.jackpot_win_amount;
|
||||
const pub = data.published_draw_results;
|
||||
const first = pub?.results?.["1st"] ?? "";
|
||||
@@ -146,119 +136,144 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
|
||||
: null;
|
||||
|
||||
const tierLabel = data.settlement?.matched_prize_tier
|
||||
? TIER_ZH[data.settlement.matched_prize_tier] ?? data.settlement.matched_prize_tier
|
||||
? t(`prizeTier.${data.settlement.matched_prize_tier}`, {
|
||||
defaultValue: data.settlement.matched_prize_tier,
|
||||
})
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Card>
|
||||
<CardHeader className="space-y-2 pb-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<CardTitle className="text-base">注单详情</CardTitle>
|
||||
<StatusDot label={st.label} dotClass={st.dotClass} ring={st.ring} />
|
||||
</div>
|
||||
<CardDescription className="font-mono text-xs">
|
||||
注单号 {data.ticket_no} · 订单 {data.order_no ?? "—"}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<div className="grid gap-1 text-xs">
|
||||
<p>
|
||||
<span className="text-muted-foreground">期号</span>{" "}
|
||||
<span className="font-mono font-medium">{data.draw_no ?? "—"}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">下单时间</span>{" "}
|
||||
{formatLotteryInstant(data.placed_at ?? null)}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">号码</span>{" "}
|
||||
<span className="font-mono">{data.original_number ?? "—"}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">玩法</span> {playLabelZh(data.play_code)} (
|
||||
{data.dimension ?? "—"}D)
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">下注金额</span>{" "}
|
||||
{formatMinorAsCurrency(data.total_bet_amount, cur)}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">回水率</span>{" "}
|
||||
{(Number(data.rebate_rate_snapshot) * 100).toFixed(1)}%
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">实扣金额</span>{" "}
|
||||
{formatMinorAsCurrency(data.actual_deduct_amount, cur)}
|
||||
</p>
|
||||
</div>
|
||||
<PlayerPanel
|
||||
title={t("orders.betDetail")}
|
||||
subtitle={data.ticket_no}
|
||||
eyebrow={t("orders.title")}
|
||||
backHref="/orders"
|
||||
backLabel={t("orders.title")}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Card className="rounded-xl border-[#e5edf8] shadow-[0_8px_24px_rgba(15,23,42,0.05)]">
|
||||
<CardHeader className="space-y-2 pb-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<CardTitle className="text-base">{t("orders.detailTitle")}</CardTitle>
|
||||
<StatusDot label={st.label} dotClass={st.dotClass} ring={st.ring} />
|
||||
</div>
|
||||
<CardDescription className="font-mono text-xs">
|
||||
{t("orders.ticketNo", { ticketNo: data.ticket_no })} ·{" "}
|
||||
{t("orders.orderNo", { orderNo: data.order_no ?? "—" })}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<div className="grid gap-1 text-xs">
|
||||
<p>
|
||||
<span className="text-muted-foreground">{t("orders.drawNo")}</span>{" "}
|
||||
<span className="font-mono font-medium">{data.draw_no ?? "—"}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">{t("orders.placedAt")}</span>{" "}
|
||||
{formatLotteryInstant(data.placed_at ?? null)}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">{t("orders.number")}</span>{" "}
|
||||
<span className="font-mono">{data.original_number ?? "—"}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">{t("orders.play")}</span>{" "}
|
||||
{playLabel(data.play_code, t)} (
|
||||
{data.dimension ?? "—"}D)
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">{t("orders.amount")}</span>{" "}
|
||||
{formatMinorAsCurrency(data.total_bet_amount, cur)}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">{t("orders.rebateRate")}</span>{" "}
|
||||
{(Number(data.rebate_rate_snapshot) * 100).toFixed(1)}%
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">{t("orders.actualDeduct")}</span>{" "}
|
||||
{formatMinorAsCurrency(data.actual_deduct_amount, cur)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border bg-muted/30 px-3 py-2 text-xs">
|
||||
<p className="font-medium text-foreground">赔率快照</p>
|
||||
<p className="mt-1 text-muted-foreground">{formatOddsSnapshot(data.odds_snapshot_json)}</p>
|
||||
<p className="font-medium text-foreground">{t("orders.oddsSnapshot")}</p>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{formatOddsSnapshot(data.odds_snapshot_json, t)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{pub?.results ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">开奖号码(本期)</p>
|
||||
<p className="text-sm font-medium">{t("orders.drawNumbers")}</p>
|
||||
<TwentyThreeResultsGrid numbers={pub.results} highlighted4d={highlight} />
|
||||
{first ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
头奖{" "}
|
||||
{t("orders.firstPrize")}{" "}
|
||||
<span className="font-mono font-semibold text-foreground">{first}</span>
|
||||
{comboHits.length > 0 ? (
|
||||
<span className="text-emerald-600 dark:text-emerald-400"> ← 命中</span>
|
||||
<span className="text-emerald-600 dark:text-emerald-400">
|
||||
{" "}
|
||||
← {t("orders.hit")}
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">本期开奖号码尚未发布或不可展示。</p>
|
||||
<p className="text-xs text-muted-foreground">{t("orders.notPublished")}</p>
|
||||
)}
|
||||
|
||||
{data.settlement && tierLabel ? (
|
||||
<div className="rounded-md border border-emerald-500/20 bg-emerald-500/5 px-3 py-2 text-xs">
|
||||
<p className="font-medium text-emerald-900 dark:text-emerald-100">
|
||||
匹配结果:命中 {tierLabel}
|
||||
{t("orders.matchWin", { tier: tierLabel })}
|
||||
</p>
|
||||
<p className="mt-1 font-mono text-muted-foreground">
|
||||
中奖金额 {formatMinorAsCurrency(data.settlement.win_amount_minor, cur)}
|
||||
{t("orders.winAmount", {
|
||||
amount: formatMinorAsCurrency(data.settlement.win_amount_minor, cur),
|
||||
})}
|
||||
{data.settlement.jackpot_allocation_minor > 0 ? (
|
||||
<>
|
||||
{" "}
|
||||
· Jackpot {formatMinorAsCurrency(data.settlement.jackpot_allocation_minor, cur)}
|
||||
·{" "}
|
||||
{t("orders.jackpotAmount", {
|
||||
amount: formatMinorAsCurrency(
|
||||
data.settlement.jackpot_allocation_minor,
|
||||
cur,
|
||||
),
|
||||
})}
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
<p className="mt-1 font-mono text-muted-foreground">
|
||||
派彩合计 {formatMinorAsCurrency(totalWin, cur)}
|
||||
{t("orders.payoutTotal", { amount: formatMinorAsCurrency(totalWin, cur) })}
|
||||
</p>
|
||||
</div>
|
||||
) : data.status === "settled_lose" ? (
|
||||
<p className="text-xs text-muted-foreground">匹配结果:未中奖</p>
|
||||
<p className="text-xs text-muted-foreground">{t("orders.matchLose")}</p>
|
||||
) : null}
|
||||
|
||||
{data.settled_at ? (
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
结算时间 {formatLotteryInstant(data.settled_at)}
|
||||
{t("orders.settledAt", { time: formatLotteryInstant(data.settled_at) })}
|
||||
</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{data.draw_no ? (
|
||||
<Link
|
||||
href={`/results/${encodeURIComponent(data.draw_no)}`}
|
||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
|
||||
>
|
||||
查看本期开奖
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{data.draw_no ? (
|
||||
<Link
|
||||
href={`/results/${encodeURIComponent(data.draw_no)}`}
|
||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
|
||||
>
|
||||
{t("orders.viewDraw")}
|
||||
</Link>
|
||||
) : null}
|
||||
<Link href="/orders" className={cn(buttonVariants({ variant: "secondary", size: "sm" }))}>
|
||||
{t("orders.backToOrders")}
|
||||
</Link>
|
||||
) : null}
|
||||
<Link href="/orders" className={cn(buttonVariants({ variant: "secondary", size: "sm" }))}>
|
||||
返回我的注单
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PlayerPanel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { getTicketItems } from "@/api/ticket-items";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -11,11 +12,12 @@ import { PlayerPanel } from "@/components/layout/player-panel";
|
||||
import { StatusDot, ticketStatusDisplay } from "@/features/orders/ticket-item-status";
|
||||
import { formatMinorAsCurrency } from "@/lib/money";
|
||||
import { formatLotteryInstant } from "@/lib/player-datetime";
|
||||
import { playLabelZh } from "@/lib/play-labels";
|
||||
import { playLabel } from "@/lib/play-labels";
|
||||
import type { TicketItemListRow } from "@/types/api/ticket-items";
|
||||
|
||||
export function TicketOrdersListScreen() {
|
||||
const searchParams = useSearchParams();
|
||||
const { t } = useTranslation("player");
|
||||
const drawNoFilter = useMemo(
|
||||
() => (searchParams.get("draw_no") ?? "").trim(),
|
||||
[searchParams],
|
||||
@@ -45,14 +47,14 @@ export function TicketOrdersListScreen() {
|
||||
setLastPage(res.last_page);
|
||||
setTotal(res.total);
|
||||
} catch {
|
||||
setError("加载失败");
|
||||
setError(t("orders.loadFailed"));
|
||||
if (!append) setItems([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
},
|
||||
[drawNoFilter],
|
||||
[drawNoFilter, t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -67,13 +69,13 @@ export function TicketOrdersListScreen() {
|
||||
};
|
||||
|
||||
return (
|
||||
<PlayerPanel title="My Bets" subtitle="Recent ticket records" eyebrow="N lotto">
|
||||
<PlayerPanel title={t("orders.title")} subtitle={t("orders.subtitle")} eyebrow={t("brand.name")}>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl border border-[#e6edf8] bg-[#f8fbff] p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-bold text-[#32518d]">
|
||||
{drawNoFilter ? "Filtered Issue" : "Total Records"}
|
||||
{drawNoFilter ? t("orders.filteredIssue") : t("orders.totalRecords")}
|
||||
</p>
|
||||
<p className="mt-1 truncate font-mono text-lg font-black text-[#0b3f96]">
|
||||
{drawNoFilter || total}
|
||||
@@ -84,14 +86,14 @@ export function TicketOrdersListScreen() {
|
||||
href="/orders"
|
||||
className="shrink-0 rounded-full border border-[#dce7f7] bg-white px-3 py-1.5 text-xs font-bold text-[#0b56b7]"
|
||||
>
|
||||
Clear
|
||||
{t("actions.clear")}
|
||||
</Link>
|
||||
) : (
|
||||
<Link
|
||||
href="/hall"
|
||||
className="shrink-0 rounded-full bg-[#e5002c] px-3 py-1.5 text-xs font-bold text-white"
|
||||
>
|
||||
Bet Now
|
||||
{t("orders.betNow")}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
@@ -112,17 +114,17 @@ export function TicketOrdersListScreen() {
|
||||
className="mt-3 bg-[#e5002c] text-white hover:bg-[#d10028]"
|
||||
onClick={() => void fetchPage(1, false)}
|
||||
>
|
||||
Retry
|
||||
{t("actions.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-[#dce7f7] bg-[#f8fbff] px-4 py-10 text-center">
|
||||
<p className="text-sm font-bold text-slate-700">No bet records yet.</p>
|
||||
<p className="text-sm font-bold text-slate-700">{t("orders.empty")}</p>
|
||||
<Link
|
||||
href="/hall"
|
||||
className="mt-4 inline-flex h-9 items-center rounded-lg bg-[#e5002c] px-4 text-sm font-bold text-white"
|
||||
>
|
||||
Submit Bet
|
||||
{t("orders.submitBet")}
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
@@ -134,6 +136,7 @@ export function TicketOrdersListScreen() {
|
||||
row.status,
|
||||
row.win_amount,
|
||||
row.jackpot_win_amount,
|
||||
t,
|
||||
);
|
||||
const totalWin = row.win_amount + row.jackpot_win_amount;
|
||||
return (
|
||||
@@ -148,20 +151,24 @@ export function TicketOrdersListScreen() {
|
||||
{row.draw_no ?? "—"}
|
||||
</p>
|
||||
<p className="mt-1 truncate text-xs text-slate-500">
|
||||
{playLabelZh(row.play_code)} · {row.original_number ?? row.play_code}
|
||||
{playLabel(row.play_code, t)} · {row.original_number ?? row.play_code}
|
||||
</p>
|
||||
</div>
|
||||
<StatusDot label={st.label} dotClass={st.dotClass} ring={st.ring} />
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<div className="rounded-lg bg-[#f8fbff] px-3 py-2">
|
||||
<p className="text-[10px] font-bold uppercase text-[#7890b8]">Stake</p>
|
||||
<p className="text-[10px] font-bold uppercase text-[#7890b8]">
|
||||
{t("orders.stake")}
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-black text-slate-900">
|
||||
{formatMinorAsCurrency(row.total_bet_amount, cur)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-[#f8fbff] px-3 py-2">
|
||||
<p className="text-[10px] font-bold uppercase text-[#7890b8]">Deduction</p>
|
||||
<p className="text-[10px] font-bold uppercase text-[#7890b8]">
|
||||
{t("orders.deduction")}
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-black text-[#0b3f96]">
|
||||
{formatMinorAsCurrency(row.actual_deduct_amount, cur)}
|
||||
</p>
|
||||
@@ -169,7 +176,7 @@ export function TicketOrdersListScreen() {
|
||||
</div>
|
||||
{totalWin > 0 && row.status === "settled_win" ? (
|
||||
<p className="mt-2 text-xs font-bold text-emerald-600">
|
||||
Win {formatMinorAsCurrency(totalWin, cur)}
|
||||
{t("orders.win", { amount: formatMinorAsCurrency(totalWin, cur) })}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="mt-2 text-[11px] text-slate-500">
|
||||
@@ -187,7 +194,7 @@ export function TicketOrdersListScreen() {
|
||||
disabled={loadingMore}
|
||||
onClick={() => loadMore()}
|
||||
>
|
||||
{loadingMore ? "Loading..." : "Load More"}
|
||||
{loadingMore ? t("actions.loading") : t("actions.loadMore")}
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { getPlayerMe, getPlayerPing } from "@/api/player";
|
||||
@@ -97,7 +97,6 @@ export function EntryGate() {
|
||||
usePlayerSessionStore();
|
||||
|
||||
const [phase, setPhase] = useState<Phase>("loading");
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [failureDetails, setFailureDetails] = useState<FailureRow[]>([]);
|
||||
const [steps, setSteps] = useState<EntryStep[]>(initialSteps());
|
||||
|
||||
@@ -107,15 +106,11 @@ export function EntryGate() {
|
||||
setSteps((prev) => prev.map((s) => (s.id === stepId ? { ...s, status } : s)));
|
||||
}, []);
|
||||
|
||||
const calculateProgress = useCallback((currentSteps: EntryStep[]) => {
|
||||
const doneCount = currentSteps.filter((s) => s.status === "done").length;
|
||||
const inProgressCount = currentSteps.filter((s) => s.status === "in-progress").length;
|
||||
return Math.round(((doneCount + inProgressCount * 0.5) / currentSteps.length) * 100);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setProgress(calculateProgress(steps));
|
||||
}, [steps, calculateProgress]);
|
||||
const progress = useMemo(() => {
|
||||
const doneCount = steps.filter((s) => s.status === "done").length;
|
||||
const inProgressCount = steps.filter((s) => s.status === "in-progress").length;
|
||||
return Math.round(((doneCount + inProgressCount * 0.5) / steps.length) * 100);
|
||||
}, [steps]);
|
||||
|
||||
const handleRetry = useCallback(() => {
|
||||
setPhase("loading");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { UserRound } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||
@@ -10,11 +11,12 @@ import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||
*/
|
||||
export function PlayerSessionBar({ className }: { className?: string }) {
|
||||
const profile = usePlayerSessionStore((s) => s.profile);
|
||||
const { t } = useTranslation("player");
|
||||
|
||||
const label =
|
||||
profile?.nickname?.trim() ||
|
||||
profile?.username?.trim() ||
|
||||
(profile?.id != null ? `玩家 #${profile.id}` : null);
|
||||
(profile?.id != null ? t("player.fallback", { id: profile.id }) : null);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { getDrawResultByNo } from "@/api/draw";
|
||||
import { getTicketDrawMyMatch } from "@/api/ticket-items";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { PlayerPanel } from "@/components/layout/player-panel";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -29,6 +31,7 @@ type DrawResultDetailScreenProps = {
|
||||
|
||||
/** §4.6 开奖结果详情:23 分区 + [< >] 切换 + 本人命中高亮 + Jackpot */
|
||||
export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps) {
|
||||
const { t } = useTranslation("player");
|
||||
const [data, setData] = useState<DrawResultDetailPayload | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -47,11 +50,11 @@ export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps)
|
||||
setData(row);
|
||||
} catch {
|
||||
setData(null);
|
||||
setError("该期开奖结果不可用或不存在");
|
||||
setError(t("results.unavailable"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [drawNo]);
|
||||
}, [drawNo, t]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
@@ -98,34 +101,37 @@ export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-7 w-48" />
|
||||
<Skeleton className="h-4 w-56" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<PlayerPanel
|
||||
title={t("results.detailTitle")}
|
||||
subtitle={drawNo}
|
||||
eyebrow={t("results.title")}
|
||||
backHref="/results"
|
||||
backLabel={t("results.title")}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-12 rounded-xl" />
|
||||
<Skeleton className="h-56 rounded-xl" />
|
||||
</div>
|
||||
</PlayerPanel>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<Card className="border-destructive/40">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">开奖结果</CardTitle>
|
||||
<CardDescription>{error ?? "无数据"}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap gap-2">
|
||||
<PlayerPanel
|
||||
title={t("results.detailTitle")}
|
||||
subtitle={drawNo}
|
||||
eyebrow={t("results.title")}
|
||||
backHref="/results"
|
||||
backLabel={t("results.title")}
|
||||
>
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-4 text-sm text-red-700">
|
||||
<p>{error ?? t("results.noData")}</p>
|
||||
<Button type="button" size="sm" variant="secondary" onClick={() => void load()}>
|
||||
重试
|
||||
{t("actions.retry")}
|
||||
</Button>
|
||||
<Link href="/results" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||||
返回列表
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</PlayerPanel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -143,60 +149,81 @@ export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps)
|
||||
myTotals.jackpot === 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<JackpotResultsStrip currencyCode={currency} />
|
||||
<PlayerPanel
|
||||
title={t("results.detailTitle")}
|
||||
subtitle={data.draw_no}
|
||||
eyebrow={t("results.title")}
|
||||
backHref="/results"
|
||||
backLabel={t("results.title")}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<JackpotResultsStrip currencyCode={currency} />
|
||||
|
||||
<Card>
|
||||
<CardHeader className="space-y-3 pb-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
{data.previous_draw_no ? (
|
||||
<Link
|
||||
href={`/results/${encodeURIComponent(data.previous_draw_no)}`}
|
||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "min-w-[5rem]")}
|
||||
>
|
||||
‹ 上一期
|
||||
</Link>
|
||||
) : (
|
||||
<Button type="button" variant="outline" size="sm" className="min-w-[5rem]" disabled>
|
||||
‹ 上一期
|
||||
</Button>
|
||||
)}
|
||||
<CardTitle className="text-center font-mono text-lg">
|
||||
{data.draw_no}
|
||||
</CardTitle>
|
||||
{data.next_draw_no ? (
|
||||
<Link
|
||||
href={`/results/${encodeURIComponent(data.next_draw_no)}`}
|
||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "min-w-[5rem]")}
|
||||
>
|
||||
下一期 ›
|
||||
</Link>
|
||||
) : (
|
||||
<Button type="button" variant="outline" size="sm" className="min-w-[5rem]" disabled>
|
||||
下一期 ›
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<CardDescription className="text-center font-mono text-sm">
|
||||
开奖时间:{" "}
|
||||
{formatLotteryInstant(data.draw_time_iso ?? data.draw_time ?? null)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-2">
|
||||
<TwentyThreeResultsGrid
|
||||
numbers={data.results}
|
||||
highlighted4d={highlightSet ?? undefined}
|
||||
/>
|
||||
<Card className="rounded-xl border-[#e5edf8] shadow-[0_8px_24px_rgba(15,23,42,0.05)]">
|
||||
<CardHeader className="space-y-3 pb-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
{data.previous_draw_no ? (
|
||||
<Link
|
||||
href={`/results/${encodeURIComponent(data.previous_draw_no)}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"min-w-[5rem]",
|
||||
)}
|
||||
>
|
||||
{t("results.previous")}
|
||||
</Link>
|
||||
) : (
|
||||
<Button type="button" variant="outline" size="sm" className="min-w-[5rem]" disabled>
|
||||
{t("results.previous")}
|
||||
</Button>
|
||||
)}
|
||||
<CardTitle className="text-center font-mono text-lg">
|
||||
{data.draw_no}
|
||||
</CardTitle>
|
||||
{data.next_draw_no ? (
|
||||
<Link
|
||||
href={`/results/${encodeURIComponent(data.next_draw_no)}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"min-w-[5rem]",
|
||||
)}
|
||||
>
|
||||
{t("results.next")}
|
||||
</Link>
|
||||
) : (
|
||||
<Button type="button" variant="outline" size="sm" className="min-w-[5rem]" disabled>
|
||||
{t("results.next")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<CardDescription className="text-center font-mono text-sm">
|
||||
{t("results.drawTime", {
|
||||
time: formatLotteryInstant(data.draw_time_iso ?? data.draw_time ?? null),
|
||||
})}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-2">
|
||||
<TwentyThreeResultsGrid
|
||||
numbers={data.results}
|
||||
highlighted4d={highlightSet ?? undefined}
|
||||
/>
|
||||
|
||||
{showMyPayout && myTotals ? (
|
||||
<div className="mt-4 rounded-md border border-emerald-500/25 bg-emerald-500/5 px-3 py-2 text-sm">
|
||||
<p className="font-medium text-emerald-900 dark:text-emerald-100">本期我的派彩</p>
|
||||
<p className="font-medium text-emerald-900 dark:text-emerald-100">
|
||||
{t("results.myPayout")}
|
||||
</p>
|
||||
<p className="mt-1 font-mono text-xs tabular-nums text-muted-foreground">
|
||||
常规:{formatMinorAsCurrency(myTotals.win, currency)}
|
||||
{t("results.regular", {
|
||||
amount: formatMinorAsCurrency(myTotals.win, currency),
|
||||
})}
|
||||
{myTotals.jackpot > 0 ? (
|
||||
<>
|
||||
{" "}
|
||||
· Jackpot:{formatMinorAsCurrency(myTotals.jackpot, currency)}
|
||||
·{" "}
|
||||
{t("results.jackpot", {
|
||||
amount: formatMinorAsCurrency(myTotals.jackpot, currency),
|
||||
})}
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
@@ -204,13 +231,13 @@ export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps)
|
||||
) : null}
|
||||
{showHitOnly ? (
|
||||
<p className="mt-3 text-xs text-amber-900/90 dark:text-amber-100/90">
|
||||
您的注单已命中本期开奖号码中的格子;派彩完成后将显示金额汇总。
|
||||
{t("results.hitPending")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 flex flex-col gap-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
如果您中奖,与注单匹配的号码将以金色高亮显示(需登录)。
|
||||
{t("results.hitHint")}
|
||||
</p>
|
||||
<Link
|
||||
href={`/orders?draw_no=${encodeURIComponent(data.draw_no)}`}
|
||||
@@ -219,11 +246,12 @@ export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps)
|
||||
"w-full sm:w-auto sm:self-start",
|
||||
)}
|
||||
>
|
||||
查看我的中奖情况
|
||||
{t("results.viewMyWinning")}
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</PlayerPanel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { getDrawResults } from "@/api/draw";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -13,6 +14,7 @@ import { formatLotteryInstant } from "@/lib/player-datetime";
|
||||
import type { DrawResultListItem } from "@/types/api/draw-results";
|
||||
|
||||
export function DrawResultsListScreen() {
|
||||
const { t } = useTranslation("player");
|
||||
const [items, setItems] = useState<DrawResultListItem[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [date, setDate] = useState("");
|
||||
@@ -29,12 +31,12 @@ export function DrawResultsListScreen() {
|
||||
});
|
||||
setItems(res.items);
|
||||
} catch {
|
||||
setError("加载失败");
|
||||
setError(t("results.loadFailed"));
|
||||
setItems(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [date]);
|
||||
}, [date, t]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
@@ -43,12 +45,14 @@ export function DrawResultsListScreen() {
|
||||
}, [fetchList]);
|
||||
|
||||
return (
|
||||
<PlayerPanel title="Results" subtitle="Latest draw history" eyebrow="N lotto">
|
||||
<PlayerPanel title={t("results.title")} subtitle={t("results.subtitle")} eyebrow={t("brand.name")}>
|
||||
<div className="space-y-4">
|
||||
<JackpotResultsStrip currencyCode="NPR" />
|
||||
|
||||
<div className="rounded-xl border border-[#e6edf8] bg-[#f8fbff] p-3">
|
||||
<p className="mb-2 text-xs font-bold text-[#32518d]">Business Date</p>
|
||||
<p className="mb-2 text-xs font-bold text-[#32518d]">
|
||||
{t("results.businessDate")}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="date"
|
||||
@@ -62,7 +66,7 @@ export function DrawResultsListScreen() {
|
||||
className="h-10 rounded-lg bg-[#07459f] px-4 text-white hover:bg-[#063b88]"
|
||||
onClick={() => void fetchList()}
|
||||
>
|
||||
Apply
|
||||
{t("actions.apply")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -82,12 +86,12 @@ export function DrawResultsListScreen() {
|
||||
className="mt-3 bg-[#e5002c] text-white hover:bg-[#d10028]"
|
||||
onClick={() => void fetchList()}
|
||||
>
|
||||
Retry
|
||||
{t("actions.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
) : items && items.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-[#dce7f7] bg-[#f8fbff] px-4 py-10 text-center text-sm text-slate-500">
|
||||
No results yet.
|
||||
{t("results.empty")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
@@ -107,7 +111,7 @@ export function DrawResultsListScreen() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="shrink-0 rounded-full bg-[#f2f6ff] px-2.5 py-1 text-xs font-bold text-[#0b56b7]">
|
||||
Detail
|
||||
{t("results.detail")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { getJackpotSummary } from "@/api/jackpot";
|
||||
import { formatMinorAsCurrency } from "@/lib/money";
|
||||
@@ -13,6 +14,7 @@ type JackpotResultsStripProps = {
|
||||
export function JackpotResultsStrip({
|
||||
currencyCode = "NPR",
|
||||
}: JackpotResultsStripProps) {
|
||||
const { t } = useTranslation("player");
|
||||
const [minor, setMinor] = useState<number | null>(null);
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
|
||||
@@ -44,7 +46,7 @@ export function JackpotResultsStrip({
|
||||
return (
|
||||
<div className="rounded-xl border border-amber-200 bg-gradient-to-r from-amber-100 via-white to-[#f8fbff] px-3 py-3 shadow-[0_8px_20px_rgba(180,83,9,0.08)]">
|
||||
<p className="text-[11px] font-black uppercase tracking-normal text-amber-700">
|
||||
Jackpot
|
||||
{t("results.jackpotLabel")}
|
||||
</p>
|
||||
<p className="font-mono text-lg font-black tabular-nums text-[#0b3f96]">
|
||||
{formatMinorAsCurrency(minor, currencyCode.toUpperCase())}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { DrawResultsNumbers } from "@/types/api/draw-results";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { norm4d } from "@/lib/norm-4d";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -15,6 +17,7 @@ export function TwentyThreeResultsGrid({
|
||||
numbers,
|
||||
highlighted4d,
|
||||
}: TwentyThreeResultsGridProps) {
|
||||
const { t } = useTranslation("player");
|
||||
const starters = numbers.starter ?? [];
|
||||
const consos = numbers.consolation ?? [];
|
||||
const hits = highlighted4d ?? null;
|
||||
@@ -44,7 +47,11 @@ export function TwentyThreeResultsGrid({
|
||||
{(["1st", "2nd", "3rd"] as const).map((key) => (
|
||||
<div key={key} className="flex flex-col gap-1.5 text-center">
|
||||
<span className="text-xs font-medium uppercase text-muted-foreground">
|
||||
{key === "1st" ? "头奖" : key === "2nd" ? "二奖" : "三奖"}
|
||||
{key === "1st"
|
||||
? t("results.grid.first")
|
||||
: key === "2nd"
|
||||
? t("results.grid.second")
|
||||
: t("results.grid.third")}
|
||||
</span>
|
||||
<div className={cellTone(numbers[key] || "")}>
|
||||
{numbers[key] || "—"}
|
||||
@@ -54,7 +61,9 @@ export function TwentyThreeResultsGrid({
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-foreground">特别奖 (Starter)</p>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{t("results.grid.starter")} (Starter)
|
||||
</p>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<div key={`s-${i}`} className={cellTone(starters[i] ?? "—")}>
|
||||
@@ -65,7 +74,9 @@ export function TwentyThreeResultsGrid({
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-foreground">安慰奖 (Consolation)</p>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{t("results.grid.consolation")} (Consolation)
|
||||
</p>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<div key={`c-${i}`} className={cellTone(consos[i] ?? "—")}>
|
||||
|
||||
@@ -1,47 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { formatLocalDateTime } from "@/lib/format-local-datetime";
|
||||
import { formatMinorAsCurrency } from "@/lib/money";
|
||||
import type { WalletLogItem, WalletLogsData } from "@/types/api/wallet-logs";
|
||||
|
||||
/** 与 §4.9 筛选一致;接口 `type` 查询参数 */
|
||||
export const WALLET_FLOW_FILTERS: { value: string; label: string }[] = [
|
||||
{ value: "", label: "全部" },
|
||||
{ value: "transfer_in", label: "转入" },
|
||||
{ value: "transfer_out", label: "转出" },
|
||||
{ value: "bet", label: "下注扣款" },
|
||||
{ value: "prize", label: "派彩" },
|
||||
{ value: "refund", label: "退本" },
|
||||
{ value: "reversal", label: "冲正" },
|
||||
export const WALLET_FLOW_FILTERS: { value: string; labelKey: string }[] = [
|
||||
{ value: "", labelKey: "wallet.flow.all" },
|
||||
{ value: "transfer_in", labelKey: "wallet.flow.transfer_in" },
|
||||
{ value: "transfer_out", labelKey: "wallet.flow.transfer_out" },
|
||||
{ value: "bet", labelKey: "wallet.flow.bet" },
|
||||
{ value: "prize", labelKey: "wallet.flow.prize" },
|
||||
{ value: "refund", labelKey: "wallet.flow.refund" },
|
||||
{ value: "reversal", labelKey: "wallet.flow.reversal" },
|
||||
];
|
||||
|
||||
export function logTypeLabel(t: string): string {
|
||||
const map: Record<string, string> = {
|
||||
transfer_in: "转入",
|
||||
transfer_out: "转出",
|
||||
refund: "退本",
|
||||
reversal: "冲正",
|
||||
bet: "下注扣款",
|
||||
prize: "派彩",
|
||||
};
|
||||
return map[t] ?? t;
|
||||
export function logTypeLabel(
|
||||
type: string,
|
||||
t?: (key: string, options?: { defaultValue?: string }) => string,
|
||||
): string {
|
||||
return t?.(`wallet.flow.${type}`, { defaultValue: type }) ?? type;
|
||||
}
|
||||
|
||||
function txnStatusLabel(status: string): string {
|
||||
if (status === "posted") return "成功";
|
||||
if (status === "pending_reconcile") return "待对账";
|
||||
if (status === "reversed") return "已冲正";
|
||||
if (status === "manually_processed") return "已人工处理";
|
||||
return status;
|
||||
function txnStatusLabel(
|
||||
status: string,
|
||||
t: (key: string, options?: { defaultValue?: string }) => string,
|
||||
): string {
|
||||
return t(`wallet.txnStatus.${status}`, { defaultValue: status });
|
||||
}
|
||||
|
||||
type WalletLogsBlockProps = {
|
||||
@@ -61,47 +51,59 @@ export function WalletLogsBlock({
|
||||
filter,
|
||||
onFilterChange,
|
||||
currency,
|
||||
title = "资金流水",
|
||||
title,
|
||||
}: WalletLogsBlockProps) {
|
||||
const { t } = useTranslation("player");
|
||||
const resolvedTitle = title ?? t("wallet.flowsTitle");
|
||||
const filters = useMemo(
|
||||
() =>
|
||||
WALLET_FLOW_FILTERS.map((f) => ({
|
||||
...f,
|
||||
label: t(f.labelKey),
|
||||
})),
|
||||
[t],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{logs && logs.pending_reconcile.length > 0 ? (
|
||||
<Card className="border-[#faad14]/50 bg-[#faad14]/5">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm text-[#d48806]">待对账</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
以下划转主站结果未最终确认;若长时间未到账请联系客服(界面文档 §4.10
|
||||
超时说明)。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
<section className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-3">
|
||||
<p className="text-sm font-black text-amber-700">{t("wallet.pendingTitle")}</p>
|
||||
<p className="mt-1 text-xs text-amber-700/80">
|
||||
{t("wallet.pendingDescription")}
|
||||
</p>
|
||||
<div className="mt-2 space-y-2 text-sm">
|
||||
{logs.pending_reconcile.map((p) => (
|
||||
<div
|
||||
key={p.transfer_no}
|
||||
className="flex flex-wrap items-baseline justify-between gap-1 border-b border-dashed border-border py-2 last:border-0"
|
||||
>
|
||||
<span className="text-muted-foreground">
|
||||
{p.type === "transfer_in" ? "转入" : "转出"}{" "}
|
||||
{logTypeLabel(p.type, t)}{" "}
|
||||
{formatMinorAsCurrency(p.amount, p.currency_code)}
|
||||
</span>
|
||||
<span className="text-xs text-amber-700">处理中</span>
|
||||
<span className="text-xs text-amber-700">{t("wallet.pendingStatus")}</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="space-y-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="text-sm font-medium">{title}</h2>
|
||||
<h2 className="text-sm font-black text-[#0b3f96]">{resolvedTitle}</h2>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{WALLET_FLOW_FILTERS.map((f) => (
|
||||
{filters.map((f) => (
|
||||
<Button
|
||||
key={f.value || "all"}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={filter === f.value ? "default" : "outline"}
|
||||
className="h-8 rounded-full text-xs"
|
||||
className={
|
||||
filter === f.value
|
||||
? "h-8 rounded-full bg-[#07459f] px-3 text-xs font-bold text-white hover:bg-[#063b88]"
|
||||
: "h-8 rounded-full border-[#dce7f7] bg-white px-3 text-xs font-bold text-[#32518d] hover:bg-[#f8fbff]"
|
||||
}
|
||||
onClick={() => onFilterChange(f.value)}
|
||||
>
|
||||
{f.label}
|
||||
@@ -117,12 +119,12 @@ export function WalletLogsBlock({
|
||||
{logs ? (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
共 {logs.total} 条记录
|
||||
{t("wallet.totalRecords", { total: logs.total })}
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{logs.items.length === 0 ? (
|
||||
<li className="rounded-lg border border-dashed py-8 text-center text-sm text-muted-foreground">
|
||||
暂无流水
|
||||
{t("wallet.emptyLogs")}
|
||||
</li>
|
||||
) : (
|
||||
logs.items.map((row) => (
|
||||
@@ -144,15 +146,16 @@ export function LogRow({
|
||||
item: WalletLogItem;
|
||||
currency: string;
|
||||
}) {
|
||||
const { t } = useTranslation("player");
|
||||
const ccy = item.currency_code || currency;
|
||||
const isIn = item.direction === "in";
|
||||
return (
|
||||
<li className="rounded-xl border bg-card px-3 py-2.5 text-sm shadow-sm">
|
||||
<li className="rounded-xl border border-[#e5edf8] bg-white px-3 py-3 text-sm shadow-[0_8px_24px_rgba(15,23,42,0.05)]">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<span className="font-medium">
|
||||
{logTypeLabel(item.type)}{" "}
|
||||
<span className={isIn ? "text-[#52c41a]" : "text-foreground"}>
|
||||
{logTypeLabel(item.type, t)}{" "}
|
||||
<span className={isIn ? "font-black text-emerald-600" : "font-black text-[#0b3f96]"}>
|
||||
{isIn ? "+" : "−"}
|
||||
{formatMinorAsCurrency(item.amount_abs, ccy)}
|
||||
</span>
|
||||
@@ -160,7 +163,7 @@ export function LogRow({
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{formatLocalDateTime(item.created_at)}{" "}
|
||||
<span className="text-foreground/80">
|
||||
· {txnStatusLabel(item.status)}
|
||||
· {txnStatusLabel(item.status, t)}
|
||||
</span>
|
||||
</p>
|
||||
{item.ref_id ? (
|
||||
|
||||
@@ -1,26 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { getWalletLogs } from "@/api/wallet";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PlayerPanel } from "@/components/layout/player-panel";
|
||||
import { WalletLogsBlock } from "@/features/wallet/wallet-logs-block";
|
||||
import { formatWalletClientError } from "@/lib/wallet-api-error";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||
import type { WalletLogsData } from "@/types/api/wallet-logs";
|
||||
|
||||
/** 独立路由 `/wallet/logs` */
|
||||
export function WalletLogsScreen() {
|
||||
const profile = usePlayerSessionStore((s) => s.profile);
|
||||
const { t } = useTranslation("player");
|
||||
const [logs, setLogs] = useState<WalletLogsData | null>(null);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -50,12 +43,12 @@ export function WalletLogsScreen() {
|
||||
});
|
||||
setLogs(L);
|
||||
} catch (e) {
|
||||
setError(formatWalletClientError(e));
|
||||
setError(formatWalletClientError(e, t));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLogsLoading(false);
|
||||
}
|
||||
}, [filter]);
|
||||
}, [filter, t]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
@@ -64,39 +57,36 @@ export function WalletLogsScreen() {
|
||||
}, [load]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h1 className="text-lg font-semibold tracking-tight">资金流水</h1>
|
||||
<Link
|
||||
href="/wallet"
|
||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
|
||||
>
|
||||
返回钱包概览
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<Card className="border-destructive/40">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive">加载失败</CardTitle>
|
||||
<CardDescription>{error}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button type="button" onClick={() => void load()}>
|
||||
重试
|
||||
<PlayerPanel
|
||||
title={t("wallet.logsTitle")}
|
||||
subtitle={t("wallet.logsSubtitle")}
|
||||
eyebrow={t("brand.name")}
|
||||
backHref="/wallet"
|
||||
backLabel={t("wallet.title")}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{error ? (
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-4 text-sm text-red-700">
|
||||
<p>{error}</p>
|
||||
<Button
|
||||
type="button"
|
||||
className="mt-3 bg-[#e5002c] text-white hover:bg-[#d10028]"
|
||||
onClick={() => void load()}
|
||||
>
|
||||
{t("actions.retry")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<WalletLogsBlock
|
||||
logs={logs}
|
||||
logsLoading={loading || logsLoading}
|
||||
filter={filter}
|
||||
onFilterChange={setFilter}
|
||||
currency={currency}
|
||||
title="类型筛选"
|
||||
/>
|
||||
</div>
|
||||
<WalletLogsBlock
|
||||
logs={logs}
|
||||
logsLoading={loading || logsLoading}
|
||||
filter={filter}
|
||||
onFilterChange={setFilter}
|
||||
currency={currency}
|
||||
title={t("wallet.typeFilter")}
|
||||
/>
|
||||
</div>
|
||||
</PlayerPanel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,17 +3,12 @@
|
||||
import { Wallet } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { getWalletBalance, getWalletLogs } from "@/api/wallet";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { PlayerPanel } from "@/components/layout/player-panel";
|
||||
import {
|
||||
TransferInDialog,
|
||||
TransferOutDialog,
|
||||
@@ -21,14 +16,13 @@ import {
|
||||
import { WalletLogsBlock } from "@/features/wallet/wallet-logs-block";
|
||||
import { formatMinorAsCurrency } from "@/lib/money";
|
||||
import { formatWalletClientError } from "@/lib/wallet-api-error";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||
import type { WalletLogsData } from "@/types/api/wallet-logs";
|
||||
import type { WalletBalanceData } from "@/types/api/wallet-balance";
|
||||
import type { WalletLogsData } from "@/types/api/wallet-logs";
|
||||
|
||||
export function WalletScreen() {
|
||||
const profile = usePlayerSessionStore((s) => s.profile);
|
||||
|
||||
const { t } = useTranslation("player");
|
||||
const [balance, setBalance] = useState<WalletBalanceData | null>(null);
|
||||
const [logs, setLogs] = useState<WalletLogsData | null>(null);
|
||||
const [filter, setFilter] = useState("");
|
||||
@@ -70,7 +64,7 @@ export function WalletScreen() {
|
||||
setLogs(L);
|
||||
} catch (e) {
|
||||
if (!cancelled) {
|
||||
setError(formatWalletClientError(e));
|
||||
setError(formatWalletClientError(e, t));
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
@@ -83,7 +77,7 @@ export function WalletScreen() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [filter]);
|
||||
}, [filter, t]);
|
||||
|
||||
const refreshAll = useCallback(async () => {
|
||||
setError(null);
|
||||
@@ -98,138 +92,102 @@ export function WalletScreen() {
|
||||
});
|
||||
setLogs(L);
|
||||
} catch (e) {
|
||||
setError(formatWalletClientError(e));
|
||||
setError(formatWalletClientError(e, t));
|
||||
} finally {
|
||||
setLogsLoading(false);
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filter]);
|
||||
}, [filter, t]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold tracking-tight">彩票钱包</h1>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<Link
|
||||
href="/wallet/transfer-in"
|
||||
className={cn(
|
||||
buttonVariants({ variant: "secondary", size: "sm" }),
|
||||
"text-xs",
|
||||
)}
|
||||
<PlayerPanel title={t("wallet.title")} subtitle={t("wallet.subtitle")} eyebrow={t("brand.name")}>
|
||||
<div className="space-y-4">
|
||||
{error ? (
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-4 text-sm text-red-700">
|
||||
<p>{error}</p>
|
||||
<Button
|
||||
type="button"
|
||||
className="mt-3 bg-[#e5002c] text-white hover:bg-[#d10028]"
|
||||
onClick={() => void refreshAll()}
|
||||
>
|
||||
转入页
|
||||
</Link>
|
||||
<Link
|
||||
href="/wallet/transfer-out"
|
||||
className={cn(
|
||||
buttonVariants({ variant: "secondary", size: "sm" }),
|
||||
"text-xs",
|
||||
)}
|
||||
>
|
||||
转出页
|
||||
</Link>
|
||||
<Link
|
||||
href="/wallet/logs"
|
||||
className={cn(
|
||||
buttonVariants({ variant: "secondary", size: "sm" }),
|
||||
"text-xs",
|
||||
)}
|
||||
>
|
||||
流水页
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href="/hall"
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"shrink-0 self-start",
|
||||
)}
|
||||
>
|
||||
返回大厅
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<Card className="border-destructive/40">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive">加载失败</CardTitle>
|
||||
<CardDescription>{error}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button type="button" onClick={() => void refreshAll()}>
|
||||
重试
|
||||
{t("actions.retry")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Wallet className="size-5 opacity-80" aria-hidden />
|
||||
余额
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{loading ? (
|
||||
<Skeleton className="h-12 w-full max-w-xs rounded-lg" />
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">彩票钱包余额</p>
|
||||
<p className="font-heading text-2xl font-semibold tabular-nums text-[#52c41a]">
|
||||
{formatMinorAsCurrency(
|
||||
balance?.balance ?? 0,
|
||||
currency,
|
||||
)}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
可用{" "}
|
||||
{formatMinorAsCurrency(
|
||||
balance?.available_balance ?? 0,
|
||||
currency,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
|
||||
主站钱包余额{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{balance?.main_balance == null
|
||||
? "—(待接入主站)"
|
||||
: formatMinorAsCurrency(balance.main_balance, currency)}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<TransferInDialog
|
||||
idPrefix="wallet-"
|
||||
currency={currency}
|
||||
lotteryMinor={Number(balance?.balance ?? 0)}
|
||||
onSuccess={refreshAll}
|
||||
triggerVariant="wallet"
|
||||
/>
|
||||
<TransferOutDialog
|
||||
idPrefix="wallet-"
|
||||
currency={currency}
|
||||
availableMinor={Number(balance?.available_balance ?? 0)}
|
||||
onSuccess={refreshAll}
|
||||
triggerVariant="wallet"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<WalletLogsBlock
|
||||
logs={logs}
|
||||
logsLoading={loading || logsLoading}
|
||||
filter={filter}
|
||||
onFilterChange={setFilter}
|
||||
currency={currency}
|
||||
/>
|
||||
</div>
|
||||
<section className="relative overflow-hidden rounded-xl bg-[#e5002c] px-4 py-5 text-white shadow-[0_10px_28px_rgba(229,0,44,0.25)]">
|
||||
<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">
|
||||
<Wallet className="size-7" aria-hidden />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold text-white/90">{t("wallet.balance")}</p>
|
||||
{loading ? (
|
||||
<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">
|
||||
{formatMinorAsCurrency(balance?.balance ?? 0, currency)}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-white/75">
|
||||
{t("wallet.available", {
|
||||
amount: formatMinorAsCurrency(balance?.available_balance ?? 0, currency),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<TransferInDialog
|
||||
idPrefix="wallet-"
|
||||
currency={currency}
|
||||
lotteryMinor={Number(balance?.balance ?? 0)}
|
||||
onSuccess={refreshAll}
|
||||
triggerVariant="hall"
|
||||
triggerLabel={t("wallet.transferIn")}
|
||||
triggerClassName="h-12 rounded-lg text-base font-bold"
|
||||
/>
|
||||
<TransferOutDialog
|
||||
idPrefix="wallet-"
|
||||
currency={currency}
|
||||
availableMinor={Number(balance?.available_balance ?? 0)}
|
||||
onSuccess={refreshAll}
|
||||
triggerVariant="hall"
|
||||
triggerLabel={t("wallet.transferOut")}
|
||||
triggerClassName="h-12 rounded-lg text-base font-bold"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 text-center text-xs font-bold">
|
||||
<Link
|
||||
className="rounded-lg border border-[#e5edf8] bg-[#f8fbff] py-2 text-[#0b56b7]"
|
||||
href="/wallet/transfer-in"
|
||||
>
|
||||
{t("wallet.inPage")}
|
||||
</Link>
|
||||
<Link
|
||||
className="rounded-lg border border-[#e5edf8] bg-[#f8fbff] py-2 text-[#0b56b7]"
|
||||
href="/wallet/transfer-out"
|
||||
>
|
||||
{t("wallet.outPage")}
|
||||
</Link>
|
||||
<Link
|
||||
className="rounded-lg border border-[#e5edf8] bg-[#f8fbff] py-2 text-[#0b56b7]"
|
||||
href="/wallet/logs"
|
||||
>
|
||||
{t("wallet.logs")}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<WalletLogsBlock
|
||||
logs={logs}
|
||||
logsLoading={loading || logsLoading}
|
||||
filter={filter}
|
||||
onFilterChange={setFilter}
|
||||
currency={currency}
|
||||
/>
|
||||
</div>
|
||||
</PlayerPanel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { ArrowDownLeft, ArrowUpRight } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -42,7 +43,7 @@ export function TransferInDialog({
|
||||
idPrefix = "",
|
||||
triggerClassName,
|
||||
triggerVariant = "wallet",
|
||||
triggerLabel = "转入",
|
||||
triggerLabel,
|
||||
}: BaseProps & {
|
||||
lotteryMinor: number;
|
||||
triggerClassName?: string;
|
||||
@@ -50,6 +51,8 @@ export function TransferInDialog({
|
||||
triggerLabel?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { t } = useTranslation("player");
|
||||
const resolvedTriggerLabel = triggerLabel ?? t("wallet.transferIn");
|
||||
|
||||
const triggerCombined = cn(
|
||||
"inline-flex h-10 min-h-10 w-full min-w-0 flex-1 items-center justify-center gap-1.5 px-3 text-sm font-medium",
|
||||
@@ -66,14 +69,13 @@ export function TransferInDialog({
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<ArrowDownLeft className="size-4 shrink-0" />
|
||||
{triggerLabel}
|
||||
{resolvedTriggerLabel}
|
||||
</Button>
|
||||
<DialogContent showCloseButton>
|
||||
<DialogHeader>
|
||||
<DialogTitle>转入资金</DialogTitle>
|
||||
<DialogTitle>{t("wallet.transferInTitle")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
从主站钱包划入彩票钱包(最小单笔以服务端校验为准,默认约 1.00{" "}
|
||||
{currency})。
|
||||
{t("wallet.dialogInDescription", { currency })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<TransferInPanel
|
||||
@@ -99,7 +101,7 @@ export function TransferOutDialog({
|
||||
idPrefix = "",
|
||||
triggerClassName,
|
||||
triggerVariant = "wallet",
|
||||
triggerLabel = "转出",
|
||||
triggerLabel,
|
||||
}: BaseProps & {
|
||||
availableMinor: number;
|
||||
triggerClassName?: string;
|
||||
@@ -107,6 +109,8 @@ export function TransferOutDialog({
|
||||
triggerLabel?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { t } = useTranslation("player");
|
||||
const resolvedTriggerLabel = triggerLabel ?? t("wallet.transferOut");
|
||||
|
||||
const triggerCombined = cn(
|
||||
"inline-flex h-10 min-h-10 w-full min-w-0 flex-1 items-center justify-center gap-1.5 px-3 text-sm font-medium",
|
||||
@@ -128,13 +132,13 @@ export function TransferOutDialog({
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<ArrowUpRight className="size-4 shrink-0" />
|
||||
{triggerLabel}
|
||||
{resolvedTriggerLabel}
|
||||
</Button>
|
||||
<DialogContent showCloseButton>
|
||||
<DialogHeader>
|
||||
<DialogTitle>转出资金</DialogTitle>
|
||||
<DialogTitle>{t("wallet.transferOutTitle")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
划回主站钱包;单笔限额以服务端校验为准。
|
||||
{t("wallet.dialogOutDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<TransferOutPanel
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { isAxiosError } from "axios";
|
||||
import { ChevronLeft, Loader2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { postWalletTransferIn, postWalletTransferOut } from "@/api/wallet";
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { PlayerPanel } from "@/components/layout/player-panel";
|
||||
import { formatMinorAsCurrency, parseDecimalInputToMinor } from "@/lib/money";
|
||||
import { formatWalletClientError } from "@/lib/wallet-api-error";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
@@ -25,14 +26,15 @@ import { LotteryApiBizError } from "@/types/api/errors";
|
||||
async function handleTransferMaybePending(
|
||||
e: unknown,
|
||||
onRefresh: () => Promise<void>,
|
||||
t: (key: string) => string,
|
||||
): Promise<boolean> {
|
||||
if (e instanceof LotteryApiBizError && e.code === 1002) {
|
||||
toast.message(e.message || "处理中…");
|
||||
toast.message(e.message || t("wallet.pendingToast"));
|
||||
await onRefresh();
|
||||
return true;
|
||||
}
|
||||
if (isAxiosError(e) && e.response?.status === 409) {
|
||||
toast.message("转账处理中,请稍后刷新。");
|
||||
toast.message(t("wallet.pendingShort"));
|
||||
await onRefresh();
|
||||
return true;
|
||||
}
|
||||
@@ -61,6 +63,7 @@ export function TransferInPanel({
|
||||
onCancel: () => void;
|
||||
variant?: PanelVariant;
|
||||
}) {
|
||||
const { t } = useTranslation("player");
|
||||
const [amountText, setAmountText] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
@@ -76,7 +79,7 @@ export function TransferInPanel({
|
||||
const submit = async () => {
|
||||
setLocalError(null);
|
||||
if (parsedMinor == null || parsedMinor < 1) {
|
||||
setLocalError("请输入有效金额。");
|
||||
setLocalError(t("wallet.invalidAmount"));
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
@@ -86,15 +89,15 @@ export function TransferInPanel({
|
||||
currency,
|
||||
idempotent_key: crypto.randomUUID(),
|
||||
});
|
||||
toast.success("转入成功,彩票钱包余额已更新。");
|
||||
toast.success(t("wallet.successIn"));
|
||||
setAmountText("");
|
||||
await onSuccess();
|
||||
} catch (e) {
|
||||
if (await handleTransferMaybePending(e, onSuccess)) {
|
||||
setLocalError(formatWalletClientError(e));
|
||||
if (await handleTransferMaybePending(e, onSuccess, t)) {
|
||||
setLocalError(formatWalletClientError(e, t));
|
||||
return;
|
||||
}
|
||||
setLocalError(formatWalletClientError(e));
|
||||
setLocalError(formatWalletClientError(e, t));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -104,17 +107,17 @@ export function TransferInPanel({
|
||||
variant === "page" ? (
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
className="h-11 w-full rounded-lg bg-[#07459f] text-base font-bold text-white hover:bg-[#063b88]"
|
||||
disabled={submitting}
|
||||
onClick={() => void submit()}
|
||||
>
|
||||
{submitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
处理中…
|
||||
{t("actions.processing")}
|
||||
</>
|
||||
) : (
|
||||
"确认转入"
|
||||
t("wallet.confirmIn")
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
@@ -125,7 +128,7 @@ export function TransferInPanel({
|
||||
disabled={submitting}
|
||||
onClick={onCancel}
|
||||
>
|
||||
取消
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -135,10 +138,10 @@ export function TransferInPanel({
|
||||
{submitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
处理中…
|
||||
{t("actions.processing")}
|
||||
</>
|
||||
) : (
|
||||
"确认转入"
|
||||
t("wallet.confirmIn")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -147,32 +150,34 @@ export function TransferInPanel({
|
||||
return (
|
||||
<>
|
||||
<div className="grid gap-3 py-1">
|
||||
<div className="rounded-lg bg-muted/50 px-3 py-2 text-xs">
|
||||
<div className="rounded-xl border border-[#e5edf8] bg-[#f8fbff] px-3 py-2 text-xs">
|
||||
<p>
|
||||
主站钱包余额:{" "}
|
||||
<span className="text-muted-foreground">—(待接入主站)</span>
|
||||
{t("wallet.mainBalance")}{" "}
|
||||
<span className="text-muted-foreground">{t("wallet.mainPending")}</span>
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
彩票钱包余额:{" "}
|
||||
{t("wallet.lotteryBalance")}{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{formatMinorAsCurrency(lotteryMinor, currency)}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={tid}>转入金额</Label>
|
||||
<Label htmlFor={tid}>{t("wallet.inAmount")}</Label>
|
||||
<Input
|
||||
id={tid}
|
||||
inputMode="decimal"
|
||||
placeholder="例如 1000.00"
|
||||
placeholder={t("wallet.exampleIn")}
|
||||
value={amountText}
|
||||
onChange={(ev) => setAmountText(ev.target.value)}
|
||||
disabled={submitting}
|
||||
autoComplete="off"
|
||||
className="h-11 rounded-lg border-[#dce7f7] bg-white text-base"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
转入后彩票余额(预览):{" "}
|
||||
{formatMinorAsCurrency(previewAfter, currency)}
|
||||
{t("wallet.afterInPreview", {
|
||||
amount: formatMinorAsCurrency(previewAfter, currency),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
{localError ? (
|
||||
@@ -196,6 +201,7 @@ export function TransferOutPanel({
|
||||
onCancel: () => void;
|
||||
variant?: PanelVariant;
|
||||
}) {
|
||||
const { t } = useTranslation("player");
|
||||
const [amountText, setAmountText] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
@@ -218,11 +224,11 @@ export function TransferOutPanel({
|
||||
const submit = async () => {
|
||||
setLocalError(null);
|
||||
if (parsedMinor == null || parsedMinor < 1) {
|
||||
setLocalError("请输入有效金额。");
|
||||
setLocalError(t("wallet.invalidAmount"));
|
||||
return;
|
||||
}
|
||||
if (parsedMinor > availableMinor) {
|
||||
setLocalError("转出金额不能超过可用余额。");
|
||||
setLocalError(t("wallet.outExceeds"));
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
@@ -232,15 +238,15 @@ export function TransferOutPanel({
|
||||
currency,
|
||||
idempotent_key: crypto.randomUUID(),
|
||||
});
|
||||
toast.success("转出成功,资金将返回主站钱包。");
|
||||
toast.success(t("wallet.successOut"));
|
||||
setAmountText("");
|
||||
await onSuccess();
|
||||
} catch (e) {
|
||||
if (await handleTransferMaybePending(e, onSuccess)) {
|
||||
setLocalError(formatWalletClientError(e));
|
||||
if (await handleTransferMaybePending(e, onSuccess, t)) {
|
||||
setLocalError(formatWalletClientError(e, t));
|
||||
return;
|
||||
}
|
||||
setLocalError(formatWalletClientError(e));
|
||||
setLocalError(formatWalletClientError(e, t));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -250,17 +256,17 @@ export function TransferOutPanel({
|
||||
variant === "page" ? (
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
className="h-11 w-full rounded-lg bg-[#e5002c] text-base font-bold text-white hover:bg-[#d10028]"
|
||||
disabled={submitting}
|
||||
onClick={() => void submit()}
|
||||
>
|
||||
{submitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
处理中…
|
||||
{t("actions.processing")}
|
||||
</>
|
||||
) : (
|
||||
"确认转出"
|
||||
t("wallet.confirmOut")
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
@@ -271,7 +277,7 @@ export function TransferOutPanel({
|
||||
disabled={submitting}
|
||||
onClick={onCancel}
|
||||
>
|
||||
取消
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -281,10 +287,10 @@ export function TransferOutPanel({
|
||||
{submitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
处理中…
|
||||
{t("actions.processing")}
|
||||
</>
|
||||
) : (
|
||||
"确认转出"
|
||||
t("wallet.confirmOut")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -293,9 +299,9 @@ export function TransferOutPanel({
|
||||
return (
|
||||
<>
|
||||
<div className="grid gap-3 py-1">
|
||||
<div className="rounded-lg bg-muted/50 px-3 py-2 text-xs">
|
||||
<div className="rounded-xl border border-[#e5edf8] bg-[#f8fbff] px-3 py-2 text-xs">
|
||||
<p>
|
||||
彩票钱包可用:{" "}
|
||||
{t("wallet.lotteryAvailable")}{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{formatMinorAsCurrency(availableMinor, currency)}
|
||||
</span>
|
||||
@@ -303,7 +309,7 @@ export function TransferOutPanel({
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-end justify-between gap-2">
|
||||
<Label htmlFor={tid}>转出金额</Label>
|
||||
<Label htmlFor={tid}>{t("wallet.outAmount")}</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
@@ -311,22 +317,25 @@ export function TransferOutPanel({
|
||||
onClick={fillAll}
|
||||
disabled={submitting || availableMinor < 1}
|
||||
>
|
||||
全部转出{" "}
|
||||
{formatMinorAsCurrency(availableMinor, currency)}
|
||||
{t("wallet.allOut", {
|
||||
amount: formatMinorAsCurrency(availableMinor, currency),
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
id={tid}
|
||||
inputMode="decimal"
|
||||
placeholder="例如 500.00"
|
||||
placeholder={t("wallet.exampleOut")}
|
||||
value={amountText}
|
||||
onChange={(ev) => setAmountText(ev.target.value)}
|
||||
disabled={submitting}
|
||||
autoComplete="off"
|
||||
className="h-11 rounded-lg border-[#dce7f7] bg-white text-base"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
转出后彩票余额(预览):{" "}
|
||||
{formatMinorAsCurrency(previewAfter, currency)}
|
||||
{t("wallet.afterOutPreview", {
|
||||
amount: formatMinorAsCurrency(previewAfter, currency),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
{localError ? (
|
||||
@@ -344,21 +353,21 @@ export function TransferInPage({
|
||||
lotteryMinor,
|
||||
onSuccess,
|
||||
}: PanelBase & { lotteryMinor: number }) {
|
||||
const { t } = useTranslation("player");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Link
|
||||
href="/wallet"
|
||||
className="inline-flex w-fit items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
返回钱包
|
||||
</Link>
|
||||
<Card>
|
||||
<PlayerPanel
|
||||
title={t("wallet.transferInTitle")}
|
||||
subtitle={t("wallet.transferInSubtitle", { currency })}
|
||||
eyebrow={t("wallet.title")}
|
||||
backHref="/wallet"
|
||||
backLabel={t("wallet.title")}
|
||||
>
|
||||
<Card className="rounded-xl border-[#e5edf8] shadow-[0_8px_24px_rgba(15,23,42,0.05)]">
|
||||
<CardHeader>
|
||||
<CardTitle>转入资金</CardTitle>
|
||||
<CardTitle className="text-[#0b3f96]">{t("wallet.transferInTitle")}</CardTitle>
|
||||
<CardDescription>
|
||||
从主站钱包划入彩票钱包(最小单笔以服务端校验为准,默认约 1.00{" "}
|
||||
{currency})。
|
||||
{t("wallet.transferInDescription")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -372,7 +381,7 @@ export function TransferInPage({
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</PlayerPanel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -382,20 +391,21 @@ export function TransferOutPage({
|
||||
availableMinor,
|
||||
onSuccess,
|
||||
}: PanelBase & { availableMinor: number }) {
|
||||
const { t } = useTranslation("player");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Link
|
||||
href="/wallet"
|
||||
className="inline-flex w-fit items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
返回钱包
|
||||
</Link>
|
||||
<Card>
|
||||
<PlayerPanel
|
||||
title={t("wallet.transferOutTitle")}
|
||||
subtitle={t("wallet.transferOutSubtitle", { currency })}
|
||||
eyebrow={t("wallet.title")}
|
||||
backHref="/wallet"
|
||||
backLabel={t("wallet.title")}
|
||||
>
|
||||
<Card className="rounded-xl border-[#e5edf8] shadow-[0_8px_24px_rgba(15,23,42,0.05)]">
|
||||
<CardHeader>
|
||||
<CardTitle>转出资金</CardTitle>
|
||||
<CardTitle className="text-[#0b3f96]">{t("wallet.transferOutTitle")}</CardTitle>
|
||||
<CardDescription>
|
||||
划回主站钱包;单笔限额以服务端校验为准。
|
||||
{t("wallet.transferOutDescription")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -409,6 +419,6 @@ export function TransferOutPage({
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</PlayerPanel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,6 @@ import { useCallback, useEffect, useRef } from "react";
|
||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||
import { useErrorStore } from "@/stores/error-store";
|
||||
|
||||
/** Token 刷新间隔(毫秒):5 分钟 - 30 秒缓冲 = 4.5 分钟 */
|
||||
const TOKEN_REFRESH_INTERVAL = 4.5 * 60 * 1000;
|
||||
|
||||
/** Token 过期前警告阈值(毫秒) */
|
||||
const TOKEN_WARNING_THRESHOLD = 60 * 1000; // 1 分钟
|
||||
|
||||
|
||||
@@ -44,11 +44,11 @@ export type UseWebSocketManagerReturn = {
|
||||
export function useWebSocketManager(): UseWebSocketManagerReturn {
|
||||
const store = useNetworkConnectionStore();
|
||||
const reconnectTimerRef = useRef<number | null>(null);
|
||||
const attemptReconnectRef = useRef<() => void>(() => {});
|
||||
|
||||
const {
|
||||
mode,
|
||||
isWebSocketConnected,
|
||||
isReconnecting,
|
||||
reconnectAttempts,
|
||||
setWebSocketConnected,
|
||||
setReconnecting,
|
||||
@@ -212,7 +212,7 @@ export function useWebSocketManager(): UseWebSocketManagerReturn {
|
||||
} else {
|
||||
// 继续重连
|
||||
reconnectTimerRef.current = window.setTimeout(
|
||||
attemptReconnect,
|
||||
() => attemptReconnectRef.current(),
|
||||
RECONNECT_INTERVAL_MS,
|
||||
);
|
||||
}
|
||||
@@ -224,6 +224,10 @@ export function useWebSocketManager(): UseWebSocketManagerReturn {
|
||||
setReconnecting,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
attemptReconnectRef.current = attemptReconnect;
|
||||
}, [attemptReconnect]);
|
||||
|
||||
// 手动重连
|
||||
const reconnect = useCallback(() => {
|
||||
resetReconnectAttempts();
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import i18n from "i18next";
|
||||
import LanguageDetector from "i18next-browser-languagedetector";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
@@ -5,12 +7,15 @@ import { initReactI18next } from "react-i18next";
|
||||
import enCommon from "./locales/en/common.json";
|
||||
import enEntry from "./locales/en/entry.json";
|
||||
import enLayout from "./locales/en/layout.json";
|
||||
import enPlayer from "./locales/en/player.json";
|
||||
import neCommon from "./locales/ne/common.json";
|
||||
import neEntry from "./locales/ne/entry.json";
|
||||
import neLayout from "./locales/ne/layout.json";
|
||||
import nePlayer from "./locales/ne/player.json";
|
||||
import zhCommon from "./locales/zh/common.json";
|
||||
import zhEntry from "./locales/zh/entry.json";
|
||||
import zhLayout from "./locales/zh/layout.json";
|
||||
import zhPlayer from "./locales/zh/player.json";
|
||||
|
||||
/** 对齐后端与产品:尼泊尔语 / 英语 / 中文(简体) */
|
||||
export const SUPPORTED_LANGUAGES = [
|
||||
@@ -23,23 +28,26 @@ export type AppLanguage = (typeof SUPPORTED_LANGUAGES)[number]["code"];
|
||||
|
||||
export const DEFAULT_LANGUAGE: AppLanguage = "en";
|
||||
|
||||
const namespaces = ["common", "entry", "layout"] as const;
|
||||
const namespaces = ["common", "entry", "layout", "player"] as const;
|
||||
|
||||
const resources = {
|
||||
en: {
|
||||
common: enCommon,
|
||||
entry: enEntry,
|
||||
layout: enLayout,
|
||||
player: enPlayer,
|
||||
},
|
||||
ne: {
|
||||
common: neCommon,
|
||||
entry: neEntry,
|
||||
layout: neLayout,
|
||||
player: nePlayer,
|
||||
},
|
||||
zh: {
|
||||
common: zhCommon,
|
||||
entry: zhEntry,
|
||||
layout: zhLayout,
|
||||
player: zhPlayer,
|
||||
},
|
||||
} satisfies Record<
|
||||
AppLanguage,
|
||||
@@ -53,6 +61,12 @@ export function normalizeLanguage(lang: string | undefined): AppLanguage {
|
||||
return "en";
|
||||
}
|
||||
|
||||
export function syncDocumentLanguage(lang: AppLanguage): void {
|
||||
if (typeof document === "undefined") return;
|
||||
|
||||
document.documentElement.lang = lang;
|
||||
}
|
||||
|
||||
if (!i18n.isInitialized) {
|
||||
void i18n
|
||||
.use(LanguageDetector)
|
||||
@@ -80,6 +94,12 @@ if (!i18n.isInitialized) {
|
||||
useSuspense: false,
|
||||
},
|
||||
});
|
||||
|
||||
syncDocumentLanguage(normalizeLanguage(i18n.resolvedLanguage ?? i18n.language));
|
||||
|
||||
i18n.on("languageChanged", (lang) => {
|
||||
syncDocumentLanguage(normalizeLanguage(lang));
|
||||
});
|
||||
}
|
||||
|
||||
export default i18n;
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
"pending": "Pending",
|
||||
"failed": "Failed"
|
||||
},
|
||||
"navigation": {
|
||||
"notifications": "Notifications"
|
||||
},
|
||||
"errors": {
|
||||
"general": "General"
|
||||
}
|
||||
|
||||
397
src/i18n/locales/en/player.json
Normal file
397
src/i18n/locales/en/player.json
Normal file
@@ -0,0 +1,397 @@
|
||||
{
|
||||
"brand": {
|
||||
"name": "N lotto"
|
||||
},
|
||||
"actions": {
|
||||
"retry": "Retry",
|
||||
"refresh": "Refresh",
|
||||
"apply": "Apply",
|
||||
"clear": "Clear",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"loading": "Loading...",
|
||||
"loadMore": "Load More",
|
||||
"processing": "Processing...",
|
||||
"deleteRow": "Delete row {{row}}"
|
||||
},
|
||||
"nav": {
|
||||
"aria": "Main navigation",
|
||||
"home": "Home",
|
||||
"results": "Results",
|
||||
"orders": "My Bets",
|
||||
"wallet": "Wallet"
|
||||
},
|
||||
"panel": {
|
||||
"home": "Home"
|
||||
},
|
||||
"network": {
|
||||
"offline": "Network disconnected. Please check your connection.",
|
||||
"degraded": "Network is unstable. Switched to fallback mode (polling...).",
|
||||
"retryReconnect": "Try reconnecting",
|
||||
"retry": "Retry",
|
||||
"recoverWebsocket": "Try restoring WebSocket connection",
|
||||
"recover": "Recover",
|
||||
"reconnect": "Reconnect"
|
||||
},
|
||||
"token": {
|
||||
"critical": "Login will expire soon",
|
||||
"warning": "Login expiring soon",
|
||||
"remaining": "Remaining {{time}}",
|
||||
"autoRenewing": "Auto-renewing...",
|
||||
"renewNow": "Renew now"
|
||||
},
|
||||
"serverError": {
|
||||
"title": "Server error",
|
||||
"appTitle": "Application error",
|
||||
"unavailable": "Server temporarily unavailable",
|
||||
"serverMessage": "Server temporarily unavailable. Please try again later.",
|
||||
"appMessage": "Sorry, the application encountered a problem. Try refreshing the page.",
|
||||
"details": "Error details:",
|
||||
"errorId": "Error ID: {{id}}",
|
||||
"refreshPage": "Refresh page",
|
||||
"hall": "Betting hall",
|
||||
"wallet": "My wallet",
|
||||
"code": "Error code: 500 Internal Server Error",
|
||||
"home": "Back home"
|
||||
},
|
||||
"notFound": {
|
||||
"title": "Page not found",
|
||||
"description": "Sorry, the page you visited may have been deleted, moved, or never existed.",
|
||||
"home": "Back home",
|
||||
"hall": "Betting hall",
|
||||
"results": "Results",
|
||||
"wallet": "My wallet"
|
||||
},
|
||||
"player": {
|
||||
"fallback": "Player #{{id}}"
|
||||
},
|
||||
"draw": {
|
||||
"currentIssue": "Current issue",
|
||||
"currentTime": "Current Time",
|
||||
"closesIn": "Closes In",
|
||||
"drawsIn": "Draws In",
|
||||
"coolDown": "Cool Down",
|
||||
"loadFailedRefresh": "Failed to load. Pull down to refresh.",
|
||||
"issueNo": "Issue No.",
|
||||
"hall": "Betting Hall",
|
||||
"noIssue": "No available issue. Please try again later.",
|
||||
"sealedNotice": "Closed. Please wait for the next issue.",
|
||||
"status": {
|
||||
"pending": "Not started",
|
||||
"open": "Open",
|
||||
"closing": "Closed",
|
||||
"closed": "Awaiting draw",
|
||||
"drawing": "Drawing",
|
||||
"review": "Under review",
|
||||
"cooldown": "Cool down",
|
||||
"settling": "Settling",
|
||||
"settled": "Settled",
|
||||
"cancelled": "Cancelled"
|
||||
}
|
||||
},
|
||||
"hall": {
|
||||
"aria": "Betting table",
|
||||
"loadingError": "Failed to load play rules. Please try again later.",
|
||||
"noDraw": "No current issue. Cannot submit.",
|
||||
"notBettable": "This issue is closed or cannot accept bets.",
|
||||
"catalogNotReady": "Play configuration is still loading.",
|
||||
"emptyLines": "Please enter at least one valid number and stake amount.",
|
||||
"previewFailed": "Preview failed",
|
||||
"closedSubmit": "Closed. Cannot submit.",
|
||||
"changedBeforeSubmit": "Your draft changed before submission. Close the preview and try again.",
|
||||
"placeFailed": "Submission failed",
|
||||
"placeSuccess": "Bet submitted. Order {{orderNo}}, deducted {{amount}}.",
|
||||
"closed": {
|
||||
"title": "Closed",
|
||||
"subtitle": "This issue is now closed.",
|
||||
"description": "The betting window has closed. Please wait for the next issue to place your bets."
|
||||
},
|
||||
"boxMode": {
|
||||
"iboxTitle": "iBox",
|
||||
"iboxDesc": "Divide all by amount",
|
||||
"boxTitle": "Box",
|
||||
"boxDesc": "Multiply all by amount"
|
||||
},
|
||||
"table": {
|
||||
"no": "No.",
|
||||
"number": "Number",
|
||||
"stake": "Stake Amount",
|
||||
"rebate": "Commission / Rebate",
|
||||
"actual": "Actual Deduction",
|
||||
"delete": "Delete",
|
||||
"addRow": "Add Row",
|
||||
"draftTotal": "Draft Total",
|
||||
"sealedHint": "Closed: this table is locked. Please wait for the next issue.",
|
||||
"previewing": "Previewing...",
|
||||
"submitBet": "Submit Bet"
|
||||
},
|
||||
"preview": {
|
||||
"title": "Confirm bet",
|
||||
"description": "Check the number, play type, and actual deduction. After confirmation, the lottery wallet will be debited and the ticket cannot be cancelled.",
|
||||
"sealedTitle": "Closed",
|
||||
"sealedDescription": "This issue has stopped accepting tickets. Please choose the next issue.",
|
||||
"empty": "No preview data",
|
||||
"draw": "Issue",
|
||||
"status": "Status",
|
||||
"totalBet": "Total stake",
|
||||
"rebateDeduct": "Rebate deduction",
|
||||
"actualDeduct": "Actual deduction",
|
||||
"estimatedPayout": "Estimated max payout",
|
||||
"lines": "Ticket lines",
|
||||
"normalizedNumber": "Normalized number",
|
||||
"combinationCount": "Combinations",
|
||||
"actual": "Actual",
|
||||
"estimatedMax": "Estimated max",
|
||||
"backEdit": "Back to edit",
|
||||
"submitting": "Submitting...",
|
||||
"confirmSubmit": "Confirm submit",
|
||||
"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."
|
||||
},
|
||||
"amountInput": {
|
||||
"limit": "Limit {{min}} - {{max}}",
|
||||
"placeholder": "e.g. 100.00"
|
||||
},
|
||||
"numberInput": {
|
||||
"rollPlaceholder": "e.g. 12R4",
|
||||
"digitPlaceholder": "0-9"
|
||||
},
|
||||
"playSwitcher": {
|
||||
"empty": "No open play types are available for this currency.",
|
||||
"label": "Play"
|
||||
},
|
||||
"playCatalog": {
|
||||
"notReady": "Play configuration has not been initialized. Run the seed that includes OperationalConfigV1Seeder in Laravel.",
|
||||
"loadFailed": "Failed to load play configuration. Please try again later.",
|
||||
"loading": "Loading plays and odds...",
|
||||
"meta": "Currency {{currency}} · Config versions play#{{playVersion}} / odds#{{oddsVersion}} · Limits use the smallest currency unit, same as wallet.",
|
||||
"play": "Play",
|
||||
"status": "Status",
|
||||
"limit": "Bet limit",
|
||||
"odds": "Odds x",
|
||||
"description": "Description",
|
||||
"open": "Open",
|
||||
"closed": "Closed",
|
||||
"riskTitle": "Risk cap (sample numbers)",
|
||||
"number": "Number",
|
||||
"capAmount": "Cap amount",
|
||||
"type": "Type",
|
||||
"title": "Plays and Odds",
|
||||
"descriptionText": "Data comes from GET /api/v1/play/effective. After admin changes are published, it refreshes automatically within about {{seconds}}s, or manually.",
|
||||
"refresh": "Refresh config"
|
||||
},
|
||||
"ticketError": {
|
||||
"4001": "This number has insufficient payout pool capacity for this issue and is sold out. Change the number, amount, or play type and try again.",
|
||||
"1001": "Insufficient balance. Transfer in before betting.",
|
||||
"2001": "This issue is closed. Betting is no longer available.",
|
||||
"2002": "This play type is closed. Choose another play type.",
|
||||
"2004": "The number format or length does not match this play type.",
|
||||
"2005": "Play parameters are incomplete, such as digit slot or dimension.",
|
||||
"2006": "This issue cannot accept bets.",
|
||||
"2007": "This play type is unsupported or missing odds configuration.",
|
||||
"2008": "Odds or play configuration has changed. Close the preview and try again.",
|
||||
"1003": "Stake amount is outside the allowed range for this play type.",
|
||||
"fallback": "Bet failed. Please try again later."
|
||||
},
|
||||
"amountHint": {
|
||||
"iboxRoll": "This play uses a per-bet amount. The system calculates total stake and deduction after expanding combinations.",
|
||||
"mbox": "This play uses a total input amount. It is split across permutations, rounded down to the smallest currency unit.",
|
||||
"default": "Amount is the stake for this ticket line, using the smallest currency unit just like wallet."
|
||||
}
|
||||
},
|
||||
"wallet": {
|
||||
"title": "Wallet",
|
||||
"subtitle": "Balance and transfer records",
|
||||
"balance": "Wallet Balance",
|
||||
"available": "Available {{amount}}",
|
||||
"transferIn": "Transfer In",
|
||||
"transferOut": "Transfer Out",
|
||||
"inPage": "In Page",
|
||||
"outPage": "Out Page",
|
||||
"logs": "Logs",
|
||||
"logsTitle": "Wallet Logs",
|
||||
"logsSubtitle": "Transfer and betting records",
|
||||
"typeFilter": "Type Filter",
|
||||
"transferInTitle": "Transfer In",
|
||||
"transferOutTitle": "Transfer Out",
|
||||
"transferInSubtitle": "Add funds to lottery wallet ({{currency}})",
|
||||
"transferOutSubtitle": "Move available balance back ({{currency}})",
|
||||
"transferInDescription": "Move funds from the main-site wallet into the lottery wallet. Per-transaction limits are validated by the server.",
|
||||
"transferOutDescription": "Move funds back to the main-site wallet. Per-transaction limits are validated by the server.",
|
||||
"dialogInDescription": "Move funds from the main-site wallet into the lottery wallet. The minimum amount is validated by the server, usually about 1.00 {{currency}}.",
|
||||
"dialogOutDescription": "Move funds back to the main-site wallet. Per-transaction limits are validated by the server.",
|
||||
"mainBalance": "Main-site wallet balance:",
|
||||
"mainPending": "— (main site integration pending)",
|
||||
"lotteryBalance": "Lottery wallet balance:",
|
||||
"lotteryAvailable": "Lottery wallet available:",
|
||||
"inAmount": "Transfer-in amount",
|
||||
"outAmount": "Transfer-out amount",
|
||||
"exampleIn": "e.g. 1000.00",
|
||||
"exampleOut": "e.g. 500.00",
|
||||
"afterInPreview": "Lottery balance after transfer-in (preview): {{amount}}",
|
||||
"afterOutPreview": "Lottery balance after transfer-out (preview): {{amount}}",
|
||||
"allOut": "Transfer all {{amount}}",
|
||||
"confirmIn": "Confirm transfer in",
|
||||
"confirmOut": "Confirm transfer out",
|
||||
"successIn": "Transfer in succeeded. Lottery wallet balance updated.",
|
||||
"successOut": "Transfer out succeeded. Funds will return to the main-site wallet.",
|
||||
"pendingShort": "Transfer is processing. Refresh later.",
|
||||
"pendingToast": "Processing...",
|
||||
"invalidAmount": "Enter a valid amount.",
|
||||
"outExceeds": "Transfer-out amount cannot exceed available balance.",
|
||||
"pendingTitle": "Pending reconciliation",
|
||||
"pendingDescription": "The following transfers have not been finally confirmed by the main site. Contact support if they do not arrive after a long time.",
|
||||
"pendingStatus": "Processing",
|
||||
"flowsTitle": "Wallet logs",
|
||||
"totalRecords": "{{total}} records",
|
||||
"emptyLogs": "No wallet logs",
|
||||
"flow": {
|
||||
"all": "All",
|
||||
"transfer_in": "Transfer in",
|
||||
"transfer_out": "Transfer out",
|
||||
"bet": "Bet deduction",
|
||||
"prize": "Payout",
|
||||
"refund": "Refund",
|
||||
"reversal": "Reversal"
|
||||
},
|
||||
"txnStatus": {
|
||||
"posted": "Success",
|
||||
"pending_reconcile": "Pending reconciliation",
|
||||
"reversed": "Reversed",
|
||||
"manually_processed": "Manually processed"
|
||||
},
|
||||
"error": {
|
||||
"401": "Login expired. Please return and enter again.",
|
||||
"409": "Transfer is processing. Refresh later. Contact support if it does not arrive after a long time.",
|
||||
"network": "Network error. Check your connection and try again.",
|
||||
"timeout": "Request timed out. Please try again later.",
|
||||
"fallback": "Request failed. Please try again later.",
|
||||
"1001": "Lottery wallet balance is insufficient. Transfer in or reduce the transfer-out amount.",
|
||||
"1002": "Transfer is processing. Refresh the balance here later. Contact support if it does not arrive after a long time.",
|
||||
"1003": "Amount exceeds the per-transaction or daily limit. Adjust the amount.",
|
||||
"1004": "Transfer in is temporarily paused. Try again later or contact support.",
|
||||
"1005": "Currency is invalid or unavailable.",
|
||||
"1006": "Transfer out is temporarily paused. Try again later or contact support.",
|
||||
"1007": "Lottery wallet is frozen and cannot transfer funds.",
|
||||
"1008": "Amount format is invalid.",
|
||||
"1009": "The main site could not complete this transfer. Please try again later.",
|
||||
"1010": "Do not use the same idempotency key for transfers with different amounts."
|
||||
}
|
||||
},
|
||||
"orders": {
|
||||
"title": "My Bets",
|
||||
"subtitle": "Recent ticket records",
|
||||
"betDetail": "Bet Detail",
|
||||
"filteredIssue": "Filtered Issue",
|
||||
"totalRecords": "Total Records",
|
||||
"betNow": "Bet Now",
|
||||
"empty": "No bet records yet.",
|
||||
"submitBet": "Submit Bet",
|
||||
"stake": "Stake",
|
||||
"deduction": "Deduction",
|
||||
"win": "Win {{amount}}",
|
||||
"detailTitle": "Ticket detail",
|
||||
"ticketNo": "Ticket {{ticketNo}}",
|
||||
"orderNo": "Order {{orderNo}}",
|
||||
"drawNo": "Issue",
|
||||
"placedAt": "Placed at",
|
||||
"number": "Number",
|
||||
"play": "Play",
|
||||
"amount": "Stake amount",
|
||||
"rebateRate": "Rebate rate",
|
||||
"actualDeduct": "Actual deduction",
|
||||
"oddsSnapshot": "Odds snapshot",
|
||||
"drawNumbers": "Draw numbers (this issue)",
|
||||
"firstPrize": "First prize",
|
||||
"hit": "Hit",
|
||||
"notPublished": "Draw numbers are not published or cannot be displayed yet.",
|
||||
"matchWin": "Match result: hit {{tier}}",
|
||||
"winAmount": "Win amount {{amount}}",
|
||||
"jackpotAmount": "Jackpot {{amount}}",
|
||||
"payoutTotal": "Payout total {{amount}}",
|
||||
"matchLose": "Match result: not won",
|
||||
"settledAt": "Settled at {{time}}",
|
||||
"viewDraw": "View this draw",
|
||||
"backToOrders": "Back to My Bets",
|
||||
"notFound": "Ticket does not exist or cannot be viewed.",
|
||||
"noData": "No data",
|
||||
"loadFailed": "Failed to load"
|
||||
},
|
||||
"results": {
|
||||
"title": "Results",
|
||||
"subtitle": "Latest draw history",
|
||||
"detailTitle": "Result Detail",
|
||||
"businessDate": "Business Date",
|
||||
"empty": "No results yet.",
|
||||
"detail": "Detail",
|
||||
"loadFailed": "Failed to load",
|
||||
"unavailable": "This result is unavailable or does not exist.",
|
||||
"noData": "No data",
|
||||
"previous": "‹ Previous",
|
||||
"next": "Next ›",
|
||||
"drawTime": "Draw time: {{time}}",
|
||||
"myPayout": "My payout for this issue",
|
||||
"regular": "Regular: {{amount}}",
|
||||
"jackpot": "Jackpot: {{amount}}",
|
||||
"hitPending": "Your ticket has hit a result cell in this issue. The amount summary will show after payout is completed.",
|
||||
"hitHint": "If you win, numbers matched by your tickets are highlighted in gold (login required).",
|
||||
"viewMyWinning": "View my winning status",
|
||||
"jackpotLabel": "Jackpot",
|
||||
"tier": {
|
||||
"first": "First prize",
|
||||
"second": "Second prize",
|
||||
"third": "Third prize",
|
||||
"starter": "Starter",
|
||||
"consolation": "Consolation"
|
||||
},
|
||||
"grid": {
|
||||
"first": "First prize",
|
||||
"second": "Second prize",
|
||||
"third": "Third prize",
|
||||
"starter": "Starter",
|
||||
"consolation": "Consolation"
|
||||
}
|
||||
},
|
||||
"ticketStatus": {
|
||||
"success": "Awaiting draw",
|
||||
"settled_win": "Paid",
|
||||
"settled_lose": "Not won",
|
||||
"unknown": "{{status}}"
|
||||
},
|
||||
"prizeTier": {
|
||||
"first": "First prize",
|
||||
"second": "Second prize",
|
||||
"third": "Third prize",
|
||||
"starter": "Starter",
|
||||
"consolation": "Consolation"
|
||||
},
|
||||
"playLabels": {
|
||||
"big": "Big",
|
||||
"small": "Small",
|
||||
"pos_4a": "4A",
|
||||
"pos_4b": "4B",
|
||||
"pos_4c": "4C",
|
||||
"pos_4d": "4D",
|
||||
"pos_4e": "4E",
|
||||
"pos_3a": "3A",
|
||||
"pos_3b": "3B",
|
||||
"pos_3c": "3C",
|
||||
"pos_3abc": "3ABC",
|
||||
"pos_2a": "2A",
|
||||
"pos_2b": "2B",
|
||||
"pos_2c": "2C",
|
||||
"pos_2abc": "2ABC",
|
||||
"straight": "Straight",
|
||||
"box": "Box",
|
||||
"ibox": "iBox",
|
||||
"mbox": "mBox",
|
||||
"roll": "Roll",
|
||||
"half_box": "Half Box",
|
||||
"head": "Head",
|
||||
"tail": "Tail",
|
||||
"odd": "Odd",
|
||||
"even": "Even",
|
||||
"digit_big": "Big Digit",
|
||||
"digit_small": "Small Digit"
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,9 @@
|
||||
"pending": "बाँकी",
|
||||
"failed": "असफल"
|
||||
},
|
||||
"navigation": {
|
||||
"notifications": "सूचनाहरू"
|
||||
},
|
||||
"errors": {
|
||||
"general": "सामान्य"
|
||||
}
|
||||
|
||||
397
src/i18n/locales/ne/player.json
Normal file
397
src/i18n/locales/ne/player.json
Normal file
@@ -0,0 +1,397 @@
|
||||
{
|
||||
"brand": {
|
||||
"name": "N lotto"
|
||||
},
|
||||
"actions": {
|
||||
"retry": "पुन: प्रयास",
|
||||
"refresh": "रिफ्रेस",
|
||||
"apply": "लागू गर्नुहोस्",
|
||||
"clear": "हटाउनुहोस्",
|
||||
"cancel": "रद्द",
|
||||
"confirm": "पुष्टि",
|
||||
"loading": "लोड हुँदैछ...",
|
||||
"loadMore": "थप लोड गर्नुहोस्",
|
||||
"processing": "प्रक्रिया हुँदैछ...",
|
||||
"deleteRow": "{{row}} नम्बर पंक्ति हटाउनुहोस्"
|
||||
},
|
||||
"nav": {
|
||||
"aria": "मुख्य नेभिगेसन",
|
||||
"home": "गृह",
|
||||
"results": "नतिजा",
|
||||
"orders": "मेरा बेट",
|
||||
"wallet": "वालेट"
|
||||
},
|
||||
"panel": {
|
||||
"home": "गृह"
|
||||
},
|
||||
"network": {
|
||||
"offline": "नेटवर्क काटिएको छ। कृपया जडान जाँच गर्नुहोस्।",
|
||||
"degraded": "नेटवर्क अस्थिर छ। fallback मोडमा स्विच गरियो (polling...).",
|
||||
"retryReconnect": "पुन: जडान प्रयास",
|
||||
"retry": "पुन: प्रयास",
|
||||
"recoverWebsocket": "WebSocket जडान पुनर्स्थापना प्रयास",
|
||||
"recover": "पुनर्स्थापना",
|
||||
"reconnect": "पुन: जडान"
|
||||
},
|
||||
"token": {
|
||||
"critical": "लगइन छिट्टै समाप्त हुँदैछ",
|
||||
"warning": "लगइन समाप्त हुन लागेको छ",
|
||||
"remaining": "बाँकी {{time}}",
|
||||
"autoRenewing": "स्वचालित नवीकरण हुँदैछ...",
|
||||
"renewNow": "अहिले नवीकरण"
|
||||
},
|
||||
"serverError": {
|
||||
"title": "सर्भर त्रुटि",
|
||||
"appTitle": "एप त्रुटि",
|
||||
"unavailable": "सर्भर अस्थायी रूपमा उपलब्ध छैन",
|
||||
"serverMessage": "सर्भर अस्थायी रूपमा उपलब्ध छैन। कृपया पछि प्रयास गर्नुहोस्।",
|
||||
"appMessage": "माफ गर्नुहोस्, एपमा समस्या आयो। पृष्ठ रिफ्रेस गरेर प्रयास गर्नुहोस्।",
|
||||
"details": "त्रुटि विवरण:",
|
||||
"errorId": "त्रुटि ID: {{id}}",
|
||||
"refreshPage": "पृष्ठ रिफ्रेस",
|
||||
"hall": "बेटिङ हल",
|
||||
"wallet": "मेरो वालेट",
|
||||
"code": "त्रुटि कोड: 500 Internal Server Error",
|
||||
"home": "गृहमा फर्कनुहोस्"
|
||||
},
|
||||
"notFound": {
|
||||
"title": "पृष्ठ भेटिएन",
|
||||
"description": "माफ गर्नुहोस्, तपाईंले खोलेको पृष्ठ हटाइएको, सारिएको वा कहिल्यै नभएको हुन सक्छ।",
|
||||
"home": "गृहमा फर्कनुहोस्",
|
||||
"hall": "बेटिङ हल",
|
||||
"results": "नतिजा",
|
||||
"wallet": "मेरो वालेट"
|
||||
},
|
||||
"player": {
|
||||
"fallback": "खेलाडी #{{id}}"
|
||||
},
|
||||
"draw": {
|
||||
"currentIssue": "हालको इश्यू",
|
||||
"currentTime": "हालको समय",
|
||||
"closesIn": "बन्द हुन बाँकी",
|
||||
"drawsIn": "ड्र हुन बाँकी",
|
||||
"coolDown": "कुल डाउन",
|
||||
"loadFailedRefresh": "लोड असफल भयो। तल तानेर रिफ्रेस गर्नुहोस्।",
|
||||
"issueNo": "इश्यू नं.",
|
||||
"hall": "बेटिङ हल",
|
||||
"noIssue": "उपलब्ध इश्यू छैन। कृपया पछि प्रयास गर्नुहोस्।",
|
||||
"sealedNotice": "बन्द भयो। कृपया अर्को इश्यू पर्खनुहोस्।",
|
||||
"status": {
|
||||
"pending": "सुरु भएको छैन",
|
||||
"open": "खुला",
|
||||
"closing": "बन्द",
|
||||
"closed": "ड्र पर्खँदै",
|
||||
"drawing": "ड्र हुँदैछ",
|
||||
"review": "समीक्षामा",
|
||||
"cooldown": "कुल डाउन",
|
||||
"settling": "सेटल हुँदैछ",
|
||||
"settled": "सेटल भयो",
|
||||
"cancelled": "रद्द"
|
||||
}
|
||||
},
|
||||
"hall": {
|
||||
"aria": "बेटिङ तालिका",
|
||||
"loadingError": "प्ले नियम लोड गर्न असफल। कृपया पछि प्रयास गर्नुहोस्।",
|
||||
"noDraw": "हालको इश्यू छैन। पेश गर्न सकिँदैन।",
|
||||
"notBettable": "यो इश्यू बन्द छ वा बेट स्वीकार गर्न सक्दैन।",
|
||||
"catalogNotReady": "प्ले कन्फिगरेसन अझै लोड हुँदैछ।",
|
||||
"emptyLines": "कृपया कम्तीमा एक मान्य नम्बर र रकम प्रविष्ट गर्नुहोस्।",
|
||||
"previewFailed": "पूर्वावलोकन असफल",
|
||||
"closedSubmit": "बन्द भयो। पेश गर्न सकिँदैन।",
|
||||
"changedBeforeSubmit": "पेश गर्नु अघि ड्राफ्ट परिवर्तन भयो। पूर्वावलोकन बन्द गरी फेरि प्रयास गर्नुहोस्।",
|
||||
"placeFailed": "पेश गर्न असफल",
|
||||
"placeSuccess": "बेट पेश भयो। अर्डर {{orderNo}}, कट्टा {{amount}}।",
|
||||
"closed": {
|
||||
"title": "बन्द",
|
||||
"subtitle": "यो इश्यू बन्द भएको छ।",
|
||||
"description": "बेटिङ समय बन्द भएको छ। अर्को इश्यू पर्खेर बेट राख्नुहोस्।"
|
||||
},
|
||||
"boxMode": {
|
||||
"iboxTitle": "iBox",
|
||||
"iboxDesc": "रकम सबैमा बाँड्नुहोस्",
|
||||
"boxTitle": "Box",
|
||||
"boxDesc": "रकम सबैसँग गुणा गर्नुहोस्"
|
||||
},
|
||||
"table": {
|
||||
"no": "नं.",
|
||||
"number": "नम्बर",
|
||||
"stake": "बेट रकम",
|
||||
"rebate": "कमिशन / रिबेट",
|
||||
"actual": "वास्तविक कट्टा",
|
||||
"delete": "हटाउनुहोस्",
|
||||
"addRow": "पंक्ति थप्नुहोस्",
|
||||
"draftTotal": "ड्राफ्ट जम्मा",
|
||||
"sealedHint": "बन्द: यो तालिका लक छ। कृपया अर्को इश्यू पर्खनुहोस्।",
|
||||
"previewing": "पूर्वावलोकन...",
|
||||
"submitBet": "बेट पेश गर्नुहोस्"
|
||||
},
|
||||
"preview": {
|
||||
"title": "बेट पुष्टि गर्नुहोस्",
|
||||
"description": "नम्बर, प्ले प्रकार र वास्तविक कट्टा जाँच गर्नुहोस्। पुष्टि गरेपछि वालेटबाट रकम कट्टा हुनेछ र टिकट रद्द गर्न सकिँदैन।",
|
||||
"sealedTitle": "बन्द",
|
||||
"sealedDescription": "यो इश्यूले टिकट स्वीकार गर्न बन्द गरिसकेको छ। कृपया अर्को इश्यू छान्नुहोस्।",
|
||||
"empty": "पूर्वावलोकन डेटा छैन",
|
||||
"draw": "इश्यू",
|
||||
"status": "स्थिति",
|
||||
"totalBet": "कुल बेट",
|
||||
"rebateDeduct": "रिबेट कट्टा",
|
||||
"actualDeduct": "वास्तविक कट्टा",
|
||||
"estimatedPayout": "अनुमानित अधिकतम भुक्तानी",
|
||||
"lines": "टिकट लाइनहरू",
|
||||
"normalizedNumber": "सामान्य नम्बर",
|
||||
"combinationCount": "संयोजन",
|
||||
"actual": "वास्तविक",
|
||||
"estimatedMax": "अनुमानित अधिकतम",
|
||||
"backEdit": "सम्पादनमा फर्कनुहोस्",
|
||||
"submitting": "पेश हुँदैछ...",
|
||||
"confirmSubmit": "पेश पुष्टि",
|
||||
"warningsTitle": "भुक्तानी पूल चेतावनी",
|
||||
"warningsDescription": "यी नम्बरहरूमा यस इश्यूमा भुक्तानी पूल प्रयोग उच्च छ। बेट अझै गर्न सकिन्छ, तर क्षमता अपुग भए अर्डर sold out हुन सक्छ।"
|
||||
},
|
||||
"amountInput": {
|
||||
"limit": "सीमा {{min}} - {{max}}",
|
||||
"placeholder": "जस्तै 100.00"
|
||||
},
|
||||
"numberInput": {
|
||||
"rollPlaceholder": "जस्तै 12R4",
|
||||
"digitPlaceholder": "0-9"
|
||||
},
|
||||
"playSwitcher": {
|
||||
"empty": "यो मुद्राका लागि खुला प्ले प्रकार छैन।",
|
||||
"label": "प्ले"
|
||||
},
|
||||
"playCatalog": {
|
||||
"notReady": "प्ले कन्फिगरेसन सुरु गरिएको छैन। Laravel मा OperationalConfigV1Seeder समावेश भएको seed चलाउनुहोस्।",
|
||||
"loadFailed": "प्ले कन्फिगरेसन लोड गर्न असफल। कृपया पछि प्रयास गर्नुहोस्।",
|
||||
"loading": "प्ले र odds लोड हुँदैछ...",
|
||||
"meta": "मुद्रा {{currency}} · कन्फिग संस्करण play#{{playVersion}} / odds#{{oddsVersion}} · सीमा वालेट जस्तै सबैभन्दा सानो मुद्रा एकाइमा छ।",
|
||||
"play": "प्ले",
|
||||
"status": "स्थिति",
|
||||
"limit": "बेट सीमा",
|
||||
"odds": "Odds x",
|
||||
"description": "विवरण",
|
||||
"open": "खुला",
|
||||
"closed": "बन्द",
|
||||
"riskTitle": "जोखिम क्याप (नमुना नम्बर)",
|
||||
"number": "नम्बर",
|
||||
"capAmount": "क्याप रकम",
|
||||
"type": "प्रकार",
|
||||
"title": "प्ले र Odds",
|
||||
"descriptionText": "डेटा GET /api/v1/play/effective बाट आउँछ। एडमिन परिवर्तन publish भएपछि करिब {{seconds}}s भित्र आफैं रिफ्रेस हुन्छ, वा हातैले गर्न सकिन्छ।",
|
||||
"refresh": "कन्फिग रिफ्रेस"
|
||||
},
|
||||
"ticketError": {
|
||||
"4001": "यो नम्बरमा यस इश्यूका लागि भुक्तानी पूल क्षमता अपुग छ र sold out भयो। नम्बर, रकम वा प्ले प्रकार परिवर्तन गरी फेरि प्रयास गर्नुहोस्।",
|
||||
"1001": "ब्यालेन्स अपुग छ। बेट गर्नु अघि ट्रान्सफर इन गर्नुहोस्।",
|
||||
"2001": "यो इश्यू बन्द भइसकेको छ। बेट गर्न सकिँदैन।",
|
||||
"2002": "यो प्ले प्रकार बन्द छ। अर्को प्ले प्रकार छान्नुहोस्।",
|
||||
"2004": "नम्बर ढाँचा वा लम्बाइ यस प्ले प्रकारसँग मिल्दैन।",
|
||||
"2005": "प्ले प्यारामिटर अपूर्ण छ, जस्तै digit slot वा dimension।",
|
||||
"2006": "यो इश्यूले बेट स्वीकार गर्न सक्दैन।",
|
||||
"2007": "यो प्ले प्रकार समर्थित छैन वा odds कन्फिगरेसन छैन।",
|
||||
"2008": "Odds वा प्ले कन्फिगरेसन परिवर्तन भयो। पूर्वावलोकन बन्द गरी फेरि प्रयास गर्नुहोस्।",
|
||||
"1003": "बेट रकम यो प्ले प्रकारको अनुमत दायराभन्दा बाहिर छ।",
|
||||
"fallback": "बेट असफल। कृपया पछि प्रयास गर्नुहोस्।"
|
||||
},
|
||||
"amountHint": {
|
||||
"iboxRoll": "यो प्लेमा प्रति-बेट रकम प्रयोग हुन्छ। सिस्टमले संयोजन विस्तारपछि कुल बेट र कट्टा गणना गर्छ।",
|
||||
"mbox": "यो प्लेमा कुल इनपुट रकम प्रयोग हुन्छ। यो permutation हरूमा बाँडिन्छ र सानो मुद्रा एकाइमा तल राउन्ड हुन्छ।",
|
||||
"default": "रकम यस टिकट लाइनको बेट रकम हो, वालेट जस्तै सबैभन्दा सानो मुद्रा एकाइमा।"
|
||||
}
|
||||
},
|
||||
"wallet": {
|
||||
"title": "वालेट",
|
||||
"subtitle": "ब्यालेन्स र ट्रान्सफर रेकर्ड",
|
||||
"balance": "वालेट ब्यालेन्स",
|
||||
"available": "उपलब्ध {{amount}}",
|
||||
"transferIn": "ट्रान्सफर इन",
|
||||
"transferOut": "ट्रान्सफर आउट",
|
||||
"inPage": "इन पेज",
|
||||
"outPage": "आउट पेज",
|
||||
"logs": "लग",
|
||||
"logsTitle": "वालेट लग",
|
||||
"logsSubtitle": "ट्रान्सफर र बेट रेकर्ड",
|
||||
"typeFilter": "प्रकार फिल्टर",
|
||||
"transferInTitle": "फन्ड ट्रान्सफर इन",
|
||||
"transferOutTitle": "फन्ड ट्रान्सफर आउट",
|
||||
"transferInSubtitle": "लटरी वालेटमा रकम थप्नुहोस् ({{currency}})",
|
||||
"transferOutSubtitle": "उपलब्ध रकम फिर्ता पठाउनुहोस् ({{currency}})",
|
||||
"transferInDescription": "मुख्य साइट वालेटबाट लटरी वालेटमा रकम सार्नुहोस्। प्रति कारोबार सीमा सर्भरले जाँच गर्छ।",
|
||||
"transferOutDescription": "मुख्य साइट वालेटमा रकम फिर्ता सार्नुहोस्। प्रति कारोबार सीमा सर्भरले जाँच गर्छ।",
|
||||
"dialogInDescription": "मुख्य साइट वालेटबाट लटरी वालेटमा रकम सार्नुहोस्। न्यूनतम रकम सर्भरले जाँच गर्छ, सामान्यतया करिब 1.00 {{currency}}।",
|
||||
"dialogOutDescription": "मुख्य साइट वालेटमा रकम फिर्ता सार्नुहोस्। प्रति कारोबार सीमा सर्भरले जाँच गर्छ।",
|
||||
"mainBalance": "मुख्य साइट वालेट ब्यालेन्स:",
|
||||
"mainPending": "— (मुख्य साइट जोड्न बाँकी)",
|
||||
"lotteryBalance": "लटरी वालेट ब्यालेन्स:",
|
||||
"lotteryAvailable": "लटरी वालेट उपलब्ध:",
|
||||
"inAmount": "ट्रान्सफर इन रकम",
|
||||
"outAmount": "ट्रान्सफर आउट रकम",
|
||||
"exampleIn": "जस्तै 1000.00",
|
||||
"exampleOut": "जस्तै 500.00",
|
||||
"afterInPreview": "ट्रान्सफर इनपछि लटरी ब्यालेन्स (पूर्वावलोकन): {{amount}}",
|
||||
"afterOutPreview": "ट्रान्सफर आउटपछि लटरी ब्यालेन्स (पूर्वावलोकन): {{amount}}",
|
||||
"allOut": "सबै ट्रान्सफर आउट {{amount}}",
|
||||
"confirmIn": "ट्रान्सफर इन पुष्टि",
|
||||
"confirmOut": "ट्रान्सफर आउट पुष्टि",
|
||||
"successIn": "ट्रान्सफर इन सफल। लटरी वालेट अपडेट भयो।",
|
||||
"successOut": "ट्रान्सफर आउट सफल। रकम मुख्य साइट वालेटमा फर्कनेछ।",
|
||||
"pendingShort": "ट्रान्सफर प्रक्रिया हुँदैछ। पछि रिफ्रेस गर्नुहोस्।",
|
||||
"pendingToast": "प्रक्रिया हुँदैछ...",
|
||||
"invalidAmount": "मान्य रकम प्रविष्ट गर्नुहोस्।",
|
||||
"outExceeds": "ट्रान्सफर आउट रकम उपलब्ध ब्यालेन्सभन्दा बढी हुन सक्दैन।",
|
||||
"pendingTitle": "मिलान बाँकी",
|
||||
"pendingDescription": "यी ट्रान्सफरहरू मुख्य साइटबाट अन्तिम पुष्टि भएका छैनन्। लामो समयसम्म नआए support सम्पर्क गर्नुहोस्।",
|
||||
"pendingStatus": "प्रक्रिया हुँदैछ",
|
||||
"flowsTitle": "वालेट लग",
|
||||
"totalRecords": "{{total}} रेकर्ड",
|
||||
"emptyLogs": "वालेट लग छैन",
|
||||
"flow": {
|
||||
"all": "सबै",
|
||||
"transfer_in": "ट्रान्सफर इन",
|
||||
"transfer_out": "ट्रान्सफर आउट",
|
||||
"bet": "बेट कट्टा",
|
||||
"prize": "भुक्तानी",
|
||||
"refund": "रिफन्ड",
|
||||
"reversal": "रिभर्सल"
|
||||
},
|
||||
"txnStatus": {
|
||||
"posted": "सफल",
|
||||
"pending_reconcile": "मिलान बाँकी",
|
||||
"reversed": "रिभर्स भयो",
|
||||
"manually_processed": "हातैले प्रक्रिया भयो"
|
||||
},
|
||||
"error": {
|
||||
"401": "लगइन समाप्त भयो। कृपया फर्केर फेरि प्रवेश गर्नुहोस्।",
|
||||
"409": "ट्रान्सफर प्रक्रिया हुँदैछ। पछि रिफ्रेस गर्नुहोस्। लामो समयसम्म नआए support सम्पर्क गर्नुहोस्।",
|
||||
"network": "नेटवर्क त्रुटि। जडान जाँच गरी फेरि प्रयास गर्नुहोस्।",
|
||||
"timeout": "अनुरोध timeout भयो। कृपया पछि प्रयास गर्नुहोस्।",
|
||||
"fallback": "अनुरोध असफल। कृपया पछि प्रयास गर्नुहोस्।",
|
||||
"1001": "लटरी वालेट ब्यालेन्स अपुग छ। ट्रान्सफर इन गर्नुहोस् वा रकम घटाउनुहोस्।",
|
||||
"1002": "ट्रान्सफर प्रक्रिया हुँदैछ। पछि ब्यालेन्स रिफ्रेस गर्नुहोस्। लामो समयसम्म नआए support सम्पर्क गर्नुहोस्।",
|
||||
"1003": "रकम प्रति कारोबार वा दैनिक सीमाभन्दा बढी छ। रकम समायोजन गर्नुहोस्।",
|
||||
"1004": "ट्रान्सफर इन अस्थायी रूपमा रोकिएको छ। पछि प्रयास गर्नुहोस् वा support सम्पर्क गर्नुहोस्।",
|
||||
"1005": "मुद्रा अमान्य वा उपलब्ध छैन।",
|
||||
"1006": "ट्रान्सफर आउट अस्थायी रूपमा रोकिएको छ। पछि प्रयास गर्नुहोस् वा support सम्पर्क गर्नुहोस्।",
|
||||
"1007": "लटरी वालेट freeze छ र रकम सार्न सकिँदैन।",
|
||||
"1008": "रकम ढाँचा अमान्य छ।",
|
||||
"1009": "मुख्य साइटले यो ट्रान्सफर पूरा गर्न सकेन। कृपया पछि प्रयास गर्नुहोस्।",
|
||||
"1010": "फरक रकमका ट्रान्सफरका लागि एउटै idempotency key प्रयोग नगर्नुहोस्।"
|
||||
}
|
||||
},
|
||||
"orders": {
|
||||
"title": "मेरा बेट",
|
||||
"subtitle": "हालका टिकट रेकर्ड",
|
||||
"betDetail": "बेट विवरण",
|
||||
"filteredIssue": "फिल्टर गरिएको इश्यू",
|
||||
"totalRecords": "कुल रेकर्ड",
|
||||
"betNow": "अहिले बेट",
|
||||
"empty": "अहिलेसम्म बेट रेकर्ड छैन।",
|
||||
"submitBet": "बेट पेश गर्नुहोस्",
|
||||
"stake": "बेट",
|
||||
"deduction": "कट्टा",
|
||||
"win": "जित {{amount}}",
|
||||
"detailTitle": "टिकट विवरण",
|
||||
"ticketNo": "टिकट {{ticketNo}}",
|
||||
"orderNo": "अर्डर {{orderNo}}",
|
||||
"drawNo": "इश्यू",
|
||||
"placedAt": "राखेको समय",
|
||||
"number": "नम्बर",
|
||||
"play": "प्ले",
|
||||
"amount": "बेट रकम",
|
||||
"rebateRate": "रिबेट दर",
|
||||
"actualDeduct": "वास्तविक कट्टा",
|
||||
"oddsSnapshot": "Odds snapshot",
|
||||
"drawNumbers": "ड्र नम्बरहरू (यो इश्यू)",
|
||||
"firstPrize": "पहिलो पुरस्कार",
|
||||
"hit": "हिट",
|
||||
"notPublished": "यस इश्यूका ड्र नम्बर प्रकाशित छैनन् वा देखाउन मिल्दैन।",
|
||||
"matchWin": "मिलान नतिजा: {{tier}} हिट",
|
||||
"winAmount": "जित रकम {{amount}}",
|
||||
"jackpotAmount": "Jackpot {{amount}}",
|
||||
"payoutTotal": "कुल भुक्तानी {{amount}}",
|
||||
"matchLose": "मिलान नतिजा: जितेन",
|
||||
"settledAt": "सेटल समय {{time}}",
|
||||
"viewDraw": "यो ड्र हेर्नुहोस्",
|
||||
"backToOrders": "मेरा बेटमा फर्कनुहोस्",
|
||||
"notFound": "टिकट छैन वा हेर्न अनुमति छैन।",
|
||||
"noData": "डेटा छैन",
|
||||
"loadFailed": "लोड असफल"
|
||||
},
|
||||
"results": {
|
||||
"title": "नतिजा",
|
||||
"subtitle": "हालका ड्र इतिहास",
|
||||
"detailTitle": "नतिजा विवरण",
|
||||
"businessDate": "व्यावसायिक मिति",
|
||||
"empty": "अहिलेसम्म नतिजा छैन।",
|
||||
"detail": "विवरण",
|
||||
"loadFailed": "लोड असफल",
|
||||
"unavailable": "यो नतिजा उपलब्ध छैन वा अस्तित्वमा छैन।",
|
||||
"noData": "डेटा छैन",
|
||||
"previous": "‹ अघिल्लो",
|
||||
"next": "अर्को ›",
|
||||
"drawTime": "ड्र समय: {{time}}",
|
||||
"myPayout": "यस इश्यूमा मेरो भुक्तानी",
|
||||
"regular": "सामान्य: {{amount}}",
|
||||
"jackpot": "Jackpot: {{amount}}",
|
||||
"hitPending": "तपाईंको टिकटले यस इश्यूको नतिजा सेल हिट गरेको छ। भुक्तानी पूरा भएपछि रकम देखिनेछ।",
|
||||
"hitHint": "तपाईं जित्नुभयो भने, तपाईंका टिकटसँग मिलेका नम्बरहरू सुनौलो रंगमा देखिन्छन् (लगइन आवश्यक)।",
|
||||
"viewMyWinning": "मेरो जित स्थिति हेर्नुहोस्",
|
||||
"jackpotLabel": "Jackpot",
|
||||
"tier": {
|
||||
"first": "पहिलो पुरस्कार",
|
||||
"second": "दोस्रो पुरस्कार",
|
||||
"third": "तेस्रो पुरस्कार",
|
||||
"starter": "Starter",
|
||||
"consolation": "Consolation"
|
||||
},
|
||||
"grid": {
|
||||
"first": "पहिलो पुरस्कार",
|
||||
"second": "दोस्रो पुरस्कार",
|
||||
"third": "तेस्रो पुरस्कार",
|
||||
"starter": "Starter",
|
||||
"consolation": "Consolation"
|
||||
}
|
||||
},
|
||||
"ticketStatus": {
|
||||
"success": "ड्र पर्खँदै",
|
||||
"settled_win": "भुक्तानी भयो",
|
||||
"settled_lose": "जितेन",
|
||||
"unknown": "{{status}}"
|
||||
},
|
||||
"prizeTier": {
|
||||
"first": "पहिलो पुरस्कार",
|
||||
"second": "दोस्रो पुरस्कार",
|
||||
"third": "तेस्रो पुरस्कार",
|
||||
"starter": "Starter",
|
||||
"consolation": "Consolation"
|
||||
},
|
||||
"playLabels": {
|
||||
"big": "ठूलो",
|
||||
"small": "सानो",
|
||||
"pos_4a": "4A",
|
||||
"pos_4b": "4B",
|
||||
"pos_4c": "4C",
|
||||
"pos_4d": "4D",
|
||||
"pos_4e": "4E",
|
||||
"pos_3a": "3A",
|
||||
"pos_3b": "3B",
|
||||
"pos_3c": "3C",
|
||||
"pos_3abc": "3ABC",
|
||||
"pos_2a": "2A",
|
||||
"pos_2b": "2B",
|
||||
"pos_2c": "2C",
|
||||
"pos_2abc": "2ABC",
|
||||
"straight": "Straight",
|
||||
"box": "Box",
|
||||
"ibox": "iBox",
|
||||
"mbox": "mBox",
|
||||
"roll": "Roll",
|
||||
"half_box": "Half Box",
|
||||
"head": "Head",
|
||||
"tail": "Tail",
|
||||
"odd": "Odd",
|
||||
"even": "Even",
|
||||
"digit_big": "Big Digit",
|
||||
"digit_small": "Small Digit"
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,9 @@
|
||||
"pending": "待处理",
|
||||
"failed": "失败"
|
||||
},
|
||||
"navigation": {
|
||||
"notifications": "通知"
|
||||
},
|
||||
"errors": {
|
||||
"general": "通用"
|
||||
}
|
||||
|
||||
397
src/i18n/locales/zh/player.json
Normal file
397
src/i18n/locales/zh/player.json
Normal file
@@ -0,0 +1,397 @@
|
||||
{
|
||||
"brand": {
|
||||
"name": "N lotto"
|
||||
},
|
||||
"actions": {
|
||||
"retry": "重试",
|
||||
"refresh": "刷新",
|
||||
"apply": "应用",
|
||||
"clear": "清除",
|
||||
"cancel": "取消",
|
||||
"confirm": "确认",
|
||||
"loading": "加载中...",
|
||||
"loadMore": "加载更多",
|
||||
"processing": "处理中...",
|
||||
"deleteRow": "删除第 {{row}} 行"
|
||||
},
|
||||
"nav": {
|
||||
"aria": "主导航",
|
||||
"home": "首页",
|
||||
"results": "开奖结果",
|
||||
"orders": "我的注单",
|
||||
"wallet": "钱包"
|
||||
},
|
||||
"panel": {
|
||||
"home": "首页"
|
||||
},
|
||||
"network": {
|
||||
"offline": "网络已断开,请检查网络连接",
|
||||
"degraded": "网络不稳定,已切换至降级模式(轮询中...)",
|
||||
"retryReconnect": "尝试重连",
|
||||
"retry": "重试",
|
||||
"recoverWebsocket": "尝试恢复 WebSocket 连接",
|
||||
"recover": "恢复",
|
||||
"reconnect": "重新连接"
|
||||
},
|
||||
"token": {
|
||||
"critical": "登录即将失效",
|
||||
"warning": "登录即将过期",
|
||||
"remaining": "剩余 {{time}}",
|
||||
"autoRenewing": "正在自动续签...",
|
||||
"renewNow": "立即续签"
|
||||
},
|
||||
"serverError": {
|
||||
"title": "服务器错误",
|
||||
"appTitle": "应用发生错误",
|
||||
"unavailable": "服务器暂时不可用",
|
||||
"serverMessage": "服务器暂时不可用,请稍后重试",
|
||||
"appMessage": "抱歉,应用遇到了问题,请尝试刷新页面",
|
||||
"details": "错误详情:",
|
||||
"errorId": "错误 ID: {{id}}",
|
||||
"refreshPage": "刷新页面",
|
||||
"hall": "投注大厅",
|
||||
"wallet": "我的钱包",
|
||||
"code": "错误代码: 500 Internal Server Error",
|
||||
"home": "返回首页"
|
||||
},
|
||||
"notFound": {
|
||||
"title": "页面不存在",
|
||||
"description": "抱歉,您访问的页面可能已被删除、移动或从未存在过。",
|
||||
"home": "返回首页",
|
||||
"hall": "投注大厅",
|
||||
"results": "开奖结果",
|
||||
"wallet": "我的钱包"
|
||||
},
|
||||
"player": {
|
||||
"fallback": "玩家 #{{id}}"
|
||||
},
|
||||
"draw": {
|
||||
"currentIssue": "当前期号",
|
||||
"currentTime": "当前时间",
|
||||
"closesIn": "距封盘",
|
||||
"drawsIn": "距开奖",
|
||||
"coolDown": "冷静期",
|
||||
"loadFailedRefresh": "加载失败,请下拉刷新",
|
||||
"issueNo": "期号",
|
||||
"hall": "下注大厅",
|
||||
"noIssue": "暂无可用期号,请稍后再试",
|
||||
"sealedNotice": "已封盘,请等待下一期。",
|
||||
"status": {
|
||||
"pending": "未开始",
|
||||
"open": "可下注",
|
||||
"closing": "已封盘",
|
||||
"closed": "待开奖",
|
||||
"drawing": "开奖中",
|
||||
"review": "待审核",
|
||||
"cooldown": "冷静期",
|
||||
"settling": "结算中",
|
||||
"settled": "已结算",
|
||||
"cancelled": "已取消"
|
||||
}
|
||||
},
|
||||
"hall": {
|
||||
"aria": "下注表格",
|
||||
"loadingError": "加载玩法失败,请稍后重试。",
|
||||
"noDraw": "暂无当期期号,无法提交。",
|
||||
"notBettable": "当前已封盘或不可下注。",
|
||||
"catalogNotReady": "玩法配置尚未加载完成。",
|
||||
"emptyLines": "请至少填写一组有效号码和下注金额。",
|
||||
"previewFailed": "预览失败",
|
||||
"closedSubmit": "已封盘,无法提交。",
|
||||
"changedBeforeSubmit": "提交前数据已变化,请关闭预览后重试。",
|
||||
"placeFailed": "提交失败",
|
||||
"placeSuccess": "下注成功,订单号 {{orderNo}},实扣 {{amount}}。",
|
||||
"closed": {
|
||||
"title": "已封盘",
|
||||
"subtitle": "当前期已停止接收注单。",
|
||||
"description": "下注窗口已关闭,请等待下一期再下注。"
|
||||
},
|
||||
"boxMode": {
|
||||
"iboxTitle": "iBox",
|
||||
"iboxDesc": "按金额分摊全部组合",
|
||||
"boxTitle": "Box",
|
||||
"boxDesc": "按金额乘以全部组合"
|
||||
},
|
||||
"table": {
|
||||
"no": "序号",
|
||||
"number": "号码",
|
||||
"stake": "下注金额",
|
||||
"rebate": "佣金 / 回水",
|
||||
"actual": "实扣金额",
|
||||
"delete": "删除",
|
||||
"addRow": "添加一行",
|
||||
"draftTotal": "草稿合计",
|
||||
"sealedHint": "已封盘:当前表格不可编辑,请等待下一期。",
|
||||
"previewing": "预览中...",
|
||||
"submitBet": "提交下注"
|
||||
},
|
||||
"preview": {
|
||||
"title": "确认下注",
|
||||
"description": "请核对号码、玩法与实扣金额;确认后将扣减彩票钱包且不可撤单。",
|
||||
"sealedTitle": "已封盘",
|
||||
"sealedDescription": "当前期已停止接收注单,无法提交。请选择下一期。",
|
||||
"empty": "暂无预览数据",
|
||||
"draw": "期号",
|
||||
"status": "状态",
|
||||
"totalBet": "总下注",
|
||||
"rebateDeduct": "回水抵扣",
|
||||
"actualDeduct": "实扣金额",
|
||||
"estimatedPayout": "预估最高赔付",
|
||||
"lines": "注项明细",
|
||||
"normalizedNumber": "归一号码",
|
||||
"combinationCount": "组合数",
|
||||
"actual": "实扣",
|
||||
"estimatedMax": "预估最高赔",
|
||||
"backEdit": "返回修改",
|
||||
"submitting": "提交中...",
|
||||
"confirmSubmit": "确认提交",
|
||||
"warningsTitle": "赔付池预警",
|
||||
"warningsDescription": "以下号码本期赔付池占用较高,仍允许下注;若实际占用不足将售罄拒单。"
|
||||
},
|
||||
"amountInput": {
|
||||
"limit": "限额 {{min}} - {{max}}",
|
||||
"placeholder": "例如 100.00"
|
||||
},
|
||||
"numberInput": {
|
||||
"rollPlaceholder": "如 12R4",
|
||||
"digitPlaceholder": "0-9"
|
||||
},
|
||||
"playSwitcher": {
|
||||
"empty": "当前币种下没有可下注的开放玩法。",
|
||||
"label": "玩法"
|
||||
},
|
||||
"playCatalog": {
|
||||
"notReady": "玩法配置尚未初始化。请在 Laravel 执行含 OperationalConfigV1Seeder 的 seed。",
|
||||
"loadFailed": "加载玩法配置失败,请稍后重试。",
|
||||
"loading": "加载玩法与赔率...",
|
||||
"meta": "币种 {{currency}} · 配置版本 play#{{playVersion}} / odds#{{oddsVersion}} · 限额单位为最小货币单位(与钱包一致)。",
|
||||
"play": "玩法",
|
||||
"status": "状态",
|
||||
"limit": "下注限额",
|
||||
"odds": "赔率×",
|
||||
"description": "说明",
|
||||
"open": "开放",
|
||||
"closed": "关闭",
|
||||
"riskTitle": "风控封顶(示例号码)",
|
||||
"number": "号码",
|
||||
"capAmount": "封顶额",
|
||||
"type": "类型",
|
||||
"title": "玩法与赔率",
|
||||
"descriptionText": "数据来自 GET /api/v1/play/effective;后台修改并发布后,最长约 {{seconds}}s 内自动刷新,也可手动刷新。",
|
||||
"refresh": "刷新配置"
|
||||
},
|
||||
"ticketError": {
|
||||
"4001": "该号码本期赔付池不足,已售罄。请更换号码、金额或玩法后重试。",
|
||||
"1001": "余额不足,请先转入后再下注。",
|
||||
"2001": "本期已封盘,无法继续下注。",
|
||||
"2002": "该玩法已关闭,请选择其他玩法。",
|
||||
"2004": "号码格式或长度不符合该玩法要求。",
|
||||
"2005": "玩法参数不完整(如单双大小需选择位数与维度)。",
|
||||
"2006": "当前期号不可下注。",
|
||||
"2007": "该玩法暂不支持或缺少赔率配置。",
|
||||
"2008": "赔率或玩法配置已更新,请关闭预览后重新操作。",
|
||||
"1003": "下注金额超出该玩法允许范围。",
|
||||
"fallback": "下注失败,请稍后重试。"
|
||||
},
|
||||
"amountHint": {
|
||||
"iboxRoll": "本玩法金额为单注金额,系统按展开组合数计算总下注与实扣。",
|
||||
"mbox": "本玩法金额为总输入金额,将均摊到各排列组合(向下取整到最小单位)。",
|
||||
"default": "金额为该笔注单的下注额(最小货币单位整数,与钱包一致)。"
|
||||
}
|
||||
},
|
||||
"wallet": {
|
||||
"title": "钱包",
|
||||
"subtitle": "余额与划转记录",
|
||||
"balance": "钱包余额",
|
||||
"available": "可用 {{amount}}",
|
||||
"transferIn": "转入",
|
||||
"transferOut": "转出",
|
||||
"inPage": "转入页",
|
||||
"outPage": "转出页",
|
||||
"logs": "流水",
|
||||
"logsTitle": "钱包流水",
|
||||
"logsSubtitle": "划转与下注记录",
|
||||
"typeFilter": "类型筛选",
|
||||
"transferInTitle": "转入资金",
|
||||
"transferOutTitle": "转出资金",
|
||||
"transferInSubtitle": "转入彩票钱包({{currency}})",
|
||||
"transferOutSubtitle": "转回主站钱包({{currency}})",
|
||||
"transferInDescription": "从主站钱包划入彩票钱包,单笔限额以服务端校验为准。",
|
||||
"transferOutDescription": "划回主站钱包;单笔限额以服务端校验为准。",
|
||||
"dialogInDescription": "从主站钱包划入彩票钱包(最小单笔以服务端校验为准,默认约 1.00 {{currency}})。",
|
||||
"dialogOutDescription": "划回主站钱包;单笔限额以服务端校验为准。",
|
||||
"mainBalance": "主站钱包余额:",
|
||||
"mainPending": "—(待接入主站)",
|
||||
"lotteryBalance": "彩票钱包余额:",
|
||||
"lotteryAvailable": "彩票钱包可用:",
|
||||
"inAmount": "转入金额",
|
||||
"outAmount": "转出金额",
|
||||
"exampleIn": "例如 1000.00",
|
||||
"exampleOut": "例如 500.00",
|
||||
"afterInPreview": "转入后彩票余额(预览): {{amount}}",
|
||||
"afterOutPreview": "转出后彩票余额(预览): {{amount}}",
|
||||
"allOut": "全部转出 {{amount}}",
|
||||
"confirmIn": "确认转入",
|
||||
"confirmOut": "确认转出",
|
||||
"successIn": "转入成功,彩票钱包余额已更新。",
|
||||
"successOut": "转出成功,资金将返回主站钱包。",
|
||||
"pendingShort": "转账处理中,请稍后刷新。",
|
||||
"pendingToast": "处理中...",
|
||||
"invalidAmount": "请输入有效金额。",
|
||||
"outExceeds": "转出金额不能超过可用余额。",
|
||||
"pendingTitle": "待对账",
|
||||
"pendingDescription": "以下划转主站结果未最终确认;若长时间未到账请联系客服。",
|
||||
"pendingStatus": "处理中",
|
||||
"flowsTitle": "资金流水",
|
||||
"totalRecords": "共 {{total}} 条记录",
|
||||
"emptyLogs": "暂无流水",
|
||||
"flow": {
|
||||
"all": "全部",
|
||||
"transfer_in": "转入",
|
||||
"transfer_out": "转出",
|
||||
"bet": "下注扣款",
|
||||
"prize": "派彩",
|
||||
"refund": "退本",
|
||||
"reversal": "冲正"
|
||||
},
|
||||
"txnStatus": {
|
||||
"posted": "成功",
|
||||
"pending_reconcile": "待对账",
|
||||
"reversed": "已冲正",
|
||||
"manually_processed": "已人工处理"
|
||||
},
|
||||
"error": {
|
||||
"401": "登录已失效,请返回重新进入。",
|
||||
"409": "转账处理中,请稍后刷新;若长时间未到账请联系客服。",
|
||||
"network": "网络异常,请检查连接后重试。",
|
||||
"timeout": "请求超时,请稍后重试。",
|
||||
"fallback": "请求失败,请稍后重试。",
|
||||
"1001": "彩票钱包余额不足,请转入或减少转出金额。",
|
||||
"1002": "转账处理中,请稍后在本页刷新余额;若长时间未到账请联系客服。",
|
||||
"1003": "金额超出单笔或每日限额,请调整金额。",
|
||||
"1004": "当前已暂停转入,请稍后再试或联系客服。",
|
||||
"1005": "币种无效或未开通。",
|
||||
"1006": "当前已暂停转出,请稍后再试或联系客服。",
|
||||
"1007": "彩票钱包已冻结,暂无法划转。",
|
||||
"1008": "金额格式不正确。",
|
||||
"1009": "主站未能完成本次划转,请稍后重试。",
|
||||
"1010": "请勿用同一幂等键发起不同金额的转账。"
|
||||
}
|
||||
},
|
||||
"orders": {
|
||||
"title": "我的注单",
|
||||
"subtitle": "最近下注记录",
|
||||
"betDetail": "注单详情",
|
||||
"filteredIssue": "已筛选期号",
|
||||
"totalRecords": "总记录数",
|
||||
"betNow": "立即下注",
|
||||
"empty": "暂无下注记录。",
|
||||
"submitBet": "提交下注",
|
||||
"stake": "下注",
|
||||
"deduction": "实扣",
|
||||
"win": "中奖 {{amount}}",
|
||||
"detailTitle": "注单详情",
|
||||
"ticketNo": "注单号 {{ticketNo}}",
|
||||
"orderNo": "订单 {{orderNo}}",
|
||||
"drawNo": "期号",
|
||||
"placedAt": "下单时间",
|
||||
"number": "号码",
|
||||
"play": "玩法",
|
||||
"amount": "下注金额",
|
||||
"rebateRate": "回水率",
|
||||
"actualDeduct": "实扣金额",
|
||||
"oddsSnapshot": "赔率快照",
|
||||
"drawNumbers": "开奖号码(本期)",
|
||||
"firstPrize": "头奖",
|
||||
"hit": "命中",
|
||||
"notPublished": "本期开奖号码尚未发布或不可展示。",
|
||||
"matchWin": "匹配结果:命中 {{tier}}",
|
||||
"winAmount": "中奖金额 {{amount}}",
|
||||
"jackpotAmount": "Jackpot {{amount}}",
|
||||
"payoutTotal": "派彩合计 {{amount}}",
|
||||
"matchLose": "匹配结果:未中奖",
|
||||
"settledAt": "结算时间 {{time}}",
|
||||
"viewDraw": "查看本期开奖",
|
||||
"backToOrders": "返回我的注单",
|
||||
"notFound": "注单不存在或无权查看",
|
||||
"noData": "无数据",
|
||||
"loadFailed": "加载失败"
|
||||
},
|
||||
"results": {
|
||||
"title": "开奖结果",
|
||||
"subtitle": "最新开奖历史",
|
||||
"detailTitle": "开奖详情",
|
||||
"businessDate": "业务日期",
|
||||
"empty": "暂无开奖结果。",
|
||||
"detail": "详情",
|
||||
"loadFailed": "加载失败",
|
||||
"unavailable": "该期开奖结果不可用或不存在",
|
||||
"noData": "无数据",
|
||||
"previous": "‹ 上一期",
|
||||
"next": "下一期 ›",
|
||||
"drawTime": "开奖时间: {{time}}",
|
||||
"myPayout": "本期我的派彩",
|
||||
"regular": "常规:{{amount}}",
|
||||
"jackpot": "Jackpot:{{amount}}",
|
||||
"hitPending": "您的注单已命中本期开奖号码中的格子;派彩完成后将显示金额汇总。",
|
||||
"hitHint": "如果您中奖,与注单匹配的号码将以金色高亮显示(需登录)。",
|
||||
"viewMyWinning": "查看我的中奖情况",
|
||||
"jackpotLabel": "Jackpot",
|
||||
"tier": {
|
||||
"first": "头奖",
|
||||
"second": "二奖",
|
||||
"third": "三奖",
|
||||
"starter": "特别奖",
|
||||
"consolation": "安慰奖"
|
||||
},
|
||||
"grid": {
|
||||
"first": "头奖",
|
||||
"second": "二奖",
|
||||
"third": "三奖",
|
||||
"starter": "特别奖",
|
||||
"consolation": "安慰奖"
|
||||
}
|
||||
},
|
||||
"ticketStatus": {
|
||||
"success": "待开奖",
|
||||
"settled_win": "已派彩",
|
||||
"settled_lose": "未中奖",
|
||||
"unknown": "{{status}}"
|
||||
},
|
||||
"prizeTier": {
|
||||
"first": "头奖",
|
||||
"second": "二奖",
|
||||
"third": "三奖",
|
||||
"starter": "特别奖",
|
||||
"consolation": "安慰奖"
|
||||
},
|
||||
"playLabels": {
|
||||
"big": "大",
|
||||
"small": "小",
|
||||
"pos_4a": "4A",
|
||||
"pos_4b": "4B",
|
||||
"pos_4c": "4C",
|
||||
"pos_4d": "4D",
|
||||
"pos_4e": "4E",
|
||||
"pos_3a": "3A",
|
||||
"pos_3b": "3B",
|
||||
"pos_3c": "3C",
|
||||
"pos_3abc": "3ABC",
|
||||
"pos_2a": "2A",
|
||||
"pos_2b": "2B",
|
||||
"pos_2c": "2C",
|
||||
"pos_2abc": "2ABC",
|
||||
"straight": "Straight",
|
||||
"box": "Box",
|
||||
"ibox": "iBox",
|
||||
"mbox": "mBox",
|
||||
"roll": "Roll",
|
||||
"half_box": "Half Box",
|
||||
"head": "头",
|
||||
"tail": "尾",
|
||||
"odd": "单",
|
||||
"even": "双",
|
||||
"digit_big": "位数大",
|
||||
"digit_small": "位数小"
|
||||
}
|
||||
}
|
||||
@@ -32,3 +32,13 @@ const LABELS: Record<string, string> = {
|
||||
export function playLabelZh(playCode: string): string {
|
||||
return LABELS[playCode] ?? playCode;
|
||||
}
|
||||
|
||||
export function playLabel(
|
||||
playCode: string,
|
||||
t?: (key: string, options?: { defaultValue?: string }) => string,
|
||||
): string {
|
||||
if (!t) {
|
||||
return playLabelZh(playCode);
|
||||
}
|
||||
return t(`playLabels.${playCode}`, { defaultValue: LABELS[playCode] ?? playCode });
|
||||
}
|
||||
|
||||
@@ -3,30 +3,37 @@ import { isAxiosError } from "axios";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
|
||||
/** 钱包 / 转账 API 对用户展示的中文说明(优先业务码,其次 HTTP) */
|
||||
export function formatWalletClientError(error: unknown): string {
|
||||
export function formatWalletClientError(
|
||||
error: unknown,
|
||||
t?: (key: string) => string,
|
||||
): string {
|
||||
if (error instanceof LotteryApiBizError) {
|
||||
const m = WALLET_CODE_MESSAGES[error.code];
|
||||
const m = walletCodeMessage(error.code, t);
|
||||
if (m) return m;
|
||||
if (error.message.trim()) return error.message;
|
||||
}
|
||||
if (isAxiosError(error)) {
|
||||
if (error.response?.status === 401) {
|
||||
return "登录已失效,请返回重新进入。";
|
||||
return msg(t, "wallet.error.401", "登录已失效,请返回重新进入。");
|
||||
}
|
||||
if (error.response?.status === 409) {
|
||||
return "转账处理中,请稍后刷新;若长时间未到账请联系客服。";
|
||||
return msg(
|
||||
t,
|
||||
"wallet.error.409",
|
||||
"转账处理中,请稍后刷新;若长时间未到账请联系客服。",
|
||||
);
|
||||
}
|
||||
if (!error.response) {
|
||||
return "网络异常,请检查连接后重试。";
|
||||
return msg(t, "wallet.error.network", "网络异常,请检查连接后重试。");
|
||||
}
|
||||
if (error.code === "ECONNABORTED") {
|
||||
return "请求超时,请稍后重试。";
|
||||
return msg(t, "wallet.error.timeout", "请求超时,请稍后重试。");
|
||||
}
|
||||
}
|
||||
if (error instanceof Error && error.message.trim()) {
|
||||
return error.message;
|
||||
}
|
||||
return "请求失败,请稍后重试。";
|
||||
return msg(t, "wallet.error.fallback", "请求失败,请稍后重试。");
|
||||
}
|
||||
|
||||
const WALLET_CODE_MESSAGES: Record<number, string> = {
|
||||
@@ -41,3 +48,20 @@ const WALLET_CODE_MESSAGES: Record<number, string> = {
|
||||
1009: "主站未能完成本次划转,请稍后重试。",
|
||||
1010: "请勿用同一幂等键发起不同金额的转账。",
|
||||
};
|
||||
|
||||
function msg(
|
||||
t: ((key: string) => string) | undefined,
|
||||
key: string,
|
||||
fallback: string,
|
||||
): string {
|
||||
return t?.(key) ?? fallback;
|
||||
}
|
||||
|
||||
function walletCodeMessage(
|
||||
code: number,
|
||||
t: ((key: string) => string) | undefined,
|
||||
): string | undefined {
|
||||
const fallback = WALLET_CODE_MESSAGES[code];
|
||||
if (!fallback) return undefined;
|
||||
return msg(t, `wallet.error.${code}`, fallback);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user