refactor: 完成全站国际化改造,统一多语言支持
此提交完成了全项目的国际化适配: 1. 新增多语言翻译文件与基础配置 2. 替换所有硬编码文本为i18n调用 3. 优化语言切换与文档语言同步逻辑 4. 重构部分业务逻辑以支持动态翻译 5. 移除过时代码与硬编码配置
This commit is contained in:
@@ -10,10 +10,6 @@ export default async function DrawResultByNoPage(props: PageProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<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} />
|
<DrawResultDetailScreen drawNo={drawNo} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,13 +3,8 @@ import { DrawResultsListScreen } from "@/features/results/draw-results-list-scre
|
|||||||
export default function DrawResultsHistoryPage() {
|
export default function DrawResultsHistoryPage() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<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 />
|
<DrawResultsListScreen />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { EntryGate } from "@/features/player/entry-gate";
|
|||||||
function EntryFallback(): ReactNode {
|
function EntryFallback(): ReactNode {
|
||||||
return (
|
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">
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,10 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { AlertTriangle, Home, RotateCcw } from "lucide-react";
|
import { AlertTriangle, Home, RotateCcw } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { ERROR_COLORS } from "@/stores/error-store";
|
import { ERROR_COLORS } from "@/stores/error-store";
|
||||||
|
import "@/i18n";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Next.js 错误边界组件
|
* Next.js 错误边界组件
|
||||||
@@ -18,6 +20,8 @@ export default function ErrorBoundary({
|
|||||||
error: Error & { digest?: string };
|
error: Error & { digest?: string };
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
|
const { t } = useTranslation("player");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 记录错误日志
|
// 记录错误日志
|
||||||
console.error("[ErrorBoundary] Application error:", error);
|
console.error("[ErrorBoundary] Application error:", error);
|
||||||
@@ -52,26 +56,28 @@ export default function ErrorBoundary({
|
|||||||
|
|
||||||
{/* 错误标题 */}
|
{/* 错误标题 */}
|
||||||
<h1 className="mb-2 text-2xl font-bold text-foreground">
|
<h1 className="mb-2 text-2xl font-bold text-foreground">
|
||||||
{isServerError ? "服务器暂时不可用" : "应用发生错误"}
|
{isServerError ? t("serverError.unavailable") : t("serverError.appTitle")}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{/* 错误消息 */}
|
{/* 错误消息 */}
|
||||||
<p className="mb-2 text-base text-muted-foreground">
|
<p className="mb-2 text-base text-muted-foreground">
|
||||||
{isServerError
|
{isServerError
|
||||||
? "服务器暂时不可用,请稍后重试"
|
? t("serverError.serverMessage")
|
||||||
: "抱歉,应用遇到了问题,请尝试刷新页面"}
|
: t("serverError.appMessage")}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* 技术详情(开发模式) */}
|
{/* 技术详情(开发模式) */}
|
||||||
{process.env.NODE_ENV === "development" && (
|
{process.env.NODE_ENV === "development" && (
|
||||||
<div className="mb-6 w-full rounded-lg bg-muted p-3 text-left">
|
<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">
|
<code className="block max-h-32 overflow-auto whitespace-pre-wrap break-words text-xs text-destructive">
|
||||||
{error.message}
|
{error.message}
|
||||||
</code>
|
</code>
|
||||||
{error.digest && (
|
{error.digest && (
|
||||||
<p className="mt-2 text-xs text-muted-foreground">
|
<p className="mt-2 text-xs text-muted-foreground">
|
||||||
错误 ID: {error.digest}
|
{t("serverError.errorId", { id: error.digest })}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 }}
|
style={{ borderColor: isServerError ? ERROR_COLORS.error : ERROR_COLORS.warning, color: isServerError ? ERROR_COLORS.error : ERROR_COLORS.warning }}
|
||||||
>
|
>
|
||||||
<RotateCcw className="size-4" />
|
<RotateCcw className="size-4" />
|
||||||
重试
|
{t("actions.retry")}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
@@ -96,7 +102,7 @@ export default function ErrorBoundary({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Home className="size-4" />
|
<Home className="size-4" />
|
||||||
返回首页
|
{t("serverError.home")}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -106,21 +112,21 @@ export default function ErrorBoundary({
|
|||||||
onClick={() => window.location.reload()}
|
onClick={() => window.location.reload()}
|
||||||
className="hover:text-foreground hover:underline"
|
className="hover:text-foreground hover:underline"
|
||||||
>
|
>
|
||||||
刷新页面
|
{t("serverError.refreshPage")}
|
||||||
</button>
|
</button>
|
||||||
<span>|</span>
|
<span>|</span>
|
||||||
<Link
|
<Link
|
||||||
href="/hall"
|
href="/hall"
|
||||||
className="hover:text-foreground hover:underline"
|
className="hover:text-foreground hover:underline"
|
||||||
>
|
>
|
||||||
投注大厅
|
{t("serverError.hall")}
|
||||||
</Link>
|
</Link>
|
||||||
<span>|</span>
|
<span>|</span>
|
||||||
<Link
|
<Link
|
||||||
href="/wallet"
|
href="/wallet"
|
||||||
className="hover:text-foreground hover:underline"
|
className="hover:text-foreground hover:underline"
|
||||||
>
|
>
|
||||||
我的钱包
|
{t("serverError.wallet")}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
|||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
|
||||||
import { Providers } from "@/components/providers";
|
import { Providers } from "@/components/providers";
|
||||||
|
import { DEFAULT_LANGUAGE } from "@/i18n";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
@@ -26,7 +27,7 @@ export default function RootLayout({
|
|||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
lang="en"
|
lang={DEFAULT_LANGUAGE}
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { FileQuestion, Home } from "lucide-react";
|
import { FileQuestion, Home } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { ERROR_COLORS } from "@/stores/error-store";
|
import { ERROR_COLORS } from "@/stores/error-store";
|
||||||
|
import "@/i18n";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 全局 404 页面
|
* 全局 404 页面
|
||||||
@@ -13,6 +15,8 @@ import { ERROR_COLORS } from "@/stores/error-store";
|
|||||||
* 使用中性颜色 #d9d9d9 作为背景/插图
|
* 使用中性颜色 #d9d9d9 作为背景/插图
|
||||||
*/
|
*/
|
||||||
export default function NotFoundPage(): React.ReactElement {
|
export default function NotFoundPage(): React.ReactElement {
|
||||||
|
const { t } = useTranslation("player");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex min-h-screen flex-col items-center justify-center p-4"
|
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">
|
<h1 className="mb-3 text-2xl font-bold text-gray-800">
|
||||||
页面不存在
|
{t("notFound.title")}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{/* 描述 */}
|
{/* 描述 */}
|
||||||
<p className="mb-8 text-base text-gray-500">
|
<p className="mb-8 text-base text-gray-500">
|
||||||
抱歉,您访问的页面可能已被删除、移动或从未存在过。
|
{t("notFound.description")}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* 返回首页按钮 */}
|
{/* 返回首页按钮 */}
|
||||||
@@ -57,21 +61,21 @@ export default function NotFoundPage(): React.ReactElement {
|
|||||||
style={{ backgroundColor: "#333" }}
|
style={{ backgroundColor: "#333" }}
|
||||||
>
|
>
|
||||||
<Home className="size-5" />
|
<Home className="size-5" />
|
||||||
返回首页
|
{t("notFound.home")}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* 帮助链接 */}
|
{/* 帮助链接 */}
|
||||||
<div className="mt-8 flex gap-4 text-sm text-gray-400">
|
<div className="mt-8 flex gap-4 text-sm text-gray-400">
|
||||||
<Link href="/hall" className="hover:text-gray-600 hover:underline">
|
<Link href="/hall" className="hover:text-gray-600 hover:underline">
|
||||||
投注大厅
|
{t("notFound.hall")}
|
||||||
</Link>
|
</Link>
|
||||||
<span>|</span>
|
<span>|</span>
|
||||||
<Link href="/results" className="hover:text-gray-600 hover:underline">
|
<Link href="/results" className="hover:text-gray-600 hover:underline">
|
||||||
开奖结果
|
{t("notFound.results")}
|
||||||
</Link>
|
</Link>
|
||||||
<span>|</span>
|
<span>|</span>
|
||||||
<Link href="/wallet" className="hover:text-gray-600 hover:underline">
|
<Link href="/wallet" className="hover:text-gray-600 hover:underline">
|
||||||
我的钱包
|
{t("notFound.wallet")}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -172,13 +172,12 @@ export function IframeBridge({ children }: { children: ReactNode }): ReactNode {
|
|||||||
window.removeEventListener("message", handleMessage);
|
window.removeEventListener("message", handleMessage);
|
||||||
clearInterval(heartbeat);
|
clearInterval(heartbeat);
|
||||||
};
|
};
|
||||||
}, [notifyReady, sendToParent, setBearerToken]);
|
}, [notifyReady, notifyTokenRefreshed, sendToParent, setBearerToken]);
|
||||||
|
|
||||||
// 暴露全局方法供调试
|
// 暴露全局方法供调试
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
(window as unknown as Record<string, unknown>).lotteryIframeBridge = {
|
(window as unknown as Record<string, unknown>).lotteryIframeBridge = {
|
||||||
notifyReady,
|
notifyReady,
|
||||||
notifyTokenNeeded,
|
notifyTokenNeeded,
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export function LanguageSwitcher({
|
|||||||
label: t(`language.${item.code}`),
|
label: t(`language.${item.code}`),
|
||||||
short: t(`languageShort.${item.code}`),
|
short: t(`languageShort.${item.code}`),
|
||||||
})),
|
})),
|
||||||
[t, i18n.language],
|
[t],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ type PlayerAppShellProps = {
|
|||||||
*/
|
*/
|
||||||
export function PlayerAppShell({ children }: PlayerAppShellProps): ReactNode {
|
export function PlayerAppShell({ children }: PlayerAppShellProps): ReactNode {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-dvh bg-[#f3f7fd] text-foreground">
|
<div className="min-h-dvh bg-white text-foreground">
|
||||||
<NetworkStatusBanner />
|
<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}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
<PlayerBottomNav />
|
<PlayerBottomNav />
|
||||||
|
|||||||
@@ -4,26 +4,27 @@ import Link from "next/link";
|
|||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
import { BarChart3, ClipboardList, Home, Wallet } from "lucide-react";
|
import { BarChart3, ClipboardList, Home, Wallet } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const tabs = [
|
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",
|
href: "/results",
|
||||||
label: "Results",
|
labelKey: "nav.results",
|
||||||
icon: BarChart3,
|
icon: BarChart3,
|
||||||
match: (p: string) => p === "/results" || p.startsWith("/results/"),
|
match: (p: string) => p === "/results" || p.startsWith("/results/"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: "/orders",
|
href: "/orders",
|
||||||
label: "My Bets",
|
labelKey: "nav.orders",
|
||||||
icon: ClipboardList,
|
icon: ClipboardList,
|
||||||
match: (p: string) => p === "/orders" || p.startsWith("/orders/"),
|
match: (p: string) => p === "/orders" || p.startsWith("/orders/"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: "/wallet",
|
href: "/wallet",
|
||||||
label: "Wallet",
|
labelKey: "nav.wallet",
|
||||||
icon: Wallet,
|
icon: Wallet,
|
||||||
match: (p: string) => p === "/wallet" || p.startsWith("/wallet/"),
|
match: (p: string) => p === "/wallet" || p.startsWith("/wallet/"),
|
||||||
},
|
},
|
||||||
@@ -34,15 +35,17 @@ const tabs = [
|
|||||||
*/
|
*/
|
||||||
export function PlayerBottomNav() {
|
export function PlayerBottomNav() {
|
||||||
const pathname = usePathname() ?? "";
|
const pathname = usePathname() ?? "";
|
||||||
|
const { t } = useTranslation("player");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav
|
<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"
|
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))]">
|
<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 active = match(pathname);
|
||||||
|
const label = t(labelKey);
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={href}
|
key={href}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Link from "next/link";
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
import { Bell, ChevronLeft } from "lucide-react";
|
import { Bell, ChevronLeft } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -24,24 +25,28 @@ export function PlayerPanel({
|
|||||||
eyebrow,
|
eyebrow,
|
||||||
children,
|
children,
|
||||||
backHref = "/hall",
|
backHref = "/hall",
|
||||||
backLabel = "Home",
|
backLabel,
|
||||||
className,
|
className,
|
||||||
}: PlayerPanelProps) {
|
}: PlayerPanelProps) {
|
||||||
|
const { t } = useTranslation("common");
|
||||||
|
const { t: tp } = useTranslation("player");
|
||||||
|
const resolvedBackLabel = backLabel ?? tp("panel.home");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-[480px]">
|
<div className="mx-auto w-full max-w-[480px]">
|
||||||
<section
|
<section
|
||||||
className={cn(
|
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,
|
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
|
<Link
|
||||||
href={backHref}
|
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]"
|
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 />
|
<ChevronLeft className="size-4" aria-hidden />
|
||||||
{backLabel}
|
{resolvedBackLabel}
|
||||||
</Link>
|
</Link>
|
||||||
<div className="min-w-0 flex-1 text-center">
|
<div className="min-w-0 flex-1 text-center">
|
||||||
{eyebrow ? (
|
{eyebrow ? (
|
||||||
@@ -66,7 +71,7 @@ export function PlayerPanel({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="relative flex size-9 shrink-0 items-center justify-center rounded-full text-[#1d57b7] hover:bg-[#f4f7fb]"
|
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 />
|
<Bell className="size-5" aria-hidden />
|
||||||
<span className="absolute right-2 top-2 size-2 rounded-full bg-[#ff143d]" />
|
<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 { WifiOff, RefreshCw, AlertCircle } from "lucide-react";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { useWebSocketManager } from "@/hooks/use-websocket-manager";
|
import { useWebSocketManager } from "@/hooks/use-websocket-manager";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -20,6 +21,7 @@ const COLORS = {
|
|||||||
*/
|
*/
|
||||||
export function NetworkStatusBanner(): React.ReactElement | null {
|
export function NetworkStatusBanner(): React.ReactElement | null {
|
||||||
const { showDegradedBanner, mode, reconnect } = useWebSocketManager();
|
const { showDegradedBanner, mode, reconnect } = useWebSocketManager();
|
||||||
|
const { t } = useTranslation("player");
|
||||||
|
|
||||||
const handleReconnectClick = useCallback(() => {
|
const handleReconnectClick = useCallback(() => {
|
||||||
reconnect();
|
reconnect();
|
||||||
@@ -48,29 +50,27 @@ export function NetworkStatusBanner(): React.ReactElement | null {
|
|||||||
{isOffline ? (
|
{isOffline ? (
|
||||||
<>
|
<>
|
||||||
<WifiOff className="size-4 shrink-0" aria-hidden />
|
<WifiOff className="size-4 shrink-0" aria-hidden />
|
||||||
<span className="font-medium">网络已断开,请检查网络连接</span>
|
<span className="font-medium">{t("network.offline")}</span>
|
||||||
<button
|
<button
|
||||||
onClick={handleReconnectClick}
|
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"
|
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 />
|
<RefreshCw className="size-3" aria-hidden />
|
||||||
重试
|
{t("network.retry")}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<AlertCircle className="size-4 shrink-0" aria-hidden />
|
<AlertCircle className="size-4 shrink-0" aria-hidden />
|
||||||
<span className="font-medium">
|
<span className="font-medium">{t("network.degraded")}</span>
|
||||||
网络不稳定,已切换至降级模式(轮询中...)
|
|
||||||
</span>
|
|
||||||
<button
|
<button
|
||||||
onClick={handleReconnectClick}
|
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"
|
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 />
|
<RefreshCw className="size-3" aria-hidden />
|
||||||
恢复
|
{t("network.recover")}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { WifiOff, RefreshCw } from "lucide-react";
|
import { WifiOff, RefreshCw } from "lucide-react";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { useErrorStore, ERROR_COLORS } from "@/stores/error-store";
|
import { useErrorStore, ERROR_COLORS } from "@/stores/error-store";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -13,6 +14,7 @@ import { cn } from "@/lib/utils";
|
|||||||
*/
|
*/
|
||||||
export function OfflineBanner(): React.ReactElement | null {
|
export function OfflineBanner(): React.ReactElement | null {
|
||||||
const isOffline = useErrorStore((state) => state.isOffline);
|
const isOffline = useErrorStore((state) => state.isOffline);
|
||||||
|
const { t } = useTranslation("player");
|
||||||
|
|
||||||
const handleReconnect = useCallback(() => {
|
const handleReconnect = useCallback(() => {
|
||||||
// 尝试重新加载页面
|
// 尝试重新加载页面
|
||||||
@@ -40,14 +42,14 @@ export function OfflineBanner(): React.ReactElement | null {
|
|||||||
aria-live="assertive"
|
aria-live="assertive"
|
||||||
>
|
>
|
||||||
<WifiOff className="size-4 shrink-0" aria-hidden />
|
<WifiOff className="size-4 shrink-0" aria-hidden />
|
||||||
<span className="font-medium">网络已断开,请检查网络连接</span>
|
<span className="font-medium">{t("network.offline")}</span>
|
||||||
<button
|
<button
|
||||||
onClick={handleReconnect}
|
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"
|
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 />
|
<RefreshCw className="size-3.5" aria-hidden />
|
||||||
重新连接
|
{t("network.reconnect")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { ServerCrash, Home, RefreshCw } from "lucide-react";
|
import { ServerCrash, Home, RefreshCw } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { useErrorStore, ERROR_COLORS } from "@/stores/error-store";
|
import { useErrorStore, ERROR_COLORS } from "@/stores/error-store";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -15,6 +16,7 @@ import { cn } from "@/lib/utils";
|
|||||||
*/
|
*/
|
||||||
export function ServerError(): React.ReactElement | null {
|
export function ServerError(): React.ReactElement | null {
|
||||||
const { isServerError, serverErrorMessage, clearServerError } = useErrorStore();
|
const { isServerError, serverErrorMessage, clearServerError } = useErrorStore();
|
||||||
|
const { t } = useTranslation("player");
|
||||||
|
|
||||||
const handleRetry = (): void => {
|
const handleRetry = (): void => {
|
||||||
clearServerError();
|
clearServerError();
|
||||||
@@ -49,7 +51,7 @@ export function ServerError(): React.ReactElement | null {
|
|||||||
className="mb-2 text-2xl font-bold"
|
className="mb-2 text-2xl font-bold"
|
||||||
style={{ color: ERROR_COLORS.error }}
|
style={{ color: ERROR_COLORS.error }}
|
||||||
>
|
>
|
||||||
服务器错误
|
{t("serverError.title")}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{/* 错误消息 */}
|
{/* 错误消息 */}
|
||||||
@@ -68,7 +70,7 @@ export function ServerError(): React.ReactElement | null {
|
|||||||
style={{ borderColor: ERROR_COLORS.error, color: ERROR_COLORS.error }}
|
style={{ borderColor: ERROR_COLORS.error, color: ERROR_COLORS.error }}
|
||||||
>
|
>
|
||||||
<RefreshCw className="size-4" />
|
<RefreshCw className="size-4" />
|
||||||
重试
|
{t("actions.retry")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
@@ -77,13 +79,13 @@ export function ServerError(): React.ReactElement | null {
|
|||||||
style={{ backgroundColor: ERROR_COLORS.error }}
|
style={{ backgroundColor: ERROR_COLORS.error }}
|
||||||
>
|
>
|
||||||
<Home className="size-4" />
|
<Home className="size-4" />
|
||||||
返回首页
|
{t("serverError.home")}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 状态码提示 */}
|
{/* 状态码提示 */}
|
||||||
<p className="mt-8 text-xs text-muted-foreground/60">
|
<p className="mt-8 text-xs text-muted-foreground/60">
|
||||||
错误代码: 500 Internal Server Error
|
{t("serverError.code")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -95,6 +97,7 @@ export function ServerError(): React.ReactElement | null {
|
|||||||
*/
|
*/
|
||||||
export function ServerErrorModal(): React.ReactElement | null {
|
export function ServerErrorModal(): React.ReactElement | null {
|
||||||
const { isServerError, serverErrorMessage, clearServerError } = useErrorStore();
|
const { isServerError, serverErrorMessage, clearServerError } = useErrorStore();
|
||||||
|
const { t } = useTranslation("player");
|
||||||
|
|
||||||
const handleRetry = (): void => {
|
const handleRetry = (): void => {
|
||||||
clearServerError();
|
clearServerError();
|
||||||
@@ -122,7 +125,7 @@ export function ServerErrorModal(): React.ReactElement | null {
|
|||||||
|
|
||||||
{/* 错误消息 */}
|
{/* 错误消息 */}
|
||||||
<h2 className="mb-2 text-lg font-semibold text-foreground">
|
<h2 className="mb-2 text-lg font-semibold text-foreground">
|
||||||
服务器暂时不可用
|
{t("serverError.unavailable")}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mb-6 text-sm text-muted-foreground">
|
<p className="mb-6 text-sm text-muted-foreground">
|
||||||
{serverErrorMessage}
|
{serverErrorMessage}
|
||||||
@@ -136,7 +139,7 @@ export function ServerErrorModal(): React.ReactElement | null {
|
|||||||
onClick={handleRetry}
|
onClick={handleRetry}
|
||||||
>
|
>
|
||||||
<RefreshCw className="mr-2 size-4" />
|
<RefreshCw className="mr-2 size-4" />
|
||||||
重试
|
{t("actions.retry")}
|
||||||
</Button>
|
</Button>
|
||||||
<Link
|
<Link
|
||||||
href="/hall"
|
href="/hall"
|
||||||
@@ -144,7 +147,7 @@ export function ServerErrorModal(): React.ReactElement | null {
|
|||||||
style={{ backgroundColor: ERROR_COLORS.error }}
|
style={{ backgroundColor: ERROR_COLORS.error }}
|
||||||
>
|
>
|
||||||
<Home className="size-4" />
|
<Home className="size-4" />
|
||||||
返回首页
|
{t("serverError.home")}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { RefreshCw, AlertCircle } from "lucide-react";
|
import { RefreshCw, AlertCircle } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { useTokenRefresh } from "@/hooks/use-token-refresh";
|
import { useTokenRefresh } from "@/hooks/use-token-refresh";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -16,6 +17,7 @@ import { cn } from "@/lib/utils";
|
|||||||
export function TokenRefreshIndicator(): React.ReactElement | null {
|
export function TokenRefreshIndicator(): React.ReactElement | null {
|
||||||
const { isTokenExpiringSoon, getTokenRemainingTime, refreshToken } =
|
const { isTokenExpiringSoon, getTokenRemainingTime, refreshToken } =
|
||||||
useTokenRefresh();
|
useTokenRefresh();
|
||||||
|
const { t } = useTranslation("player");
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
const [showWarning, setShowWarning] = useState(false);
|
const [showWarning, setShowWarning] = useState(false);
|
||||||
const [remainingSeconds, setRemainingSeconds] = useState<number | null>(null);
|
const [remainingSeconds, setRemainingSeconds] = useState<number | null>(null);
|
||||||
@@ -24,7 +26,6 @@ export function TokenRefreshIndicator(): React.ReactElement | null {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkToken = (): void => {
|
const checkToken = (): void => {
|
||||||
const remaining = getTokenRemainingTime();
|
const remaining = getTokenRemainingTime();
|
||||||
const expiringSoon = isTokenExpiringSoon();
|
|
||||||
|
|
||||||
if (remaining > 0 && remaining < 120000) {
|
if (remaining > 0 && remaining < 120000) {
|
||||||
// 2 分钟内显示
|
// 2 分钟内显示
|
||||||
@@ -85,16 +86,19 @@ export function TokenRefreshIndicator(): React.ReactElement | null {
|
|||||||
color: isCritical ? ERROR_COLORS.error : ERROR_COLORS.warning,
|
color: isCritical ? ERROR_COLORS.error : ERROR_COLORS.warning,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isCritical ? "登录即将失效" : "登录即将过期"}
|
{isCritical ? t("token.critical") : t("token.warning")}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{remainingSeconds !== null && (
|
{remainingSeconds !== null && (
|
||||||
<>
|
<>
|
||||||
剩余 {Math.floor(remainingSeconds / 60)}:
|
{t("token.remaining", {
|
||||||
{String(remainingSeconds % 60).padStart(2, "0")} {" "}
|
time: `${Math.floor(remainingSeconds / 60)}:${String(
|
||||||
|
remainingSeconds % 60,
|
||||||
|
).padStart(2, "0")}`,
|
||||||
|
})}{" "}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
正在自动续签...
|
{t("token.autoRenewing")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -112,7 +116,7 @@ export function TokenRefreshIndicator(): React.ReactElement | null {
|
|||||||
<RefreshCw
|
<RefreshCw
|
||||||
className={cn("size-3.5", isRefreshing && "animate-spin")}
|
className={cn("size-3.5", isRefreshing && "animate-spin")}
|
||||||
/>
|
/>
|
||||||
立即续签
|
{t("token.renewNow")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export type DrawStatusHud = {
|
export type DrawStatusHud = {
|
||||||
label: string;
|
labelKey: string;
|
||||||
/** Tailwind 颜色类:状态圆点 */
|
/** Tailwind 颜色类:状态圆点 */
|
||||||
dotClass: string;
|
dotClass: string;
|
||||||
/** 文案条(如「距封盘」) */
|
/** 文案条(如「距封盘」) */
|
||||||
@@ -18,26 +18,26 @@ export function isHallSealedCountdownUi(status: string): boolean {
|
|||||||
export function drawStatusHud(status: string): DrawStatusHud {
|
export function drawStatusHud(status: string): DrawStatusHud {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "pending":
|
case "pending":
|
||||||
return { label: "未开始", dotClass: "bg-muted-foreground", countdownKind: "none" };
|
return { labelKey: "draw.status.pending", dotClass: "bg-muted-foreground", countdownKind: "none" };
|
||||||
case "open":
|
case "open":
|
||||||
return { label: "可下注", dotClass: "bg-emerald-500", countdownKind: "close" };
|
return { labelKey: "draw.status.open", dotClass: "bg-emerald-500", countdownKind: "close" };
|
||||||
case "closing":
|
case "closing":
|
||||||
return { label: "已封盘", dotClass: "bg-rose-500", countdownKind: "draw" };
|
return { labelKey: "draw.status.closing", dotClass: "bg-rose-500", countdownKind: "draw" };
|
||||||
case "closed":
|
case "closed":
|
||||||
return { label: "待开奖", dotClass: "bg-amber-500", countdownKind: "draw" };
|
return { labelKey: "draw.status.closed", dotClass: "bg-amber-500", countdownKind: "draw" };
|
||||||
case "drawing":
|
case "drawing":
|
||||||
return { label: "开奖中", dotClass: "bg-sky-500", countdownKind: "none" };
|
return { labelKey: "draw.status.drawing", dotClass: "bg-sky-500", countdownKind: "none" };
|
||||||
case "review":
|
case "review":
|
||||||
return { label: "待审核", dotClass: "bg-violet-500", countdownKind: "none" };
|
return { labelKey: "draw.status.review", dotClass: "bg-violet-500", countdownKind: "none" };
|
||||||
case "cooldown":
|
case "cooldown":
|
||||||
return { label: "冷静期", dotClass: "bg-cyan-500", countdownKind: "cooldown" };
|
return { labelKey: "draw.status.cooldown", dotClass: "bg-cyan-500", countdownKind: "cooldown" };
|
||||||
case "settling":
|
case "settling":
|
||||||
return { label: "结算中", dotClass: "bg-blue-600", countdownKind: "none" };
|
return { labelKey: "draw.status.settling", dotClass: "bg-blue-600", countdownKind: "none" };
|
||||||
case "settled":
|
case "settled":
|
||||||
return { label: "已结算", dotClass: "bg-muted-foreground", countdownKind: "none" };
|
return { labelKey: "draw.status.settled", dotClass: "bg-muted-foreground", countdownKind: "none" };
|
||||||
case "cancelled":
|
case "cancelled":
|
||||||
return { label: "已取消", dotClass: "bg-muted-foreground", countdownKind: "none" };
|
return { labelKey: "draw.status.cancelled", dotClass: "bg-muted-foreground", countdownKind: "none" };
|
||||||
default:
|
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";
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { formatMinorAsCurrency } from "@/lib/money";
|
import { formatMinorAsCurrency } from "@/lib/money";
|
||||||
@@ -31,13 +33,16 @@ export function HallBetAmountInput({
|
|||||||
disabled,
|
disabled,
|
||||||
hint,
|
hint,
|
||||||
}: HallBetAmountInputProps) {
|
}: HallBetAmountInputProps) {
|
||||||
|
const { t } = useTranslation("player");
|
||||||
|
const min = formatMinorAsCurrency(minBetMinor, currencyCode);
|
||||||
|
const max = formatMinorAsCurrency(maxBetMinor, currencyCode);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex flex-wrap items-end justify-between gap-2">
|
<div className="flex flex-wrap items-end justify-between gap-2">
|
||||||
<Label htmlFor={id}>{label}</Label>
|
<Label htmlFor={id}>{label}</Label>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
限额 {formatMinorAsCurrency(minBetMinor, currencyCode)} —{" "}
|
{t("hall.amountInput.limit", { min, max })}
|
||||||
{formatMinorAsCurrency(maxBetMinor, currencyCode)}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Input
|
<Input
|
||||||
@@ -48,7 +53,7 @@ export function HallBetAmountInput({
|
|||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
className={cn("tabular-nums")}
|
className={cn("tabular-nums")}
|
||||||
placeholder="例如 100.00"
|
placeholder={t("hall.amountInput.placeholder")}
|
||||||
/>
|
/>
|
||||||
{hint ? <p className="text-xs text-muted-foreground">{hint}</p> : null}
|
{hint ? <p className="text-xs text-muted-foreground">{hint}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,30 +1,44 @@
|
|||||||
/**
|
/**
|
||||||
* 下注业务码与玩家可见说明(对齐 Laravel `ErrorCode` 与产品文档 §6.3 / §6.4)。
|
* 下注业务码与玩家可见说明(对齐 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) {
|
switch (code) {
|
||||||
case 4001:
|
case 4001:
|
||||||
return "该号码本期赔付池不足,已售罄。请更换号码、金额或玩法后重试。";
|
return msg(
|
||||||
|
"hall.ticketError.4001",
|
||||||
|
"该号码本期赔付池不足,已售罄。请更换号码、金额或玩法后重试。",
|
||||||
|
);
|
||||||
case 2003:
|
case 2003:
|
||||||
case 1001:
|
case 1001:
|
||||||
return "余额不足,请先转入后再下注。";
|
return msg("hall.ticketError.1001", "余额不足,请先转入后再下注。");
|
||||||
case 2001:
|
case 2001:
|
||||||
return "本期已封盘,无法继续下注。";
|
return msg("hall.ticketError.2001", "本期已封盘,无法继续下注。");
|
||||||
case 2002:
|
case 2002:
|
||||||
return "该玩法已关闭,请选择其他玩法。";
|
return msg("hall.ticketError.2002", "该玩法已关闭,请选择其他玩法。");
|
||||||
case 2004:
|
case 2004:
|
||||||
return "号码格式或长度不符合该玩法要求。";
|
return msg("hall.ticketError.2004", "号码格式或长度不符合该玩法要求。");
|
||||||
case 2005:
|
case 2005:
|
||||||
return "玩法参数不完整(如单双大小需选择位数与维度)。";
|
return msg(
|
||||||
|
"hall.ticketError.2005",
|
||||||
|
"玩法参数不完整(如单双大小需选择位数与维度)。",
|
||||||
|
);
|
||||||
case 2006:
|
case 2006:
|
||||||
return "当前期号不可下注。";
|
return msg("hall.ticketError.2006", "当前期号不可下注。");
|
||||||
case 2007:
|
case 2007:
|
||||||
return "该玩法暂不支持或缺少赔率配置。";
|
return msg("hall.ticketError.2007", "该玩法暂不支持或缺少赔率配置。");
|
||||||
case 2008:
|
case 2008:
|
||||||
return "赔率或玩法配置已更新,请关闭预览后重新操作。";
|
return msg(
|
||||||
|
"hall.ticketError.2008",
|
||||||
|
"赔率或玩法配置已更新,请关闭预览后重新操作。",
|
||||||
|
);
|
||||||
case 1003:
|
case 1003:
|
||||||
return "下注金额超出该玩法允许范围。";
|
return msg("hall.ticketError.1003", "下注金额超出该玩法允许范围。");
|
||||||
default:
|
default:
|
||||||
return fallbackMsg || "下注失败,请稍后重试。";
|
return fallbackMsg || msg("hall.ticketError.fallback", "下注失败,请稍后重试。");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -36,6 +38,7 @@ export function HallBetNumberInput({
|
|||||||
disabled,
|
disabled,
|
||||||
helper,
|
helper,
|
||||||
}: HallBetNumberInputProps) {
|
}: HallBetNumberInputProps) {
|
||||||
|
const { t } = useTranslation("player");
|
||||||
const handle = (raw: string) => {
|
const handle = (raw: string) => {
|
||||||
if (spec.mode === "roll") {
|
if (spec.mode === "roll") {
|
||||||
onChange(sanitizeRoll(raw, spec.maxChars));
|
onChange(sanitizeRoll(raw, spec.maxChars));
|
||||||
@@ -56,7 +59,11 @@ export function HallBetNumberInput({
|
|||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => handle(e.target.value)}
|
onChange={(e) => handle(e.target.value)}
|
||||||
className={cn("font-mono text-base tracking-widest")}
|
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}
|
maxLength={spec.maxChars}
|
||||||
/>
|
/>
|
||||||
{helper ? <p className="text-xs text-muted-foreground">{helper}</p> : null}
|
{helper ? <p className="text-xs text-muted-foreground">{helper}</p> : null}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { AlertTriangleIcon } from "lucide-react";
|
import { AlertTriangleIcon } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -28,14 +29,16 @@ type HallBetPreviewDialogProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function WarningsBlock({ warnings }: { warnings: TicketPreviewWarning[] }) {
|
function WarningsBlock({ warnings }: { warnings: TicketPreviewWarning[] }) {
|
||||||
|
const { t } = useTranslation("player");
|
||||||
|
|
||||||
if (warnings.length === 0) return null;
|
if (warnings.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<Alert className="border-amber-500/40 bg-amber-500/5 text-amber-950 dark:text-amber-100">
|
<Alert className="border-amber-500/40 bg-amber-500/5 text-amber-950 dark:text-amber-100">
|
||||||
<AlertTriangleIcon />
|
<AlertTriangleIcon />
|
||||||
<AlertTitle>赔付池预警</AlertTitle>
|
<AlertTitle>{t("hall.preview.warningsTitle")}</AlertTitle>
|
||||||
<AlertDescription className="space-y-1">
|
<AlertDescription className="space-y-1">
|
||||||
<p className="text-xs leading-relaxed">
|
<p className="text-xs leading-relaxed">
|
||||||
产品文档 §6.4:以下号码本期赔付池占用较高,仍允许下注;若实际占用不足将售罄拒单。
|
{t("hall.preview.warningsDescription")}
|
||||||
</p>
|
</p>
|
||||||
<ul className="list-inside list-disc text-xs">
|
<ul className="list-inside list-disc text-xs">
|
||||||
{warnings.map((w, i) => (
|
{warnings.map((w, i) => (
|
||||||
@@ -61,6 +64,7 @@ export function HallBetPreviewDialog({
|
|||||||
allowSubmit = true,
|
allowSubmit = true,
|
||||||
onConfirmPlace,
|
onConfirmPlace,
|
||||||
}: HallBetPreviewDialogProps) {
|
}: HallBetPreviewDialogProps) {
|
||||||
|
const { t } = useTranslation("player");
|
||||||
const summary = data?.summary;
|
const summary = data?.summary;
|
||||||
const lines = data?.lines ?? [];
|
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">
|
<DialogContent className="max-h-[min(90vh,560px)] gap-0 overflow-hidden p-0 sm:max-w-md">
|
||||||
<div className="p-4 pb-2">
|
<div className="p-4 pb-2">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>确认下注</DialogTitle>
|
<DialogTitle>{t("hall.preview.title")}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
请核对号码、玩法与实扣金额;确认后将扣减彩票钱包且不可撤单(产品文档 §6.3)。
|
{t("hall.preview.description")}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{!allowSubmit ? (
|
{!allowSubmit ? (
|
||||||
<Alert className="mt-3 border-[#ff4d4f]/35 bg-[#ff4d4f]/8 text-[#ff4d4f] dark:bg-[#ff4d4f]/12">
|
<Alert className="mt-3 border-[#ff4d4f]/35 bg-[#ff4d4f]/8 text-[#ff4d4f] dark:bg-[#ff4d4f]/12">
|
||||||
<AlertTriangleIcon />
|
<AlertTriangleIcon />
|
||||||
<AlertTitle>已封盘</AlertTitle>
|
<AlertTitle>{t("hall.preview.sealedTitle")}</AlertTitle>
|
||||||
<AlertDescription className="text-xs leading-relaxed">
|
<AlertDescription className="text-xs leading-relaxed">
|
||||||
当前期已停止接收注单,无法提交。请选择下一期(界面文档 §4.2)。
|
{t("hall.preview.sealedDescription")}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -88,37 +92,38 @@ export function HallBetPreviewDialog({
|
|||||||
<ScrollArea className="max-h-[min(52vh,360px)] border-y px-4">
|
<ScrollArea className="max-h-[min(52vh,360px)] border-y px-4">
|
||||||
<div className="space-y-4 py-3 pr-3">
|
<div className="space-y-4 py-3 pr-3">
|
||||||
{!data ? (
|
{!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">
|
<div className="rounded-lg border bg-muted/30 p-3 text-xs">
|
||||||
<p>
|
<p>
|
||||||
期号{" "}
|
{t("hall.preview.draw")}{" "}
|
||||||
<span className="font-mono font-semibold">{data.draw.draw_id}</span> · 状态{" "}
|
<span className="font-mono font-semibold">{data.draw.draw_id}</span> ·{" "}
|
||||||
|
{t("hall.preview.status")}{" "}
|
||||||
<span className="font-medium">{data.draw.status}</span>
|
<span className="font-medium">{data.draw.status}</span>
|
||||||
</p>
|
</p>
|
||||||
{summary ? (
|
{summary ? (
|
||||||
<ul className="mt-2 space-y-1 tabular-nums">
|
<ul className="mt-2 space-y-1 tabular-nums">
|
||||||
<li>
|
<li>
|
||||||
总下注{" "}
|
{t("hall.preview.totalBet")}{" "}
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{formatMinorAsCurrency(summary.total_bet_amount, currencyCode)}
|
{formatMinorAsCurrency(summary.total_bet_amount, currencyCode)}
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
回水抵扣{" "}
|
{t("hall.preview.rebateDeduct")}{" "}
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{formatMinorAsCurrency(summary.total_rebate_amount, currencyCode)}
|
{formatMinorAsCurrency(summary.total_rebate_amount, currencyCode)}
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
实扣金额{" "}
|
{t("hall.preview.actualDeduct")}{" "}
|
||||||
<span className="font-semibold text-primary">
|
<span className="font-semibold text-primary">
|
||||||
{formatMinorAsCurrency(summary.total_actual_deduct, currencyCode)}
|
{formatMinorAsCurrency(summary.total_actual_deduct, currencyCode)}
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
预估最高赔付{" "}
|
{t("hall.preview.estimatedPayout")}{" "}
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{formatMinorAsCurrency(summary.total_estimated_payout, currencyCode)}
|
{formatMinorAsCurrency(summary.total_estimated_payout, currencyCode)}
|
||||||
</span>
|
</span>
|
||||||
@@ -130,7 +135,9 @@ export function HallBetPreviewDialog({
|
|||||||
<WarningsBlock warnings={data.warnings} />
|
<WarningsBlock warnings={data.warnings} />
|
||||||
|
|
||||||
<div className="space-y-2">
|
<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">
|
<ul className="space-y-2 text-sm">
|
||||||
{lines.map((ln) => (
|
{lines.map((ln) => (
|
||||||
<li
|
<li
|
||||||
@@ -146,15 +153,23 @@ export function HallBetPreviewDialog({
|
|||||||
<p className="mt-1 font-mono text-base">{ln.number}</p>
|
<p className="mt-1 font-mono text-base">{ln.number}</p>
|
||||||
<Separator className="my-2" />
|
<Separator className="my-2" />
|
||||||
<div className="grid grid-cols-2 gap-x-2 gap-y-1 text-xs tabular-nums">
|
<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-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-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">
|
<span className="text-right">
|
||||||
{formatMinorAsCurrency(ln.actual_deduct_amount, currencyCode)}
|
{formatMinorAsCurrency(ln.actual_deduct_amount, currencyCode)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-muted-foreground">预估最高赔</span>
|
<span className="text-muted-foreground">
|
||||||
|
{t("hall.preview.estimatedMax")}
|
||||||
|
</span>
|
||||||
<span className="text-right">
|
<span className="text-right">
|
||||||
{formatMinorAsCurrency(ln.estimated_max_payout, currencyCode)}
|
{formatMinorAsCurrency(ln.estimated_max_payout, currencyCode)}
|
||||||
</span>
|
</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">
|
<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}>
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={placing}>
|
||||||
返回修改
|
{t("hall.preview.backEdit")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" onClick={onConfirmPlace} disabled={!data || placing || !allowSubmit}>
|
<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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -47,12 +47,24 @@ export function playNeedsDigitSlot(playCode: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 产品文档:iBox/Roll 单注金额;mBox 总金额摊分 */
|
/** 产品文档:iBox/Roll 单注金额;mBox 总金额摊分 */
|
||||||
export function ticketAmountHint(playCode: string): string {
|
export function ticketAmountHint(
|
||||||
|
playCode: string,
|
||||||
|
t?: (key: string) => string,
|
||||||
|
): string {
|
||||||
if (playCode === "ibox" || playCode === "roll") {
|
if (playCode === "ibox" || playCode === "roll") {
|
||||||
return "本玩法金额为「单注金额」,系统按展开组合数计算总下注与实扣。";
|
return (
|
||||||
|
t?.("hall.amountHint.iboxRoll") ??
|
||||||
|
"本玩法金额为「单注金额」,系统按展开组合数计算总下注与实扣。"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (playCode === "mbox") {
|
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 { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { CirclePlus, Cuboid, PackageOpen, Ticket, Trash2 } from "lucide-react";
|
import { CirclePlus, Cuboid, PackageOpen, Ticket, Trash2 } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { getPlayEffective } from "@/api/play";
|
import { getPlayEffective } from "@/api/play";
|
||||||
@@ -205,6 +206,7 @@ function pickSimplePlay(
|
|||||||
|
|
||||||
export function HallBettingGrid() {
|
export function HallBettingGrid() {
|
||||||
const { display, isBettable, reload: reloadDraw } = useHallDrawLive();
|
const { display, isBettable, reload: reloadDraw } = useHallDrawLive();
|
||||||
|
const { t } = useTranslation("player");
|
||||||
const [activeCategory, setActiveCategory] = useState<HallCategory>("D2");
|
const [activeCategory, setActiveCategory] = useState<HallCategory>("D2");
|
||||||
const [boxMode, setBoxMode] = useState<BoxMode>("ibox");
|
const [boxMode, setBoxMode] = useState<BoxMode>("ibox");
|
||||||
const [rows, setRows] = useState<DraftRow[]>(() => [
|
const [rows, setRows] = useState<DraftRow[]>(() => [
|
||||||
@@ -239,10 +241,10 @@ export function HallBettingGrid() {
|
|||||||
setCatalogState({ kind: "ok", data });
|
setCatalogState({ kind: "ok", data });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg =
|
const msg =
|
||||||
e instanceof LotteryApiBizError ? e.message : "加载玩法失败,请稍后重试。";
|
e instanceof LotteryApiBizError ? e.message : t("hall.loadingError");
|
||||||
setCatalogState({ kind: "error", message: msg });
|
setCatalogState({ kind: "error", message: msg });
|
||||||
}
|
}
|
||||||
}, [currencyParam]);
|
}, [currencyParam, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
@@ -373,21 +375,21 @@ export function HallBettingGrid() {
|
|||||||
|
|
||||||
const handlePreview = async () => {
|
const handlePreview = async () => {
|
||||||
if (!display) {
|
if (!display) {
|
||||||
toast.error("暂无当期期号,无法提交。");
|
toast.error(t("hall.noDraw"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isBettable) {
|
if (!isBettable) {
|
||||||
toast.error("当前已封盘或不可下注。");
|
toast.error(t("hall.notBettable"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (catalogState.kind !== "ok") {
|
if (catalogState.kind !== "ok") {
|
||||||
toast.error("玩法配置尚未加载完成。");
|
toast.error(t("hall.catalogNotReady"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lines = buildLines();
|
const lines = buildLines();
|
||||||
if (lines.length === 0) {
|
if (lines.length === 0) {
|
||||||
toast.error("请至少填写一组有效号码和下注金额。");
|
toast.error(t("hall.emptyLines"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -403,8 +405,8 @@ export function HallBettingGrid() {
|
|||||||
setPreviewOpen(true);
|
setPreviewOpen(true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const code = e instanceof LotteryApiBizError ? e.code : 0;
|
const code = e instanceof LotteryApiBizError ? e.code : 0;
|
||||||
const msg = e instanceof LotteryApiBizError ? e.message : "预览失败";
|
const msg = e instanceof LotteryApiBizError ? e.message : t("hall.previewFailed");
|
||||||
toast.error(mapTicketBetError(code, msg));
|
toast.error(mapTicketBetError(code, msg, t));
|
||||||
} finally {
|
} finally {
|
||||||
setPreviewLoading(false);
|
setPreviewLoading(false);
|
||||||
}
|
}
|
||||||
@@ -413,13 +415,13 @@ export function HallBettingGrid() {
|
|||||||
const handlePlace = async () => {
|
const handlePlace = async () => {
|
||||||
if (!display || !previewData) return;
|
if (!display || !previewData) return;
|
||||||
if (!isBettable) {
|
if (!isBettable) {
|
||||||
toast.error("已封盘,无法提交。");
|
toast.error(t("hall.closedSubmit"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lines = buildLines();
|
const lines = buildLines();
|
||||||
if (lines.length === 0) {
|
if (lines.length === 0) {
|
||||||
toast.error("提交前数据已变化,请关闭预览后重试。");
|
toast.error(t("hall.changedBeforeSubmit"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -436,7 +438,10 @@ export function HallBettingGrid() {
|
|||||||
expected_config_versions: previewData.config_versions,
|
expected_config_versions: previewData.config_versions,
|
||||||
});
|
});
|
||||||
toast.success(
|
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);
|
setPreviewOpen(false);
|
||||||
setPreviewData(null);
|
setPreviewData(null);
|
||||||
@@ -445,8 +450,8 @@ export function HallBettingGrid() {
|
|||||||
void reloadDraw();
|
void reloadDraw();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const code = e instanceof LotteryApiBizError ? e.code : 0;
|
const code = e instanceof LotteryApiBizError ? e.code : 0;
|
||||||
const msg = e instanceof LotteryApiBizError ? e.message : "提交失败";
|
const msg = e instanceof LotteryApiBizError ? e.message : t("hall.placeFailed");
|
||||||
toast.error(mapTicketBetError(code, msg));
|
toast.error(mapTicketBetError(code, msg, t));
|
||||||
} finally {
|
} finally {
|
||||||
setPlaceLoading(false);
|
setPlaceLoading(false);
|
||||||
}
|
}
|
||||||
@@ -454,7 +459,7 @@ export function HallBettingGrid() {
|
|||||||
|
|
||||||
if (catalogState.kind === "loading") {
|
if (catalogState.kind === "loading") {
|
||||||
return (
|
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-12 rounded-xl" />
|
||||||
<Skeleton className="h-72 rounded-xl" />
|
<Skeleton className="h-72 rounded-xl" />
|
||||||
<Skeleton className="h-14 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"
|
className="mt-3 border-red-200 bg-white text-red-700 hover:bg-red-50"
|
||||||
onClick={() => void loadCatalog()}
|
onClick={() => void loadCatalog()}
|
||||||
>
|
>
|
||||||
重试
|
{t("actions.retry")}
|
||||||
</Button>
|
</Button>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
@@ -485,7 +490,7 @@ export function HallBettingGrid() {
|
|||||||
|
|
||||||
return (
|
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)]">
|
<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) => {
|
{categoryTabs.map((tab) => {
|
||||||
const active = activeCategory === tab.value;
|
const active = activeCategory === tab.value;
|
||||||
@@ -539,9 +544,11 @@ export function HallBettingGrid() {
|
|||||||
<Cuboid className="size-4" aria-hidden />
|
<Cuboid className="size-4" aria-hidden />
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0">
|
<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">
|
<span className="block truncate text-[10px] text-slate-500">
|
||||||
Divide all by amount
|
{t("hall.boxMode.iboxDesc")}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -561,9 +568,11 @@ export function HallBettingGrid() {
|
|||||||
<PackageOpen className="size-4" aria-hidden />
|
<PackageOpen className="size-4" aria-hidden />
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0">
|
<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">
|
<span className="block truncate text-[10px] text-slate-500">
|
||||||
Multiply all by amount
|
{t("hall.boxMode.boxDesc")}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</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">
|
<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 />
|
<Ticket className="size-7" aria-hidden />
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-4 text-lg font-bold text-slate-900">Closed</p>
|
<p className="mt-4 text-lg font-bold text-slate-900">
|
||||||
<p className="mt-1 text-xs">This issue is now closed.</p>
|
{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]">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -593,10 +604,10 @@ export function HallBettingGrid() {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-[#e8eef7] bg-[#f5f8fd] text-[11px] font-semibold text-[#32518d]">
|
<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">
|
<th className="sticky left-0 z-20 w-12 bg-[#f5f8fd] px-2 py-3 text-center">
|
||||||
No.
|
{t("hall.table.no")}
|
||||||
</th>
|
</th>
|
||||||
<th className="sticky left-12 z-20 w-24 bg-[#f5f8fd] px-2 py-3 text-center">
|
<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]">
|
<span className="block text-[10px] font-normal text-[#6b7896]">
|
||||||
({numberPlaceholder})
|
({numberPlaceholder})
|
||||||
</span>
|
</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">
|
||||||
<th className="min-w-28 px-2 py-3 text-center">Commission / Rebate</th>
|
{t("hall.table.stake")}
|
||||||
<th className="min-w-28 px-2 py-3 text-center">Actual Deduction</th>
|
</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -695,7 +712,7 @@ export function HallBettingGrid() {
|
|||||||
disabled={tableDisabled || rows.length <= 1}
|
disabled={tableDisabled || rows.length <= 1}
|
||||||
onClick={() => removeRow(row.id)}
|
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"
|
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 />
|
<Trash2 className="size-4" aria-hidden />
|
||||||
</button>
|
</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"
|
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 />
|
<CirclePlus className="size-4" aria-hidden />
|
||||||
Add Row
|
{t("hall.table.addRow")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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)]">
|
<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]">
|
<span className="text-lg font-bold tabular-nums text-[#0b3f96]">
|
||||||
{formatMinorAsCurrency(draftSummary.actual, currencyCode)}
|
{formatMinorAsCurrency(draftSummary.actual, currencyCode)}
|
||||||
</span>
|
</span>
|
||||||
@@ -727,7 +744,7 @@ export function HallBettingGrid() {
|
|||||||
|
|
||||||
{sealedBetUi ? (
|
{sealedBetUi ? (
|
||||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-600">
|
<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>
|
</p>
|
||||||
) : null}
|
) : 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]"
|
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 />
|
<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>
|
</Button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Hourglass, Landmark, TimerReset, WalletCards } from "lucide-react";
|
import { Hourglass, Landmark, TimerReset, WalletCards } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
@@ -12,13 +13,14 @@ import { cn } from "@/lib/utils";
|
|||||||
import type { DrawCurrentPayload } from "@/types/api/draw-current";
|
import type { DrawCurrentPayload } from "@/types/api/draw-current";
|
||||||
|
|
||||||
function CurrentTime({ payload }: { payload: DrawCurrentPayload }) {
|
function CurrentTime({ payload }: { payload: DrawCurrentPayload }) {
|
||||||
|
const { t } = useTranslation("player");
|
||||||
const source = payload.close_time ?? payload.draw_time ?? payload.start_time;
|
const source = payload.close_time ?? payload.draw_time ?? payload.start_time;
|
||||||
const formatted = source ? formatLotteryInstant(source) : null;
|
const formatted = source ? formatLotteryInstant(source) : null;
|
||||||
if (!formatted) {
|
if (!formatted) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<span className="text-lg font-black tabular-nums text-[#0b3f96]">--:--:--</span>
|
<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>;
|
hud: ReturnType<typeof drawStatusHud>;
|
||||||
payload: DrawCurrentPayload;
|
payload: DrawCurrentPayload;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation("player");
|
||||||
const sealedCountdown = isHallSealedCountdownUi(payload.status);
|
const sealedCountdown = isHallSealedCountdownUi(payload.status);
|
||||||
let seconds = 0;
|
let seconds = 0;
|
||||||
let label = "Closes In";
|
let label = t("draw.closesIn");
|
||||||
|
|
||||||
if (hud.countdownKind === "close") {
|
if (hud.countdownKind === "close") {
|
||||||
seconds = Math.max(0, payload.seconds_to_close);
|
seconds = Math.max(0, payload.seconds_to_close);
|
||||||
} else if (hud.countdownKind === "draw") {
|
} else if (hud.countdownKind === "draw") {
|
||||||
seconds = Math.max(0, payload.seconds_to_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") {
|
} else if (hud.countdownKind === "cooldown") {
|
||||||
seconds = Math.max(0, payload.seconds_remaining_in_cooldown ?? 0);
|
seconds = Math.max(0, payload.seconds_remaining_in_cooldown ?? 0);
|
||||||
label = "Cool Down";
|
label = t("draw.coolDown");
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -68,11 +71,12 @@ function CloseTime({
|
|||||||
|
|
||||||
export function HallDrawPanel() {
|
export function HallDrawPanel() {
|
||||||
const { raw, display, error, reload } = useHallDrawLive();
|
const { raw, display, error, reload } = useHallDrawLive();
|
||||||
|
const { t } = useTranslation("player");
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<section className="mb-4 rounded-xl border border-red-200 bg-red-50 px-3 py-3 text-sm text-red-700">
|
<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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -80,7 +84,7 @@ export function HallDrawPanel() {
|
|||||||
className="mt-2 border-red-200 bg-white text-red-700"
|
className="mt-2 border-red-200 bg-white text-red-700"
|
||||||
onClick={() => void reload()}
|
onClick={() => void reload()}
|
||||||
>
|
>
|
||||||
重试
|
{t("actions.retry")}
|
||||||
</Button>
|
</Button>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
@@ -101,7 +105,7 @@ export function HallDrawPanel() {
|
|||||||
if (raw === null || display === null) {
|
if (raw === null || display === null) {
|
||||||
return (
|
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">
|
<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>
|
</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)]",
|
"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",
|
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="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">
|
<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 />
|
<WalletCards className="size-4" aria-hidden />
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0">
|
<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]">
|
<p className="truncate text-sm font-black tabular-nums text-[#ff143d]">
|
||||||
{display.draw_no}
|
{display.draw_no}
|
||||||
</p>
|
</p>
|
||||||
@@ -146,17 +150,17 @@ export function HallDrawPanel() {
|
|||||||
{sealedUi ? (
|
{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">
|
<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 />
|
<TimerReset className="size-4" aria-hidden />
|
||||||
已封盘,请等待下一期。
|
{t("draw.sealedNotice")}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center justify-between border-t border-[#eef3f9] bg-[#fbfdff] px-3 py-1.5 text-[11px] text-slate-500">
|
<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="inline-flex items-center gap-1.5">
|
||||||
<span className={cn("size-2 rounded-full", hud.dotClass)} />
|
<span className={cn("size-2 rounded-full", hud.dotClass)} />
|
||||||
{hud.label}
|
{t(hud.labelKey, { defaultValue: hud.labelKey })}
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
<Landmark className="size-3.5" aria-hidden />
|
<Landmark className="size-3.5" aria-hidden />
|
||||||
Betting Hall
|
{t("draw.hall")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { getPlayEffective } from "@/api/play";
|
import { getPlayEffective } from "@/api/play";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -76,6 +77,7 @@ function formatMoneyAmount(n: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function HallPlayCatalogPanel() {
|
export function HallPlayCatalogPanel() {
|
||||||
|
const { t } = useTranslation("player");
|
||||||
const [state, setState] = useState<LoadState>({ kind: "loading" });
|
const [state, setState] = useState<LoadState>({ kind: "loading" });
|
||||||
const currencyParam = useMemo(() => {
|
const currencyParam = useMemo(() => {
|
||||||
const fromEnv = process.env.NEXT_PUBLIC_LOTTERY_PLAY_CURRENCY?.trim();
|
const fromEnv = process.env.NEXT_PUBLIC_LOTTERY_PLAY_CURRENCY?.trim();
|
||||||
@@ -93,8 +95,7 @@ export function HallPlayCatalogPanel() {
|
|||||||
if (e instanceof LotteryApiBizError && e.code === 9004) {
|
if (e instanceof LotteryApiBizError && e.code === 9004) {
|
||||||
setState({
|
setState({
|
||||||
kind: "error",
|
kind: "error",
|
||||||
message:
|
message: t("hall.playCatalog.notReady"),
|
||||||
"玩法配置尚未初始化。请在 Laravel 执行含 OperationalConfigV1Seeder 的 seed。",
|
|
||||||
notReady: true,
|
notReady: true,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -102,10 +103,10 @@ export function HallPlayCatalogPanel() {
|
|||||||
const msg =
|
const msg =
|
||||||
e instanceof LotteryApiBizError
|
e instanceof LotteryApiBizError
|
||||||
? e.message
|
? e.message
|
||||||
: "加载玩法配置失败,请稍后重试。";
|
: t("hall.playCatalog.loadFailed");
|
||||||
setState({ kind: "error", message: msg });
|
setState({ kind: "error", message: msg });
|
||||||
}
|
}
|
||||||
}, [currencyParam]);
|
}, [currencyParam, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
@@ -123,7 +124,7 @@ export function HallPlayCatalogPanel() {
|
|||||||
const body = (() => {
|
const body = (() => {
|
||||||
if (state.kind === "loading") {
|
if (state.kind === "loading") {
|
||||||
return (
|
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") {
|
if (state.kind === "error") {
|
||||||
@@ -131,7 +132,7 @@ export function HallPlayCatalogPanel() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-sm text-destructive">{state.message}</p>
|
<p className="text-sm text-destructive">{state.message}</p>
|
||||||
<Button type="button" size="sm" variant="secondary" onClick={() => void load()}>
|
<Button type="button" size="sm" variant="secondary" onClick={() => void load()}>
|
||||||
重试
|
{t("actions.retry")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -145,19 +146,28 @@ export function HallPlayCatalogPanel() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
币种 {data.currency_code} · 配置版本 play#{data.effective_versions.play_config.version_no}
|
{t("hall.playCatalog.meta", {
|
||||||
/odds#{data.effective_versions.odds.version_no} · 限额单位为最小货币单位(与钱包一致)。
|
currency: data.currency_code,
|
||||||
|
playVersion: data.effective_versions.play_config.version_no,
|
||||||
|
oddsVersion: data.effective_versions.odds.version_no,
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="overflow-x-auto rounded-md border">
|
<div className="overflow-x-auto rounded-md border">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="min-w-[140px]">玩法</TableHead>
|
<TableHead className="min-w-[140px]">{t("hall.playCatalog.play")}</TableHead>
|
||||||
<TableHead className="w-[88px] text-center">状态</TableHead>
|
<TableHead className="w-[88px] text-center">
|
||||||
<TableHead className="min-w-[160px] whitespace-nowrap">下注限额</TableHead>
|
{t("hall.playCatalog.status")}
|
||||||
<TableHead className="min-w-[100px]">赔率×</TableHead>
|
</TableHead>
|
||||||
<TableHead className="min-w-[200px]">说明</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>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -188,11 +198,11 @@ export function HallPlayCatalogPanel() {
|
|||||||
<TableCell className="text-center">
|
<TableCell className="text-center">
|
||||||
{open ? (
|
{open ? (
|
||||||
<Badge variant="default" className="font-normal">
|
<Badge variant="default" className="font-normal">
|
||||||
开放
|
{t("hall.playCatalog.open")}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
<Badge variant="secondary" className="font-normal">
|
<Badge variant="secondary" className="font-normal">
|
||||||
关闭
|
{t("hall.playCatalog.closed")}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -222,14 +232,16 @@ export function HallPlayCatalogPanel() {
|
|||||||
|
|
||||||
{data.risk_cap_items.length > 0 ? (
|
{data.risk_cap_items.length > 0 ? (
|
||||||
<div className="space-y-2">
|
<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">
|
<div className="overflow-x-auto rounded-md border">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>号码</TableHead>
|
<TableHead>{t("hall.playCatalog.number")}</TableHead>
|
||||||
<TableHead>封顶额</TableHead>
|
<TableHead>{t("hall.playCatalog.capAmount")}</TableHead>
|
||||||
<TableHead>类型</TableHead>
|
<TableHead>{t("hall.playCatalog.type")}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -257,10 +269,11 @@ export function HallPlayCatalogPanel() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
<CardHeader className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<CardTitle className="text-base">玩法与赔率</CardTitle>
|
<CardTitle className="text-base">{t("hall.playCatalog.title")}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
数据来自 <code className="text-xs">GET /api/v1/play/effective</code>
|
{t("hall.playCatalog.descriptionText", {
|
||||||
;后台修改并发布后,最长约 {DEFAULT_POLL_MS / 1000}s 内自动刷新(亦可手动刷新)。
|
seconds: DEFAULT_POLL_MS / 1000,
|
||||||
|
})}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -270,7 +283,7 @@ export function HallPlayCatalogPanel() {
|
|||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
onClick={() => void load()}
|
onClick={() => void load()}
|
||||||
>
|
>
|
||||||
刷新配置
|
{t("hall.playCatalog.refresh")}
|
||||||
</Button>
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>{body}</CardContent>
|
<CardContent>{body}</CardContent>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export type PlayChip = {
|
export type PlayChip = {
|
||||||
@@ -23,15 +25,17 @@ export function HallPlaySwitcher({
|
|||||||
onChange,
|
onChange,
|
||||||
disabled,
|
disabled,
|
||||||
}: HallPlaySwitcherProps) {
|
}: HallPlaySwitcherProps) {
|
||||||
|
const { t } = useTranslation("player");
|
||||||
|
|
||||||
if (plays.length === 0) {
|
if (plays.length === 0) {
|
||||||
return (
|
return (
|
||||||
<p className="text-sm text-muted-foreground">当前币种下没有可下注的开放玩法。</p>
|
<p className="text-sm text-muted-foreground">{t("hall.playSwitcher.empty")}</p>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<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">
|
<div className="-mx-1 flex gap-1.5 overflow-x-auto pb-1">
|
||||||
{plays.map((p) => {
|
{plays.map((p) => {
|
||||||
const active = p.play_code === value;
|
const active = p.play_code === value;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Bell } from "lucide-react";
|
import { Bell } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||||
import { HallBettingGrid } from "@/features/hall/hall-betting-grid";
|
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。
|
* 下注大厅:钱包条 §4 + 当期期号 §4.2(封盘置灰 / 倒计时错误色 / WS+轮询);玩法目录 §12.3;下注表格 §13.3。
|
||||||
*/
|
*/
|
||||||
export function HallScreen() {
|
export function HallScreen() {
|
||||||
|
const { t } = useTranslation("common");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-[480px]">
|
<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)]">
|
<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">
|
<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="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)]">
|
<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">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="relative flex size-9 shrink-0 items-center justify-center rounded-full text-[#1d57b7] hover:bg-[#f4f7fb]"
|
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 />
|
<Bell className="size-5" aria-hidden />
|
||||||
<span className="absolute right-2 top-2 size-2 rounded-full bg-[#ff143d]" />
|
<span className="absolute right-2 top-2 size-2 rounded-full bg-[#ff143d]" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<HallDrawPanel />
|
<HallDrawPanel />
|
||||||
|
|
||||||
<HallWalletStrip />
|
<HallWalletStrip />
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { Wallet } from "lucide-react";
|
import { Wallet } from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { getWalletBalance } from "@/api/wallet";
|
import { getWalletBalance } from "@/api/wallet";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
@@ -18,6 +19,7 @@ import type { WalletBalanceData } from "@/types/api/wallet-balance";
|
|||||||
export function HallWalletStrip() {
|
export function HallWalletStrip() {
|
||||||
const profile = usePlayerSessionStore((s) => s.profile);
|
const profile = usePlayerSessionStore((s) => s.profile);
|
||||||
const mode = useNetworkConnectionStore((s) => s.mode);
|
const mode = useNetworkConnectionStore((s) => s.mode);
|
||||||
|
const { t } = useTranslation("player");
|
||||||
const [balance, setBalance] = useState<WalletBalanceData | null>(null);
|
const [balance, setBalance] = useState<WalletBalanceData | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const degradedWalletPollRef = useRef<number | null>(null);
|
const degradedWalletPollRef = useRef<number | null>(null);
|
||||||
@@ -83,7 +85,7 @@ export function HallWalletStrip() {
|
|||||||
const availableMinor = Number(balance?.available_balance ?? 0);
|
const availableMinor = Number(balance?.available_balance ?? 0);
|
||||||
|
|
||||||
return (
|
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
|
<div
|
||||||
className={cn(
|
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)]",
|
"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 />
|
<Wallet className="size-7" aria-hidden />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<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 ? (
|
{loading ? (
|
||||||
<Skeleton className="mt-2 h-8 w-44 rounded-md bg-white/25" />
|
<Skeleton className="mt-2 h-8 w-44 rounded-md bg-white/25" />
|
||||||
) : (
|
) : (
|
||||||
@@ -111,7 +113,7 @@ export function HallWalletStrip() {
|
|||||||
<TransferInDialog
|
<TransferInDialog
|
||||||
idPrefix="hall-"
|
idPrefix="hall-"
|
||||||
triggerVariant="hall"
|
triggerVariant="hall"
|
||||||
triggerLabel="Transfer In"
|
triggerLabel={t("wallet.transferIn")}
|
||||||
triggerClassName="h-12 rounded-lg text-base font-bold"
|
triggerClassName="h-12 rounded-lg text-base font-bold"
|
||||||
currency={currency}
|
currency={currency}
|
||||||
lotteryMinor={lotteryMinor}
|
lotteryMinor={lotteryMinor}
|
||||||
@@ -120,7 +122,7 @@ export function HallWalletStrip() {
|
|||||||
<TransferOutDialog
|
<TransferOutDialog
|
||||||
idPrefix="hall-"
|
idPrefix="hall-"
|
||||||
triggerVariant="hall"
|
triggerVariant="hall"
|
||||||
triggerLabel="Transfer Out"
|
triggerLabel={t("wallet.transferOut")}
|
||||||
triggerClassName="h-12 rounded-lg text-base font-bold"
|
triggerClassName="h-12 rounded-lg text-base font-bold"
|
||||||
currency={currency}
|
currency={currency}
|
||||||
availableMinor={availableMinor}
|
availableMinor={availableMinor}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
import { getDrawCurrent } from "@/api/draw";
|
import { getDrawCurrent } from "@/api/draw";
|
||||||
import { getLotteryEcho } from "@/lib/lottery-echo";
|
import { getLotteryEcho } from "@/lib/lottery-echo";
|
||||||
@@ -76,17 +76,11 @@ export function useHallDrawLive(): {
|
|||||||
setRaw(d);
|
setRaw(d);
|
||||||
setEmittedAtMs(Date.now());
|
setEmittedAtMs(Date.now());
|
||||||
} catch {
|
} catch {
|
||||||
setError("加载失败,请下拉刷新");
|
setError("draw.loadFailedRefresh");
|
||||||
setRaw(undefined);
|
setRaw(undefined);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// WebSocket 正常时的轮询间隔(作为备用)
|
|
||||||
const refreshMs = useMemo(() => {
|
|
||||||
if (raw === undefined) return 30_000;
|
|
||||||
return raw ? 60_000 : 30_000; // WebSocket正常时减少轮询频率
|
|
||||||
}, [raw]);
|
|
||||||
|
|
||||||
// 初始加载
|
// 初始加载
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = window.setTimeout(() => {
|
const timer = window.setTimeout(() => {
|
||||||
@@ -199,14 +193,22 @@ export function useHallDrawLive(): {
|
|||||||
if (!isWebSocketConnected && mode !== "websocket") {
|
if (!isWebSocketConnected && mode !== "websocket") {
|
||||||
const currentPollingId = useNetworkConnectionStore.getState().drawPollingIntervalId;
|
const currentPollingId = useNetworkConnectionStore.getState().drawPollingIntervalId;
|
||||||
if (!currentPollingId) {
|
if (!currentPollingId) {
|
||||||
// 立即执行一次
|
const initialLoadId = window.setTimeout(() => {
|
||||||
void load();
|
void load();
|
||||||
|
}, 0);
|
||||||
|
|
||||||
// 设置30秒轮询
|
// 设置30秒轮询
|
||||||
intervalId = window.setInterval(() => {
|
intervalId = window.setInterval(() => {
|
||||||
void load();
|
void load();
|
||||||
}, 30_000);
|
}, 30_000);
|
||||||
setDrawPollingIntervalId(intervalId);
|
setDrawPollingIntervalId(intervalId);
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(initialLoadId);
|
||||||
|
if (intervalId) {
|
||||||
|
window.clearInterval(intervalId);
|
||||||
|
setDrawPollingIntervalId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,22 +4,26 @@ export function ticketStatusDisplay(
|
|||||||
status: string,
|
status: string,
|
||||||
winMinor: number,
|
winMinor: number,
|
||||||
jackpotMinor: number,
|
jackpotMinor: number,
|
||||||
|
t?: (key: string, options?: { defaultValue?: string; status?: string }) => string,
|
||||||
): { label: string; dotClass: string; ring?: boolean } {
|
): { label: string; dotClass: string; ring?: boolean } {
|
||||||
const total = winMinor + jackpotMinor;
|
const total = winMinor + jackpotMinor;
|
||||||
if (status === "success") {
|
if (status === "success") {
|
||||||
return { label: "待开奖", dotClass: "bg-sky-500" };
|
return { label: t?.("ticketStatus.success") ?? "待开奖", dotClass: "bg-sky-500" };
|
||||||
}
|
}
|
||||||
if (status === "settled_win" && total > 0) {
|
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") {
|
if (status === "settled_lose" || status === "settled_win") {
|
||||||
return {
|
return {
|
||||||
label: "未中奖",
|
label: t?.("ticketStatus.settled_lose") ?? "未中奖",
|
||||||
dotClass: "bg-background",
|
dotClass: "bg-background",
|
||||||
ring: true,
|
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({
|
export function StatusDot({
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { getTicketItemDetail } from "@/api/ticket-items";
|
import { getTicketItemDetail } from "@/api/ticket-items";
|
||||||
import { Button, buttonVariants } from "@/components/ui/button";
|
import { Button, buttonVariants } from "@/components/ui/button";
|
||||||
|
import { PlayerPanel } from "@/components/layout/player-panel";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -18,46 +20,31 @@ import { StatusDot, ticketStatusDisplay } from "@/features/orders/ticket-item-st
|
|||||||
import { formatLotteryInstant } from "@/lib/player-datetime";
|
import { formatLotteryInstant } from "@/lib/player-datetime";
|
||||||
import { formatMinorAsCurrency } from "@/lib/money";
|
import { formatMinorAsCurrency } from "@/lib/money";
|
||||||
import { norm4d } from "@/lib/norm-4d";
|
import { norm4d } from "@/lib/norm-4d";
|
||||||
import { playLabelZh } from "@/lib/play-labels";
|
import { playLabel } from "@/lib/play-labels";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { TicketItemDetailPayload } from "@/types/api/ticket-items";
|
import type { TicketItemDetailPayload } from "@/types/api/ticket-items";
|
||||||
|
|
||||||
type OddsSnapRow = { prize_scope?: string; odds_value?: number };
|
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 "—";
|
if (!Array.isArray(json)) return "—";
|
||||||
const parts = (json as OddsSnapRow[])
|
const parts = (json as OddsSnapRow[])
|
||||||
.filter((r) => r.prize_scope && r.odds_value != null)
|
.filter((r) => r.prize_scope && r.odds_value != null)
|
||||||
.map((r) => {
|
.map((r) => {
|
||||||
const scope = String(r.prize_scope);
|
const scope = String(r.prize_scope);
|
||||||
const label =
|
const label = t(`prizeTier.${scope}`, { defaultValue: scope });
|
||||||
scope === "first"
|
|
||||||
? "头奖"
|
|
||||||
: scope === "second"
|
|
||||||
? "二奖"
|
|
||||||
: scope === "third"
|
|
||||||
? "三奖"
|
|
||||||
: scope === "starter"
|
|
||||||
? "特别奖"
|
|
||||||
: scope === "consolation"
|
|
||||||
? "安慰奖"
|
|
||||||
: scope;
|
|
||||||
const mult = Number(r.odds_value) / 10_000;
|
const mult = Number(r.odds_value) / 10_000;
|
||||||
return `${label} ${mult}x`;
|
return `${label} ${mult}x`;
|
||||||
});
|
});
|
||||||
return parts.length ? parts.join(" · ") : "—";
|
return parts.length ? parts.join(" · ") : "—";
|
||||||
}
|
}
|
||||||
|
|
||||||
const TIER_ZH: Record<string, string> = {
|
|
||||||
first: "头奖",
|
|
||||||
second: "二奖",
|
|
||||||
third: "三奖",
|
|
||||||
starter: "特别奖",
|
|
||||||
consolation: "安慰奖",
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 界面文档 §4.8 注单详情 */
|
/** 界面文档 §4.8 注单详情 */
|
||||||
export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
|
export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
|
||||||
|
const { t } = useTranslation("player");
|
||||||
const [data, setData] = useState<TicketItemDetailPayload | null>(null);
|
const [data, setData] = useState<TicketItemDetailPayload | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -70,11 +57,11 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
|
|||||||
setData(row);
|
setData(row);
|
||||||
} catch {
|
} catch {
|
||||||
setData(null);
|
setData(null);
|
||||||
setError("注单不存在或无权查看");
|
setError(t("orders.notFound"));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [ticketNo]);
|
}, [ticketNo, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
@@ -84,39 +71,42 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<PlayerPanel
|
||||||
<CardHeader>
|
title={t("orders.betDetail")}
|
||||||
<Skeleton className="h-6 w-40" />
|
subtitle={ticketNo}
|
||||||
<Skeleton className="h-4 w-56" />
|
eyebrow={t("orders.title")}
|
||||||
</CardHeader>
|
backHref="/orders"
|
||||||
<CardContent className="space-y-3">
|
backLabel={t("orders.title")}
|
||||||
<Skeleton className="h-32 w-full" />
|
>
|
||||||
</CardContent>
|
<div className="space-y-4">
|
||||||
</Card>
|
<Skeleton className="h-12 rounded-xl" />
|
||||||
|
<Skeleton className="h-56 rounded-xl" />
|
||||||
|
</div>
|
||||||
|
</PlayerPanel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error || !data) {
|
if (error || !data) {
|
||||||
return (
|
return (
|
||||||
<Card className="border-destructive/40">
|
<PlayerPanel
|
||||||
<CardHeader>
|
title={t("orders.betDetail")}
|
||||||
<CardTitle className="text-base">注单详情</CardTitle>
|
subtitle={ticketNo}
|
||||||
<CardDescription>{error ?? "无数据"}</CardDescription>
|
eyebrow={t("orders.title")}
|
||||||
</CardHeader>
|
backHref="/orders"
|
||||||
<CardContent className="flex flex-wrap gap-2">
|
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()}>
|
<Button type="button" size="sm" variant="secondary" onClick={() => void load()}>
|
||||||
重试
|
{t("actions.retry")}
|
||||||
</Button>
|
</Button>
|
||||||
<Link href="/orders" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
</div>
|
||||||
返回列表
|
</PlayerPanel>
|
||||||
</Link>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const cur = data.currency_code ?? "NPR";
|
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 totalWin = data.win_amount + data.jackpot_win_amount;
|
||||||
const pub = data.published_draw_results;
|
const pub = data.published_draw_results;
|
||||||
const first = pub?.results?.["1st"] ?? "";
|
const first = pub?.results?.["1st"] ?? "";
|
||||||
@@ -146,119 +136,144 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
const tierLabel = data.settlement?.matched_prize_tier
|
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;
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<PlayerPanel
|
||||||
<Card>
|
title={t("orders.betDetail")}
|
||||||
<CardHeader className="space-y-2 pb-2">
|
subtitle={data.ticket_no}
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
eyebrow={t("orders.title")}
|
||||||
<CardTitle className="text-base">注单详情</CardTitle>
|
backHref="/orders"
|
||||||
<StatusDot label={st.label} dotClass={st.dotClass} ring={st.ring} />
|
backLabel={t("orders.title")}
|
||||||
</div>
|
>
|
||||||
<CardDescription className="font-mono text-xs">
|
<div className="flex flex-col gap-4">
|
||||||
注单号 {data.ticket_no} · 订单 {data.order_no ?? "—"}
|
<Card className="rounded-xl border-[#e5edf8] shadow-[0_8px_24px_rgba(15,23,42,0.05)]">
|
||||||
</CardDescription>
|
<CardHeader className="space-y-2 pb-2">
|
||||||
</CardHeader>
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<CardContent className="space-y-3 text-sm">
|
<CardTitle className="text-base">{t("orders.detailTitle")}</CardTitle>
|
||||||
<div className="grid gap-1 text-xs">
|
<StatusDot label={st.label} dotClass={st.dotClass} ring={st.ring} />
|
||||||
<p>
|
</div>
|
||||||
<span className="text-muted-foreground">期号</span>{" "}
|
<CardDescription className="font-mono text-xs">
|
||||||
<span className="font-mono font-medium">{data.draw_no ?? "—"}</span>
|
{t("orders.ticketNo", { ticketNo: data.ticket_no })} ·{" "}
|
||||||
</p>
|
{t("orders.orderNo", { orderNo: data.order_no ?? "—" })}
|
||||||
<p>
|
</CardDescription>
|
||||||
<span className="text-muted-foreground">下单时间</span>{" "}
|
</CardHeader>
|
||||||
{formatLotteryInstant(data.placed_at ?? null)}
|
<CardContent className="space-y-3 text-sm">
|
||||||
</p>
|
<div className="grid gap-1 text-xs">
|
||||||
<p>
|
<p>
|
||||||
<span className="text-muted-foreground">号码</span>{" "}
|
<span className="text-muted-foreground">{t("orders.drawNo")}</span>{" "}
|
||||||
<span className="font-mono">{data.original_number ?? "—"}</span>
|
<span className="font-mono font-medium">{data.draw_no ?? "—"}</span>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<span className="text-muted-foreground">玩法</span> {playLabelZh(data.play_code)} (
|
<span className="text-muted-foreground">{t("orders.placedAt")}</span>{" "}
|
||||||
{data.dimension ?? "—"}D)
|
{formatLotteryInstant(data.placed_at ?? null)}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<span className="text-muted-foreground">下注金额</span>{" "}
|
<span className="text-muted-foreground">{t("orders.number")}</span>{" "}
|
||||||
{formatMinorAsCurrency(data.total_bet_amount, cur)}
|
<span className="font-mono">{data.original_number ?? "—"}</span>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<span className="text-muted-foreground">回水率</span>{" "}
|
<span className="text-muted-foreground">{t("orders.play")}</span>{" "}
|
||||||
{(Number(data.rebate_rate_snapshot) * 100).toFixed(1)}%
|
{playLabel(data.play_code, t)} (
|
||||||
</p>
|
{data.dimension ?? "—"}D)
|
||||||
<p>
|
</p>
|
||||||
<span className="text-muted-foreground">实扣金额</span>{" "}
|
<p>
|
||||||
{formatMinorAsCurrency(data.actual_deduct_amount, cur)}
|
<span className="text-muted-foreground">{t("orders.amount")}</span>{" "}
|
||||||
</p>
|
{formatMinorAsCurrency(data.total_bet_amount, cur)}
|
||||||
</div>
|
</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">
|
<div className="rounded-md border bg-muted/30 px-3 py-2 text-xs">
|
||||||
<p className="font-medium text-foreground">赔率快照</p>
|
<p className="font-medium text-foreground">{t("orders.oddsSnapshot")}</p>
|
||||||
<p className="mt-1 text-muted-foreground">{formatOddsSnapshot(data.odds_snapshot_json)}</p>
|
<p className="mt-1 text-muted-foreground">
|
||||||
|
{formatOddsSnapshot(data.odds_snapshot_json, t)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{pub?.results ? (
|
{pub?.results ? (
|
||||||
<div className="space-y-2">
|
<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} />
|
<TwentyThreeResultsGrid numbers={pub.results} highlighted4d={highlight} />
|
||||||
{first ? (
|
{first ? (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
头奖{" "}
|
{t("orders.firstPrize")}{" "}
|
||||||
<span className="font-mono font-semibold text-foreground">{first}</span>
|
<span className="font-mono font-semibold text-foreground">{first}</span>
|
||||||
{comboHits.length > 0 ? (
|
{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}
|
) : null}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-muted-foreground">本期开奖号码尚未发布或不可展示。</p>
|
<p className="text-xs text-muted-foreground">{t("orders.notPublished")}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{data.settlement && tierLabel ? (
|
{data.settlement && tierLabel ? (
|
||||||
<div className="rounded-md border border-emerald-500/20 bg-emerald-500/5 px-3 py-2 text-xs">
|
<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">
|
<p className="font-medium text-emerald-900 dark:text-emerald-100">
|
||||||
匹配结果:命中 {tierLabel}
|
{t("orders.matchWin", { tier: tierLabel })}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 font-mono text-muted-foreground">
|
<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 ? (
|
{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}
|
) : null}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 font-mono text-muted-foreground">
|
<p className="mt-1 font-mono text-muted-foreground">
|
||||||
派彩合计 {formatMinorAsCurrency(totalWin, cur)}
|
{t("orders.payoutTotal", { amount: formatMinorAsCurrency(totalWin, cur) })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : data.status === "settled_lose" ? (
|
) : data.status === "settled_lose" ? (
|
||||||
<p className="text-xs text-muted-foreground">匹配结果:未中奖</p>
|
<p className="text-xs text-muted-foreground">{t("orders.matchLose")}</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{data.settled_at ? (
|
{data.settled_at ? (
|
||||||
<p className="text-[11px] text-muted-foreground">
|
<p className="text-[11px] text-muted-foreground">
|
||||||
结算时间 {formatLotteryInstant(data.settled_at)}
|
{t("orders.settledAt", { time: formatLotteryInstant(data.settled_at) })}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{data.draw_no ? (
|
{data.draw_no ? (
|
||||||
<Link
|
<Link
|
||||||
href={`/results/${encodeURIComponent(data.draw_no)}`}
|
href={`/results/${encodeURIComponent(data.draw_no)}`}
|
||||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
|
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>
|
</Link>
|
||||||
) : null}
|
</div>
|
||||||
<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 Link from "next/link";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { getTicketItems } from "@/api/ticket-items";
|
import { getTicketItems } from "@/api/ticket-items";
|
||||||
import { Button } from "@/components/ui/button";
|
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 { StatusDot, ticketStatusDisplay } from "@/features/orders/ticket-item-status";
|
||||||
import { formatMinorAsCurrency } from "@/lib/money";
|
import { formatMinorAsCurrency } from "@/lib/money";
|
||||||
import { formatLotteryInstant } from "@/lib/player-datetime";
|
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";
|
import type { TicketItemListRow } from "@/types/api/ticket-items";
|
||||||
|
|
||||||
export function TicketOrdersListScreen() {
|
export function TicketOrdersListScreen() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
const { t } = useTranslation("player");
|
||||||
const drawNoFilter = useMemo(
|
const drawNoFilter = useMemo(
|
||||||
() => (searchParams.get("draw_no") ?? "").trim(),
|
() => (searchParams.get("draw_no") ?? "").trim(),
|
||||||
[searchParams],
|
[searchParams],
|
||||||
@@ -45,14 +47,14 @@ export function TicketOrdersListScreen() {
|
|||||||
setLastPage(res.last_page);
|
setLastPage(res.last_page);
|
||||||
setTotal(res.total);
|
setTotal(res.total);
|
||||||
} catch {
|
} catch {
|
||||||
setError("加载失败");
|
setError(t("orders.loadFailed"));
|
||||||
if (!append) setItems([]);
|
if (!append) setItems([]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setLoadingMore(false);
|
setLoadingMore(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[drawNoFilter],
|
[drawNoFilter, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -67,13 +69,13 @@ export function TicketOrdersListScreen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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="space-y-4">
|
||||||
<div className="rounded-xl border border-[#e6edf8] bg-[#f8fbff] p-3">
|
<div className="rounded-xl border border-[#e6edf8] bg-[#f8fbff] p-3">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-xs font-bold text-[#32518d]">
|
<p className="text-xs font-bold text-[#32518d]">
|
||||||
{drawNoFilter ? "Filtered Issue" : "Total Records"}
|
{drawNoFilter ? t("orders.filteredIssue") : t("orders.totalRecords")}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 truncate font-mono text-lg font-black text-[#0b3f96]">
|
<p className="mt-1 truncate font-mono text-lg font-black text-[#0b3f96]">
|
||||||
{drawNoFilter || total}
|
{drawNoFilter || total}
|
||||||
@@ -84,14 +86,14 @@ export function TicketOrdersListScreen() {
|
|||||||
href="/orders"
|
href="/orders"
|
||||||
className="shrink-0 rounded-full border border-[#dce7f7] bg-white px-3 py-1.5 text-xs font-bold text-[#0b56b7]"
|
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>
|
||||||
) : (
|
) : (
|
||||||
<Link
|
<Link
|
||||||
href="/hall"
|
href="/hall"
|
||||||
className="shrink-0 rounded-full bg-[#e5002c] px-3 py-1.5 text-xs font-bold text-white"
|
className="shrink-0 rounded-full bg-[#e5002c] px-3 py-1.5 text-xs font-bold text-white"
|
||||||
>
|
>
|
||||||
Bet Now
|
{t("orders.betNow")}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -112,17 +114,17 @@ export function TicketOrdersListScreen() {
|
|||||||
className="mt-3 bg-[#e5002c] text-white hover:bg-[#d10028]"
|
className="mt-3 bg-[#e5002c] text-white hover:bg-[#d10028]"
|
||||||
onClick={() => void fetchPage(1, false)}
|
onClick={() => void fetchPage(1, false)}
|
||||||
>
|
>
|
||||||
Retry
|
{t("actions.retry")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
<div className="rounded-xl border border-dashed border-[#dce7f7] bg-[#f8fbff] px-4 py-10 text-center">
|
<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
|
<Link
|
||||||
href="/hall"
|
href="/hall"
|
||||||
className="mt-4 inline-flex h-9 items-center rounded-lg bg-[#e5002c] px-4 text-sm font-bold text-white"
|
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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -134,6 +136,7 @@ export function TicketOrdersListScreen() {
|
|||||||
row.status,
|
row.status,
|
||||||
row.win_amount,
|
row.win_amount,
|
||||||
row.jackpot_win_amount,
|
row.jackpot_win_amount,
|
||||||
|
t,
|
||||||
);
|
);
|
||||||
const totalWin = row.win_amount + row.jackpot_win_amount;
|
const totalWin = row.win_amount + row.jackpot_win_amount;
|
||||||
return (
|
return (
|
||||||
@@ -148,20 +151,24 @@ export function TicketOrdersListScreen() {
|
|||||||
{row.draw_no ?? "—"}
|
{row.draw_no ?? "—"}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 truncate text-xs text-slate-500">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<StatusDot label={st.label} dotClass={st.dotClass} ring={st.ring} />
|
<StatusDot label={st.label} dotClass={st.dotClass} ring={st.ring} />
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||||
<div className="rounded-lg bg-[#f8fbff] px-3 py-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">
|
<p className="mt-1 text-sm font-black text-slate-900">
|
||||||
{formatMinorAsCurrency(row.total_bet_amount, cur)}
|
{formatMinorAsCurrency(row.total_bet_amount, cur)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg bg-[#f8fbff] px-3 py-2">
|
<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]">
|
<p className="mt-1 text-sm font-black text-[#0b3f96]">
|
||||||
{formatMinorAsCurrency(row.actual_deduct_amount, cur)}
|
{formatMinorAsCurrency(row.actual_deduct_amount, cur)}
|
||||||
</p>
|
</p>
|
||||||
@@ -169,7 +176,7 @@ export function TicketOrdersListScreen() {
|
|||||||
</div>
|
</div>
|
||||||
{totalWin > 0 && row.status === "settled_win" ? (
|
{totalWin > 0 && row.status === "settled_win" ? (
|
||||||
<p className="mt-2 text-xs font-bold text-emerald-600">
|
<p className="mt-2 text-xs font-bold text-emerald-600">
|
||||||
Win {formatMinorAsCurrency(totalWin, cur)}
|
{t("orders.win", { amount: formatMinorAsCurrency(totalWin, cur) })}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
<p className="mt-2 text-[11px] text-slate-500">
|
<p className="mt-2 text-[11px] text-slate-500">
|
||||||
@@ -187,7 +194,7 @@ export function TicketOrdersListScreen() {
|
|||||||
disabled={loadingMore}
|
disabled={loadingMore}
|
||||||
onClick={() => loadMore()}
|
onClick={() => loadMore()}
|
||||||
>
|
>
|
||||||
{loadingMore ? "Loading..." : "Load More"}
|
{loadingMore ? t("actions.loading") : t("actions.loadMore")}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
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 { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { getPlayerMe, getPlayerPing } from "@/api/player";
|
import { getPlayerMe, getPlayerPing } from "@/api/player";
|
||||||
@@ -97,7 +97,6 @@ export function EntryGate() {
|
|||||||
usePlayerSessionStore();
|
usePlayerSessionStore();
|
||||||
|
|
||||||
const [phase, setPhase] = useState<Phase>("loading");
|
const [phase, setPhase] = useState<Phase>("loading");
|
||||||
const [progress, setProgress] = useState(0);
|
|
||||||
const [failureDetails, setFailureDetails] = useState<FailureRow[]>([]);
|
const [failureDetails, setFailureDetails] = useState<FailureRow[]>([]);
|
||||||
const [steps, setSteps] = useState<EntryStep[]>(initialSteps());
|
const [steps, setSteps] = useState<EntryStep[]>(initialSteps());
|
||||||
|
|
||||||
@@ -107,15 +106,11 @@ export function EntryGate() {
|
|||||||
setSteps((prev) => prev.map((s) => (s.id === stepId ? { ...s, status } : s)));
|
setSteps((prev) => prev.map((s) => (s.id === stepId ? { ...s, status } : s)));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const calculateProgress = useCallback((currentSteps: EntryStep[]) => {
|
const progress = useMemo(() => {
|
||||||
const doneCount = currentSteps.filter((s) => s.status === "done").length;
|
const doneCount = steps.filter((s) => s.status === "done").length;
|
||||||
const inProgressCount = currentSteps.filter((s) => s.status === "in-progress").length;
|
const inProgressCount = steps.filter((s) => s.status === "in-progress").length;
|
||||||
return Math.round(((doneCount + inProgressCount * 0.5) / currentSteps.length) * 100);
|
return Math.round(((doneCount + inProgressCount * 0.5) / steps.length) * 100);
|
||||||
}, []);
|
}, [steps]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setProgress(calculateProgress(steps));
|
|
||||||
}, [steps, calculateProgress]);
|
|
||||||
|
|
||||||
const handleRetry = useCallback(() => {
|
const handleRetry = useCallback(() => {
|
||||||
setPhase("loading");
|
setPhase("loading");
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { UserRound } from "lucide-react";
|
import { UserRound } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||||
@@ -10,11 +11,12 @@ import { usePlayerSessionStore } from "@/stores/player-session-store";
|
|||||||
*/
|
*/
|
||||||
export function PlayerSessionBar({ className }: { className?: string }) {
|
export function PlayerSessionBar({ className }: { className?: string }) {
|
||||||
const profile = usePlayerSessionStore((s) => s.profile);
|
const profile = usePlayerSessionStore((s) => s.profile);
|
||||||
|
const { t } = useTranslation("player");
|
||||||
|
|
||||||
const label =
|
const label =
|
||||||
profile?.nickname?.trim() ||
|
profile?.nickname?.trim() ||
|
||||||
profile?.username?.trim() ||
|
profile?.username?.trim() ||
|
||||||
(profile?.id != null ? `玩家 #${profile.id}` : null);
|
(profile?.id != null ? t("player.fallback", { id: profile.id }) : null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { getDrawResultByNo } from "@/api/draw";
|
import { getDrawResultByNo } from "@/api/draw";
|
||||||
import { getTicketDrawMyMatch } from "@/api/ticket-items";
|
import { getTicketDrawMyMatch } from "@/api/ticket-items";
|
||||||
import { Button, buttonVariants } from "@/components/ui/button";
|
import { Button, buttonVariants } from "@/components/ui/button";
|
||||||
|
import { PlayerPanel } from "@/components/layout/player-panel";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -29,6 +31,7 @@ type DrawResultDetailScreenProps = {
|
|||||||
|
|
||||||
/** §4.6 开奖结果详情:23 分区 + [< >] 切换 + 本人命中高亮 + Jackpot */
|
/** §4.6 开奖结果详情:23 分区 + [< >] 切换 + 本人命中高亮 + Jackpot */
|
||||||
export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps) {
|
export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps) {
|
||||||
|
const { t } = useTranslation("player");
|
||||||
const [data, setData] = useState<DrawResultDetailPayload | null>(null);
|
const [data, setData] = useState<DrawResultDetailPayload | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -47,11 +50,11 @@ export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps)
|
|||||||
setData(row);
|
setData(row);
|
||||||
} catch {
|
} catch {
|
||||||
setData(null);
|
setData(null);
|
||||||
setError("该期开奖结果不可用或不存在");
|
setError(t("results.unavailable"));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [drawNo]);
|
}, [drawNo, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
@@ -98,34 +101,37 @@ export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps)
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<PlayerPanel
|
||||||
<CardHeader>
|
title={t("results.detailTitle")}
|
||||||
<Skeleton className="h-7 w-48" />
|
subtitle={drawNo}
|
||||||
<Skeleton className="h-4 w-56" />
|
eyebrow={t("results.title")}
|
||||||
</CardHeader>
|
backHref="/results"
|
||||||
<CardContent className="space-y-4">
|
backLabel={t("results.title")}
|
||||||
<Skeleton className="h-48 w-full" />
|
>
|
||||||
</CardContent>
|
<div className="space-y-4">
|
||||||
</Card>
|
<Skeleton className="h-12 rounded-xl" />
|
||||||
|
<Skeleton className="h-56 rounded-xl" />
|
||||||
|
</div>
|
||||||
|
</PlayerPanel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error || !data) {
|
if (error || !data) {
|
||||||
return (
|
return (
|
||||||
<Card className="border-destructive/40">
|
<PlayerPanel
|
||||||
<CardHeader>
|
title={t("results.detailTitle")}
|
||||||
<CardTitle className="text-base">开奖结果</CardTitle>
|
subtitle={drawNo}
|
||||||
<CardDescription>{error ?? "无数据"}</CardDescription>
|
eyebrow={t("results.title")}
|
||||||
</CardHeader>
|
backHref="/results"
|
||||||
<CardContent className="flex flex-wrap gap-2">
|
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()}>
|
<Button type="button" size="sm" variant="secondary" onClick={() => void load()}>
|
||||||
重试
|
{t("actions.retry")}
|
||||||
</Button>
|
</Button>
|
||||||
<Link href="/results" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
</div>
|
||||||
返回列表
|
</PlayerPanel>
|
||||||
</Link>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,60 +149,81 @@ export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps)
|
|||||||
myTotals.jackpot === 0;
|
myTotals.jackpot === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<PlayerPanel
|
||||||
<JackpotResultsStrip currencyCode={currency} />
|
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>
|
<Card className="rounded-xl border-[#e5edf8] shadow-[0_8px_24px_rgba(15,23,42,0.05)]">
|
||||||
<CardHeader className="space-y-3 pb-2">
|
<CardHeader className="space-y-3 pb-2">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
{data.previous_draw_no ? (
|
{data.previous_draw_no ? (
|
||||||
<Link
|
<Link
|
||||||
href={`/results/${encodeURIComponent(data.previous_draw_no)}`}
|
href={`/results/${encodeURIComponent(data.previous_draw_no)}`}
|
||||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "min-w-[5rem]")}
|
className={cn(
|
||||||
>
|
buttonVariants({ variant: "outline", size: "sm" }),
|
||||||
‹ 上一期
|
"min-w-[5rem]",
|
||||||
</Link>
|
)}
|
||||||
) : (
|
>
|
||||||
<Button type="button" variant="outline" size="sm" className="min-w-[5rem]" disabled>
|
{t("results.previous")}
|
||||||
‹ 上一期
|
</Link>
|
||||||
</Button>
|
) : (
|
||||||
)}
|
<Button type="button" variant="outline" size="sm" className="min-w-[5rem]" disabled>
|
||||||
<CardTitle className="text-center font-mono text-lg">
|
{t("results.previous")}
|
||||||
{data.draw_no}
|
</Button>
|
||||||
</CardTitle>
|
)}
|
||||||
{data.next_draw_no ? (
|
<CardTitle className="text-center font-mono text-lg">
|
||||||
<Link
|
{data.draw_no}
|
||||||
href={`/results/${encodeURIComponent(data.next_draw_no)}`}
|
</CardTitle>
|
||||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "min-w-[5rem]")}
|
{data.next_draw_no ? (
|
||||||
>
|
<Link
|
||||||
下一期 ›
|
href={`/results/${encodeURIComponent(data.next_draw_no)}`}
|
||||||
</Link>
|
className={cn(
|
||||||
) : (
|
buttonVariants({ variant: "outline", size: "sm" }),
|
||||||
<Button type="button" variant="outline" size="sm" className="min-w-[5rem]" disabled>
|
"min-w-[5rem]",
|
||||||
下一期 ›
|
)}
|
||||||
</Button>
|
>
|
||||||
)}
|
{t("results.next")}
|
||||||
</div>
|
</Link>
|
||||||
<CardDescription className="text-center font-mono text-sm">
|
) : (
|
||||||
开奖时间:{" "}
|
<Button type="button" variant="outline" size="sm" className="min-w-[5rem]" disabled>
|
||||||
{formatLotteryInstant(data.draw_time_iso ?? data.draw_time ?? null)}
|
{t("results.next")}
|
||||||
</CardDescription>
|
</Button>
|
||||||
</CardHeader>
|
)}
|
||||||
<CardContent className="pt-2">
|
</div>
|
||||||
<TwentyThreeResultsGrid
|
<CardDescription className="text-center font-mono text-sm">
|
||||||
numbers={data.results}
|
{t("results.drawTime", {
|
||||||
highlighted4d={highlightSet ?? undefined}
|
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 ? (
|
{showMyPayout && myTotals ? (
|
||||||
<div className="mt-4 rounded-md border border-emerald-500/25 bg-emerald-500/5 px-3 py-2 text-sm">
|
<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">
|
<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 ? (
|
{myTotals.jackpot > 0 ? (
|
||||||
<>
|
<>
|
||||||
{" "}
|
{" "}
|
||||||
· Jackpot:{formatMinorAsCurrency(myTotals.jackpot, currency)}
|
·{" "}
|
||||||
|
{t("results.jackpot", {
|
||||||
|
amount: formatMinorAsCurrency(myTotals.jackpot, currency),
|
||||||
|
})}
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</p>
|
</p>
|
||||||
@@ -204,13 +231,13 @@ export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps)
|
|||||||
) : null}
|
) : null}
|
||||||
{showHitOnly ? (
|
{showHitOnly ? (
|
||||||
<p className="mt-3 text-xs text-amber-900/90 dark:text-amber-100/90">
|
<p className="mt-3 text-xs text-amber-900/90 dark:text-amber-100/90">
|
||||||
您的注单已命中本期开奖号码中的格子;派彩完成后将显示金额汇总。
|
{t("results.hitPending")}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="mt-4 flex flex-col gap-3">
|
<div className="mt-4 flex flex-col gap-3">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
如果您中奖,与注单匹配的号码将以金色高亮显示(需登录)。
|
{t("results.hitHint")}
|
||||||
</p>
|
</p>
|
||||||
<Link
|
<Link
|
||||||
href={`/orders?draw_no=${encodeURIComponent(data.draw_no)}`}
|
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",
|
"w-full sm:w-auto sm:self-start",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
查看我的中奖情况
|
{t("results.viewMyWinning")}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
</PlayerPanel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { getDrawResults } from "@/api/draw";
|
import { getDrawResults } from "@/api/draw";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -13,6 +14,7 @@ import { formatLotteryInstant } from "@/lib/player-datetime";
|
|||||||
import type { DrawResultListItem } from "@/types/api/draw-results";
|
import type { DrawResultListItem } from "@/types/api/draw-results";
|
||||||
|
|
||||||
export function DrawResultsListScreen() {
|
export function DrawResultsListScreen() {
|
||||||
|
const { t } = useTranslation("player");
|
||||||
const [items, setItems] = useState<DrawResultListItem[] | null>(null);
|
const [items, setItems] = useState<DrawResultListItem[] | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [date, setDate] = useState("");
|
const [date, setDate] = useState("");
|
||||||
@@ -29,12 +31,12 @@ export function DrawResultsListScreen() {
|
|||||||
});
|
});
|
||||||
setItems(res.items);
|
setItems(res.items);
|
||||||
} catch {
|
} catch {
|
||||||
setError("加载失败");
|
setError(t("results.loadFailed"));
|
||||||
setItems(null);
|
setItems(null);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [date]);
|
}, [date, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
@@ -43,12 +45,14 @@ export function DrawResultsListScreen() {
|
|||||||
}, [fetchList]);
|
}, [fetchList]);
|
||||||
|
|
||||||
return (
|
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">
|
<div className="space-y-4">
|
||||||
<JackpotResultsStrip currencyCode="NPR" />
|
<JackpotResultsStrip currencyCode="NPR" />
|
||||||
|
|
||||||
<div className="rounded-xl border border-[#e6edf8] bg-[#f8fbff] p-3">
|
<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">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
type="date"
|
type="date"
|
||||||
@@ -62,7 +66,7 @@ export function DrawResultsListScreen() {
|
|||||||
className="h-10 rounded-lg bg-[#07459f] px-4 text-white hover:bg-[#063b88]"
|
className="h-10 rounded-lg bg-[#07459f] px-4 text-white hover:bg-[#063b88]"
|
||||||
onClick={() => void fetchList()}
|
onClick={() => void fetchList()}
|
||||||
>
|
>
|
||||||
Apply
|
{t("actions.apply")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -82,12 +86,12 @@ export function DrawResultsListScreen() {
|
|||||||
className="mt-3 bg-[#e5002c] text-white hover:bg-[#d10028]"
|
className="mt-3 bg-[#e5002c] text-white hover:bg-[#d10028]"
|
||||||
onClick={() => void fetchList()}
|
onClick={() => void fetchList()}
|
||||||
>
|
>
|
||||||
Retry
|
{t("actions.retry")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : items && items.length === 0 ? (
|
) : 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">
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -107,7 +111,7 @@ export function DrawResultsListScreen() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="shrink-0 rounded-full bg-[#f2f6ff] px-2.5 py-1 text-xs font-bold text-[#0b56b7]">
|
<span className="shrink-0 rounded-full bg-[#f2f6ff] px-2.5 py-1 text-xs font-bold text-[#0b56b7]">
|
||||||
Detail
|
{t("results.detail")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { getJackpotSummary } from "@/api/jackpot";
|
import { getJackpotSummary } from "@/api/jackpot";
|
||||||
import { formatMinorAsCurrency } from "@/lib/money";
|
import { formatMinorAsCurrency } from "@/lib/money";
|
||||||
@@ -13,6 +14,7 @@ type JackpotResultsStripProps = {
|
|||||||
export function JackpotResultsStrip({
|
export function JackpotResultsStrip({
|
||||||
currencyCode = "NPR",
|
currencyCode = "NPR",
|
||||||
}: JackpotResultsStripProps) {
|
}: JackpotResultsStripProps) {
|
||||||
|
const { t } = useTranslation("player");
|
||||||
const [minor, setMinor] = useState<number | null>(null);
|
const [minor, setMinor] = useState<number | null>(null);
|
||||||
const [enabled, setEnabled] = useState(false);
|
const [enabled, setEnabled] = useState(false);
|
||||||
|
|
||||||
@@ -44,7 +46,7 @@ export function JackpotResultsStrip({
|
|||||||
return (
|
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)]">
|
<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">
|
<p className="text-[11px] font-black uppercase tracking-normal text-amber-700">
|
||||||
Jackpot
|
{t("results.jackpotLabel")}
|
||||||
</p>
|
</p>
|
||||||
<p className="font-mono text-lg font-black tabular-nums text-[#0b3f96]">
|
<p className="font-mono text-lg font-black tabular-nums text-[#0b3f96]">
|
||||||
{formatMinorAsCurrency(minor, currencyCode.toUpperCase())}
|
{formatMinorAsCurrency(minor, currencyCode.toUpperCase())}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import type { DrawResultsNumbers } from "@/types/api/draw-results";
|
import type { DrawResultsNumbers } from "@/types/api/draw-results";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { norm4d } from "@/lib/norm-4d";
|
import { norm4d } from "@/lib/norm-4d";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -15,6 +17,7 @@ export function TwentyThreeResultsGrid({
|
|||||||
numbers,
|
numbers,
|
||||||
highlighted4d,
|
highlighted4d,
|
||||||
}: TwentyThreeResultsGridProps) {
|
}: TwentyThreeResultsGridProps) {
|
||||||
|
const { t } = useTranslation("player");
|
||||||
const starters = numbers.starter ?? [];
|
const starters = numbers.starter ?? [];
|
||||||
const consos = numbers.consolation ?? [];
|
const consos = numbers.consolation ?? [];
|
||||||
const hits = highlighted4d ?? null;
|
const hits = highlighted4d ?? null;
|
||||||
@@ -44,7 +47,11 @@ export function TwentyThreeResultsGrid({
|
|||||||
{(["1st", "2nd", "3rd"] as const).map((key) => (
|
{(["1st", "2nd", "3rd"] as const).map((key) => (
|
||||||
<div key={key} className="flex flex-col gap-1.5 text-center">
|
<div key={key} className="flex flex-col gap-1.5 text-center">
|
||||||
<span className="text-xs font-medium uppercase text-muted-foreground">
|
<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>
|
</span>
|
||||||
<div className={cellTone(numbers[key] || "")}>
|
<div className={cellTone(numbers[key] || "")}>
|
||||||
{numbers[key] || "—"}
|
{numbers[key] || "—"}
|
||||||
@@ -54,7 +61,9 @@ export function TwentyThreeResultsGrid({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<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">
|
<div className="grid grid-cols-5 gap-2">
|
||||||
{Array.from({ length: 10 }).map((_, i) => (
|
{Array.from({ length: 10 }).map((_, i) => (
|
||||||
<div key={`s-${i}`} className={cellTone(starters[i] ?? "—")}>
|
<div key={`s-${i}`} className={cellTone(starters[i] ?? "—")}>
|
||||||
@@ -65,7 +74,9 @@ export function TwentyThreeResultsGrid({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<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">
|
<div className="grid grid-cols-5 gap-2">
|
||||||
{Array.from({ length: 10 }).map((_, i) => (
|
{Array.from({ length: 10 }).map((_, i) => (
|
||||||
<div key={`c-${i}`} className={cellTone(consos[i] ?? "—")}>
|
<div key={`c-${i}`} className={cellTone(consos[i] ?? "—")}>
|
||||||
|
|||||||
@@ -1,47 +1,37 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { formatLocalDateTime } from "@/lib/format-local-datetime";
|
import { formatLocalDateTime } from "@/lib/format-local-datetime";
|
||||||
import { formatMinorAsCurrency } from "@/lib/money";
|
import { formatMinorAsCurrency } from "@/lib/money";
|
||||||
import type { WalletLogItem, WalletLogsData } from "@/types/api/wallet-logs";
|
import type { WalletLogItem, WalletLogsData } from "@/types/api/wallet-logs";
|
||||||
|
|
||||||
/** 与 §4.9 筛选一致;接口 `type` 查询参数 */
|
/** 与 §4.9 筛选一致;接口 `type` 查询参数 */
|
||||||
export const WALLET_FLOW_FILTERS: { value: string; label: string }[] = [
|
export const WALLET_FLOW_FILTERS: { value: string; labelKey: string }[] = [
|
||||||
{ value: "", label: "全部" },
|
{ value: "", labelKey: "wallet.flow.all" },
|
||||||
{ value: "transfer_in", label: "转入" },
|
{ value: "transfer_in", labelKey: "wallet.flow.transfer_in" },
|
||||||
{ value: "transfer_out", label: "转出" },
|
{ value: "transfer_out", labelKey: "wallet.flow.transfer_out" },
|
||||||
{ value: "bet", label: "下注扣款" },
|
{ value: "bet", labelKey: "wallet.flow.bet" },
|
||||||
{ value: "prize", label: "派彩" },
|
{ value: "prize", labelKey: "wallet.flow.prize" },
|
||||||
{ value: "refund", label: "退本" },
|
{ value: "refund", labelKey: "wallet.flow.refund" },
|
||||||
{ value: "reversal", label: "冲正" },
|
{ value: "reversal", labelKey: "wallet.flow.reversal" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function logTypeLabel(t: string): string {
|
export function logTypeLabel(
|
||||||
const map: Record<string, string> = {
|
type: string,
|
||||||
transfer_in: "转入",
|
t?: (key: string, options?: { defaultValue?: string }) => string,
|
||||||
transfer_out: "转出",
|
): string {
|
||||||
refund: "退本",
|
return t?.(`wallet.flow.${type}`, { defaultValue: type }) ?? type;
|
||||||
reversal: "冲正",
|
|
||||||
bet: "下注扣款",
|
|
||||||
prize: "派彩",
|
|
||||||
};
|
|
||||||
return map[t] ?? t;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function txnStatusLabel(status: string): string {
|
function txnStatusLabel(
|
||||||
if (status === "posted") return "成功";
|
status: string,
|
||||||
if (status === "pending_reconcile") return "待对账";
|
t: (key: string, options?: { defaultValue?: string }) => string,
|
||||||
if (status === "reversed") return "已冲正";
|
): string {
|
||||||
if (status === "manually_processed") return "已人工处理";
|
return t(`wallet.txnStatus.${status}`, { defaultValue: status });
|
||||||
return status;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type WalletLogsBlockProps = {
|
type WalletLogsBlockProps = {
|
||||||
@@ -61,47 +51,59 @@ export function WalletLogsBlock({
|
|||||||
filter,
|
filter,
|
||||||
onFilterChange,
|
onFilterChange,
|
||||||
currency,
|
currency,
|
||||||
title = "资金流水",
|
title,
|
||||||
}: WalletLogsBlockProps) {
|
}: 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{logs && logs.pending_reconcile.length > 0 ? (
|
{logs && logs.pending_reconcile.length > 0 ? (
|
||||||
<Card className="border-[#faad14]/50 bg-[#faad14]/5">
|
<section className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-3">
|
||||||
<CardHeader className="pb-2">
|
<p className="text-sm font-black text-amber-700">{t("wallet.pendingTitle")}</p>
|
||||||
<CardTitle className="text-sm text-[#d48806]">待对账</CardTitle>
|
<p className="mt-1 text-xs text-amber-700/80">
|
||||||
<CardDescription className="text-xs">
|
{t("wallet.pendingDescription")}
|
||||||
以下划转主站结果未最终确认;若长时间未到账请联系客服(界面文档 §4.10
|
</p>
|
||||||
超时说明)。
|
<div className="mt-2 space-y-2 text-sm">
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-2 text-sm">
|
|
||||||
{logs.pending_reconcile.map((p) => (
|
{logs.pending_reconcile.map((p) => (
|
||||||
<div
|
<div
|
||||||
key={p.transfer_no}
|
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"
|
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">
|
<span className="text-muted-foreground">
|
||||||
{p.type === "transfer_in" ? "转入" : "转出"}{" "}
|
{logTypeLabel(p.type, t)}{" "}
|
||||||
{formatMinorAsCurrency(p.amount, p.currency_code)}
|
{formatMinorAsCurrency(p.amount, p.currency_code)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-amber-700">处理中</span>
|
<span className="text-xs text-amber-700">{t("wallet.pendingStatus")}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<div className="flex flex-col gap-2">
|
<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">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{WALLET_FLOW_FILTERS.map((f) => (
|
{filters.map((f) => (
|
||||||
<Button
|
<Button
|
||||||
key={f.value || "all"}
|
key={f.value || "all"}
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant={filter === f.value ? "default" : "outline"}
|
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)}
|
onClick={() => onFilterChange(f.value)}
|
||||||
>
|
>
|
||||||
{f.label}
|
{f.label}
|
||||||
@@ -117,12 +119,12 @@ export function WalletLogsBlock({
|
|||||||
{logs ? (
|
{logs ? (
|
||||||
<>
|
<>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
共 {logs.total} 条记录
|
{t("wallet.totalRecords", { total: logs.total })}
|
||||||
</p>
|
</p>
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{logs.items.length === 0 ? (
|
{logs.items.length === 0 ? (
|
||||||
<li className="rounded-lg border border-dashed py-8 text-center text-sm text-muted-foreground">
|
<li className="rounded-lg border border-dashed py-8 text-center text-sm text-muted-foreground">
|
||||||
暂无流水
|
{t("wallet.emptyLogs")}
|
||||||
</li>
|
</li>
|
||||||
) : (
|
) : (
|
||||||
logs.items.map((row) => (
|
logs.items.map((row) => (
|
||||||
@@ -144,15 +146,16 @@ export function LogRow({
|
|||||||
item: WalletLogItem;
|
item: WalletLogItem;
|
||||||
currency: string;
|
currency: string;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation("player");
|
||||||
const ccy = item.currency_code || currency;
|
const ccy = item.currency_code || currency;
|
||||||
const isIn = item.direction === "in";
|
const isIn = item.direction === "in";
|
||||||
return (
|
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 className="flex items-start justify-between gap-2">
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{logTypeLabel(item.type)}{" "}
|
{logTypeLabel(item.type, t)}{" "}
|
||||||
<span className={isIn ? "text-[#52c41a]" : "text-foreground"}>
|
<span className={isIn ? "font-black text-emerald-600" : "font-black text-[#0b3f96]"}>
|
||||||
{isIn ? "+" : "−"}
|
{isIn ? "+" : "−"}
|
||||||
{formatMinorAsCurrency(item.amount_abs, ccy)}
|
{formatMinorAsCurrency(item.amount_abs, ccy)}
|
||||||
</span>
|
</span>
|
||||||
@@ -160,7 +163,7 @@ export function LogRow({
|
|||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
{formatLocalDateTime(item.created_at)}{" "}
|
{formatLocalDateTime(item.created_at)}{" "}
|
||||||
<span className="text-foreground/80">
|
<span className="text-foreground/80">
|
||||||
· {txnStatusLabel(item.status)}
|
· {txnStatusLabel(item.status, t)}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
{item.ref_id ? (
|
{item.ref_id ? (
|
||||||
|
|||||||
@@ -1,26 +1,19 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { getWalletLogs } from "@/api/wallet";
|
import { getWalletLogs } from "@/api/wallet";
|
||||||
import { Button, buttonVariants } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import { PlayerPanel } from "@/components/layout/player-panel";
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { WalletLogsBlock } from "@/features/wallet/wallet-logs-block";
|
import { WalletLogsBlock } from "@/features/wallet/wallet-logs-block";
|
||||||
import { formatWalletClientError } from "@/lib/wallet-api-error";
|
import { formatWalletClientError } from "@/lib/wallet-api-error";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||||
import type { WalletLogsData } from "@/types/api/wallet-logs";
|
import type { WalletLogsData } from "@/types/api/wallet-logs";
|
||||||
|
|
||||||
/** 独立路由 `/wallet/logs` */
|
|
||||||
export function WalletLogsScreen() {
|
export function WalletLogsScreen() {
|
||||||
const profile = usePlayerSessionStore((s) => s.profile);
|
const profile = usePlayerSessionStore((s) => s.profile);
|
||||||
|
const { t } = useTranslation("player");
|
||||||
const [logs, setLogs] = useState<WalletLogsData | null>(null);
|
const [logs, setLogs] = useState<WalletLogsData | null>(null);
|
||||||
const [filter, setFilter] = useState("");
|
const [filter, setFilter] = useState("");
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -50,12 +43,12 @@ export function WalletLogsScreen() {
|
|||||||
});
|
});
|
||||||
setLogs(L);
|
setLogs(L);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(formatWalletClientError(e));
|
setError(formatWalletClientError(e, t));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setLogsLoading(false);
|
setLogsLoading(false);
|
||||||
}
|
}
|
||||||
}, [filter]);
|
}, [filter, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
@@ -64,39 +57,36 @@ export function WalletLogsScreen() {
|
|||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<PlayerPanel
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
title={t("wallet.logsTitle")}
|
||||||
<h1 className="text-lg font-semibold tracking-tight">资金流水</h1>
|
subtitle={t("wallet.logsSubtitle")}
|
||||||
<Link
|
eyebrow={t("brand.name")}
|
||||||
href="/wallet"
|
backHref="/wallet"
|
||||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
|
backLabel={t("wallet.title")}
|
||||||
>
|
>
|
||||||
返回钱包概览
|
<div className="space-y-4">
|
||||||
</Link>
|
{error ? (
|
||||||
</div>
|
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-4 text-sm text-red-700">
|
||||||
|
<p>{error}</p>
|
||||||
{error ? (
|
<Button
|
||||||
<Card className="border-destructive/40">
|
type="button"
|
||||||
<CardHeader>
|
className="mt-3 bg-[#e5002c] text-white hover:bg-[#d10028]"
|
||||||
<CardTitle className="text-destructive">加载失败</CardTitle>
|
onClick={() => void load()}
|
||||||
<CardDescription>{error}</CardDescription>
|
>
|
||||||
</CardHeader>
|
{t("actions.retry")}
|
||||||
<CardContent>
|
|
||||||
<Button type="button" onClick={() => void load()}>
|
|
||||||
重试
|
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
) : null}
|
||||||
) : null}
|
|
||||||
|
|
||||||
<WalletLogsBlock
|
<WalletLogsBlock
|
||||||
logs={logs}
|
logs={logs}
|
||||||
logsLoading={loading || logsLoading}
|
logsLoading={loading || logsLoading}
|
||||||
filter={filter}
|
filter={filter}
|
||||||
onFilterChange={setFilter}
|
onFilterChange={setFilter}
|
||||||
currency={currency}
|
currency={currency}
|
||||||
title="类型筛选"
|
title={t("wallet.typeFilter")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</PlayerPanel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,17 +3,12 @@
|
|||||||
import { Wallet } from "lucide-react";
|
import { Wallet } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { getWalletBalance, getWalletLogs } from "@/api/wallet";
|
import { getWalletBalance, getWalletLogs } from "@/api/wallet";
|
||||||
import { Button, buttonVariants } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { PlayerPanel } from "@/components/layout/player-panel";
|
||||||
import {
|
import {
|
||||||
TransferInDialog,
|
TransferInDialog,
|
||||||
TransferOutDialog,
|
TransferOutDialog,
|
||||||
@@ -21,14 +16,13 @@ import {
|
|||||||
import { WalletLogsBlock } from "@/features/wallet/wallet-logs-block";
|
import { WalletLogsBlock } from "@/features/wallet/wallet-logs-block";
|
||||||
import { formatMinorAsCurrency } from "@/lib/money";
|
import { formatMinorAsCurrency } from "@/lib/money";
|
||||||
import { formatWalletClientError } from "@/lib/wallet-api-error";
|
import { formatWalletClientError } from "@/lib/wallet-api-error";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
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 { WalletBalanceData } from "@/types/api/wallet-balance";
|
||||||
|
import type { WalletLogsData } from "@/types/api/wallet-logs";
|
||||||
|
|
||||||
export function WalletScreen() {
|
export function WalletScreen() {
|
||||||
const profile = usePlayerSessionStore((s) => s.profile);
|
const profile = usePlayerSessionStore((s) => s.profile);
|
||||||
|
const { t } = useTranslation("player");
|
||||||
const [balance, setBalance] = useState<WalletBalanceData | null>(null);
|
const [balance, setBalance] = useState<WalletBalanceData | null>(null);
|
||||||
const [logs, setLogs] = useState<WalletLogsData | null>(null);
|
const [logs, setLogs] = useState<WalletLogsData | null>(null);
|
||||||
const [filter, setFilter] = useState("");
|
const [filter, setFilter] = useState("");
|
||||||
@@ -70,7 +64,7 @@ export function WalletScreen() {
|
|||||||
setLogs(L);
|
setLogs(L);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setError(formatWalletClientError(e));
|
setError(formatWalletClientError(e, t));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
@@ -83,7 +77,7 @@ export function WalletScreen() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [filter]);
|
}, [filter, t]);
|
||||||
|
|
||||||
const refreshAll = useCallback(async () => {
|
const refreshAll = useCallback(async () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -98,138 +92,102 @@ export function WalletScreen() {
|
|||||||
});
|
});
|
||||||
setLogs(L);
|
setLogs(L);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(formatWalletClientError(e));
|
setError(formatWalletClientError(e, t));
|
||||||
} finally {
|
} finally {
|
||||||
setLogsLoading(false);
|
setLogsLoading(false);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [filter]);
|
}, [filter, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<PlayerPanel title={t("wallet.title")} subtitle={t("wallet.subtitle")} eyebrow={t("brand.name")}>
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
<div className="space-y-4">
|
||||||
<div>
|
{error ? (
|
||||||
<h1 className="text-lg font-semibold tracking-tight">彩票钱包</h1>
|
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-4 text-sm text-red-700">
|
||||||
<div className="mt-2 flex flex-wrap gap-2">
|
<p>{error}</p>
|
||||||
<Link
|
<Button
|
||||||
href="/wallet/transfer-in"
|
type="button"
|
||||||
className={cn(
|
className="mt-3 bg-[#e5002c] text-white hover:bg-[#d10028]"
|
||||||
buttonVariants({ variant: "secondary", size: "sm" }),
|
onClick={() => void refreshAll()}
|
||||||
"text-xs",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
转入页
|
{t("actions.retry")}
|
||||||
</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()}>
|
|
||||||
重试
|
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
</CardContent>
|
) : null}
|
||||||
</Card>
|
|
||||||
|
|
||||||
<WalletLogsBlock
|
<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)]">
|
||||||
logs={logs}
|
<div className="relative flex items-center gap-3">
|
||||||
logsLoading={loading || logsLoading}
|
<div className="flex size-13 shrink-0 items-center justify-center rounded-full bg-white text-[#d81435] shadow-sm">
|
||||||
filter={filter}
|
<Wallet className="size-7" aria-hidden />
|
||||||
onFilterChange={setFilter}
|
</div>
|
||||||
currency={currency}
|
<div className="min-w-0 flex-1">
|
||||||
/>
|
<p className="text-sm font-semibold text-white/90">{t("wallet.balance")}</p>
|
||||||
</div>
|
{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 { ArrowDownLeft, ArrowUpRight } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -42,7 +43,7 @@ export function TransferInDialog({
|
|||||||
idPrefix = "",
|
idPrefix = "",
|
||||||
triggerClassName,
|
triggerClassName,
|
||||||
triggerVariant = "wallet",
|
triggerVariant = "wallet",
|
||||||
triggerLabel = "转入",
|
triggerLabel,
|
||||||
}: BaseProps & {
|
}: BaseProps & {
|
||||||
lotteryMinor: number;
|
lotteryMinor: number;
|
||||||
triggerClassName?: string;
|
triggerClassName?: string;
|
||||||
@@ -50,6 +51,8 @@ export function TransferInDialog({
|
|||||||
triggerLabel?: string;
|
triggerLabel?: string;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const { t } = useTranslation("player");
|
||||||
|
const resolvedTriggerLabel = triggerLabel ?? t("wallet.transferIn");
|
||||||
|
|
||||||
const triggerCombined = cn(
|
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",
|
"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)}
|
onClick={() => setOpen(true)}
|
||||||
>
|
>
|
||||||
<ArrowDownLeft className="size-4 shrink-0" />
|
<ArrowDownLeft className="size-4 shrink-0" />
|
||||||
{triggerLabel}
|
{resolvedTriggerLabel}
|
||||||
</Button>
|
</Button>
|
||||||
<DialogContent showCloseButton>
|
<DialogContent showCloseButton>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>转入资金</DialogTitle>
|
<DialogTitle>{t("wallet.transferInTitle")}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
从主站钱包划入彩票钱包(最小单笔以服务端校验为准,默认约 1.00{" "}
|
{t("wallet.dialogInDescription", { currency })}
|
||||||
{currency})。
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<TransferInPanel
|
<TransferInPanel
|
||||||
@@ -99,7 +101,7 @@ export function TransferOutDialog({
|
|||||||
idPrefix = "",
|
idPrefix = "",
|
||||||
triggerClassName,
|
triggerClassName,
|
||||||
triggerVariant = "wallet",
|
triggerVariant = "wallet",
|
||||||
triggerLabel = "转出",
|
triggerLabel,
|
||||||
}: BaseProps & {
|
}: BaseProps & {
|
||||||
availableMinor: number;
|
availableMinor: number;
|
||||||
triggerClassName?: string;
|
triggerClassName?: string;
|
||||||
@@ -107,6 +109,8 @@ export function TransferOutDialog({
|
|||||||
triggerLabel?: string;
|
triggerLabel?: string;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const { t } = useTranslation("player");
|
||||||
|
const resolvedTriggerLabel = triggerLabel ?? t("wallet.transferOut");
|
||||||
|
|
||||||
const triggerCombined = cn(
|
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",
|
"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)}
|
onClick={() => setOpen(true)}
|
||||||
>
|
>
|
||||||
<ArrowUpRight className="size-4 shrink-0" />
|
<ArrowUpRight className="size-4 shrink-0" />
|
||||||
{triggerLabel}
|
{resolvedTriggerLabel}
|
||||||
</Button>
|
</Button>
|
||||||
<DialogContent showCloseButton>
|
<DialogContent showCloseButton>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>转出资金</DialogTitle>
|
<DialogTitle>{t("wallet.transferOutTitle")}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
划回主站钱包;单笔限额以服务端校验为准。
|
{t("wallet.dialogOutDescription")}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<TransferOutPanel
|
<TransferOutPanel
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { isAxiosError } from "axios";
|
import { isAxiosError } from "axios";
|
||||||
import { ChevronLeft, Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import Link from "next/link";
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { postWalletTransferIn, postWalletTransferOut } from "@/api/wallet";
|
import { postWalletTransferIn, postWalletTransferOut } from "@/api/wallet";
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { PlayerPanel } from "@/components/layout/player-panel";
|
||||||
import { formatMinorAsCurrency, parseDecimalInputToMinor } from "@/lib/money";
|
import { formatMinorAsCurrency, parseDecimalInputToMinor } from "@/lib/money";
|
||||||
import { formatWalletClientError } from "@/lib/wallet-api-error";
|
import { formatWalletClientError } from "@/lib/wallet-api-error";
|
||||||
import { LotteryApiBizError } from "@/types/api/errors";
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
@@ -25,14 +26,15 @@ import { LotteryApiBizError } from "@/types/api/errors";
|
|||||||
async function handleTransferMaybePending(
|
async function handleTransferMaybePending(
|
||||||
e: unknown,
|
e: unknown,
|
||||||
onRefresh: () => Promise<void>,
|
onRefresh: () => Promise<void>,
|
||||||
|
t: (key: string) => string,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (e instanceof LotteryApiBizError && e.code === 1002) {
|
if (e instanceof LotteryApiBizError && e.code === 1002) {
|
||||||
toast.message(e.message || "处理中…");
|
toast.message(e.message || t("wallet.pendingToast"));
|
||||||
await onRefresh();
|
await onRefresh();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (isAxiosError(e) && e.response?.status === 409) {
|
if (isAxiosError(e) && e.response?.status === 409) {
|
||||||
toast.message("转账处理中,请稍后刷新。");
|
toast.message(t("wallet.pendingShort"));
|
||||||
await onRefresh();
|
await onRefresh();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -61,6 +63,7 @@ export function TransferInPanel({
|
|||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
variant?: PanelVariant;
|
variant?: PanelVariant;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation("player");
|
||||||
const [amountText, setAmountText] = useState("");
|
const [amountText, setAmountText] = useState("");
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [localError, setLocalError] = useState<string | null>(null);
|
const [localError, setLocalError] = useState<string | null>(null);
|
||||||
@@ -76,7 +79,7 @@ export function TransferInPanel({
|
|||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
setLocalError(null);
|
setLocalError(null);
|
||||||
if (parsedMinor == null || parsedMinor < 1) {
|
if (parsedMinor == null || parsedMinor < 1) {
|
||||||
setLocalError("请输入有效金额。");
|
setLocalError(t("wallet.invalidAmount"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
@@ -86,15 +89,15 @@ export function TransferInPanel({
|
|||||||
currency,
|
currency,
|
||||||
idempotent_key: crypto.randomUUID(),
|
idempotent_key: crypto.randomUUID(),
|
||||||
});
|
});
|
||||||
toast.success("转入成功,彩票钱包余额已更新。");
|
toast.success(t("wallet.successIn"));
|
||||||
setAmountText("");
|
setAmountText("");
|
||||||
await onSuccess();
|
await onSuccess();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (await handleTransferMaybePending(e, onSuccess)) {
|
if (await handleTransferMaybePending(e, onSuccess, t)) {
|
||||||
setLocalError(formatWalletClientError(e));
|
setLocalError(formatWalletClientError(e, t));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLocalError(formatWalletClientError(e));
|
setLocalError(formatWalletClientError(e, t));
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -104,17 +107,17 @@ export function TransferInPanel({
|
|||||||
variant === "page" ? (
|
variant === "page" ? (
|
||||||
<Button
|
<Button
|
||||||
type="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}
|
disabled={submitting}
|
||||||
onClick={() => void submit()}
|
onClick={() => void submit()}
|
||||||
>
|
>
|
||||||
{submitting ? (
|
{submitting ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||||
处理中…
|
{t("actions.processing")}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
"确认转入"
|
t("wallet.confirmIn")
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
@@ -125,7 +128,7 @@ export function TransferInPanel({
|
|||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
>
|
>
|
||||||
取消
|
{t("actions.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -135,10 +138,10 @@ export function TransferInPanel({
|
|||||||
{submitting ? (
|
{submitting ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||||
处理中…
|
{t("actions.processing")}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
"确认转入"
|
t("wallet.confirmIn")
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -147,32 +150,34 @@ export function TransferInPanel({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="grid gap-3 py-1">
|
<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>
|
<p>
|
||||||
主站钱包余额:{" "}
|
{t("wallet.mainBalance")}{" "}
|
||||||
<span className="text-muted-foreground">—(待接入主站)</span>
|
<span className="text-muted-foreground">{t("wallet.mainPending")}</span>
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1">
|
<p className="mt-1">
|
||||||
彩票钱包余额:{" "}
|
{t("wallet.lotteryBalance")}{" "}
|
||||||
<span className="font-medium text-foreground">
|
<span className="font-medium text-foreground">
|
||||||
{formatMinorAsCurrency(lotteryMinor, currency)}
|
{formatMinorAsCurrency(lotteryMinor, currency)}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor={tid}>转入金额</Label>
|
<Label htmlFor={tid}>{t("wallet.inAmount")}</Label>
|
||||||
<Input
|
<Input
|
||||||
id={tid}
|
id={tid}
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
placeholder="例如 1000.00"
|
placeholder={t("wallet.exampleIn")}
|
||||||
value={amountText}
|
value={amountText}
|
||||||
onChange={(ev) => setAmountText(ev.target.value)}
|
onChange={(ev) => setAmountText(ev.target.value)}
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
|
className="h-11 rounded-lg border-[#dce7f7] bg-white text-base"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
转入后彩票余额(预览):{" "}
|
{t("wallet.afterInPreview", {
|
||||||
{formatMinorAsCurrency(previewAfter, currency)}
|
amount: formatMinorAsCurrency(previewAfter, currency),
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{localError ? (
|
{localError ? (
|
||||||
@@ -196,6 +201,7 @@ export function TransferOutPanel({
|
|||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
variant?: PanelVariant;
|
variant?: PanelVariant;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation("player");
|
||||||
const [amountText, setAmountText] = useState("");
|
const [amountText, setAmountText] = useState("");
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [localError, setLocalError] = useState<string | null>(null);
|
const [localError, setLocalError] = useState<string | null>(null);
|
||||||
@@ -218,11 +224,11 @@ export function TransferOutPanel({
|
|||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
setLocalError(null);
|
setLocalError(null);
|
||||||
if (parsedMinor == null || parsedMinor < 1) {
|
if (parsedMinor == null || parsedMinor < 1) {
|
||||||
setLocalError("请输入有效金额。");
|
setLocalError(t("wallet.invalidAmount"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (parsedMinor > availableMinor) {
|
if (parsedMinor > availableMinor) {
|
||||||
setLocalError("转出金额不能超过可用余额。");
|
setLocalError(t("wallet.outExceeds"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
@@ -232,15 +238,15 @@ export function TransferOutPanel({
|
|||||||
currency,
|
currency,
|
||||||
idempotent_key: crypto.randomUUID(),
|
idempotent_key: crypto.randomUUID(),
|
||||||
});
|
});
|
||||||
toast.success("转出成功,资金将返回主站钱包。");
|
toast.success(t("wallet.successOut"));
|
||||||
setAmountText("");
|
setAmountText("");
|
||||||
await onSuccess();
|
await onSuccess();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (await handleTransferMaybePending(e, onSuccess)) {
|
if (await handleTransferMaybePending(e, onSuccess, t)) {
|
||||||
setLocalError(formatWalletClientError(e));
|
setLocalError(formatWalletClientError(e, t));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLocalError(formatWalletClientError(e));
|
setLocalError(formatWalletClientError(e, t));
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -250,17 +256,17 @@ export function TransferOutPanel({
|
|||||||
variant === "page" ? (
|
variant === "page" ? (
|
||||||
<Button
|
<Button
|
||||||
type="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}
|
disabled={submitting}
|
||||||
onClick={() => void submit()}
|
onClick={() => void submit()}
|
||||||
>
|
>
|
||||||
{submitting ? (
|
{submitting ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||||
处理中…
|
{t("actions.processing")}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
"确认转出"
|
t("wallet.confirmOut")
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
@@ -271,7 +277,7 @@ export function TransferOutPanel({
|
|||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
>
|
>
|
||||||
取消
|
{t("actions.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -281,10 +287,10 @@ export function TransferOutPanel({
|
|||||||
{submitting ? (
|
{submitting ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||||
处理中…
|
{t("actions.processing")}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
"确认转出"
|
t("wallet.confirmOut")
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -293,9 +299,9 @@ export function TransferOutPanel({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="grid gap-3 py-1">
|
<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>
|
<p>
|
||||||
彩票钱包可用:{" "}
|
{t("wallet.lotteryAvailable")}{" "}
|
||||||
<span className="font-medium text-foreground">
|
<span className="font-medium text-foreground">
|
||||||
{formatMinorAsCurrency(availableMinor, currency)}
|
{formatMinorAsCurrency(availableMinor, currency)}
|
||||||
</span>
|
</span>
|
||||||
@@ -303,7 +309,7 @@ export function TransferOutPanel({
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<div className="flex items-end justify-between gap-2">
|
<div className="flex items-end justify-between gap-2">
|
||||||
<Label htmlFor={tid}>转出金额</Label>
|
<Label htmlFor={tid}>{t("wallet.outAmount")}</Label>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="link"
|
variant="link"
|
||||||
@@ -311,22 +317,25 @@ export function TransferOutPanel({
|
|||||||
onClick={fillAll}
|
onClick={fillAll}
|
||||||
disabled={submitting || availableMinor < 1}
|
disabled={submitting || availableMinor < 1}
|
||||||
>
|
>
|
||||||
全部转出{" "}
|
{t("wallet.allOut", {
|
||||||
{formatMinorAsCurrency(availableMinor, currency)}
|
amount: formatMinorAsCurrency(availableMinor, currency),
|
||||||
|
})}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Input
|
<Input
|
||||||
id={tid}
|
id={tid}
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
placeholder="例如 500.00"
|
placeholder={t("wallet.exampleOut")}
|
||||||
value={amountText}
|
value={amountText}
|
||||||
onChange={(ev) => setAmountText(ev.target.value)}
|
onChange={(ev) => setAmountText(ev.target.value)}
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
|
className="h-11 rounded-lg border-[#dce7f7] bg-white text-base"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
转出后彩票余额(预览):{" "}
|
{t("wallet.afterOutPreview", {
|
||||||
{formatMinorAsCurrency(previewAfter, currency)}
|
amount: formatMinorAsCurrency(previewAfter, currency),
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{localError ? (
|
{localError ? (
|
||||||
@@ -344,21 +353,21 @@ export function TransferInPage({
|
|||||||
lotteryMinor,
|
lotteryMinor,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
}: PanelBase & { lotteryMinor: number }) {
|
}: PanelBase & { lotteryMinor: number }) {
|
||||||
|
const { t } = useTranslation("player");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<PlayerPanel
|
||||||
<Link
|
title={t("wallet.transferInTitle")}
|
||||||
href="/wallet"
|
subtitle={t("wallet.transferInSubtitle", { currency })}
|
||||||
className="inline-flex w-fit items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
|
eyebrow={t("wallet.title")}
|
||||||
>
|
backHref="/wallet"
|
||||||
<ChevronLeft className="size-4" />
|
backLabel={t("wallet.title")}
|
||||||
返回钱包
|
>
|
||||||
</Link>
|
<Card className="rounded-xl border-[#e5edf8] shadow-[0_8px_24px_rgba(15,23,42,0.05)]">
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>转入资金</CardTitle>
|
<CardTitle className="text-[#0b3f96]">{t("wallet.transferInTitle")}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
从主站钱包划入彩票钱包(最小单笔以服务端校验为准,默认约 1.00{" "}
|
{t("wallet.transferInDescription")}
|
||||||
{currency})。
|
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -372,7 +381,7 @@ export function TransferInPage({
|
|||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</PlayerPanel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,20 +391,21 @@ export function TransferOutPage({
|
|||||||
availableMinor,
|
availableMinor,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
}: PanelBase & { availableMinor: number }) {
|
}: PanelBase & { availableMinor: number }) {
|
||||||
|
const { t } = useTranslation("player");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<PlayerPanel
|
||||||
<Link
|
title={t("wallet.transferOutTitle")}
|
||||||
href="/wallet"
|
subtitle={t("wallet.transferOutSubtitle", { currency })}
|
||||||
className="inline-flex w-fit items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
|
eyebrow={t("wallet.title")}
|
||||||
>
|
backHref="/wallet"
|
||||||
<ChevronLeft className="size-4" />
|
backLabel={t("wallet.title")}
|
||||||
返回钱包
|
>
|
||||||
</Link>
|
<Card className="rounded-xl border-[#e5edf8] shadow-[0_8px_24px_rgba(15,23,42,0.05)]">
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>转出资金</CardTitle>
|
<CardTitle className="text-[#0b3f96]">{t("wallet.transferOutTitle")}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
划回主站钱包;单笔限额以服务端校验为准。
|
{t("wallet.transferOutDescription")}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -409,6 +419,6 @@ export function TransferOutPage({
|
|||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</PlayerPanel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,6 @@ import { useCallback, useEffect, useRef } from "react";
|
|||||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||||
import { useErrorStore } from "@/stores/error-store";
|
import { useErrorStore } from "@/stores/error-store";
|
||||||
|
|
||||||
/** Token 刷新间隔(毫秒):5 分钟 - 30 秒缓冲 = 4.5 分钟 */
|
|
||||||
const TOKEN_REFRESH_INTERVAL = 4.5 * 60 * 1000;
|
|
||||||
|
|
||||||
/** Token 过期前警告阈值(毫秒) */
|
/** Token 过期前警告阈值(毫秒) */
|
||||||
const TOKEN_WARNING_THRESHOLD = 60 * 1000; // 1 分钟
|
const TOKEN_WARNING_THRESHOLD = 60 * 1000; // 1 分钟
|
||||||
|
|
||||||
|
|||||||
@@ -44,11 +44,11 @@ export type UseWebSocketManagerReturn = {
|
|||||||
export function useWebSocketManager(): UseWebSocketManagerReturn {
|
export function useWebSocketManager(): UseWebSocketManagerReturn {
|
||||||
const store = useNetworkConnectionStore();
|
const store = useNetworkConnectionStore();
|
||||||
const reconnectTimerRef = useRef<number | null>(null);
|
const reconnectTimerRef = useRef<number | null>(null);
|
||||||
|
const attemptReconnectRef = useRef<() => void>(() => {});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
mode,
|
mode,
|
||||||
isWebSocketConnected,
|
isWebSocketConnected,
|
||||||
isReconnecting,
|
|
||||||
reconnectAttempts,
|
reconnectAttempts,
|
||||||
setWebSocketConnected,
|
setWebSocketConnected,
|
||||||
setReconnecting,
|
setReconnecting,
|
||||||
@@ -212,7 +212,7 @@ export function useWebSocketManager(): UseWebSocketManagerReturn {
|
|||||||
} else {
|
} else {
|
||||||
// 继续重连
|
// 继续重连
|
||||||
reconnectTimerRef.current = window.setTimeout(
|
reconnectTimerRef.current = window.setTimeout(
|
||||||
attemptReconnect,
|
() => attemptReconnectRef.current(),
|
||||||
RECONNECT_INTERVAL_MS,
|
RECONNECT_INTERVAL_MS,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -224,6 +224,10 @@ export function useWebSocketManager(): UseWebSocketManagerReturn {
|
|||||||
setReconnecting,
|
setReconnecting,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
attemptReconnectRef.current = attemptReconnect;
|
||||||
|
}, [attemptReconnect]);
|
||||||
|
|
||||||
// 手动重连
|
// 手动重连
|
||||||
const reconnect = useCallback(() => {
|
const reconnect = useCallback(() => {
|
||||||
resetReconnectAttempts();
|
resetReconnectAttempts();
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import i18n from "i18next";
|
import i18n from "i18next";
|
||||||
import LanguageDetector from "i18next-browser-languagedetector";
|
import LanguageDetector from "i18next-browser-languagedetector";
|
||||||
import { initReactI18next } from "react-i18next";
|
import { initReactI18next } from "react-i18next";
|
||||||
@@ -5,12 +7,15 @@ import { initReactI18next } from "react-i18next";
|
|||||||
import enCommon from "./locales/en/common.json";
|
import enCommon from "./locales/en/common.json";
|
||||||
import enEntry from "./locales/en/entry.json";
|
import enEntry from "./locales/en/entry.json";
|
||||||
import enLayout from "./locales/en/layout.json";
|
import enLayout from "./locales/en/layout.json";
|
||||||
|
import enPlayer from "./locales/en/player.json";
|
||||||
import neCommon from "./locales/ne/common.json";
|
import neCommon from "./locales/ne/common.json";
|
||||||
import neEntry from "./locales/ne/entry.json";
|
import neEntry from "./locales/ne/entry.json";
|
||||||
import neLayout from "./locales/ne/layout.json";
|
import neLayout from "./locales/ne/layout.json";
|
||||||
|
import nePlayer from "./locales/ne/player.json";
|
||||||
import zhCommon from "./locales/zh/common.json";
|
import zhCommon from "./locales/zh/common.json";
|
||||||
import zhEntry from "./locales/zh/entry.json";
|
import zhEntry from "./locales/zh/entry.json";
|
||||||
import zhLayout from "./locales/zh/layout.json";
|
import zhLayout from "./locales/zh/layout.json";
|
||||||
|
import zhPlayer from "./locales/zh/player.json";
|
||||||
|
|
||||||
/** 对齐后端与产品:尼泊尔语 / 英语 / 中文(简体) */
|
/** 对齐后端与产品:尼泊尔语 / 英语 / 中文(简体) */
|
||||||
export const SUPPORTED_LANGUAGES = [
|
export const SUPPORTED_LANGUAGES = [
|
||||||
@@ -23,23 +28,26 @@ export type AppLanguage = (typeof SUPPORTED_LANGUAGES)[number]["code"];
|
|||||||
|
|
||||||
export const DEFAULT_LANGUAGE: AppLanguage = "en";
|
export const DEFAULT_LANGUAGE: AppLanguage = "en";
|
||||||
|
|
||||||
const namespaces = ["common", "entry", "layout"] as const;
|
const namespaces = ["common", "entry", "layout", "player"] as const;
|
||||||
|
|
||||||
const resources = {
|
const resources = {
|
||||||
en: {
|
en: {
|
||||||
common: enCommon,
|
common: enCommon,
|
||||||
entry: enEntry,
|
entry: enEntry,
|
||||||
layout: enLayout,
|
layout: enLayout,
|
||||||
|
player: enPlayer,
|
||||||
},
|
},
|
||||||
ne: {
|
ne: {
|
||||||
common: neCommon,
|
common: neCommon,
|
||||||
entry: neEntry,
|
entry: neEntry,
|
||||||
layout: neLayout,
|
layout: neLayout,
|
||||||
|
player: nePlayer,
|
||||||
},
|
},
|
||||||
zh: {
|
zh: {
|
||||||
common: zhCommon,
|
common: zhCommon,
|
||||||
entry: zhEntry,
|
entry: zhEntry,
|
||||||
layout: zhLayout,
|
layout: zhLayout,
|
||||||
|
player: zhPlayer,
|
||||||
},
|
},
|
||||||
} satisfies Record<
|
} satisfies Record<
|
||||||
AppLanguage,
|
AppLanguage,
|
||||||
@@ -53,6 +61,12 @@ export function normalizeLanguage(lang: string | undefined): AppLanguage {
|
|||||||
return "en";
|
return "en";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function syncDocumentLanguage(lang: AppLanguage): void {
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
|
||||||
|
document.documentElement.lang = lang;
|
||||||
|
}
|
||||||
|
|
||||||
if (!i18n.isInitialized) {
|
if (!i18n.isInitialized) {
|
||||||
void i18n
|
void i18n
|
||||||
.use(LanguageDetector)
|
.use(LanguageDetector)
|
||||||
@@ -80,6 +94,12 @@ if (!i18n.isInitialized) {
|
|||||||
useSuspense: false,
|
useSuspense: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
syncDocumentLanguage(normalizeLanguage(i18n.resolvedLanguage ?? i18n.language));
|
||||||
|
|
||||||
|
i18n.on("languageChanged", (lang) => {
|
||||||
|
syncDocumentLanguage(normalizeLanguage(lang));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export default i18n;
|
export default i18n;
|
||||||
|
|||||||
@@ -15,6 +15,9 @@
|
|||||||
"pending": "Pending",
|
"pending": "Pending",
|
||||||
"failed": "Failed"
|
"failed": "Failed"
|
||||||
},
|
},
|
||||||
|
"navigation": {
|
||||||
|
"notifications": "Notifications"
|
||||||
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"general": "General"
|
"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": "बाँकी",
|
"pending": "बाँकी",
|
||||||
"failed": "असफल"
|
"failed": "असफल"
|
||||||
},
|
},
|
||||||
|
"navigation": {
|
||||||
|
"notifications": "सूचनाहरू"
|
||||||
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"general": "सामान्य"
|
"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": "待处理",
|
"pending": "待处理",
|
||||||
"failed": "失败"
|
"failed": "失败"
|
||||||
},
|
},
|
||||||
|
"navigation": {
|
||||||
|
"notifications": "通知"
|
||||||
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"general": "通用"
|
"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 {
|
export function playLabelZh(playCode: string): string {
|
||||||
return LABELS[playCode] ?? playCode;
|
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";
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
|
|
||||||
/** 钱包 / 转账 API 对用户展示的中文说明(优先业务码,其次 HTTP) */
|
/** 钱包 / 转账 API 对用户展示的中文说明(优先业务码,其次 HTTP) */
|
||||||
export function formatWalletClientError(error: unknown): string {
|
export function formatWalletClientError(
|
||||||
|
error: unknown,
|
||||||
|
t?: (key: string) => string,
|
||||||
|
): string {
|
||||||
if (error instanceof LotteryApiBizError) {
|
if (error instanceof LotteryApiBizError) {
|
||||||
const m = WALLET_CODE_MESSAGES[error.code];
|
const m = walletCodeMessage(error.code, t);
|
||||||
if (m) return m;
|
if (m) return m;
|
||||||
if (error.message.trim()) return error.message;
|
if (error.message.trim()) return error.message;
|
||||||
}
|
}
|
||||||
if (isAxiosError(error)) {
|
if (isAxiosError(error)) {
|
||||||
if (error.response?.status === 401) {
|
if (error.response?.status === 401) {
|
||||||
return "登录已失效,请返回重新进入。";
|
return msg(t, "wallet.error.401", "登录已失效,请返回重新进入。");
|
||||||
}
|
}
|
||||||
if (error.response?.status === 409) {
|
if (error.response?.status === 409) {
|
||||||
return "转账处理中,请稍后刷新;若长时间未到账请联系客服。";
|
return msg(
|
||||||
|
t,
|
||||||
|
"wallet.error.409",
|
||||||
|
"转账处理中,请稍后刷新;若长时间未到账请联系客服。",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (!error.response) {
|
if (!error.response) {
|
||||||
return "网络异常,请检查连接后重试。";
|
return msg(t, "wallet.error.network", "网络异常,请检查连接后重试。");
|
||||||
}
|
}
|
||||||
if (error.code === "ECONNABORTED") {
|
if (error.code === "ECONNABORTED") {
|
||||||
return "请求超时,请稍后重试。";
|
return msg(t, "wallet.error.timeout", "请求超时,请稍后重试。");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (error instanceof Error && error.message.trim()) {
|
if (error instanceof Error && error.message.trim()) {
|
||||||
return error.message;
|
return error.message;
|
||||||
}
|
}
|
||||||
return "请求失败,请稍后重试。";
|
return msg(t, "wallet.error.fallback", "请求失败,请稍后重试。");
|
||||||
}
|
}
|
||||||
|
|
||||||
const WALLET_CODE_MESSAGES: Record<number, string> = {
|
const WALLET_CODE_MESSAGES: Record<number, string> = {
|
||||||
@@ -41,3 +48,20 @@ const WALLET_CODE_MESSAGES: Record<number, string> = {
|
|||||||
1009: "主站未能完成本次划转,请稍后重试。",
|
1009: "主站未能完成本次划转,请稍后重试。",
|
||||||
1010: "请勿用同一幂等键发起不同金额的转账。",
|
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