refactor: 完成全站国际化改造,统一多语言支持

此提交完成了全项目的国际化适配:
1. 新增多语言翻译文件与基础配置
2. 替换所有硬编码文本为i18n调用
3. 优化语言切换与文档语言同步逻辑
4. 重构部分业务逻辑以支持动态翻译
5. 移除过时代码与硬编码配置
This commit is contained in:
2026-05-15 10:41:14 +08:00
parent ac612cb32c
commit f2c7f5e4f1
53 changed files with 2179 additions and 767 deletions

View File

@@ -10,10 +10,6 @@ export default async function DrawResultByNoPage(props: PageProps) {
return (
<div className="flex flex-col gap-4">
<div>
<h1 className="text-lg font-semibold tracking-tight"></h1>
<p className="text-xs text-muted-foreground"> · 23 </p>
</div>
<DrawResultDetailScreen drawNo={drawNo} />
</div>
);

View File

@@ -3,13 +3,8 @@ import { DrawResultsListScreen } from "@/features/results/draw-results-list-scre
export default function DrawResultsHistoryPage() {
return (
<div className="flex flex-col gap-4">
<div>
<h1 className="text-lg font-semibold tracking-tight"></h1>
<p className="text-xs text-muted-foreground">
GMT §4.6
</p>
</div>
<DrawResultsListScreen />
</div>
);
}

View File

@@ -6,7 +6,7 @@ import { EntryGate } from "@/features/player/entry-gate";
function EntryFallback(): ReactNode {
return (
<div className="flex min-h-dvh flex-col items-center justify-center bg-gradient-to-b from-red-800 to-red-950 px-4 text-sm text-white/90">
<p></p>
<p>Loading...</p>
</div>
);
}

View File

@@ -3,8 +3,10 @@
import { useEffect } from "react";
import Link from "next/link";
import { AlertTriangle, Home, RotateCcw } from "lucide-react";
import { useTranslation } from "react-i18next";
import { ERROR_COLORS } from "@/stores/error-store";
import "@/i18n";
/**
* Next.js 错误边界组件
@@ -18,6 +20,8 @@ export default function ErrorBoundary({
error: Error & { digest?: string };
reset: () => void;
}): React.ReactElement {
const { t } = useTranslation("player");
useEffect(() => {
// 记录错误日志
console.error("[ErrorBoundary] Application error:", error);
@@ -52,26 +56,28 @@ export default function ErrorBoundary({
{/* 错误标题 */}
<h1 className="mb-2 text-2xl font-bold text-foreground">
{isServerError ? "服务器暂时不可用" : "应用发生错误"}
{isServerError ? t("serverError.unavailable") : t("serverError.appTitle")}
</h1>
{/* 错误消息 */}
<p className="mb-2 text-base text-muted-foreground">
{isServerError
? "服务器暂时不可用,请稍后重试"
: "抱歉,应用遇到了问题,请尝试刷新页面"}
? t("serverError.serverMessage")
: t("serverError.appMessage")}
</p>
{/* 技术详情(开发模式) */}
{process.env.NODE_ENV === "development" && (
<div className="mb-6 w-full rounded-lg bg-muted p-3 text-left">
<p className="mb-1 text-xs font-medium text-muted-foreground"></p>
<p className="mb-1 text-xs font-medium text-muted-foreground">
{t("serverError.details")}
</p>
<code className="block max-h-32 overflow-auto whitespace-pre-wrap break-words text-xs text-destructive">
{error.message}
</code>
{error.digest && (
<p className="mt-2 text-xs text-muted-foreground">
ID: {error.digest}
{t("serverError.errorId", { id: error.digest })}
</p>
)}
</div>
@@ -85,7 +91,7 @@ export default function ErrorBoundary({
style={{ borderColor: isServerError ? ERROR_COLORS.error : ERROR_COLORS.warning, color: isServerError ? ERROR_COLORS.error : ERROR_COLORS.warning }}
>
<RotateCcw className="size-4" />
{t("actions.retry")}
</button>
<Link
@@ -96,7 +102,7 @@ export default function ErrorBoundary({
}}
>
<Home className="size-4" />
{t("serverError.home")}
</Link>
</div>
@@ -106,21 +112,21 @@ export default function ErrorBoundary({
onClick={() => window.location.reload()}
className="hover:text-foreground hover:underline"
>
{t("serverError.refreshPage")}
</button>
<span>|</span>
<Link
href="/hall"
className="hover:text-foreground hover:underline"
>
{t("serverError.hall")}
</Link>
<span>|</span>
<Link
href="/wallet"
className="hover:text-foreground hover:underline"
>
{t("serverError.wallet")}
</Link>
</div>
</div>

View File

@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Providers } from "@/components/providers";
import { DEFAULT_LANGUAGE } from "@/i18n";
import "./globals.css";
const geistSans = Geist({
@@ -26,7 +27,7 @@ export default function RootLayout({
}>) {
return (
<html
lang="en"
lang={DEFAULT_LANGUAGE}
suppressHydrationWarning
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>

View File

@@ -2,8 +2,10 @@
import Link from "next/link";
import { FileQuestion, Home } from "lucide-react";
import { useTranslation } from "react-i18next";
import { ERROR_COLORS } from "@/stores/error-store";
import "@/i18n";
/**
* 全局 404 页面
@@ -13,6 +15,8 @@ import { ERROR_COLORS } from "@/stores/error-store";
* 使用中性颜色 #d9d9d9 作为背景/插图
*/
export default function NotFoundPage(): React.ReactElement {
const { t } = useTranslation("player");
return (
<div
className="flex min-h-screen flex-col items-center justify-center p-4"
@@ -42,12 +46,12 @@ export default function NotFoundPage(): React.ReactElement {
{/* 标题 */}
<h1 className="mb-3 text-2xl font-bold text-gray-800">
{t("notFound.title")}
</h1>
{/* 描述 */}
<p className="mb-8 text-base text-gray-500">
访
{t("notFound.description")}
</p>
{/* 返回首页按钮 */}
@@ -57,21 +61,21 @@ export default function NotFoundPage(): React.ReactElement {
style={{ backgroundColor: "#333" }}
>
<Home className="size-5" />
{t("notFound.home")}
</Link>
{/* 帮助链接 */}
<div className="mt-8 flex gap-4 text-sm text-gray-400">
<Link href="/hall" className="hover:text-gray-600 hover:underline">
{t("notFound.hall")}
</Link>
<span>|</span>
<Link href="/results" className="hover:text-gray-600 hover:underline">
{t("notFound.results")}
</Link>
<span>|</span>
<Link href="/wallet" className="hover:text-gray-600 hover:underline">
{t("notFound.wallet")}
</Link>
</div>
</div>

View File

@@ -172,13 +172,12 @@ export function IframeBridge({ children }: { children: ReactNode }): ReactNode {
window.removeEventListener("message", handleMessage);
clearInterval(heartbeat);
};
}, [notifyReady, sendToParent, setBearerToken]);
}, [notifyReady, notifyTokenRefreshed, sendToParent, setBearerToken]);
// 暴露全局方法供调试
useEffect(() => {
if (typeof window === "undefined") return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as unknown as Record<string, unknown>).lotteryIframeBridge = {
notifyReady,
notifyTokenNeeded,

View File

@@ -35,7 +35,7 @@ export function LanguageSwitcher({
label: t(`language.${item.code}`),
short: t(`languageShort.${item.code}`),
})),
[t, i18n.language],
[t],
);
useEffect(() => {

View File

@@ -18,9 +18,9 @@ type PlayerAppShellProps = {
*/
export function PlayerAppShell({ children }: PlayerAppShellProps): ReactNode {
return (
<div className="min-h-dvh bg-[#f3f7fd] text-foreground">
<div className="min-h-dvh bg-white text-foreground">
<NetworkStatusBanner />
<main className="mx-auto flex w-full max-w-lg flex-col px-3 pb-[calc(4.25rem+env(safe-area-inset-bottom,0px)+0.75rem)] pt-3 sm:px-4">
<main className="mx-auto flex w-full max-w-lg flex-col pb-[calc(4rem+env(safe-area-inset-bottom,0px)+1.5rem)]">
{children}
</main>
<PlayerBottomNav />

View File

@@ -4,26 +4,27 @@ import Link from "next/link";
import { usePathname } from "next/navigation";
import { BarChart3, ClipboardList, Home, Wallet } from "lucide-react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
const tabs = [
{ href: "/hall", label: "Home", icon: Home, match: (p: string) => p === "/hall" },
{ href: "/hall", labelKey: "nav.home", icon: Home, match: (p: string) => p === "/hall" },
{
href: "/results",
label: "Results",
labelKey: "nav.results",
icon: BarChart3,
match: (p: string) => p === "/results" || p.startsWith("/results/"),
},
{
href: "/orders",
label: "My Bets",
labelKey: "nav.orders",
icon: ClipboardList,
match: (p: string) => p === "/orders" || p.startsWith("/orders/"),
},
{
href: "/wallet",
label: "Wallet",
labelKey: "nav.wallet",
icon: Wallet,
match: (p: string) => p === "/wallet" || p.startsWith("/wallet/"),
},
@@ -34,15 +35,17 @@ const tabs = [
*/
export function PlayerBottomNav() {
const pathname = usePathname() ?? "";
const { t } = useTranslation("player");
return (
<nav
className="fixed bottom-0 left-0 right-0 z-50 border-t border-[#e4ebf5] bg-white/96 pb-[env(safe-area-inset-bottom,0px)] shadow-[0_-10px_30px_rgba(15,23,42,0.08)] backdrop-blur-md"
aria-label="主导航"
aria-label={t("nav.aria")}
>
<div className="mx-auto grid h-16 w-full max-w-lg grid-rows-1 [grid-template-columns:repeat(4,minmax(0,1fr))]">
{tabs.map(({ href, label, icon: Icon, match }) => {
{tabs.map(({ href, labelKey, icon: Icon, match }) => {
const active = match(pathname);
const label = t(labelKey);
return (
<Link
key={href}

View File

@@ -4,6 +4,7 @@ import Link from "next/link";
import type { ReactNode } from "react";
import { Bell, ChevronLeft } from "lucide-react";
import { useTranslation } from "react-i18next";
import { LanguageSwitcher } from "@/components/language-switcher";
import { cn } from "@/lib/utils";
@@ -24,24 +25,28 @@ export function PlayerPanel({
eyebrow,
children,
backHref = "/hall",
backLabel = "Home",
backLabel,
className,
}: PlayerPanelProps) {
const { t } = useTranslation("common");
const { t: tp } = useTranslation("player");
const resolvedBackLabel = backLabel ?? tp("panel.home");
return (
<div className="mx-auto w-full max-w-[480px]">
<section
className={cn(
"overflow-hidden rounded-[18px] border border-[#dce7f7] bg-white px-2.5 py-3 text-slate-900 shadow-[0_18px_50px_rgba(15,44,92,0.12)]",
"overflow-hidden bg-white px-4 pb-8 pt-4 text-slate-900",
className,
)}
>
<div className="mb-3 flex items-center gap-2 px-1">
<div className="mb-3 flex items-center gap-2 px-1 pt-3">
<Link
href={backHref}
className="flex h-9 shrink-0 items-center gap-1 rounded-full border border-[#e4eaf4] bg-[#f8fafc] px-2.5 text-xs font-bold text-[#0b3f96] hover:bg-[#f1f6ff]"
>
<ChevronLeft className="size-4" aria-hidden />
{backLabel}
{resolvedBackLabel}
</Link>
<div className="min-w-0 flex-1 text-center">
{eyebrow ? (
@@ -66,7 +71,7 @@ export function PlayerPanel({
<button
type="button"
className="relative flex size-9 shrink-0 items-center justify-center rounded-full text-[#1d57b7] hover:bg-[#f4f7fb]"
aria-label="Notifications"
aria-label={t("navigation.notifications")}
>
<Bell className="size-5" aria-hidden />
<span className="absolute right-2 top-2 size-2 rounded-full bg-[#ff143d]" />

View File

@@ -2,6 +2,7 @@
import { WifiOff, RefreshCw, AlertCircle } from "lucide-react";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useWebSocketManager } from "@/hooks/use-websocket-manager";
import { cn } from "@/lib/utils";
@@ -20,6 +21,7 @@ const COLORS = {
*/
export function NetworkStatusBanner(): React.ReactElement | null {
const { showDegradedBanner, mode, reconnect } = useWebSocketManager();
const { t } = useTranslation("player");
const handleReconnectClick = useCallback(() => {
reconnect();
@@ -48,29 +50,27 @@ export function NetworkStatusBanner(): React.ReactElement | null {
{isOffline ? (
<>
<WifiOff className="size-4 shrink-0" aria-hidden />
<span className="font-medium"></span>
<span className="font-medium">{t("network.offline")}</span>
<button
onClick={handleReconnectClick}
className="ml-2 flex items-center gap-1 rounded px-2 py-0.5 text-xs font-medium hover:bg-white/20 focus:outline-none focus:ring-2 focus:ring-white/50"
aria-label="尝试重连"
aria-label={t("network.retryReconnect")}
>
<RefreshCw className="size-3" aria-hidden />
{t("network.retry")}
</button>
</>
) : (
<>
<AlertCircle className="size-4 shrink-0" aria-hidden />
<span className="font-medium">
...
</span>
<span className="font-medium">{t("network.degraded")}</span>
<button
onClick={handleReconnectClick}
className="ml-2 flex items-center gap-1 rounded px-2 py-0.5 text-xs font-medium hover:bg-white/20 focus:outline-none focus:ring-2 focus:ring-white/50"
aria-label="尝试恢复 WebSocket 连接"
aria-label={t("network.recoverWebsocket")}
>
<RefreshCw className="size-3" aria-hidden />
{t("network.recover")}
</button>
</>
)}

View File

@@ -2,6 +2,7 @@
import { WifiOff, RefreshCw } from "lucide-react";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useErrorStore, ERROR_COLORS } from "@/stores/error-store";
import { cn } from "@/lib/utils";
@@ -13,6 +14,7 @@ import { cn } from "@/lib/utils";
*/
export function OfflineBanner(): React.ReactElement | null {
const isOffline = useErrorStore((state) => state.isOffline);
const { t } = useTranslation("player");
const handleReconnect = useCallback(() => {
// 尝试重新加载页面
@@ -40,14 +42,14 @@ export function OfflineBanner(): React.ReactElement | null {
aria-live="assertive"
>
<WifiOff className="size-4 shrink-0" aria-hidden />
<span className="font-medium"></span>
<span className="font-medium">{t("network.offline")}</span>
<button
onClick={handleReconnect}
className="ml-3 flex items-center gap-1.5 rounded-md bg-white/20 px-3 py-1.5 text-xs font-medium transition-colors hover:bg-white/30 focus:outline-none focus:ring-2 focus:ring-white/50 active:bg-white/25"
aria-label="重新连接"
aria-label={t("network.reconnect")}
>
<RefreshCw className="size-3.5" aria-hidden />
{t("network.reconnect")}
</button>
</div>
);

View File

@@ -2,6 +2,7 @@
import { ServerCrash, Home, RefreshCw } from "lucide-react";
import Link from "next/link";
import { useTranslation } from "react-i18next";
import { useErrorStore, ERROR_COLORS } from "@/stores/error-store";
import { Button } from "@/components/ui/button";
@@ -15,6 +16,7 @@ import { cn } from "@/lib/utils";
*/
export function ServerError(): React.ReactElement | null {
const { isServerError, serverErrorMessage, clearServerError } = useErrorStore();
const { t } = useTranslation("player");
const handleRetry = (): void => {
clearServerError();
@@ -49,7 +51,7 @@ export function ServerError(): React.ReactElement | null {
className="mb-2 text-2xl font-bold"
style={{ color: ERROR_COLORS.error }}
>
{t("serverError.title")}
</h1>
{/* 错误消息 */}
@@ -68,7 +70,7 @@ export function ServerError(): React.ReactElement | null {
style={{ borderColor: ERROR_COLORS.error, color: ERROR_COLORS.error }}
>
<RefreshCw className="size-4" />
{t("actions.retry")}
</Button>
<Link
@@ -77,13 +79,13 @@ export function ServerError(): React.ReactElement | null {
style={{ backgroundColor: ERROR_COLORS.error }}
>
<Home className="size-4" />
{t("serverError.home")}
</Link>
</div>
{/* 状态码提示 */}
<p className="mt-8 text-xs text-muted-foreground/60">
错误代码: 500 Internal Server Error
{t("serverError.code")}
</p>
</div>
</div>
@@ -95,6 +97,7 @@ export function ServerError(): React.ReactElement | null {
*/
export function ServerErrorModal(): React.ReactElement | null {
const { isServerError, serverErrorMessage, clearServerError } = useErrorStore();
const { t } = useTranslation("player");
const handleRetry = (): void => {
clearServerError();
@@ -122,7 +125,7 @@ export function ServerErrorModal(): React.ReactElement | null {
{/* 错误消息 */}
<h2 className="mb-2 text-lg font-semibold text-foreground">
{t("serverError.unavailable")}
</h2>
<p className="mb-6 text-sm text-muted-foreground">
{serverErrorMessage}
@@ -136,7 +139,7 @@ export function ServerErrorModal(): React.ReactElement | null {
onClick={handleRetry}
>
<RefreshCw className="mr-2 size-4" />
{t("actions.retry")}
</Button>
<Link
href="/hall"
@@ -144,7 +147,7 @@ export function ServerErrorModal(): React.ReactElement | null {
style={{ backgroundColor: ERROR_COLORS.error }}
>
<Home className="size-4" />
{t("serverError.home")}
</Link>
</div>
</div>

View File

@@ -2,6 +2,7 @@
import { useState, useEffect } from "react";
import { RefreshCw, AlertCircle } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useTokenRefresh } from "@/hooks/use-token-refresh";
import { Button } from "@/components/ui/button";
@@ -16,6 +17,7 @@ import { cn } from "@/lib/utils";
export function TokenRefreshIndicator(): React.ReactElement | null {
const { isTokenExpiringSoon, getTokenRemainingTime, refreshToken } =
useTokenRefresh();
const { t } = useTranslation("player");
const [isRefreshing, setIsRefreshing] = useState(false);
const [showWarning, setShowWarning] = useState(false);
const [remainingSeconds, setRemainingSeconds] = useState<number | null>(null);
@@ -24,7 +26,6 @@ export function TokenRefreshIndicator(): React.ReactElement | null {
useEffect(() => {
const checkToken = (): void => {
const remaining = getTokenRemainingTime();
const expiringSoon = isTokenExpiringSoon();
if (remaining > 0 && remaining < 120000) {
// 2 分钟内显示
@@ -85,16 +86,19 @@ export function TokenRefreshIndicator(): React.ReactElement | null {
color: isCritical ? ERROR_COLORS.error : ERROR_COLORS.warning,
}}
>
{isCritical ? "登录即将失效" : "登录即将过期"}
{isCritical ? t("token.critical") : t("token.warning")}
</span>
<span className="text-xs text-muted-foreground">
{remainingSeconds !== null && (
<>
{Math.floor(remainingSeconds / 60)}:
{String(remainingSeconds % 60).padStart(2, "0")} {" "}
{t("token.remaining", {
time: `${Math.floor(remainingSeconds / 60)}:${String(
remainingSeconds % 60,
).padStart(2, "0")}`,
})}{" "}
</>
)}
...
{t("token.autoRenewing")}
</span>
</div>
@@ -112,7 +116,7 @@ export function TokenRefreshIndicator(): React.ReactElement | null {
<RefreshCw
className={cn("size-3.5", isRefreshing && "animate-spin")}
/>
{t("token.renewNow")}
</Button>
</div>
);

View File

@@ -1,5 +1,5 @@
export type DrawStatusHud = {
label: string;
labelKey: string;
/** Tailwind 颜色类:状态圆点 */
dotClass: string;
/** 文案条(如「距封盘」) */
@@ -18,26 +18,26 @@ export function isHallSealedCountdownUi(status: string): boolean {
export function drawStatusHud(status: string): DrawStatusHud {
switch (status) {
case "pending":
return { label: "未开始", dotClass: "bg-muted-foreground", countdownKind: "none" };
return { labelKey: "draw.status.pending", dotClass: "bg-muted-foreground", countdownKind: "none" };
case "open":
return { label: "可下注", dotClass: "bg-emerald-500", countdownKind: "close" };
return { labelKey: "draw.status.open", dotClass: "bg-emerald-500", countdownKind: "close" };
case "closing":
return { label: "已封盘", dotClass: "bg-rose-500", countdownKind: "draw" };
return { labelKey: "draw.status.closing", dotClass: "bg-rose-500", countdownKind: "draw" };
case "closed":
return { label: "待开奖", dotClass: "bg-amber-500", countdownKind: "draw" };
return { labelKey: "draw.status.closed", dotClass: "bg-amber-500", countdownKind: "draw" };
case "drawing":
return { label: "开奖中", dotClass: "bg-sky-500", countdownKind: "none" };
return { labelKey: "draw.status.drawing", dotClass: "bg-sky-500", countdownKind: "none" };
case "review":
return { label: "待审核", dotClass: "bg-violet-500", countdownKind: "none" };
return { labelKey: "draw.status.review", dotClass: "bg-violet-500", countdownKind: "none" };
case "cooldown":
return { label: "冷静期", dotClass: "bg-cyan-500", countdownKind: "cooldown" };
return { labelKey: "draw.status.cooldown", dotClass: "bg-cyan-500", countdownKind: "cooldown" };
case "settling":
return { label: "结算中", dotClass: "bg-blue-600", countdownKind: "none" };
return { labelKey: "draw.status.settling", dotClass: "bg-blue-600", countdownKind: "none" };
case "settled":
return { label: "已结算", dotClass: "bg-muted-foreground", countdownKind: "none" };
return { labelKey: "draw.status.settled", dotClass: "bg-muted-foreground", countdownKind: "none" };
case "cancelled":
return { label: "已取消", dotClass: "bg-muted-foreground", countdownKind: "none" };
return { labelKey: "draw.status.cancelled", dotClass: "bg-muted-foreground", countdownKind: "none" };
default:
return { label: status, dotClass: "bg-muted-foreground", countdownKind: "none" };
return { labelKey: status, dotClass: "bg-muted-foreground", countdownKind: "none" };
}
}

View File

@@ -1,5 +1,7 @@
"use client";
import { useTranslation } from "react-i18next";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { formatMinorAsCurrency } from "@/lib/money";
@@ -31,13 +33,16 @@ export function HallBetAmountInput({
disabled,
hint,
}: HallBetAmountInputProps) {
const { t } = useTranslation("player");
const min = formatMinorAsCurrency(minBetMinor, currencyCode);
const max = formatMinorAsCurrency(maxBetMinor, currencyCode);
return (
<div className="space-y-2">
<div className="flex flex-wrap items-end justify-between gap-2">
<Label htmlFor={id}>{label}</Label>
<p className="text-xs text-muted-foreground">
{formatMinorAsCurrency(minBetMinor, currencyCode)} {" "}
{formatMinorAsCurrency(maxBetMinor, currencyCode)}
{t("hall.amountInput.limit", { min, max })}
</p>
</div>
<Input
@@ -48,7 +53,7 @@ export function HallBetAmountInput({
value={value}
onChange={(e) => onChange(e.target.value)}
className={cn("tabular-nums")}
placeholder="例如 100.00"
placeholder={t("hall.amountInput.placeholder")}
/>
{hint ? <p className="text-xs text-muted-foreground">{hint}</p> : null}
</div>

View File

@@ -1,30 +1,44 @@
/**
* 下注业务码与玩家可见说明(对齐 Laravel `ErrorCode` 与产品文档 §6.3 / §6.4)。
*/
export function mapTicketBetError(code: number, fallbackMsg: string): string {
export function mapTicketBetError(
code: number,
fallbackMsg: string,
t?: (key: string) => string,
): string {
const msg = (key: string, fallback: string) => t?.(key) ?? fallback;
switch (code) {
case 4001:
return "该号码本期赔付池不足,已售罄。请更换号码、金额或玩法后重试。";
return msg(
"hall.ticketError.4001",
"该号码本期赔付池不足,已售罄。请更换号码、金额或玩法后重试。",
);
case 2003:
case 1001:
return "余额不足,请先转入后再下注。";
return msg("hall.ticketError.1001", "余额不足,请先转入后再下注。");
case 2001:
return "本期已封盘,无法继续下注。";
return msg("hall.ticketError.2001", "本期已封盘,无法继续下注。");
case 2002:
return "该玩法已关闭,请选择其他玩法。";
return msg("hall.ticketError.2002", "该玩法已关闭,请选择其他玩法。");
case 2004:
return "号码格式或长度不符合该玩法要求。";
return msg("hall.ticketError.2004", "号码格式或长度不符合该玩法要求。");
case 2005:
return "玩法参数不完整(如单双大小需选择位数与维度)。";
return msg(
"hall.ticketError.2005",
"玩法参数不完整(如单双大小需选择位数与维度)。",
);
case 2006:
return "当前期号不可下注。";
return msg("hall.ticketError.2006", "当前期号不可下注。");
case 2007:
return "该玩法暂不支持或缺少赔率配置。";
return msg("hall.ticketError.2007", "该玩法暂不支持或缺少赔率配置。");
case 2008:
return "赔率或玩法配置已更新,请关闭预览后重新操作。";
return msg(
"hall.ticketError.2008",
"赔率或玩法配置已更新,请关闭预览后重新操作。",
);
case 1003:
return "下注金额超出该玩法允许范围。";
return msg("hall.ticketError.1003", "下注金额超出该玩法允许范围。");
default:
return fallbackMsg || "下注失败,请稍后重试。";
return fallbackMsg || msg("hall.ticketError.fallback", "下注失败,请稍后重试。");
}
}

View File

@@ -1,5 +1,7 @@
"use client";
import { useTranslation } from "react-i18next";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
@@ -36,6 +38,7 @@ export function HallBetNumberInput({
disabled,
helper,
}: HallBetNumberInputProps) {
const { t } = useTranslation("player");
const handle = (raw: string) => {
if (spec.mode === "roll") {
onChange(sanitizeRoll(raw, spec.maxChars));
@@ -56,7 +59,11 @@ export function HallBetNumberInput({
value={value}
onChange={(e) => handle(e.target.value)}
className={cn("font-mono text-base tracking-widest")}
placeholder={spec.mode === "roll" ? "如 12R4" : "0-9"}
placeholder={
spec.mode === "roll"
? t("hall.numberInput.rollPlaceholder")
: t("hall.numberInput.digitPlaceholder")
}
maxLength={spec.maxChars}
/>
{helper ? <p className="text-xs text-muted-foreground">{helper}</p> : null}

View File

@@ -1,6 +1,7 @@
"use client";
import { AlertTriangleIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
@@ -28,14 +29,16 @@ type HallBetPreviewDialogProps = {
};
function WarningsBlock({ warnings }: { warnings: TicketPreviewWarning[] }) {
const { t } = useTranslation("player");
if (warnings.length === 0) return null;
return (
<Alert className="border-amber-500/40 bg-amber-500/5 text-amber-950 dark:text-amber-100">
<AlertTriangleIcon />
<AlertTitle></AlertTitle>
<AlertTitle>{t("hall.preview.warningsTitle")}</AlertTitle>
<AlertDescription className="space-y-1">
<p className="text-xs leading-relaxed">
§6.4
{t("hall.preview.warningsDescription")}
</p>
<ul className="list-inside list-disc text-xs">
{warnings.map((w, i) => (
@@ -61,6 +64,7 @@ export function HallBetPreviewDialog({
allowSubmit = true,
onConfirmPlace,
}: HallBetPreviewDialogProps) {
const { t } = useTranslation("player");
const summary = data?.summary;
const lines = data?.lines ?? [];
@@ -69,17 +73,17 @@ export function HallBetPreviewDialog({
<DialogContent className="max-h-[min(90vh,560px)] gap-0 overflow-hidden p-0 sm:max-w-md">
<div className="p-4 pb-2">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogTitle>{t("hall.preview.title")}</DialogTitle>
<DialogDescription>
§6.3
{t("hall.preview.description")}
</DialogDescription>
</DialogHeader>
{!allowSubmit ? (
<Alert className="mt-3 border-[#ff4d4f]/35 bg-[#ff4d4f]/8 text-[#ff4d4f] dark:bg-[#ff4d4f]/12">
<AlertTriangleIcon />
<AlertTitle></AlertTitle>
<AlertTitle>{t("hall.preview.sealedTitle")}</AlertTitle>
<AlertDescription className="text-xs leading-relaxed">
§4.2
{t("hall.preview.sealedDescription")}
</AlertDescription>
</Alert>
) : null}
@@ -88,37 +92,38 @@ export function HallBetPreviewDialog({
<ScrollArea className="max-h-[min(52vh,360px)] border-y px-4">
<div className="space-y-4 py-3 pr-3">
{!data ? (
<p className="text-sm text-muted-foreground"></p>
<p className="text-sm text-muted-foreground">{t("hall.preview.empty")}</p>
) : (
<>
<div className="rounded-lg border bg-muted/30 p-3 text-xs">
<p>
{" "}
<span className="font-mono font-semibold">{data.draw.draw_id}</span> · {" "}
{t("hall.preview.draw")}{" "}
<span className="font-mono font-semibold">{data.draw.draw_id}</span> ·{" "}
{t("hall.preview.status")}{" "}
<span className="font-medium">{data.draw.status}</span>
</p>
{summary ? (
<ul className="mt-2 space-y-1 tabular-nums">
<li>
{" "}
{t("hall.preview.totalBet")}{" "}
<span className="font-medium">
{formatMinorAsCurrency(summary.total_bet_amount, currencyCode)}
</span>
</li>
<li>
{" "}
{t("hall.preview.rebateDeduct")}{" "}
<span className="font-medium">
{formatMinorAsCurrency(summary.total_rebate_amount, currencyCode)}
</span>
</li>
<li>
{" "}
{t("hall.preview.actualDeduct")}{" "}
<span className="font-semibold text-primary">
{formatMinorAsCurrency(summary.total_actual_deduct, currencyCode)}
</span>
</li>
<li>
{" "}
{t("hall.preview.estimatedPayout")}{" "}
<span className="font-medium">
{formatMinorAsCurrency(summary.total_estimated_payout, currencyCode)}
</span>
@@ -130,7 +135,9 @@ export function HallBetPreviewDialog({
<WarningsBlock warnings={data.warnings} />
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground"></p>
<p className="text-xs font-medium text-muted-foreground">
{t("hall.preview.lines")}
</p>
<ul className="space-y-2 text-sm">
{lines.map((ln) => (
<li
@@ -146,15 +153,23 @@ export function HallBetPreviewDialog({
<p className="mt-1 font-mono text-base">{ln.number}</p>
<Separator className="my-2" />
<div className="grid grid-cols-2 gap-x-2 gap-y-1 text-xs tabular-nums">
<span className="text-muted-foreground"></span>
<span className="text-muted-foreground">
{t("hall.preview.normalizedNumber")}
</span>
<span className="text-right font-mono">{ln.normalized_number}</span>
<span className="text-muted-foreground"></span>
<span className="text-muted-foreground">
{t("hall.preview.combinationCount")}
</span>
<span className="text-right">{ln.combination_count}</span>
<span className="text-muted-foreground"></span>
<span className="text-muted-foreground">
{t("hall.preview.actual")}
</span>
<span className="text-right">
{formatMinorAsCurrency(ln.actual_deduct_amount, currencyCode)}
</span>
<span className="text-muted-foreground"></span>
<span className="text-muted-foreground">
{t("hall.preview.estimatedMax")}
</span>
<span className="text-right">
{formatMinorAsCurrency(ln.estimated_max_payout, currencyCode)}
</span>
@@ -170,10 +185,14 @@ export function HallBetPreviewDialog({
<div className="flex flex-col-reverse gap-2 border-t bg-muted/30 p-4 sm:flex-row sm:justify-between">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={placing}>
{t("hall.preview.backEdit")}
</Button>
<Button type="button" onClick={onConfirmPlace} disabled={!data || placing || !allowSubmit}>
{placing ? "提交中…" : allowSubmit ? "确认提交" : "已封盘"}
{placing
? t("hall.preview.submitting")
: allowSubmit
? t("hall.preview.confirmSubmit")
: t("hall.preview.sealedTitle")}
</Button>
</div>
</DialogContent>

View File

@@ -47,12 +47,24 @@ export function playNeedsDigitSlot(playCode: string): boolean {
}
/** 产品文档iBox/Roll 单注金额mBox 总金额摊分 */
export function ticketAmountHint(playCode: string): string {
export function ticketAmountHint(
playCode: string,
t?: (key: string) => string,
): string {
if (playCode === "ibox" || playCode === "roll") {
return "本玩法金额为「单注金额」,系统按展开组合数计算总下注与实扣。";
return (
t?.("hall.amountHint.iboxRoll") ??
"本玩法金额为「单注金额」,系统按展开组合数计算总下注与实扣。"
);
}
if (playCode === "mbox") {
return "本玩法金额为「总输入金额」,将均摊到各排列组合(向下取整到最小单位)。";
return (
t?.("hall.amountHint.mbox") ??
"本玩法金额为「总输入金额」,将均摊到各排列组合(向下取整到最小单位)。"
);
}
return "金额为该笔注单的下注额(最小货币单位整数,与钱包一致)。";
return (
t?.("hall.amountHint.default") ??
"金额为该笔注单的下注额(最小货币单位整数,与钱包一致)。"
);
}

View File

@@ -2,6 +2,7 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { CirclePlus, Cuboid, PackageOpen, Ticket, Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { getPlayEffective } from "@/api/play";
@@ -205,6 +206,7 @@ function pickSimplePlay(
export function HallBettingGrid() {
const { display, isBettable, reload: reloadDraw } = useHallDrawLive();
const { t } = useTranslation("player");
const [activeCategory, setActiveCategory] = useState<HallCategory>("D2");
const [boxMode, setBoxMode] = useState<BoxMode>("ibox");
const [rows, setRows] = useState<DraftRow[]>(() => [
@@ -239,10 +241,10 @@ export function HallBettingGrid() {
setCatalogState({ kind: "ok", data });
} catch (e) {
const msg =
e instanceof LotteryApiBizError ? e.message : "加载玩法失败,请稍后重试。";
e instanceof LotteryApiBizError ? e.message : t("hall.loadingError");
setCatalogState({ kind: "error", message: msg });
}
}, [currencyParam]);
}, [currencyParam, t]);
useEffect(() => {
queueMicrotask(() => {
@@ -373,21 +375,21 @@ export function HallBettingGrid() {
const handlePreview = async () => {
if (!display) {
toast.error("暂无当期期号,无法提交。");
toast.error(t("hall.noDraw"));
return;
}
if (!isBettable) {
toast.error("当前已封盘或不可下注。");
toast.error(t("hall.notBettable"));
return;
}
if (catalogState.kind !== "ok") {
toast.error("玩法配置尚未加载完成。");
toast.error(t("hall.catalogNotReady"));
return;
}
const lines = buildLines();
if (lines.length === 0) {
toast.error("请至少填写一组有效号码和下注金额。");
toast.error(t("hall.emptyLines"));
return;
}
@@ -403,8 +405,8 @@ export function HallBettingGrid() {
setPreviewOpen(true);
} catch (e) {
const code = e instanceof LotteryApiBizError ? e.code : 0;
const msg = e instanceof LotteryApiBizError ? e.message : "预览失败";
toast.error(mapTicketBetError(code, msg));
const msg = e instanceof LotteryApiBizError ? e.message : t("hall.previewFailed");
toast.error(mapTicketBetError(code, msg, t));
} finally {
setPreviewLoading(false);
}
@@ -413,13 +415,13 @@ export function HallBettingGrid() {
const handlePlace = async () => {
if (!display || !previewData) return;
if (!isBettable) {
toast.error("已封盘,无法提交。");
toast.error(t("hall.closedSubmit"));
return;
}
const lines = buildLines();
if (lines.length === 0) {
toast.error("提交前数据已变化,请关闭预览后重试。");
toast.error(t("hall.changedBeforeSubmit"));
return;
}
@@ -436,7 +438,10 @@ export function HallBettingGrid() {
expected_config_versions: previewData.config_versions,
});
toast.success(
`下注成功,订单号 ${data.order_no},实扣 ${formatMinorAsCurrency(data.summary.total_actual_deduct, currencyCode)}`,
t("hall.placeSuccess", {
orderNo: data.order_no,
amount: formatMinorAsCurrency(data.summary.total_actual_deduct, currencyCode),
}),
);
setPreviewOpen(false);
setPreviewData(null);
@@ -445,8 +450,8 @@ export function HallBettingGrid() {
void reloadDraw();
} catch (e) {
const code = e instanceof LotteryApiBizError ? e.code : 0;
const msg = e instanceof LotteryApiBizError ? e.message : "提交失败";
toast.error(mapTicketBetError(code, msg));
const msg = e instanceof LotteryApiBizError ? e.message : t("hall.placeFailed");
toast.error(mapTicketBetError(code, msg, t));
} finally {
setPlaceLoading(false);
}
@@ -454,7 +459,7 @@ export function HallBettingGrid() {
if (catalogState.kind === "loading") {
return (
<section className="space-y-3" aria-label="Betting table">
<section className="space-y-3" aria-label={t("hall.aria")}>
<Skeleton className="h-12 rounded-xl" />
<Skeleton className="h-72 rounded-xl" />
<Skeleton className="h-14 rounded-xl" />
@@ -473,7 +478,7 @@ export function HallBettingGrid() {
className="mt-3 border-red-200 bg-white text-red-700 hover:bg-red-50"
onClick={() => void loadCatalog()}
>
{t("actions.retry")}
</Button>
</section>
);
@@ -485,7 +490,7 @@ export function HallBettingGrid() {
return (
<>
<section className="space-y-4" aria-label="Betting table">
<section className="space-y-4" aria-label={t("hall.aria")}>
<div className="grid grid-cols-4 rounded-xl border border-[#e8eef7] bg-white p-1 shadow-[0_6px_18px_rgba(30,64,175,0.06)]">
{categoryTabs.map((tab) => {
const active = activeCategory === tab.value;
@@ -539,9 +544,11 @@ export function HallBettingGrid() {
<Cuboid className="size-4" aria-hidden />
</span>
<span className="min-w-0">
<span className="block truncate text-sm font-bold">iBox</span>
<span className="block truncate text-sm font-bold">
{t("hall.boxMode.iboxTitle")}
</span>
<span className="block truncate text-[10px] text-slate-500">
Divide all by amount
{t("hall.boxMode.iboxDesc")}
</span>
</span>
</button>
@@ -561,9 +568,11 @@ export function HallBettingGrid() {
<PackageOpen className="size-4" aria-hidden />
</span>
<span className="min-w-0">
<span className="block truncate text-sm font-bold">Box</span>
<span className="block truncate text-sm font-bold">
{t("hall.boxMode.boxTitle")}
</span>
<span className="block truncate text-[10px] text-slate-500">
Multiply all by amount
{t("hall.boxMode.boxDesc")}
</span>
</span>
</button>
@@ -575,10 +584,12 @@ export function HallBettingGrid() {
<div className="mx-auto flex size-14 items-center justify-center rounded-full bg-slate-200 text-slate-600">
<Ticket className="size-7" aria-hidden />
</div>
<p className="mt-4 text-lg font-bold text-slate-900">Closed</p>
<p className="mt-1 text-xs">This issue is now closed.</p>
<p className="mt-4 text-lg font-bold text-slate-900">
{t("hall.closed.title")}
</p>
<p className="mt-1 text-xs">{t("hall.closed.subtitle")}</p>
<div className="mt-5 rounded-lg border border-[#cbdcf7] bg-white px-3 py-3 text-left text-xs text-[#315a9f]">
The betting window has closed. Please wait for the next issue to place your bets.
{t("hall.closed.description")}
</div>
</div>
) : (
@@ -593,10 +604,10 @@ export function HallBettingGrid() {
<thead>
<tr className="border-b border-[#e8eef7] bg-[#f5f8fd] text-[11px] font-semibold text-[#32518d]">
<th className="sticky left-0 z-20 w-12 bg-[#f5f8fd] px-2 py-3 text-center">
No.
{t("hall.table.no")}
</th>
<th className="sticky left-12 z-20 w-24 bg-[#f5f8fd] px-2 py-3 text-center">
Number
{t("hall.table.number")}
<span className="block text-[10px] font-normal text-[#6b7896]">
({numberPlaceholder})
</span>
@@ -609,12 +620,18 @@ export function HallBettingGrid() {
))
) : (
<>
<th className="min-w-28 px-2 py-3 text-center">Stake Amount</th>
<th className="min-w-28 px-2 py-3 text-center">Commission / Rebate</th>
<th className="min-w-28 px-2 py-3 text-center">Actual Deduction</th>
<th className="min-w-28 px-2 py-3 text-center">
{t("hall.table.stake")}
</th>
<th className="min-w-28 px-2 py-3 text-center">
{t("hall.table.rebate")}
</th>
<th className="min-w-28 px-2 py-3 text-center">
{t("hall.table.actual")}
</th>
</>
)}
<th className="w-10 px-2 py-3" aria-label="Delete" />
<th className="w-10 px-2 py-3" aria-label={t("hall.table.delete")} />
</tr>
</thead>
<tbody>
@@ -695,7 +712,7 @@ export function HallBettingGrid() {
disabled={tableDisabled || rows.length <= 1}
onClick={() => removeRow(row.id)}
className="inline-flex size-8 items-center justify-center rounded-full text-[#ff4d4f] hover:bg-red-50 disabled:text-slate-300 disabled:hover:bg-transparent"
aria-label={`删除第 ${index + 1}`}
aria-label={t("actions.deleteRow", { row: index + 1 })}
>
<Trash2 className="size-4" aria-hidden />
</button>
@@ -713,13 +730,13 @@ export function HallBettingGrid() {
className="flex h-11 w-full items-center justify-center gap-1.5 border-t border-[#edf2f9] text-sm font-semibold text-[#1d57b7] hover:bg-[#f7faff] disabled:text-slate-300"
>
<CirclePlus className="size-4" aria-hidden />
Add Row
{t("hall.table.addRow")}
</button>
</div>
)}
<div className="flex items-center justify-between rounded-xl border border-[#e9eef7] bg-[#f8fbff] px-4 py-3 text-sm shadow-[0_6px_20px_rgba(15,23,42,0.04)]">
<span className="font-medium text-slate-800">Draft Total</span>
<span className="font-medium text-slate-800">{t("hall.table.draftTotal")}</span>
<span className="text-lg font-bold tabular-nums text-[#0b3f96]">
{formatMinorAsCurrency(draftSummary.actual, currencyCode)}
</span>
@@ -727,7 +744,7 @@ export function HallBettingGrid() {
{sealedBetUi ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-600">
{t("hall.table.sealedHint")}
</p>
) : null}
@@ -738,7 +755,11 @@ export function HallBettingGrid() {
className="h-12 w-full rounded-xl border-0 bg-[#e5002c] text-base font-bold text-white shadow-[0_8px_20px_rgba(229,0,44,0.26)] hover:bg-[#d10028]"
>
<Ticket className="size-5" aria-hidden />
{previewLoading ? "Previewing..." : activeCategory === "JACKPOT" ? "Closed" : "Submit Bet"}
{previewLoading
? t("hall.table.previewing")
: activeCategory === "JACKPOT"
? t("hall.closed.title")
: t("hall.table.submitBet")}
</Button>
</section>

View File

@@ -1,6 +1,7 @@
"use client";
import { Hourglass, Landmark, TimerReset, WalletCards } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
@@ -12,13 +13,14 @@ import { cn } from "@/lib/utils";
import type { DrawCurrentPayload } from "@/types/api/draw-current";
function CurrentTime({ payload }: { payload: DrawCurrentPayload }) {
const { t } = useTranslation("player");
const source = payload.close_time ?? payload.draw_time ?? payload.start_time;
const formatted = source ? formatLotteryInstant(source) : null;
if (!formatted) {
return (
<>
<span className="text-lg font-black tabular-nums text-[#0b3f96]">--:--:--</span>
<span className="mt-1 text-[11px] text-slate-500">Current Time</span>
<span className="mt-1 text-[11px] text-slate-500">{t("draw.currentTime")}</span>
</>
);
}
@@ -42,18 +44,19 @@ function CloseTime({
hud: ReturnType<typeof drawStatusHud>;
payload: DrawCurrentPayload;
}) {
const { t } = useTranslation("player");
const sealedCountdown = isHallSealedCountdownUi(payload.status);
let seconds = 0;
let label = "Closes In";
let label = t("draw.closesIn");
if (hud.countdownKind === "close") {
seconds = Math.max(0, payload.seconds_to_close);
} else if (hud.countdownKind === "draw") {
seconds = Math.max(0, payload.seconds_to_draw);
label = sealedCountdown ? "Draws In" : "Closes In";
label = sealedCountdown ? t("draw.drawsIn") : t("draw.closesIn");
} else if (hud.countdownKind === "cooldown") {
seconds = Math.max(0, payload.seconds_remaining_in_cooldown ?? 0);
label = "Cool Down";
label = t("draw.coolDown");
}
return (
@@ -68,11 +71,12 @@ function CloseTime({
export function HallDrawPanel() {
const { raw, display, error, reload } = useHallDrawLive();
const { t } = useTranslation("player");
if (error) {
return (
<section className="mb-4 rounded-xl border border-red-200 bg-red-50 px-3 py-3 text-sm text-red-700">
<p>{error}</p>
<p>{t(error, { defaultValue: error })}</p>
<Button
type="button"
variant="outline"
@@ -80,7 +84,7 @@ export function HallDrawPanel() {
className="mt-2 border-red-200 bg-white text-red-700"
onClick={() => void reload()}
>
{t("actions.retry")}
</Button>
</section>
);
@@ -101,7 +105,7 @@ export function HallDrawPanel() {
if (raw === null || display === null) {
return (
<section className="mb-4 rounded-xl border border-[#e3ebf6] bg-white px-3 py-4 text-center text-sm text-slate-500 shadow-sm">
{t("draw.noIssue")}
</section>
);
}
@@ -115,7 +119,7 @@ export function HallDrawPanel() {
"mb-4 overflow-hidden rounded-xl border border-[#e1e9f5] bg-white shadow-[0_6px_20px_rgba(15,23,42,0.06)]",
sealedUi && "border-red-200 bg-red-50/30",
)}
aria-label="Current issue"
aria-label={t("draw.currentIssue")}
>
<div className="grid grid-cols-[1fr_1.05fr_1fr] divide-x divide-[#e7edf6]">
<div className="flex min-w-0 items-center justify-center gap-2 px-2 py-3 text-center">
@@ -123,7 +127,7 @@ export function HallDrawPanel() {
<WalletCards className="size-4" aria-hidden />
</span>
<div className="min-w-0">
<p className="text-[11px] font-semibold text-slate-500">Issue No.</p>
<p className="text-[11px] font-semibold text-slate-500">{t("draw.issueNo")}</p>
<p className="truncate text-sm font-black tabular-nums text-[#ff143d]">
{display.draw_no}
</p>
@@ -146,17 +150,17 @@ export function HallDrawPanel() {
{sealedUi ? (
<div className="flex items-center gap-2 border-t border-red-100 bg-red-50 px-3 py-2 text-xs font-medium text-red-600">
<TimerReset className="size-4" aria-hidden />
{t("draw.sealedNotice")}
</div>
) : (
<div className="flex items-center justify-between border-t border-[#eef3f9] bg-[#fbfdff] px-3 py-1.5 text-[11px] text-slate-500">
<span className="inline-flex items-center gap-1.5">
<span className={cn("size-2 rounded-full", hud.dotClass)} />
{hud.label}
{t(hud.labelKey, { defaultValue: hud.labelKey })}
</span>
<span className="inline-flex items-center gap-1">
<Landmark className="size-3.5" aria-hidden />
Betting Hall
{t("draw.hall")}
</span>
</div>
)}

View File

@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { getPlayEffective } from "@/api/play";
import { Badge } from "@/components/ui/badge";
@@ -76,6 +77,7 @@ function formatMoneyAmount(n: number): string {
}
export function HallPlayCatalogPanel() {
const { t } = useTranslation("player");
const [state, setState] = useState<LoadState>({ kind: "loading" });
const currencyParam = useMemo(() => {
const fromEnv = process.env.NEXT_PUBLIC_LOTTERY_PLAY_CURRENCY?.trim();
@@ -93,8 +95,7 @@ export function HallPlayCatalogPanel() {
if (e instanceof LotteryApiBizError && e.code === 9004) {
setState({
kind: "error",
message:
"玩法配置尚未初始化。请在 Laravel 执行含 OperationalConfigV1Seeder 的 seed。",
message: t("hall.playCatalog.notReady"),
notReady: true,
});
return;
@@ -102,10 +103,10 @@ export function HallPlayCatalogPanel() {
const msg =
e instanceof LotteryApiBizError
? e.message
: "加载玩法配置失败,请稍后重试。";
: t("hall.playCatalog.loadFailed");
setState({ kind: "error", message: msg });
}
}, [currencyParam]);
}, [currencyParam, t]);
useEffect(() => {
queueMicrotask(() => {
@@ -123,7 +124,7 @@ export function HallPlayCatalogPanel() {
const body = (() => {
if (state.kind === "loading") {
return (
<p className="text-sm text-muted-foreground"></p>
<p className="text-sm text-muted-foreground">{t("hall.playCatalog.loading")}</p>
);
}
if (state.kind === "error") {
@@ -131,7 +132,7 @@ export function HallPlayCatalogPanel() {
<div className="space-y-2">
<p className="text-sm text-destructive">{state.message}</p>
<Button type="button" size="sm" variant="secondary" onClick={() => void load()}>
{t("actions.retry")}
</Button>
</div>
);
@@ -145,19 +146,28 @@ export function HallPlayCatalogPanel() {
return (
<div className="space-y-6">
<p className="text-xs text-muted-foreground">
{data.currency_code} · play#{data.effective_versions.play_config.version_no}
odds#{data.effective_versions.odds.version_no} ·
{t("hall.playCatalog.meta", {
currency: data.currency_code,
playVersion: data.effective_versions.play_config.version_no,
oddsVersion: data.effective_versions.odds.version_no,
})}
</p>
<div className="overflow-x-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="min-w-[140px]"></TableHead>
<TableHead className="w-[88px] text-center"></TableHead>
<TableHead className="min-w-[160px] whitespace-nowrap"></TableHead>
<TableHead className="min-w-[100px]">×</TableHead>
<TableHead className="min-w-[200px]"></TableHead>
<TableHead className="min-w-[140px]">{t("hall.playCatalog.play")}</TableHead>
<TableHead className="w-[88px] text-center">
{t("hall.playCatalog.status")}
</TableHead>
<TableHead className="min-w-[160px] whitespace-nowrap">
{t("hall.playCatalog.limit")}
</TableHead>
<TableHead className="min-w-[100px]">{t("hall.playCatalog.odds")}</TableHead>
<TableHead className="min-w-[200px]">
{t("hall.playCatalog.description")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -188,11 +198,11 @@ export function HallPlayCatalogPanel() {
<TableCell className="text-center">
{open ? (
<Badge variant="default" className="font-normal">
{t("hall.playCatalog.open")}
</Badge>
) : (
<Badge variant="secondary" className="font-normal">
{t("hall.playCatalog.closed")}
</Badge>
)}
</TableCell>
@@ -222,14 +232,16 @@ export function HallPlayCatalogPanel() {
{data.risk_cap_items.length > 0 ? (
<div className="space-y-2">
<h3 className="text-sm font-medium text-foreground"></h3>
<h3 className="text-sm font-medium text-foreground">
{t("hall.playCatalog.riskTitle")}
</h3>
<div className="overflow-x-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>{t("hall.playCatalog.number")}</TableHead>
<TableHead>{t("hall.playCatalog.capAmount")}</TableHead>
<TableHead>{t("hall.playCatalog.type")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -257,10 +269,11 @@ export function HallPlayCatalogPanel() {
<Card>
<CardHeader className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1">
<CardTitle className="text-base"></CardTitle>
<CardTitle className="text-base">{t("hall.playCatalog.title")}</CardTitle>
<CardDescription>
<code className="text-xs">GET /api/v1/play/effective</code>
{DEFAULT_POLL_MS / 1000}s
{t("hall.playCatalog.descriptionText", {
seconds: DEFAULT_POLL_MS / 1000,
})}
</CardDescription>
</div>
<Button
@@ -270,7 +283,7 @@ export function HallPlayCatalogPanel() {
className="shrink-0"
onClick={() => void load()}
>
{t("hall.playCatalog.refresh")}
</Button>
</CardHeader>
<CardContent>{body}</CardContent>

View File

@@ -1,5 +1,7 @@
"use client";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
export type PlayChip = {
@@ -23,15 +25,17 @@ export function HallPlaySwitcher({
onChange,
disabled,
}: HallPlaySwitcherProps) {
const { t } = useTranslation("player");
if (plays.length === 0) {
return (
<p className="text-sm text-muted-foreground"></p>
<p className="text-sm text-muted-foreground">{t("hall.playSwitcher.empty")}</p>
);
}
return (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground"></p>
<p className="text-xs font-medium text-muted-foreground">{t("hall.playSwitcher.label")}</p>
<div className="-mx-1 flex gap-1.5 overflow-x-auto pb-1">
{plays.map((p) => {
const active = p.play_code === value;

View File

@@ -1,6 +1,7 @@
"use client";
import { Bell } from "lucide-react";
import { useTranslation } from "react-i18next";
import { LanguageSwitcher } from "@/components/language-switcher";
import { HallBettingGrid } from "@/features/hall/hall-betting-grid";
@@ -11,10 +12,12 @@ import { HallWalletStrip } from "@/features/hall/hall-wallet-strip";
* 下注大厅:钱包条 §4 + 当期期号 §4.2(封盘置灰 / 倒计时错误色 / WS+轮询);玩法目录 §12.3;下注表格 §13.3。
*/
export function HallScreen() {
const { t } = useTranslation("common");
return (
<div className="mx-auto w-full max-w-[480px]">
<section className="overflow-hidden rounded-[18px] border border-[#dce7f7] bg-white px-2.5 py-3 text-slate-900 shadow-[0_18px_50px_rgba(15,44,92,0.12)]">
<div className="mb-3 flex items-center gap-2 px-1">
<section className="overflow-hidden bg-white px-4 pb-8 pt-4 text-slate-900">
<div className="mb-3 flex items-center gap-2 px-1 pt-3">
<div className="flex min-w-0 flex-1 items-center gap-2">
<div className="relative flex size-10 shrink-0 rotate-[-10deg] items-center justify-center rounded-lg bg-[#e60023] text-white shadow-[0_7px_14px_rgba(230,0,35,0.22)]">
<span className="absolute -left-1.5 top-1 flex size-6 rotate-[18deg] items-center justify-center rounded-sm bg-[#0b56b7] text-xs font-black">
@@ -35,14 +38,14 @@ export function HallScreen() {
<button
type="button"
className="relative flex size-9 shrink-0 items-center justify-center rounded-full text-[#1d57b7] hover:bg-[#f4f7fb]"
aria-label="Notifications"
aria-label={t("navigation.notifications")}
>
<Bell className="size-5" aria-hidden />
<span className="absolute right-2 top-2 size-2 rounded-full bg-[#ff143d]" />
</button>
</div>
<HallDrawPanel />
<HallDrawPanel />
<HallWalletStrip />

View File

@@ -2,6 +2,7 @@
import { Wallet } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { getWalletBalance } from "@/api/wallet";
import { Skeleton } from "@/components/ui/skeleton";
@@ -18,6 +19,7 @@ import type { WalletBalanceData } from "@/types/api/wallet-balance";
export function HallWalletStrip() {
const profile = usePlayerSessionStore((s) => s.profile);
const mode = useNetworkConnectionStore((s) => s.mode);
const { t } = useTranslation("player");
const [balance, setBalance] = useState<WalletBalanceData | null>(null);
const [loading, setLoading] = useState(true);
const degradedWalletPollRef = useRef<number | null>(null);
@@ -83,7 +85,7 @@ export function HallWalletStrip() {
const availableMinor = Number(balance?.available_balance ?? 0);
return (
<section className="mb-4 space-y-3" aria-label="Wallet balance">
<section className="mb-4 space-y-3" aria-label={t("wallet.balance")}>
<div
className={cn(
"relative overflow-hidden rounded-xl bg-[#e5002c] px-4 py-4 text-white shadow-[0_10px_28px_rgba(229,0,44,0.25)]",
@@ -95,7 +97,7 @@ export function HallWalletStrip() {
<Wallet className="size-7" aria-hidden />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-white/90">Wallet Balance</p>
<p className="text-sm font-semibold text-white/90">{t("wallet.balance")}</p>
{loading ? (
<Skeleton className="mt-2 h-8 w-44 rounded-md bg-white/25" />
) : (
@@ -111,7 +113,7 @@ export function HallWalletStrip() {
<TransferInDialog
idPrefix="hall-"
triggerVariant="hall"
triggerLabel="Transfer In"
triggerLabel={t("wallet.transferIn")}
triggerClassName="h-12 rounded-lg text-base font-bold"
currency={currency}
lotteryMinor={lotteryMinor}
@@ -120,7 +122,7 @@ export function HallWalletStrip() {
<TransferOutDialog
idPrefix="hall-"
triggerVariant="hall"
triggerLabel="Transfer Out"
triggerLabel={t("wallet.transferOut")}
triggerClassName="h-12 rounded-lg text-base font-bold"
currency={currency}
availableMinor={availableMinor}

View File

@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { getDrawCurrent } from "@/api/draw";
import { getLotteryEcho } from "@/lib/lottery-echo";
@@ -76,17 +76,11 @@ export function useHallDrawLive(): {
setRaw(d);
setEmittedAtMs(Date.now());
} catch {
setError("加载失败,请下拉刷新");
setError("draw.loadFailedRefresh");
setRaw(undefined);
}
}, []);
// WebSocket 正常时的轮询间隔(作为备用)
const refreshMs = useMemo(() => {
if (raw === undefined) return 30_000;
return raw ? 60_000 : 30_000; // WebSocket正常时减少轮询频率
}, [raw]);
// 初始加载
useEffect(() => {
const timer = window.setTimeout(() => {
@@ -199,14 +193,22 @@ export function useHallDrawLive(): {
if (!isWebSocketConnected && mode !== "websocket") {
const currentPollingId = useNetworkConnectionStore.getState().drawPollingIntervalId;
if (!currentPollingId) {
// 立即执行一次
void load();
const initialLoadId = window.setTimeout(() => {
void load();
}, 0);
// 设置30秒轮询
intervalId = window.setInterval(() => {
void load();
}, 30_000);
setDrawPollingIntervalId(intervalId);
return () => {
window.clearTimeout(initialLoadId);
if (intervalId) {
window.clearInterval(intervalId);
setDrawPollingIntervalId(null);
}
};
}
}

View File

@@ -4,22 +4,26 @@ export function ticketStatusDisplay(
status: string,
winMinor: number,
jackpotMinor: number,
t?: (key: string, options?: { defaultValue?: string; status?: string }) => string,
): { label: string; dotClass: string; ring?: boolean } {
const total = winMinor + jackpotMinor;
if (status === "success") {
return { label: "待开奖", dotClass: "bg-sky-500" };
return { label: t?.("ticketStatus.success") ?? "待开奖", dotClass: "bg-sky-500" };
}
if (status === "settled_win" && total > 0) {
return { label: "已派彩", dotClass: "bg-emerald-500" };
return { label: t?.("ticketStatus.settled_win") ?? "已派彩", dotClass: "bg-emerald-500" };
}
if (status === "settled_lose" || status === "settled_win") {
return {
label: "未中奖",
label: t?.("ticketStatus.settled_lose") ?? "未中奖",
dotClass: "bg-background",
ring: true,
};
}
return { label: status, dotClass: "bg-red-500" };
return {
label: t?.("ticketStatus.unknown", { status, defaultValue: status }) ?? status,
dotClass: "bg-red-500",
};
}
export function StatusDot({

View File

@@ -2,9 +2,11 @@
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { getTicketItemDetail } from "@/api/ticket-items";
import { Button, buttonVariants } from "@/components/ui/button";
import { PlayerPanel } from "@/components/layout/player-panel";
import {
Card,
CardContent,
@@ -18,46 +20,31 @@ import { StatusDot, ticketStatusDisplay } from "@/features/orders/ticket-item-st
import { formatLotteryInstant } from "@/lib/player-datetime";
import { formatMinorAsCurrency } from "@/lib/money";
import { norm4d } from "@/lib/norm-4d";
import { playLabelZh } from "@/lib/play-labels";
import { playLabel } from "@/lib/play-labels";
import { cn } from "@/lib/utils";
import type { TicketItemDetailPayload } from "@/types/api/ticket-items";
type OddsSnapRow = { prize_scope?: string; odds_value?: number };
function formatOddsSnapshot(json: unknown): string {
function formatOddsSnapshot(
json: unknown,
t: (key: string, options?: { defaultValue?: string }) => string,
): string {
if (!Array.isArray(json)) return "—";
const parts = (json as OddsSnapRow[])
.filter((r) => r.prize_scope && r.odds_value != null)
.map((r) => {
const scope = String(r.prize_scope);
const label =
scope === "first"
? "头奖"
: scope === "second"
? "二奖"
: scope === "third"
? "三奖"
: scope === "starter"
? "特别奖"
: scope === "consolation"
? "安慰奖"
: scope;
const label = t(`prizeTier.${scope}`, { defaultValue: scope });
const mult = Number(r.odds_value) / 10_000;
return `${label} ${mult}x`;
});
return parts.length ? parts.join(" · ") : "—";
}
const TIER_ZH: Record<string, string> = {
first: "头奖",
second: "二奖",
third: "三奖",
starter: "特别奖",
consolation: "安慰奖",
};
/** 界面文档 §4.8 注单详情 */
export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
const { t } = useTranslation("player");
const [data, setData] = useState<TicketItemDetailPayload | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
@@ -70,11 +57,11 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
setData(row);
} catch {
setData(null);
setError("注单不存在或无权查看");
setError(t("orders.notFound"));
} finally {
setLoading(false);
}
}, [ticketNo]);
}, [ticketNo, t]);
useEffect(() => {
queueMicrotask(() => {
@@ -84,39 +71,42 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
if (loading) {
return (
<Card>
<CardHeader>
<Skeleton className="h-6 w-40" />
<Skeleton className="h-4 w-56" />
</CardHeader>
<CardContent className="space-y-3">
<Skeleton className="h-32 w-full" />
</CardContent>
</Card>
<PlayerPanel
title={t("orders.betDetail")}
subtitle={ticketNo}
eyebrow={t("orders.title")}
backHref="/orders"
backLabel={t("orders.title")}
>
<div className="space-y-4">
<Skeleton className="h-12 rounded-xl" />
<Skeleton className="h-56 rounded-xl" />
</div>
</PlayerPanel>
);
}
if (error || !data) {
return (
<Card className="border-destructive/40">
<CardHeader>
<CardTitle className="text-base"></CardTitle>
<CardDescription>{error ?? "无数据"}</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
<PlayerPanel
title={t("orders.betDetail")}
subtitle={ticketNo}
eyebrow={t("orders.title")}
backHref="/orders"
backLabel={t("orders.title")}
>
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-4 text-sm text-red-700">
<p>{error ?? t("orders.noData")}</p>
<Button type="button" size="sm" variant="secondary" onClick={() => void load()}>
{t("actions.retry")}
</Button>
<Link href="/orders" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
</Link>
</CardContent>
</Card>
</div>
</PlayerPanel>
);
}
const cur = data.currency_code ?? "NPR";
const st = ticketStatusDisplay(data.status, data.win_amount, data.jackpot_win_amount);
const st = ticketStatusDisplay(data.status, data.win_amount, data.jackpot_win_amount, t);
const totalWin = data.win_amount + data.jackpot_win_amount;
const pub = data.published_draw_results;
const first = pub?.results?.["1st"] ?? "";
@@ -146,119 +136,144 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
: null;
const tierLabel = data.settlement?.matched_prize_tier
? TIER_ZH[data.settlement.matched_prize_tier] ?? data.settlement.matched_prize_tier
? t(`prizeTier.${data.settlement.matched_prize_tier}`, {
defaultValue: data.settlement.matched_prize_tier,
})
: null;
return (
<div className="flex flex-col gap-4">
<Card>
<CardHeader className="space-y-2 pb-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<CardTitle className="text-base"></CardTitle>
<StatusDot label={st.label} dotClass={st.dotClass} ring={st.ring} />
</div>
<CardDescription className="font-mono text-xs">
{data.ticket_no} · {data.order_no ?? "—"}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="grid gap-1 text-xs">
<p>
<span className="text-muted-foreground"></span>{" "}
<span className="font-mono font-medium">{data.draw_no ?? "—"}</span>
</p>
<p>
<span className="text-muted-foreground"></span>{" "}
{formatLotteryInstant(data.placed_at ?? null)}
</p>
<p>
<span className="text-muted-foreground"></span>{" "}
<span className="font-mono">{data.original_number ?? "—"}</span>
</p>
<p>
<span className="text-muted-foreground"></span> {playLabelZh(data.play_code)} (
{data.dimension ?? "—"}D)
</p>
<p>
<span className="text-muted-foreground"></span>{" "}
{formatMinorAsCurrency(data.total_bet_amount, cur)}
</p>
<p>
<span className="text-muted-foreground"></span>{" "}
{(Number(data.rebate_rate_snapshot) * 100).toFixed(1)}%
</p>
<p>
<span className="text-muted-foreground"></span>{" "}
{formatMinorAsCurrency(data.actual_deduct_amount, cur)}
</p>
</div>
<PlayerPanel
title={t("orders.betDetail")}
subtitle={data.ticket_no}
eyebrow={t("orders.title")}
backHref="/orders"
backLabel={t("orders.title")}
>
<div className="flex flex-col gap-4">
<Card className="rounded-xl border-[#e5edf8] shadow-[0_8px_24px_rgba(15,23,42,0.05)]">
<CardHeader className="space-y-2 pb-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<CardTitle className="text-base">{t("orders.detailTitle")}</CardTitle>
<StatusDot label={st.label} dotClass={st.dotClass} ring={st.ring} />
</div>
<CardDescription className="font-mono text-xs">
{t("orders.ticketNo", { ticketNo: data.ticket_no })} ·{" "}
{t("orders.orderNo", { orderNo: data.order_no ?? "—" })}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="grid gap-1 text-xs">
<p>
<span className="text-muted-foreground">{t("orders.drawNo")}</span>{" "}
<span className="font-mono font-medium">{data.draw_no ?? "—"}</span>
</p>
<p>
<span className="text-muted-foreground">{t("orders.placedAt")}</span>{" "}
{formatLotteryInstant(data.placed_at ?? null)}
</p>
<p>
<span className="text-muted-foreground">{t("orders.number")}</span>{" "}
<span className="font-mono">{data.original_number ?? "—"}</span>
</p>
<p>
<span className="text-muted-foreground">{t("orders.play")}</span>{" "}
{playLabel(data.play_code, t)} (
{data.dimension ?? "—"}D)
</p>
<p>
<span className="text-muted-foreground">{t("orders.amount")}</span>{" "}
{formatMinorAsCurrency(data.total_bet_amount, cur)}
</p>
<p>
<span className="text-muted-foreground">{t("orders.rebateRate")}</span>{" "}
{(Number(data.rebate_rate_snapshot) * 100).toFixed(1)}%
</p>
<p>
<span className="text-muted-foreground">{t("orders.actualDeduct")}</span>{" "}
{formatMinorAsCurrency(data.actual_deduct_amount, cur)}
</p>
</div>
<div className="rounded-md border bg-muted/30 px-3 py-2 text-xs">
<p className="font-medium text-foreground"></p>
<p className="mt-1 text-muted-foreground">{formatOddsSnapshot(data.odds_snapshot_json)}</p>
<p className="font-medium text-foreground">{t("orders.oddsSnapshot")}</p>
<p className="mt-1 text-muted-foreground">
{formatOddsSnapshot(data.odds_snapshot_json, t)}
</p>
</div>
{pub?.results ? (
<div className="space-y-2">
<p className="text-sm font-medium"></p>
<p className="text-sm font-medium">{t("orders.drawNumbers")}</p>
<TwentyThreeResultsGrid numbers={pub.results} highlighted4d={highlight} />
{first ? (
<p className="text-xs text-muted-foreground">
{" "}
{t("orders.firstPrize")}{" "}
<span className="font-mono font-semibold text-foreground">{first}</span>
{comboHits.length > 0 ? (
<span className="text-emerald-600 dark:text-emerald-400"> </span>
<span className="text-emerald-600 dark:text-emerald-400">
{" "}
{t("orders.hit")}
</span>
) : null}
</p>
) : null}
</div>
) : (
<p className="text-xs text-muted-foreground"></p>
<p className="text-xs text-muted-foreground">{t("orders.notPublished")}</p>
)}
{data.settlement && tierLabel ? (
<div className="rounded-md border border-emerald-500/20 bg-emerald-500/5 px-3 py-2 text-xs">
<p className="font-medium text-emerald-900 dark:text-emerald-100">
{tierLabel}
{t("orders.matchWin", { tier: tierLabel })}
</p>
<p className="mt-1 font-mono text-muted-foreground">
{formatMinorAsCurrency(data.settlement.win_amount_minor, cur)}
{t("orders.winAmount", {
amount: formatMinorAsCurrency(data.settlement.win_amount_minor, cur),
})}
{data.settlement.jackpot_allocation_minor > 0 ? (
<>
{" "}
· Jackpot {formatMinorAsCurrency(data.settlement.jackpot_allocation_minor, cur)}
·{" "}
{t("orders.jackpotAmount", {
amount: formatMinorAsCurrency(
data.settlement.jackpot_allocation_minor,
cur,
),
})}
</>
) : null}
</p>
<p className="mt-1 font-mono text-muted-foreground">
{formatMinorAsCurrency(totalWin, cur)}
{t("orders.payoutTotal", { amount: formatMinorAsCurrency(totalWin, cur) })}
</p>
</div>
) : data.status === "settled_lose" ? (
<p className="text-xs text-muted-foreground"></p>
<p className="text-xs text-muted-foreground">{t("orders.matchLose")}</p>
) : null}
{data.settled_at ? (
<p className="text-[11px] text-muted-foreground">
{formatLotteryInstant(data.settled_at)}
{t("orders.settledAt", { time: formatLotteryInstant(data.settled_at) })}
</p>
) : null}
</CardContent>
</Card>
</CardContent>
</Card>
<div className="flex flex-wrap gap-2">
{data.draw_no ? (
<Link
href={`/results/${encodeURIComponent(data.draw_no)}`}
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
>
<div className="flex flex-wrap gap-2">
{data.draw_no ? (
<Link
href={`/results/${encodeURIComponent(data.draw_no)}`}
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
>
{t("orders.viewDraw")}
</Link>
) : null}
<Link href="/orders" className={cn(buttonVariants({ variant: "secondary", size: "sm" }))}>
{t("orders.backToOrders")}
</Link>
) : null}
<Link href="/orders" className={cn(buttonVariants({ variant: "secondary", size: "sm" }))}>
</Link>
</div>
</div>
</div>
</PlayerPanel>
);
}

View File

@@ -3,6 +3,7 @@
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { getTicketItems } from "@/api/ticket-items";
import { Button } from "@/components/ui/button";
@@ -11,11 +12,12 @@ import { PlayerPanel } from "@/components/layout/player-panel";
import { StatusDot, ticketStatusDisplay } from "@/features/orders/ticket-item-status";
import { formatMinorAsCurrency } from "@/lib/money";
import { formatLotteryInstant } from "@/lib/player-datetime";
import { playLabelZh } from "@/lib/play-labels";
import { playLabel } from "@/lib/play-labels";
import type { TicketItemListRow } from "@/types/api/ticket-items";
export function TicketOrdersListScreen() {
const searchParams = useSearchParams();
const { t } = useTranslation("player");
const drawNoFilter = useMemo(
() => (searchParams.get("draw_no") ?? "").trim(),
[searchParams],
@@ -45,14 +47,14 @@ export function TicketOrdersListScreen() {
setLastPage(res.last_page);
setTotal(res.total);
} catch {
setError("加载失败");
setError(t("orders.loadFailed"));
if (!append) setItems([]);
} finally {
setLoading(false);
setLoadingMore(false);
}
},
[drawNoFilter],
[drawNoFilter, t],
);
useEffect(() => {
@@ -67,13 +69,13 @@ export function TicketOrdersListScreen() {
};
return (
<PlayerPanel title="My Bets" subtitle="Recent ticket records" eyebrow="N lotto">
<PlayerPanel title={t("orders.title")} subtitle={t("orders.subtitle")} eyebrow={t("brand.name")}>
<div className="space-y-4">
<div className="rounded-xl border border-[#e6edf8] bg-[#f8fbff] p-3">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-xs font-bold text-[#32518d]">
{drawNoFilter ? "Filtered Issue" : "Total Records"}
{drawNoFilter ? t("orders.filteredIssue") : t("orders.totalRecords")}
</p>
<p className="mt-1 truncate font-mono text-lg font-black text-[#0b3f96]">
{drawNoFilter || total}
@@ -84,14 +86,14 @@ export function TicketOrdersListScreen() {
href="/orders"
className="shrink-0 rounded-full border border-[#dce7f7] bg-white px-3 py-1.5 text-xs font-bold text-[#0b56b7]"
>
Clear
{t("actions.clear")}
</Link>
) : (
<Link
href="/hall"
className="shrink-0 rounded-full bg-[#e5002c] px-3 py-1.5 text-xs font-bold text-white"
>
Bet Now
{t("orders.betNow")}
</Link>
)}
</div>
@@ -112,17 +114,17 @@ export function TicketOrdersListScreen() {
className="mt-3 bg-[#e5002c] text-white hover:bg-[#d10028]"
onClick={() => void fetchPage(1, false)}
>
Retry
{t("actions.retry")}
</Button>
</div>
) : items.length === 0 ? (
<div className="rounded-xl border border-dashed border-[#dce7f7] bg-[#f8fbff] px-4 py-10 text-center">
<p className="text-sm font-bold text-slate-700">No bet records yet.</p>
<p className="text-sm font-bold text-slate-700">{t("orders.empty")}</p>
<Link
href="/hall"
className="mt-4 inline-flex h-9 items-center rounded-lg bg-[#e5002c] px-4 text-sm font-bold text-white"
>
Submit Bet
{t("orders.submitBet")}
</Link>
</div>
) : (
@@ -134,6 +136,7 @@ export function TicketOrdersListScreen() {
row.status,
row.win_amount,
row.jackpot_win_amount,
t,
);
const totalWin = row.win_amount + row.jackpot_win_amount;
return (
@@ -148,20 +151,24 @@ export function TicketOrdersListScreen() {
{row.draw_no ?? "—"}
</p>
<p className="mt-1 truncate text-xs text-slate-500">
{playLabelZh(row.play_code)} · {row.original_number ?? row.play_code}
{playLabel(row.play_code, t)} · {row.original_number ?? row.play_code}
</p>
</div>
<StatusDot label={st.label} dotClass={st.dotClass} ring={st.ring} />
</div>
<div className="mt-3 grid grid-cols-2 gap-2">
<div className="rounded-lg bg-[#f8fbff] px-3 py-2">
<p className="text-[10px] font-bold uppercase text-[#7890b8]">Stake</p>
<p className="text-[10px] font-bold uppercase text-[#7890b8]">
{t("orders.stake")}
</p>
<p className="mt-1 text-sm font-black text-slate-900">
{formatMinorAsCurrency(row.total_bet_amount, cur)}
</p>
</div>
<div className="rounded-lg bg-[#f8fbff] px-3 py-2">
<p className="text-[10px] font-bold uppercase text-[#7890b8]">Deduction</p>
<p className="text-[10px] font-bold uppercase text-[#7890b8]">
{t("orders.deduction")}
</p>
<p className="mt-1 text-sm font-black text-[#0b3f96]">
{formatMinorAsCurrency(row.actual_deduct_amount, cur)}
</p>
@@ -169,7 +176,7 @@ export function TicketOrdersListScreen() {
</div>
{totalWin > 0 && row.status === "settled_win" ? (
<p className="mt-2 text-xs font-bold text-emerald-600">
Win {formatMinorAsCurrency(totalWin, cur)}
{t("orders.win", { amount: formatMinorAsCurrency(totalWin, cur) })}
</p>
) : null}
<p className="mt-2 text-[11px] text-slate-500">
@@ -187,7 +194,7 @@ export function TicketOrdersListScreen() {
disabled={loadingMore}
onClick={() => loadMore()}
>
{loadingMore ? "Loading..." : "Load More"}
{loadingMore ? t("actions.loading") : t("actions.loadMore")}
</Button>
) : null}
</>

View File

@@ -12,7 +12,7 @@ import {
} from "lucide-react";
import Image from "next/image";
import { useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { getPlayerMe, getPlayerPing } from "@/api/player";
@@ -97,7 +97,6 @@ export function EntryGate() {
usePlayerSessionStore();
const [phase, setPhase] = useState<Phase>("loading");
const [progress, setProgress] = useState(0);
const [failureDetails, setFailureDetails] = useState<FailureRow[]>([]);
const [steps, setSteps] = useState<EntryStep[]>(initialSteps());
@@ -107,15 +106,11 @@ export function EntryGate() {
setSteps((prev) => prev.map((s) => (s.id === stepId ? { ...s, status } : s)));
}, []);
const calculateProgress = useCallback((currentSteps: EntryStep[]) => {
const doneCount = currentSteps.filter((s) => s.status === "done").length;
const inProgressCount = currentSteps.filter((s) => s.status === "in-progress").length;
return Math.round(((doneCount + inProgressCount * 0.5) / currentSteps.length) * 100);
}, []);
useEffect(() => {
setProgress(calculateProgress(steps));
}, [steps, calculateProgress]);
const progress = useMemo(() => {
const doneCount = steps.filter((s) => s.status === "done").length;
const inProgressCount = steps.filter((s) => s.status === "in-progress").length;
return Math.round(((doneCount + inProgressCount * 0.5) / steps.length) * 100);
}, [steps]);
const handleRetry = useCallback(() => {
setPhase("loading");

View File

@@ -1,6 +1,7 @@
"use client";
import { UserRound } from "lucide-react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { usePlayerSessionStore } from "@/stores/player-session-store";
@@ -10,11 +11,12 @@ import { usePlayerSessionStore } from "@/stores/player-session-store";
*/
export function PlayerSessionBar({ className }: { className?: string }) {
const profile = usePlayerSessionStore((s) => s.profile);
const { t } = useTranslation("player");
const label =
profile?.nickname?.trim() ||
profile?.username?.trim() ||
(profile?.id != null ? `玩家 #${profile.id}` : null);
(profile?.id != null ? t("player.fallback", { id: profile.id }) : null);
return (
<div

View File

@@ -2,10 +2,12 @@
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { getDrawResultByNo } from "@/api/draw";
import { getTicketDrawMyMatch } from "@/api/ticket-items";
import { Button, buttonVariants } from "@/components/ui/button";
import { PlayerPanel } from "@/components/layout/player-panel";
import {
Card,
CardContent,
@@ -29,6 +31,7 @@ type DrawResultDetailScreenProps = {
/** §4.6 开奖结果详情23 分区 + [< >] 切换 + 本人命中高亮 + Jackpot */
export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps) {
const { t } = useTranslation("player");
const [data, setData] = useState<DrawResultDetailPayload | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
@@ -47,11 +50,11 @@ export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps)
setData(row);
} catch {
setData(null);
setError("该期开奖结果不可用或不存在");
setError(t("results.unavailable"));
} finally {
setLoading(false);
}
}, [drawNo]);
}, [drawNo, t]);
useEffect(() => {
queueMicrotask(() => {
@@ -98,34 +101,37 @@ export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps)
if (loading) {
return (
<Card>
<CardHeader>
<Skeleton className="h-7 w-48" />
<Skeleton className="h-4 w-56" />
</CardHeader>
<CardContent className="space-y-4">
<Skeleton className="h-48 w-full" />
</CardContent>
</Card>
<PlayerPanel
title={t("results.detailTitle")}
subtitle={drawNo}
eyebrow={t("results.title")}
backHref="/results"
backLabel={t("results.title")}
>
<div className="space-y-4">
<Skeleton className="h-12 rounded-xl" />
<Skeleton className="h-56 rounded-xl" />
</div>
</PlayerPanel>
);
}
if (error || !data) {
return (
<Card className="border-destructive/40">
<CardHeader>
<CardTitle className="text-base"></CardTitle>
<CardDescription>{error ?? "无数据"}</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
<PlayerPanel
title={t("results.detailTitle")}
subtitle={drawNo}
eyebrow={t("results.title")}
backHref="/results"
backLabel={t("results.title")}
>
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-4 text-sm text-red-700">
<p>{error ?? t("results.noData")}</p>
<Button type="button" size="sm" variant="secondary" onClick={() => void load()}>
{t("actions.retry")}
</Button>
<Link href="/results" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
</Link>
</CardContent>
</Card>
</div>
</PlayerPanel>
);
}
@@ -143,60 +149,81 @@ export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps)
myTotals.jackpot === 0;
return (
<div className="flex flex-col gap-4">
<JackpotResultsStrip currencyCode={currency} />
<PlayerPanel
title={t("results.detailTitle")}
subtitle={data.draw_no}
eyebrow={t("results.title")}
backHref="/results"
backLabel={t("results.title")}
>
<div className="flex flex-col gap-4">
<JackpotResultsStrip currencyCode={currency} />
<Card>
<CardHeader className="space-y-3 pb-2">
<div className="flex flex-wrap items-center justify-between gap-2">
{data.previous_draw_no ? (
<Link
href={`/results/${encodeURIComponent(data.previous_draw_no)}`}
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "min-w-[5rem]")}
>
</Link>
) : (
<Button type="button" variant="outline" size="sm" className="min-w-[5rem]" disabled>
</Button>
)}
<CardTitle className="text-center font-mono text-lg">
{data.draw_no}
</CardTitle>
{data.next_draw_no ? (
<Link
href={`/results/${encodeURIComponent(data.next_draw_no)}`}
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "min-w-[5rem]")}
>
</Link>
) : (
<Button type="button" variant="outline" size="sm" className="min-w-[5rem]" disabled>
</Button>
)}
</div>
<CardDescription className="text-center font-mono text-sm">
:{" "}
{formatLotteryInstant(data.draw_time_iso ?? data.draw_time ?? null)}
</CardDescription>
</CardHeader>
<CardContent className="pt-2">
<TwentyThreeResultsGrid
numbers={data.results}
highlighted4d={highlightSet ?? undefined}
/>
<Card className="rounded-xl border-[#e5edf8] shadow-[0_8px_24px_rgba(15,23,42,0.05)]">
<CardHeader className="space-y-3 pb-2">
<div className="flex flex-wrap items-center justify-between gap-2">
{data.previous_draw_no ? (
<Link
href={`/results/${encodeURIComponent(data.previous_draw_no)}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"min-w-[5rem]",
)}
>
{t("results.previous")}
</Link>
) : (
<Button type="button" variant="outline" size="sm" className="min-w-[5rem]" disabled>
{t("results.previous")}
</Button>
)}
<CardTitle className="text-center font-mono text-lg">
{data.draw_no}
</CardTitle>
{data.next_draw_no ? (
<Link
href={`/results/${encodeURIComponent(data.next_draw_no)}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"min-w-[5rem]",
)}
>
{t("results.next")}
</Link>
) : (
<Button type="button" variant="outline" size="sm" className="min-w-[5rem]" disabled>
{t("results.next")}
</Button>
)}
</div>
<CardDescription className="text-center font-mono text-sm">
{t("results.drawTime", {
time: formatLotteryInstant(data.draw_time_iso ?? data.draw_time ?? null),
})}
</CardDescription>
</CardHeader>
<CardContent className="pt-2">
<TwentyThreeResultsGrid
numbers={data.results}
highlighted4d={highlightSet ?? undefined}
/>
{showMyPayout && myTotals ? (
<div className="mt-4 rounded-md border border-emerald-500/25 bg-emerald-500/5 px-3 py-2 text-sm">
<p className="font-medium text-emerald-900 dark:text-emerald-100"></p>
<p className="font-medium text-emerald-900 dark:text-emerald-100">
{t("results.myPayout")}
</p>
<p className="mt-1 font-mono text-xs tabular-nums text-muted-foreground">
{formatMinorAsCurrency(myTotals.win, currency)}
{t("results.regular", {
amount: formatMinorAsCurrency(myTotals.win, currency),
})}
{myTotals.jackpot > 0 ? (
<>
{" "}
· Jackpot{formatMinorAsCurrency(myTotals.jackpot, currency)}
·{" "}
{t("results.jackpot", {
amount: formatMinorAsCurrency(myTotals.jackpot, currency),
})}
</>
) : null}
</p>
@@ -204,13 +231,13 @@ export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps)
) : null}
{showHitOnly ? (
<p className="mt-3 text-xs text-amber-900/90 dark:text-amber-100/90">
{t("results.hitPending")}
</p>
) : null}
<div className="mt-4 flex flex-col gap-3">
<p className="text-xs text-muted-foreground">
{t("results.hitHint")}
</p>
<Link
href={`/orders?draw_no=${encodeURIComponent(data.draw_no)}`}
@@ -219,11 +246,12 @@ export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps)
"w-full sm:w-auto sm:self-start",
)}
>
{t("results.viewMyWinning")}
</Link>
</div>
</CardContent>
</Card>
</div>
</CardContent>
</Card>
</div>
</PlayerPanel>
);
}

View File

@@ -2,6 +2,7 @@
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { getDrawResults } from "@/api/draw";
import { Button } from "@/components/ui/button";
@@ -13,6 +14,7 @@ import { formatLotteryInstant } from "@/lib/player-datetime";
import type { DrawResultListItem } from "@/types/api/draw-results";
export function DrawResultsListScreen() {
const { t } = useTranslation("player");
const [items, setItems] = useState<DrawResultListItem[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [date, setDate] = useState("");
@@ -29,12 +31,12 @@ export function DrawResultsListScreen() {
});
setItems(res.items);
} catch {
setError("加载失败");
setError(t("results.loadFailed"));
setItems(null);
} finally {
setLoading(false);
}
}, [date]);
}, [date, t]);
useEffect(() => {
queueMicrotask(() => {
@@ -43,12 +45,14 @@ export function DrawResultsListScreen() {
}, [fetchList]);
return (
<PlayerPanel title="Results" subtitle="Latest draw history" eyebrow="N lotto">
<PlayerPanel title={t("results.title")} subtitle={t("results.subtitle")} eyebrow={t("brand.name")}>
<div className="space-y-4">
<JackpotResultsStrip currencyCode="NPR" />
<div className="rounded-xl border border-[#e6edf8] bg-[#f8fbff] p-3">
<p className="mb-2 text-xs font-bold text-[#32518d]">Business Date</p>
<p className="mb-2 text-xs font-bold text-[#32518d]">
{t("results.businessDate")}
</p>
<div className="flex gap-2">
<Input
type="date"
@@ -62,7 +66,7 @@ export function DrawResultsListScreen() {
className="h-10 rounded-lg bg-[#07459f] px-4 text-white hover:bg-[#063b88]"
onClick={() => void fetchList()}
>
Apply
{t("actions.apply")}
</Button>
</div>
</div>
@@ -82,12 +86,12 @@ export function DrawResultsListScreen() {
className="mt-3 bg-[#e5002c] text-white hover:bg-[#d10028]"
onClick={() => void fetchList()}
>
Retry
{t("actions.retry")}
</Button>
</div>
) : items && items.length === 0 ? (
<div className="rounded-xl border border-dashed border-[#dce7f7] bg-[#f8fbff] px-4 py-10 text-center text-sm text-slate-500">
No results yet.
{t("results.empty")}
</div>
) : (
<div className="space-y-3">
@@ -107,7 +111,7 @@ export function DrawResultsListScreen() {
</p>
</div>
<span className="shrink-0 rounded-full bg-[#f2f6ff] px-2.5 py-1 text-xs font-bold text-[#0b56b7]">
Detail
{t("results.detail")}
</span>
</div>

View File

@@ -1,6 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { getJackpotSummary } from "@/api/jackpot";
import { formatMinorAsCurrency } from "@/lib/money";
@@ -13,6 +14,7 @@ type JackpotResultsStripProps = {
export function JackpotResultsStrip({
currencyCode = "NPR",
}: JackpotResultsStripProps) {
const { t } = useTranslation("player");
const [minor, setMinor] = useState<number | null>(null);
const [enabled, setEnabled] = useState(false);
@@ -44,7 +46,7 @@ export function JackpotResultsStrip({
return (
<div className="rounded-xl border border-amber-200 bg-gradient-to-r from-amber-100 via-white to-[#f8fbff] px-3 py-3 shadow-[0_8px_20px_rgba(180,83,9,0.08)]">
<p className="text-[11px] font-black uppercase tracking-normal text-amber-700">
Jackpot
{t("results.jackpotLabel")}
</p>
<p className="font-mono text-lg font-black tabular-nums text-[#0b3f96]">
{formatMinorAsCurrency(minor, currencyCode.toUpperCase())}

View File

@@ -1,4 +1,6 @@
import type { DrawResultsNumbers } from "@/types/api/draw-results";
import { useTranslation } from "react-i18next";
import { norm4d } from "@/lib/norm-4d";
import { cn } from "@/lib/utils";
@@ -15,6 +17,7 @@ export function TwentyThreeResultsGrid({
numbers,
highlighted4d,
}: TwentyThreeResultsGridProps) {
const { t } = useTranslation("player");
const starters = numbers.starter ?? [];
const consos = numbers.consolation ?? [];
const hits = highlighted4d ?? null;
@@ -44,7 +47,11 @@ export function TwentyThreeResultsGrid({
{(["1st", "2nd", "3rd"] as const).map((key) => (
<div key={key} className="flex flex-col gap-1.5 text-center">
<span className="text-xs font-medium uppercase text-muted-foreground">
{key === "1st" ? "头奖" : key === "2nd" ? "二奖" : "三奖"}
{key === "1st"
? t("results.grid.first")
: key === "2nd"
? t("results.grid.second")
: t("results.grid.third")}
</span>
<div className={cellTone(numbers[key] || "")}>
{numbers[key] || "—"}
@@ -54,7 +61,9 @@ export function TwentyThreeResultsGrid({
</div>
<div className="space-y-2">
<p className="text-sm font-medium text-foreground"> (Starter)</p>
<p className="text-sm font-medium text-foreground">
{t("results.grid.starter")} (Starter)
</p>
<div className="grid grid-cols-5 gap-2">
{Array.from({ length: 10 }).map((_, i) => (
<div key={`s-${i}`} className={cellTone(starters[i] ?? "—")}>
@@ -65,7 +74,9 @@ export function TwentyThreeResultsGrid({
</div>
<div className="space-y-2">
<p className="text-sm font-medium text-foreground"> (Consolation)</p>
<p className="text-sm font-medium text-foreground">
{t("results.grid.consolation")} (Consolation)
</p>
<div className="grid grid-cols-5 gap-2">
{Array.from({ length: 10 }).map((_, i) => (
<div key={`c-${i}`} className={cellTone(consos[i] ?? "—")}>

View File

@@ -1,47 +1,37 @@
"use client";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { formatLocalDateTime } from "@/lib/format-local-datetime";
import { formatMinorAsCurrency } from "@/lib/money";
import type { WalletLogItem, WalletLogsData } from "@/types/api/wallet-logs";
/** 与 §4.9 筛选一致;接口 `type` 查询参数 */
export const WALLET_FLOW_FILTERS: { value: string; label: string }[] = [
{ value: "", label: "全部" },
{ value: "transfer_in", label: "转入" },
{ value: "transfer_out", label: "转出" },
{ value: "bet", label: "下注扣款" },
{ value: "prize", label: "派彩" },
{ value: "refund", label: "退本" },
{ value: "reversal", label: "冲正" },
export const WALLET_FLOW_FILTERS: { value: string; labelKey: string }[] = [
{ value: "", labelKey: "wallet.flow.all" },
{ value: "transfer_in", labelKey: "wallet.flow.transfer_in" },
{ value: "transfer_out", labelKey: "wallet.flow.transfer_out" },
{ value: "bet", labelKey: "wallet.flow.bet" },
{ value: "prize", labelKey: "wallet.flow.prize" },
{ value: "refund", labelKey: "wallet.flow.refund" },
{ value: "reversal", labelKey: "wallet.flow.reversal" },
];
export function logTypeLabel(t: string): string {
const map: Record<string, string> = {
transfer_in: "转入",
transfer_out: "转出",
refund: "退本",
reversal: "冲正",
bet: "下注扣款",
prize: "派彩",
};
return map[t] ?? t;
export function logTypeLabel(
type: string,
t?: (key: string, options?: { defaultValue?: string }) => string,
): string {
return t?.(`wallet.flow.${type}`, { defaultValue: type }) ?? type;
}
function txnStatusLabel(status: string): string {
if (status === "posted") return "成功";
if (status === "pending_reconcile") return "待对账";
if (status === "reversed") return "已冲正";
if (status === "manually_processed") return "已人工处理";
return status;
function txnStatusLabel(
status: string,
t: (key: string, options?: { defaultValue?: string }) => string,
): string {
return t(`wallet.txnStatus.${status}`, { defaultValue: status });
}
type WalletLogsBlockProps = {
@@ -61,47 +51,59 @@ export function WalletLogsBlock({
filter,
onFilterChange,
currency,
title = "资金流水",
title,
}: WalletLogsBlockProps) {
const { t } = useTranslation("player");
const resolvedTitle = title ?? t("wallet.flowsTitle");
const filters = useMemo(
() =>
WALLET_FLOW_FILTERS.map((f) => ({
...f,
label: t(f.labelKey),
})),
[t],
);
return (
<>
{logs && logs.pending_reconcile.length > 0 ? (
<Card className="border-[#faad14]/50 bg-[#faad14]/5">
<CardHeader className="pb-2">
<CardTitle className="text-sm text-[#d48806]"></CardTitle>
<CardDescription className="text-xs">
§4.10
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<section className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-3">
<p className="text-sm font-black text-amber-700">{t("wallet.pendingTitle")}</p>
<p className="mt-1 text-xs text-amber-700/80">
{t("wallet.pendingDescription")}
</p>
<div className="mt-2 space-y-2 text-sm">
{logs.pending_reconcile.map((p) => (
<div
key={p.transfer_no}
className="flex flex-wrap items-baseline justify-between gap-1 border-b border-dashed border-border py-2 last:border-0"
>
<span className="text-muted-foreground">
{p.type === "transfer_in" ? "转入" : "转出"}{" "}
{logTypeLabel(p.type, t)}{" "}
{formatMinorAsCurrency(p.amount, p.currency_code)}
</span>
<span className="text-xs text-amber-700"></span>
<span className="text-xs text-amber-700">{t("wallet.pendingStatus")}</span>
</div>
))}
</CardContent>
</Card>
</div>
</section>
) : null}
<section className="space-y-3">
<div className="flex flex-col gap-2">
<h2 className="text-sm font-medium">{title}</h2>
<h2 className="text-sm font-black text-[#0b3f96]">{resolvedTitle}</h2>
<div className="flex flex-wrap gap-1.5">
{WALLET_FLOW_FILTERS.map((f) => (
{filters.map((f) => (
<Button
key={f.value || "all"}
type="button"
size="sm"
variant={filter === f.value ? "default" : "outline"}
className="h-8 rounded-full text-xs"
className={
filter === f.value
? "h-8 rounded-full bg-[#07459f] px-3 text-xs font-bold text-white hover:bg-[#063b88]"
: "h-8 rounded-full border-[#dce7f7] bg-white px-3 text-xs font-bold text-[#32518d] hover:bg-[#f8fbff]"
}
onClick={() => onFilterChange(f.value)}
>
{f.label}
@@ -117,12 +119,12 @@ export function WalletLogsBlock({
{logs ? (
<>
<p className="text-xs text-muted-foreground">
{logs.total}
{t("wallet.totalRecords", { total: logs.total })}
</p>
<ul className="space-y-2">
{logs.items.length === 0 ? (
<li className="rounded-lg border border-dashed py-8 text-center text-sm text-muted-foreground">
{t("wallet.emptyLogs")}
</li>
) : (
logs.items.map((row) => (
@@ -144,15 +146,16 @@ export function LogRow({
item: WalletLogItem;
currency: string;
}) {
const { t } = useTranslation("player");
const ccy = item.currency_code || currency;
const isIn = item.direction === "in";
return (
<li className="rounded-xl border bg-card px-3 py-2.5 text-sm shadow-sm">
<li className="rounded-xl border border-[#e5edf8] bg-white px-3 py-3 text-sm shadow-[0_8px_24px_rgba(15,23,42,0.05)]">
<div className="flex items-start justify-between gap-2">
<div>
<span className="font-medium">
{logTypeLabel(item.type)}{" "}
<span className={isIn ? "text-[#52c41a]" : "text-foreground"}>
{logTypeLabel(item.type, t)}{" "}
<span className={isIn ? "font-black text-emerald-600" : "font-black text-[#0b3f96]"}>
{isIn ? "+" : ""}
{formatMinorAsCurrency(item.amount_abs, ccy)}
</span>
@@ -160,7 +163,7 @@ export function LogRow({
<p className="mt-1 text-xs text-muted-foreground">
{formatLocalDateTime(item.created_at)}{" "}
<span className="text-foreground/80">
· {txnStatusLabel(item.status)}
· {txnStatusLabel(item.status, t)}
</span>
</p>
{item.ref_id ? (

View File

@@ -1,26 +1,19 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { getWalletLogs } from "@/api/wallet";
import { Button, buttonVariants } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { PlayerPanel } from "@/components/layout/player-panel";
import { WalletLogsBlock } from "@/features/wallet/wallet-logs-block";
import { formatWalletClientError } from "@/lib/wallet-api-error";
import { cn } from "@/lib/utils";
import { usePlayerSessionStore } from "@/stores/player-session-store";
import type { WalletLogsData } from "@/types/api/wallet-logs";
/** 独立路由 `/wallet/logs` */
export function WalletLogsScreen() {
const profile = usePlayerSessionStore((s) => s.profile);
const { t } = useTranslation("player");
const [logs, setLogs] = useState<WalletLogsData | null>(null);
const [filter, setFilter] = useState("");
const [loading, setLoading] = useState(true);
@@ -50,12 +43,12 @@ export function WalletLogsScreen() {
});
setLogs(L);
} catch (e) {
setError(formatWalletClientError(e));
setError(formatWalletClientError(e, t));
} finally {
setLoading(false);
setLogsLoading(false);
}
}, [filter]);
}, [filter, t]);
useEffect(() => {
queueMicrotask(() => {
@@ -64,39 +57,36 @@ export function WalletLogsScreen() {
}, [load]);
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<h1 className="text-lg font-semibold tracking-tight"></h1>
<Link
href="/wallet"
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
>
</Link>
</div>
{error ? (
<Card className="border-destructive/40">
<CardHeader>
<CardTitle className="text-destructive"></CardTitle>
<CardDescription>{error}</CardDescription>
</CardHeader>
<CardContent>
<Button type="button" onClick={() => void load()}>
<PlayerPanel
title={t("wallet.logsTitle")}
subtitle={t("wallet.logsSubtitle")}
eyebrow={t("brand.name")}
backHref="/wallet"
backLabel={t("wallet.title")}
>
<div className="space-y-4">
{error ? (
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-4 text-sm text-red-700">
<p>{error}</p>
<Button
type="button"
className="mt-3 bg-[#e5002c] text-white hover:bg-[#d10028]"
onClick={() => void load()}
>
{t("actions.retry")}
</Button>
</CardContent>
</Card>
) : null}
</div>
) : null}
<WalletLogsBlock
logs={logs}
logsLoading={loading || logsLoading}
filter={filter}
onFilterChange={setFilter}
currency={currency}
title="类型筛选"
/>
</div>
<WalletLogsBlock
logs={logs}
logsLoading={loading || logsLoading}
filter={filter}
onFilterChange={setFilter}
currency={currency}
title={t("wallet.typeFilter")}
/>
</div>
</PlayerPanel>
);
}

View File

@@ -3,17 +3,12 @@
import { Wallet } from "lucide-react";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { getWalletBalance, getWalletLogs } from "@/api/wallet";
import { Button, buttonVariants } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { PlayerPanel } from "@/components/layout/player-panel";
import {
TransferInDialog,
TransferOutDialog,
@@ -21,14 +16,13 @@ import {
import { WalletLogsBlock } from "@/features/wallet/wallet-logs-block";
import { formatMinorAsCurrency } from "@/lib/money";
import { formatWalletClientError } from "@/lib/wallet-api-error";
import { cn } from "@/lib/utils";
import { usePlayerSessionStore } from "@/stores/player-session-store";
import type { WalletLogsData } from "@/types/api/wallet-logs";
import type { WalletBalanceData } from "@/types/api/wallet-balance";
import type { WalletLogsData } from "@/types/api/wallet-logs";
export function WalletScreen() {
const profile = usePlayerSessionStore((s) => s.profile);
const { t } = useTranslation("player");
const [balance, setBalance] = useState<WalletBalanceData | null>(null);
const [logs, setLogs] = useState<WalletLogsData | null>(null);
const [filter, setFilter] = useState("");
@@ -70,7 +64,7 @@ export function WalletScreen() {
setLogs(L);
} catch (e) {
if (!cancelled) {
setError(formatWalletClientError(e));
setError(formatWalletClientError(e, t));
}
} finally {
if (!cancelled) {
@@ -83,7 +77,7 @@ export function WalletScreen() {
return () => {
cancelled = true;
};
}, [filter]);
}, [filter, t]);
const refreshAll = useCallback(async () => {
setError(null);
@@ -98,138 +92,102 @@ export function WalletScreen() {
});
setLogs(L);
} catch (e) {
setError(formatWalletClientError(e));
setError(formatWalletClientError(e, t));
} finally {
setLogsLoading(false);
setLoading(false);
}
}, [filter]);
}, [filter, t]);
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-lg font-semibold tracking-tight"></h1>
<div className="mt-2 flex flex-wrap gap-2">
<Link
href="/wallet/transfer-in"
className={cn(
buttonVariants({ variant: "secondary", size: "sm" }),
"text-xs",
)}
<PlayerPanel title={t("wallet.title")} subtitle={t("wallet.subtitle")} eyebrow={t("brand.name")}>
<div className="space-y-4">
{error ? (
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-4 text-sm text-red-700">
<p>{error}</p>
<Button
type="button"
className="mt-3 bg-[#e5002c] text-white hover:bg-[#d10028]"
onClick={() => void refreshAll()}
>
</Link>
<Link
href="/wallet/transfer-out"
className={cn(
buttonVariants({ variant: "secondary", size: "sm" }),
"text-xs",
)}
>
</Link>
<Link
href="/wallet/logs"
className={cn(
buttonVariants({ variant: "secondary", size: "sm" }),
"text-xs",
)}
>
</Link>
</div>
</div>
<Link
href="/hall"
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"shrink-0 self-start",
)}
>
</Link>
</div>
{error ? (
<Card className="border-destructive/40">
<CardHeader>
<CardTitle className="text-destructive"></CardTitle>
<CardDescription>{error}</CardDescription>
</CardHeader>
<CardContent>
<Button type="button" onClick={() => void refreshAll()}>
{t("actions.retry")}
</Button>
</CardContent>
</Card>
) : null}
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<Wallet className="size-5 opacity-80" aria-hidden />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{loading ? (
<Skeleton className="h-12 w-full max-w-xs rounded-lg" />
) : (
<>
<div>
<p className="text-xs text-muted-foreground"></p>
<p className="font-heading text-2xl font-semibold tabular-nums text-[#52c41a]">
{formatMinorAsCurrency(
balance?.balance ?? 0,
currency,
)}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{" "}
{formatMinorAsCurrency(
balance?.available_balance ?? 0,
currency,
)}
</p>
</div>
<div className="rounded-lg border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
{" "}
<span className="font-medium text-foreground">
{balance?.main_balance == null
? "—(待接入主站)"
: formatMinorAsCurrency(balance.main_balance, currency)}
</span>
</div>
</>
)}
<div className="flex gap-2">
<TransferInDialog
idPrefix="wallet-"
currency={currency}
lotteryMinor={Number(balance?.balance ?? 0)}
onSuccess={refreshAll}
triggerVariant="wallet"
/>
<TransferOutDialog
idPrefix="wallet-"
currency={currency}
availableMinor={Number(balance?.available_balance ?? 0)}
onSuccess={refreshAll}
triggerVariant="wallet"
/>
</div>
</CardContent>
</Card>
) : null}
<WalletLogsBlock
logs={logs}
logsLoading={loading || logsLoading}
filter={filter}
onFilterChange={setFilter}
currency={currency}
/>
</div>
<section className="relative overflow-hidden rounded-xl bg-[#e5002c] px-4 py-5 text-white shadow-[0_10px_28px_rgba(229,0,44,0.25)]">
<div className="relative flex items-center gap-3">
<div className="flex size-13 shrink-0 items-center justify-center rounded-full bg-white text-[#d81435] shadow-sm">
<Wallet className="size-7" aria-hidden />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-white/90">{t("wallet.balance")}</p>
{loading ? (
<Skeleton className="mt-2 h-8 w-44 rounded-md bg-white/25" />
) : (
<p className="mt-1 text-2xl font-black leading-none tabular-nums tracking-normal">
{formatMinorAsCurrency(balance?.balance ?? 0, currency)}
</p>
)}
<p className="mt-2 text-xs text-white/75">
{t("wallet.available", {
amount: formatMinorAsCurrency(balance?.available_balance ?? 0, currency),
})}
</p>
</div>
</div>
</section>
<div className="grid grid-cols-2 gap-3">
<TransferInDialog
idPrefix="wallet-"
currency={currency}
lotteryMinor={Number(balance?.balance ?? 0)}
onSuccess={refreshAll}
triggerVariant="hall"
triggerLabel={t("wallet.transferIn")}
triggerClassName="h-12 rounded-lg text-base font-bold"
/>
<TransferOutDialog
idPrefix="wallet-"
currency={currency}
availableMinor={Number(balance?.available_balance ?? 0)}
onSuccess={refreshAll}
triggerVariant="hall"
triggerLabel={t("wallet.transferOut")}
triggerClassName="h-12 rounded-lg text-base font-bold"
/>
</div>
<div className="grid grid-cols-3 gap-2 text-center text-xs font-bold">
<Link
className="rounded-lg border border-[#e5edf8] bg-[#f8fbff] py-2 text-[#0b56b7]"
href="/wallet/transfer-in"
>
{t("wallet.inPage")}
</Link>
<Link
className="rounded-lg border border-[#e5edf8] bg-[#f8fbff] py-2 text-[#0b56b7]"
href="/wallet/transfer-out"
>
{t("wallet.outPage")}
</Link>
<Link
className="rounded-lg border border-[#e5edf8] bg-[#f8fbff] py-2 text-[#0b56b7]"
href="/wallet/logs"
>
{t("wallet.logs")}
</Link>
</div>
<WalletLogsBlock
logs={logs}
logsLoading={loading || logsLoading}
filter={filter}
onFilterChange={setFilter}
currency={currency}
/>
</div>
</PlayerPanel>
);
}

View File

@@ -2,6 +2,7 @@
import { ArrowDownLeft, ArrowUpRight } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
@@ -42,7 +43,7 @@ export function TransferInDialog({
idPrefix = "",
triggerClassName,
triggerVariant = "wallet",
triggerLabel = "转入",
triggerLabel,
}: BaseProps & {
lotteryMinor: number;
triggerClassName?: string;
@@ -50,6 +51,8 @@ export function TransferInDialog({
triggerLabel?: string;
}) {
const [open, setOpen] = useState(false);
const { t } = useTranslation("player");
const resolvedTriggerLabel = triggerLabel ?? t("wallet.transferIn");
const triggerCombined = cn(
"inline-flex h-10 min-h-10 w-full min-w-0 flex-1 items-center justify-center gap-1.5 px-3 text-sm font-medium",
@@ -66,14 +69,13 @@ export function TransferInDialog({
onClick={() => setOpen(true)}
>
<ArrowDownLeft className="size-4 shrink-0" />
{triggerLabel}
{resolvedTriggerLabel}
</Button>
<DialogContent showCloseButton>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogTitle>{t("wallet.transferInTitle")}</DialogTitle>
<DialogDescription>
1.00{" "}
{currency}
{t("wallet.dialogInDescription", { currency })}
</DialogDescription>
</DialogHeader>
<TransferInPanel
@@ -99,7 +101,7 @@ export function TransferOutDialog({
idPrefix = "",
triggerClassName,
triggerVariant = "wallet",
triggerLabel = "转出",
triggerLabel,
}: BaseProps & {
availableMinor: number;
triggerClassName?: string;
@@ -107,6 +109,8 @@ export function TransferOutDialog({
triggerLabel?: string;
}) {
const [open, setOpen] = useState(false);
const { t } = useTranslation("player");
const resolvedTriggerLabel = triggerLabel ?? t("wallet.transferOut");
const triggerCombined = cn(
"inline-flex h-10 min-h-10 w-full min-w-0 flex-1 items-center justify-center gap-1.5 px-3 text-sm font-medium",
@@ -128,13 +132,13 @@ export function TransferOutDialog({
onClick={() => setOpen(true)}
>
<ArrowUpRight className="size-4 shrink-0" />
{triggerLabel}
{resolvedTriggerLabel}
</Button>
<DialogContent showCloseButton>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogTitle>{t("wallet.transferOutTitle")}</DialogTitle>
<DialogDescription>
{t("wallet.dialogOutDescription")}
</DialogDescription>
</DialogHeader>
<TransferOutPanel

View File

@@ -1,9 +1,9 @@
"use client";
import { isAxiosError } from "axios";
import { ChevronLeft, Loader2 } from "lucide-react";
import Link from "next/link";
import { Loader2 } from "lucide-react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { postWalletTransferIn, postWalletTransferOut } from "@/api/wallet";
@@ -17,6 +17,7 @@ import {
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { PlayerPanel } from "@/components/layout/player-panel";
import { formatMinorAsCurrency, parseDecimalInputToMinor } from "@/lib/money";
import { formatWalletClientError } from "@/lib/wallet-api-error";
import { LotteryApiBizError } from "@/types/api/errors";
@@ -25,14 +26,15 @@ import { LotteryApiBizError } from "@/types/api/errors";
async function handleTransferMaybePending(
e: unknown,
onRefresh: () => Promise<void>,
t: (key: string) => string,
): Promise<boolean> {
if (e instanceof LotteryApiBizError && e.code === 1002) {
toast.message(e.message || "处理中…");
toast.message(e.message || t("wallet.pendingToast"));
await onRefresh();
return true;
}
if (isAxiosError(e) && e.response?.status === 409) {
toast.message("转账处理中,请稍后刷新。");
toast.message(t("wallet.pendingShort"));
await onRefresh();
return true;
}
@@ -61,6 +63,7 @@ export function TransferInPanel({
onCancel: () => void;
variant?: PanelVariant;
}) {
const { t } = useTranslation("player");
const [amountText, setAmountText] = useState("");
const [submitting, setSubmitting] = useState(false);
const [localError, setLocalError] = useState<string | null>(null);
@@ -76,7 +79,7 @@ export function TransferInPanel({
const submit = async () => {
setLocalError(null);
if (parsedMinor == null || parsedMinor < 1) {
setLocalError("请输入有效金额。");
setLocalError(t("wallet.invalidAmount"));
return;
}
setSubmitting(true);
@@ -86,15 +89,15 @@ export function TransferInPanel({
currency,
idempotent_key: crypto.randomUUID(),
});
toast.success("转入成功,彩票钱包余额已更新。");
toast.success(t("wallet.successIn"));
setAmountText("");
await onSuccess();
} catch (e) {
if (await handleTransferMaybePending(e, onSuccess)) {
setLocalError(formatWalletClientError(e));
if (await handleTransferMaybePending(e, onSuccess, t)) {
setLocalError(formatWalletClientError(e, t));
return;
}
setLocalError(formatWalletClientError(e));
setLocalError(formatWalletClientError(e, t));
} finally {
setSubmitting(false);
}
@@ -104,17 +107,17 @@ export function TransferInPanel({
variant === "page" ? (
<Button
type="button"
className="w-full"
className="h-11 w-full rounded-lg bg-[#07459f] text-base font-bold text-white hover:bg-[#063b88]"
disabled={submitting}
onClick={() => void submit()}
>
{submitting ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
{t("actions.processing")}
</>
) : (
"确认转入"
t("wallet.confirmIn")
)}
</Button>
) : (
@@ -125,7 +128,7 @@ export function TransferInPanel({
disabled={submitting}
onClick={onCancel}
>
{t("actions.cancel")}
</Button>
<Button
type="button"
@@ -135,10 +138,10 @@ export function TransferInPanel({
{submitting ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
{t("actions.processing")}
</>
) : (
"确认转入"
t("wallet.confirmIn")
)}
</Button>
</div>
@@ -147,32 +150,34 @@ export function TransferInPanel({
return (
<>
<div className="grid gap-3 py-1">
<div className="rounded-lg bg-muted/50 px-3 py-2 text-xs">
<div className="rounded-xl border border-[#e5edf8] bg-[#f8fbff] px-3 py-2 text-xs">
<p>
:{" "}
<span className="text-muted-foreground"></span>
{t("wallet.mainBalance")}{" "}
<span className="text-muted-foreground">{t("wallet.mainPending")}</span>
</p>
<p className="mt-1">
:{" "}
{t("wallet.lotteryBalance")}{" "}
<span className="font-medium text-foreground">
{formatMinorAsCurrency(lotteryMinor, currency)}
</span>
</p>
</div>
<div className="grid gap-2">
<Label htmlFor={tid}></Label>
<Label htmlFor={tid}>{t("wallet.inAmount")}</Label>
<Input
id={tid}
inputMode="decimal"
placeholder="例如 1000.00"
placeholder={t("wallet.exampleIn")}
value={amountText}
onChange={(ev) => setAmountText(ev.target.value)}
disabled={submitting}
autoComplete="off"
className="h-11 rounded-lg border-[#dce7f7] bg-white text-base"
/>
<p className="text-xs text-muted-foreground">
:{" "}
{formatMinorAsCurrency(previewAfter, currency)}
{t("wallet.afterInPreview", {
amount: formatMinorAsCurrency(previewAfter, currency),
})}
</p>
</div>
{localError ? (
@@ -196,6 +201,7 @@ export function TransferOutPanel({
onCancel: () => void;
variant?: PanelVariant;
}) {
const { t } = useTranslation("player");
const [amountText, setAmountText] = useState("");
const [submitting, setSubmitting] = useState(false);
const [localError, setLocalError] = useState<string | null>(null);
@@ -218,11 +224,11 @@ export function TransferOutPanel({
const submit = async () => {
setLocalError(null);
if (parsedMinor == null || parsedMinor < 1) {
setLocalError("请输入有效金额。");
setLocalError(t("wallet.invalidAmount"));
return;
}
if (parsedMinor > availableMinor) {
setLocalError("转出金额不能超过可用余额。");
setLocalError(t("wallet.outExceeds"));
return;
}
setSubmitting(true);
@@ -232,15 +238,15 @@ export function TransferOutPanel({
currency,
idempotent_key: crypto.randomUUID(),
});
toast.success("转出成功,资金将返回主站钱包。");
toast.success(t("wallet.successOut"));
setAmountText("");
await onSuccess();
} catch (e) {
if (await handleTransferMaybePending(e, onSuccess)) {
setLocalError(formatWalletClientError(e));
if (await handleTransferMaybePending(e, onSuccess, t)) {
setLocalError(formatWalletClientError(e, t));
return;
}
setLocalError(formatWalletClientError(e));
setLocalError(formatWalletClientError(e, t));
} finally {
setSubmitting(false);
}
@@ -250,17 +256,17 @@ export function TransferOutPanel({
variant === "page" ? (
<Button
type="button"
className="w-full"
className="h-11 w-full rounded-lg bg-[#e5002c] text-base font-bold text-white hover:bg-[#d10028]"
disabled={submitting}
onClick={() => void submit()}
>
{submitting ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
{t("actions.processing")}
</>
) : (
"确认转出"
t("wallet.confirmOut")
)}
</Button>
) : (
@@ -271,7 +277,7 @@ export function TransferOutPanel({
disabled={submitting}
onClick={onCancel}
>
{t("actions.cancel")}
</Button>
<Button
type="button"
@@ -281,10 +287,10 @@ export function TransferOutPanel({
{submitting ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
{t("actions.processing")}
</>
) : (
"确认转出"
t("wallet.confirmOut")
)}
</Button>
</div>
@@ -293,9 +299,9 @@ export function TransferOutPanel({
return (
<>
<div className="grid gap-3 py-1">
<div className="rounded-lg bg-muted/50 px-3 py-2 text-xs">
<div className="rounded-xl border border-[#e5edf8] bg-[#f8fbff] px-3 py-2 text-xs">
<p>
:{" "}
{t("wallet.lotteryAvailable")}{" "}
<span className="font-medium text-foreground">
{formatMinorAsCurrency(availableMinor, currency)}
</span>
@@ -303,7 +309,7 @@ export function TransferOutPanel({
</div>
<div className="grid gap-2">
<div className="flex items-end justify-between gap-2">
<Label htmlFor={tid}></Label>
<Label htmlFor={tid}>{t("wallet.outAmount")}</Label>
<Button
type="button"
variant="link"
@@ -311,22 +317,25 @@ export function TransferOutPanel({
onClick={fillAll}
disabled={submitting || availableMinor < 1}
>
{" "}
{formatMinorAsCurrency(availableMinor, currency)}
{t("wallet.allOut", {
amount: formatMinorAsCurrency(availableMinor, currency),
})}
</Button>
</div>
<Input
id={tid}
inputMode="decimal"
placeholder="例如 500.00"
placeholder={t("wallet.exampleOut")}
value={amountText}
onChange={(ev) => setAmountText(ev.target.value)}
disabled={submitting}
autoComplete="off"
className="h-11 rounded-lg border-[#dce7f7] bg-white text-base"
/>
<p className="text-xs text-muted-foreground">
:{" "}
{formatMinorAsCurrency(previewAfter, currency)}
{t("wallet.afterOutPreview", {
amount: formatMinorAsCurrency(previewAfter, currency),
})}
</p>
</div>
{localError ? (
@@ -344,21 +353,21 @@ export function TransferInPage({
lotteryMinor,
onSuccess,
}: PanelBase & { lotteryMinor: number }) {
const { t } = useTranslation("player");
return (
<div className="flex flex-col gap-4">
<Link
href="/wallet"
className="inline-flex w-fit items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
>
<ChevronLeft className="size-4" />
</Link>
<Card>
<PlayerPanel
title={t("wallet.transferInTitle")}
subtitle={t("wallet.transferInSubtitle", { currency })}
eyebrow={t("wallet.title")}
backHref="/wallet"
backLabel={t("wallet.title")}
>
<Card className="rounded-xl border-[#e5edf8] shadow-[0_8px_24px_rgba(15,23,42,0.05)]">
<CardHeader>
<CardTitle></CardTitle>
<CardTitle className="text-[#0b3f96]">{t("wallet.transferInTitle")}</CardTitle>
<CardDescription>
1.00{" "}
{currency}
{t("wallet.transferInDescription")}
</CardDescription>
</CardHeader>
<CardContent>
@@ -372,7 +381,7 @@ export function TransferInPage({
/>
</CardContent>
</Card>
</div>
</PlayerPanel>
);
}
@@ -382,20 +391,21 @@ export function TransferOutPage({
availableMinor,
onSuccess,
}: PanelBase & { availableMinor: number }) {
const { t } = useTranslation("player");
return (
<div className="flex flex-col gap-4">
<Link
href="/wallet"
className="inline-flex w-fit items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
>
<ChevronLeft className="size-4" />
</Link>
<Card>
<PlayerPanel
title={t("wallet.transferOutTitle")}
subtitle={t("wallet.transferOutSubtitle", { currency })}
eyebrow={t("wallet.title")}
backHref="/wallet"
backLabel={t("wallet.title")}
>
<Card className="rounded-xl border-[#e5edf8] shadow-[0_8px_24px_rgba(15,23,42,0.05)]">
<CardHeader>
<CardTitle></CardTitle>
<CardTitle className="text-[#0b3f96]">{t("wallet.transferOutTitle")}</CardTitle>
<CardDescription>
{t("wallet.transferOutDescription")}
</CardDescription>
</CardHeader>
<CardContent>
@@ -409,6 +419,6 @@ export function TransferOutPage({
/>
</CardContent>
</Card>
</div>
</PlayerPanel>
);
}

View File

@@ -3,9 +3,6 @@ import { useCallback, useEffect, useRef } from "react";
import { usePlayerSessionStore } from "@/stores/player-session-store";
import { useErrorStore } from "@/stores/error-store";
/** Token 刷新间隔毫秒5 分钟 - 30 秒缓冲 = 4.5 分钟 */
const TOKEN_REFRESH_INTERVAL = 4.5 * 60 * 1000;
/** Token 过期前警告阈值(毫秒) */
const TOKEN_WARNING_THRESHOLD = 60 * 1000; // 1 分钟

View File

@@ -44,11 +44,11 @@ export type UseWebSocketManagerReturn = {
export function useWebSocketManager(): UseWebSocketManagerReturn {
const store = useNetworkConnectionStore();
const reconnectTimerRef = useRef<number | null>(null);
const attemptReconnectRef = useRef<() => void>(() => {});
const {
mode,
isWebSocketConnected,
isReconnecting,
reconnectAttempts,
setWebSocketConnected,
setReconnecting,
@@ -212,7 +212,7 @@ export function useWebSocketManager(): UseWebSocketManagerReturn {
} else {
// 继续重连
reconnectTimerRef.current = window.setTimeout(
attemptReconnect,
() => attemptReconnectRef.current(),
RECONNECT_INTERVAL_MS,
);
}
@@ -224,6 +224,10 @@ export function useWebSocketManager(): UseWebSocketManagerReturn {
setReconnecting,
]);
useEffect(() => {
attemptReconnectRef.current = attemptReconnect;
}, [attemptReconnect]);
// 手动重连
const reconnect = useCallback(() => {
resetReconnectAttempts();

View File

@@ -1,3 +1,5 @@
"use client";
import i18n from "i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import { initReactI18next } from "react-i18next";
@@ -5,12 +7,15 @@ import { initReactI18next } from "react-i18next";
import enCommon from "./locales/en/common.json";
import enEntry from "./locales/en/entry.json";
import enLayout from "./locales/en/layout.json";
import enPlayer from "./locales/en/player.json";
import neCommon from "./locales/ne/common.json";
import neEntry from "./locales/ne/entry.json";
import neLayout from "./locales/ne/layout.json";
import nePlayer from "./locales/ne/player.json";
import zhCommon from "./locales/zh/common.json";
import zhEntry from "./locales/zh/entry.json";
import zhLayout from "./locales/zh/layout.json";
import zhPlayer from "./locales/zh/player.json";
/** 对齐后端与产品:尼泊尔语 / 英语 / 中文(简体) */
export const SUPPORTED_LANGUAGES = [
@@ -23,23 +28,26 @@ export type AppLanguage = (typeof SUPPORTED_LANGUAGES)[number]["code"];
export const DEFAULT_LANGUAGE: AppLanguage = "en";
const namespaces = ["common", "entry", "layout"] as const;
const namespaces = ["common", "entry", "layout", "player"] as const;
const resources = {
en: {
common: enCommon,
entry: enEntry,
layout: enLayout,
player: enPlayer,
},
ne: {
common: neCommon,
entry: neEntry,
layout: neLayout,
player: nePlayer,
},
zh: {
common: zhCommon,
entry: zhEntry,
layout: zhLayout,
player: zhPlayer,
},
} satisfies Record<
AppLanguage,
@@ -53,6 +61,12 @@ export function normalizeLanguage(lang: string | undefined): AppLanguage {
return "en";
}
export function syncDocumentLanguage(lang: AppLanguage): void {
if (typeof document === "undefined") return;
document.documentElement.lang = lang;
}
if (!i18n.isInitialized) {
void i18n
.use(LanguageDetector)
@@ -80,6 +94,12 @@ if (!i18n.isInitialized) {
useSuspense: false,
},
});
syncDocumentLanguage(normalizeLanguage(i18n.resolvedLanguage ?? i18n.language));
i18n.on("languageChanged", (lang) => {
syncDocumentLanguage(normalizeLanguage(lang));
});
}
export default i18n;

View File

@@ -15,6 +15,9 @@
"pending": "Pending",
"failed": "Failed"
},
"navigation": {
"notifications": "Notifications"
},
"errors": {
"general": "General"
}

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

View File

@@ -15,6 +15,9 @@
"pending": "बाँकी",
"failed": "असफल"
},
"navigation": {
"notifications": "सूचनाहरू"
},
"errors": {
"general": "सामान्य"
}

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

View File

@@ -15,6 +15,9 @@
"pending": "待处理",
"failed": "失败"
},
"navigation": {
"notifications": "通知"
},
"errors": {
"general": "通用"
}

View 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": "位数小"
}
}

View File

@@ -32,3 +32,13 @@ const LABELS: Record<string, string> = {
export function playLabelZh(playCode: string): string {
return LABELS[playCode] ?? playCode;
}
export function playLabel(
playCode: string,
t?: (key: string, options?: { defaultValue?: string }) => string,
): string {
if (!t) {
return playLabelZh(playCode);
}
return t(`playLabels.${playCode}`, { defaultValue: LABELS[playCode] ?? playCode });
}

View File

@@ -3,30 +3,37 @@ import { isAxiosError } from "axios";
import { LotteryApiBizError } from "@/types/api/errors";
/** 钱包 / 转账 API 对用户展示的中文说明(优先业务码,其次 HTTP */
export function formatWalletClientError(error: unknown): string {
export function formatWalletClientError(
error: unknown,
t?: (key: string) => string,
): string {
if (error instanceof LotteryApiBizError) {
const m = WALLET_CODE_MESSAGES[error.code];
const m = walletCodeMessage(error.code, t);
if (m) return m;
if (error.message.trim()) return error.message;
}
if (isAxiosError(error)) {
if (error.response?.status === 401) {
return "登录已失效,请返回重新进入。";
return msg(t, "wallet.error.401", "登录已失效,请返回重新进入。");
}
if (error.response?.status === 409) {
return "转账处理中,请稍后刷新;若长时间未到账请联系客服。";
return msg(
t,
"wallet.error.409",
"转账处理中,请稍后刷新;若长时间未到账请联系客服。",
);
}
if (!error.response) {
return "网络异常,请检查连接后重试。";
return msg(t, "wallet.error.network", "网络异常,请检查连接后重试。");
}
if (error.code === "ECONNABORTED") {
return "请求超时,请稍后重试。";
return msg(t, "wallet.error.timeout", "请求超时,请稍后重试。");
}
}
if (error instanceof Error && error.message.trim()) {
return error.message;
}
return "请求失败,请稍后重试。";
return msg(t, "wallet.error.fallback", "请求失败,请稍后重试。");
}
const WALLET_CODE_MESSAGES: Record<number, string> = {
@@ -41,3 +48,20 @@ const WALLET_CODE_MESSAGES: Record<number, string> = {
1009: "主站未能完成本次划转,请稍后重试。",
1010: "请勿用同一幂等键发起不同金额的转账。",
};
function msg(
t: ((key: string) => string) | undefined,
key: string,
fallback: string,
): string {
return t?.(key) ?? fallback;
}
function walletCodeMessage(
code: number,
t: ((key: string) => string) | undefined,
): string | undefined {
const fallback = WALLET_CODE_MESSAGES[code];
if (!fallback) return undefined;
return msg(t, `wallet.error.${code}`, fallback);
}