From f2c7f5e4f16b3dfeefddc8fddab48760d2b29050 Mon Sep 17 00:00:00 2001 From: kang Date: Fri, 15 May 2026 10:41:14 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E5=AE=8C=E6=88=90=E5=85=A8?= =?UTF-8?q?=E7=AB=99=E5=9B=BD=E9=99=85=E5=8C=96=E6=94=B9=E9=80=A0=EF=BC=8C?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E5=A4=9A=E8=AF=AD=E8=A8=80=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 此提交完成了全项目的国际化适配: 1. 新增多语言翻译文件与基础配置 2. 替换所有硬编码文本为i18n调用 3. 优化语言切换与文档语言同步逻辑 4. 重构部分业务逻辑以支持动态翻译 5. 移除过时代码与硬编码配置 --- .../(player)/(main)/results/[drawNo]/page.tsx | 4 - src/app/(player)/(main)/results/page.tsx | 7 +- src/app/(player)/page.tsx | 2 +- src/app/error.tsx | 26 +- src/app/layout.tsx | 3 +- src/app/not-found.tsx | 16 +- src/components/iframe-bridge.tsx | 3 +- src/components/language-switcher.tsx | 2 +- src/components/layout/player-app-shell.tsx | 4 +- src/components/layout/player-bottom-nav.tsx | 15 +- src/components/layout/player-panel.tsx | 15 +- src/components/network-status-banner.tsx | 16 +- src/components/offline-banner.tsx | 8 +- src/components/server-error.tsx | 17 +- src/components/token-refresh-indicator.tsx | 16 +- src/features/draw/draw-status-meta.ts | 24 +- src/features/hall/hall-bet-amount-input.tsx | 11 +- src/features/hall/hall-bet-errors.ts | 38 +- src/features/hall/hall-bet-number-input.tsx | 9 +- src/features/hall/hall-bet-preview-dialog.tsx | 59 ++- src/features/hall/hall-bet-rules.ts | 20 +- src/features/hall/hall-betting-grid.tsx | 89 ++-- src/features/hall/hall-draw-panel.tsx | 28 +- src/features/hall/hall-play-catalog-panel.tsx | 59 ++- src/features/hall/hall-play-switcher.tsx | 8 +- src/features/hall/hall-screen.tsx | 11 +- src/features/hall/hall-wallet-strip.tsx | 10 +- src/features/hall/use-hall-draw-live.ts | 22 +- src/features/orders/ticket-item-status.tsx | 12 +- .../orders/ticket-order-detail-screen.tsx | 245 ++++++----- .../orders/ticket-orders-list-screen.tsx | 37 +- src/features/player/entry-gate.tsx | 17 +- src/features/player/player-session-bar.tsx | 4 +- .../results/draw-result-detail-screen.tsx | 178 ++++---- .../results/draw-results-list-screen.tsx | 20 +- .../results/jackpot-results-strip.tsx | 4 +- .../results/twenty-three-results-grid.tsx | 17 +- src/features/wallet/wallet-logs-block.tsx | 111 ++--- src/features/wallet/wallet-logs-screen.tsx | 80 ++-- src/features/wallet/wallet-screen.tsx | 230 +++++----- .../wallet/wallet-transfer-dialogs.tsx | 22 +- src/features/wallet/wallet-transfer-forms.tsx | 146 ++++--- src/hooks/use-token-refresh.ts | 3 - src/hooks/use-websocket-manager.ts | 8 +- src/i18n/index.ts | 22 +- src/i18n/locales/en/common.json | 3 + src/i18n/locales/en/player.json | 397 ++++++++++++++++++ src/i18n/locales/ne/common.json | 3 + src/i18n/locales/ne/player.json | 397 ++++++++++++++++++ src/i18n/locales/zh/common.json | 3 + src/i18n/locales/zh/player.json | 397 ++++++++++++++++++ src/lib/play-labels.ts | 10 + src/lib/wallet-api-error.ts | 38 +- 53 files changed, 2179 insertions(+), 767 deletions(-) create mode 100644 src/i18n/locales/en/player.json create mode 100644 src/i18n/locales/ne/player.json create mode 100644 src/i18n/locales/zh/player.json diff --git a/src/app/(player)/(main)/results/[drawNo]/page.tsx b/src/app/(player)/(main)/results/[drawNo]/page.tsx index 1f68e46..db354ca 100644 --- a/src/app/(player)/(main)/results/[drawNo]/page.tsx +++ b/src/app/(player)/(main)/results/[drawNo]/page.tsx @@ -10,10 +10,6 @@ export default async function DrawResultByNoPage(props: PageProps) { return (
-
-

开奖结果

-

当期明细 · 23 组分区

-
); diff --git a/src/app/(player)/(main)/results/page.tsx b/src/app/(player)/(main)/results/page.tsx index b0310a6..2abd1d7 100644 --- a/src/app/(player)/(main)/results/page.tsx +++ b/src/app/(player)/(main)/results/page.tsx @@ -3,13 +3,8 @@ import { DrawResultsListScreen } from "@/features/results/draw-results-list-scre export default function DrawResultsHistoryPage() { return (
-
-

开奖结果

-

- 往期列表与时间以服务器 GMT 为准(界面文档 §4.6) -

-
); } + diff --git a/src/app/(player)/page.tsx b/src/app/(player)/page.tsx index bb34605..eded347 100644 --- a/src/app/(player)/page.tsx +++ b/src/app/(player)/page.tsx @@ -6,7 +6,7 @@ import { EntryGate } from "@/features/player/entry-gate"; function EntryFallback(): ReactNode { return (
-

加载入口页…

+

Loading...

); } diff --git a/src/app/error.tsx b/src/app/error.tsx index 0770111..f03c055 100644 --- a/src/app/error.tsx +++ b/src/app/error.tsx @@ -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({ {/* 错误标题 */}

- {isServerError ? "服务器暂时不可用" : "应用发生错误"} + {isServerError ? t("serverError.unavailable") : t("serverError.appTitle")}

{/* 错误消息 */}

{isServerError - ? "服务器暂时不可用,请稍后重试" - : "抱歉,应用遇到了问题,请尝试刷新页面"} + ? t("serverError.serverMessage") + : t("serverError.appMessage")}

{/* 技术详情(开发模式) */} {process.env.NODE_ENV === "development" && (
-

错误详情:

+

+ {t("serverError.details")} +

{error.message} {error.digest && (

- 错误 ID: {error.digest} + {t("serverError.errorId", { id: error.digest })}

)}
@@ -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 }} > - 重试 + {t("actions.retry")} - 返回首页 + {t("serverError.home")} @@ -106,21 +112,21 @@ export default function ErrorBoundary({ onClick={() => window.location.reload()} className="hover:text-foreground hover:underline" > - 刷新页面 + {t("serverError.refreshPage")} | - 投注大厅 + {t("serverError.hall")} | - 我的钱包 + {t("serverError.wallet")} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 1186a86..13a1763 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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 ( diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx index 19e0236..94bd6f8 100644 --- a/src/app/not-found.tsx +++ b/src/app/not-found.tsx @@ -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 (
- 页面不存在 + {t("notFound.title")} {/* 描述 */}

- 抱歉,您访问的页面可能已被删除、移动或从未存在过。 + {t("notFound.description")}

{/* 返回首页按钮 */} @@ -57,21 +61,21 @@ export default function NotFoundPage(): React.ReactElement { style={{ backgroundColor: "#333" }} > - 返回首页 + {t("notFound.home")} {/* 帮助链接 */}
- 投注大厅 + {t("notFound.hall")} | - 开奖结果 + {t("notFound.results")} | - 我的钱包 + {t("notFound.wallet")}
diff --git a/src/components/iframe-bridge.tsx b/src/components/iframe-bridge.tsx index ae0c979..ce72426 100644 --- a/src/components/iframe-bridge.tsx +++ b/src/components/iframe-bridge.tsx @@ -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).lotteryIframeBridge = { notifyReady, notifyTokenNeeded, diff --git a/src/components/language-switcher.tsx b/src/components/language-switcher.tsx index 601918d..7feeb38 100644 --- a/src/components/language-switcher.tsx +++ b/src/components/language-switcher.tsx @@ -35,7 +35,7 @@ export function LanguageSwitcher({ label: t(`language.${item.code}`), short: t(`languageShort.${item.code}`), })), - [t, i18n.language], + [t], ); useEffect(() => { diff --git a/src/components/layout/player-app-shell.tsx b/src/components/layout/player-app-shell.tsx index 0bfaf96..0315d63 100644 --- a/src/components/layout/player-app-shell.tsx +++ b/src/components/layout/player-app-shell.tsx @@ -18,9 +18,9 @@ type PlayerAppShellProps = { */ export function PlayerAppShell({ children }: PlayerAppShellProps): ReactNode { return ( -
+
-
+
{children}
diff --git a/src/components/layout/player-bottom-nav.tsx b/src/components/layout/player-bottom-nav.tsx index 3bf5419..922b0e2 100644 --- a/src/components/layout/player-bottom-nav.tsx +++ b/src/components/layout/player-bottom-nav.tsx @@ -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 (
diff --git a/src/components/token-refresh-indicator.tsx b/src/components/token-refresh-indicator.tsx index 585315a..60672cd 100644 --- a/src/components/token-refresh-indicator.tsx +++ b/src/components/token-refresh-indicator.tsx @@ -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(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")} {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")} @@ -112,7 +116,7 @@ export function TokenRefreshIndicator(): React.ReactElement | null { - 立即续签 + {t("token.renewNow")} ); diff --git a/src/features/draw/draw-status-meta.ts b/src/features/draw/draw-status-meta.ts index bd35a80..16f9fac 100644 --- a/src/features/draw/draw-status-meta.ts +++ b/src/features/draw/draw-status-meta.ts @@ -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" }; } } diff --git a/src/features/hall/hall-bet-amount-input.tsx b/src/features/hall/hall-bet-amount-input.tsx index 5b015fa..ea20782 100644 --- a/src/features/hall/hall-bet-amount-input.tsx +++ b/src/features/hall/hall-bet-amount-input.tsx @@ -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 (

- 限额 {formatMinorAsCurrency(minBetMinor, currencyCode)} —{" "} - {formatMinorAsCurrency(maxBetMinor, currencyCode)} + {t("hall.amountInput.limit", { min, max })}

onChange(e.target.value)} className={cn("tabular-nums")} - placeholder="例如 100.00" + placeholder={t("hall.amountInput.placeholder")} /> {hint ?

{hint}

: null}
diff --git a/src/features/hall/hall-bet-errors.ts b/src/features/hall/hall-bet-errors.ts index 69501b2..5b65af7 100644 --- a/src/features/hall/hall-bet-errors.ts +++ b/src/features/hall/hall-bet-errors.ts @@ -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", "下注失败,请稍后重试。"); } } diff --git a/src/features/hall/hall-bet-number-input.tsx b/src/features/hall/hall-bet-number-input.tsx index 7bf0612..16db308 100644 --- a/src/features/hall/hall-bet-number-input.tsx +++ b/src/features/hall/hall-bet-number-input.tsx @@ -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 ?

{helper}

: null} diff --git a/src/features/hall/hall-bet-preview-dialog.tsx b/src/features/hall/hall-bet-preview-dialog.tsx index 4f5c36f..c03bf73 100644 --- a/src/features/hall/hall-bet-preview-dialog.tsx +++ b/src/features/hall/hall-bet-preview-dialog.tsx @@ -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 ( - 赔付池预警 + {t("hall.preview.warningsTitle")}

- 产品文档 §6.4:以下号码本期赔付池占用较高,仍允许下注;若实际占用不足将售罄拒单。 + {t("hall.preview.warningsDescription")}

    {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({
    - 确认下注 + {t("hall.preview.title")} - 请核对号码、玩法与实扣金额;确认后将扣减彩票钱包且不可撤单(产品文档 §6.3)。 + {t("hall.preview.description")} {!allowSubmit ? ( - 已封盘 + {t("hall.preview.sealedTitle")} - 当前期已停止接收注单,无法提交。请选择下一期(界面文档 §4.2)。 + {t("hall.preview.sealedDescription")} ) : null} @@ -88,37 +92,38 @@ export function HallBetPreviewDialog({
    {!data ? ( -

    暂无预览数据

    +

    {t("hall.preview.empty")}

    ) : ( <>

    - 期号{" "} - {data.draw.draw_id} · 状态{" "} + {t("hall.preview.draw")}{" "} + {data.draw.draw_id} ·{" "} + {t("hall.preview.status")}{" "} {data.draw.status}

    {summary ? (
    • - 总下注{" "} + {t("hall.preview.totalBet")}{" "} {formatMinorAsCurrency(summary.total_bet_amount, currencyCode)}
    • - 回水抵扣{" "} + {t("hall.preview.rebateDeduct")}{" "} {formatMinorAsCurrency(summary.total_rebate_amount, currencyCode)}
    • - 实扣金额{" "} + {t("hall.preview.actualDeduct")}{" "} {formatMinorAsCurrency(summary.total_actual_deduct, currencyCode)}
    • - 预估最高赔付{" "} + {t("hall.preview.estimatedPayout")}{" "} {formatMinorAsCurrency(summary.total_estimated_payout, currencyCode)} @@ -130,7 +135,9 @@ export function HallBetPreviewDialog({
      -

      注项明细

      +

      + {t("hall.preview.lines")} +

        {lines.map((ln) => (
      • {ln.number}

        - 归一号码 + + {t("hall.preview.normalizedNumber")} + {ln.normalized_number} - 组合数 + + {t("hall.preview.combinationCount")} + {ln.combination_count} - 实扣 + + {t("hall.preview.actual")} + {formatMinorAsCurrency(ln.actual_deduct_amount, currencyCode)} - 预估最高赔 + + {t("hall.preview.estimatedMax")} + {formatMinorAsCurrency(ln.estimated_max_payout, currencyCode)} @@ -170,10 +185,14 @@ export function HallBetPreviewDialog({
        diff --git a/src/features/hall/hall-bet-rules.ts b/src/features/hall/hall-bet-rules.ts index fdda511..9b0f602 100644 --- a/src/features/hall/hall-bet-rules.ts +++ b/src/features/hall/hall-bet-rules.ts @@ -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") ?? + "金额为该笔注单的下注额(最小货币单位整数,与钱包一致)。" + ); } diff --git a/src/features/hall/hall-betting-grid.tsx b/src/features/hall/hall-betting-grid.tsx index 9043302..5f626e2 100644 --- a/src/features/hall/hall-betting-grid.tsx +++ b/src/features/hall/hall-betting-grid.tsx @@ -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("D2"); const [boxMode, setBoxMode] = useState("ibox"); const [rows, setRows] = useState(() => [ @@ -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 ( -
        +
        @@ -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")}
        ); @@ -485,7 +490,7 @@ export function HallBettingGrid() { return ( <> -
        +
        {categoryTabs.map((tab) => { const active = activeCategory === tab.value; @@ -539,9 +544,11 @@ export function HallBettingGrid() { - iBox + + {t("hall.boxMode.iboxTitle")} + - Divide all by amount + {t("hall.boxMode.iboxDesc")} @@ -561,9 +568,11 @@ export function HallBettingGrid() { - Box + + {t("hall.boxMode.boxTitle")} + - Multiply all by amount + {t("hall.boxMode.boxDesc")} @@ -575,10 +584,12 @@ export function HallBettingGrid() {
        -

        Closed

        -

        This issue is now closed.

        +

        + {t("hall.closed.title")} +

        +

        {t("hall.closed.subtitle")}

        - The betting window has closed. Please wait for the next issue to place your bets. + {t("hall.closed.description")}
        ) : ( @@ -593,10 +604,10 @@ export function HallBettingGrid() { - No. + {t("hall.table.no")} - Number + {t("hall.table.number")} ({numberPlaceholder}) @@ -609,12 +620,18 @@ export function HallBettingGrid() { )) ) : ( <> - Stake Amount - Commission / Rebate - Actual Deduction + + {t("hall.table.stake")} + + + {t("hall.table.rebate")} + + + {t("hall.table.actual")} + )} - + @@ -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 })} > @@ -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" > - Add Row + {t("hall.table.addRow")}
        )}
        - Draft Total + {t("hall.table.draftTotal")} {formatMinorAsCurrency(draftSummary.actual, currencyCode)} @@ -727,7 +744,7 @@ export function HallBettingGrid() { {sealedBetUi ? (

        - 已封盘:当前表格不可编辑,请等待下一期。 + {t("hall.table.sealedHint")}

        ) : 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]" > - {previewLoading ? "Previewing..." : activeCategory === "JACKPOT" ? "Closed" : "Submit Bet"} + {previewLoading + ? t("hall.table.previewing") + : activeCategory === "JACKPOT" + ? t("hall.closed.title") + : t("hall.table.submitBet")} diff --git a/src/features/hall/hall-draw-panel.tsx b/src/features/hall/hall-draw-panel.tsx index c28946b..489664c 100644 --- a/src/features/hall/hall-draw-panel.tsx +++ b/src/features/hall/hall-draw-panel.tsx @@ -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 ( <> --:--:-- - Current Time + {t("draw.currentTime")} ); } @@ -42,18 +44,19 @@ function CloseTime({ hud: ReturnType; 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 (
        -

        {error}

        +

        {t(error, { defaultValue: error })}

        ); @@ -101,7 +105,7 @@ export function HallDrawPanel() { if (raw === null || display === null) { return (
        - 暂无可用期号,请稍后再试 + {t("draw.noIssue")}
        ); } @@ -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")} >
        @@ -123,7 +127,7 @@ export function HallDrawPanel() {
        -

        Issue No.

        +

        {t("draw.issueNo")}

        {display.draw_no}

        @@ -146,17 +150,17 @@ export function HallDrawPanel() { {sealedUi ? (
        - 已封盘,请等待下一期。 + {t("draw.sealedNotice")}
        ) : (
        - {hud.label} + {t(hud.labelKey, { defaultValue: hud.labelKey })} - Betting Hall + {t("draw.hall")}
        )} diff --git a/src/features/hall/hall-play-catalog-panel.tsx b/src/features/hall/hall-play-catalog-panel.tsx index a2a4edd..2d21ee7 100644 --- a/src/features/hall/hall-play-catalog-panel.tsx +++ b/src/features/hall/hall-play-catalog-panel.tsx @@ -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({ 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 ( -

        加载玩法与赔率…

        +

        {t("hall.playCatalog.loading")}

        ); } if (state.kind === "error") { @@ -131,7 +132,7 @@ export function HallPlayCatalogPanel() {

        {state.message}

        ); @@ -145,19 +146,28 @@ export function HallPlayCatalogPanel() { return (

        - 币种 {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, + })}

        - 玩法 - 状态 - 下注限额 - 赔率× - 说明 + {t("hall.playCatalog.play")} + + {t("hall.playCatalog.status")} + + + {t("hall.playCatalog.limit")} + + {t("hall.playCatalog.odds")} + + {t("hall.playCatalog.description")} + @@ -188,11 +198,11 @@ export function HallPlayCatalogPanel() { {open ? ( - 开放 + {t("hall.playCatalog.open")} ) : ( - 关闭 + {t("hall.playCatalog.closed")} )} @@ -222,14 +232,16 @@ export function HallPlayCatalogPanel() { {data.risk_cap_items.length > 0 ? (
        -

        风控封顶(示例号码)

        +

        + {t("hall.playCatalog.riskTitle")} +

        - 号码 - 封顶额 - 类型 + {t("hall.playCatalog.number")} + {t("hall.playCatalog.capAmount")} + {t("hall.playCatalog.type")} @@ -257,10 +269,11 @@ export function HallPlayCatalogPanel() {
        - 玩法与赔率 + {t("hall.playCatalog.title")} - 数据来自 GET /api/v1/play/effective - ;后台修改并发布后,最长约 {DEFAULT_POLL_MS / 1000}s 内自动刷新(亦可手动刷新)。 + {t("hall.playCatalog.descriptionText", { + seconds: DEFAULT_POLL_MS / 1000, + })}
        {body} diff --git a/src/features/hall/hall-play-switcher.tsx b/src/features/hall/hall-play-switcher.tsx index 2dbd01c..240bbfc 100644 --- a/src/features/hall/hall-play-switcher.tsx +++ b/src/features/hall/hall-play-switcher.tsx @@ -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 ( -

        当前币种下没有可下注的开放玩法。

        +

        {t("hall.playSwitcher.empty")}

        ); } return (
        -

        玩法

        +

        {t("hall.playSwitcher.label")}

        {plays.map((p) => { const active = p.play_code === value; diff --git a/src/features/hall/hall-screen.tsx b/src/features/hall/hall-screen.tsx index 7d7ba05..6ec7925 100644 --- a/src/features/hall/hall-screen.tsx +++ b/src/features/hall/hall-screen.tsx @@ -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 (
        -
        -
        +
        +
        @@ -35,14 +38,14 @@ export function HallScreen() {
        - + diff --git a/src/features/hall/hall-wallet-strip.tsx b/src/features/hall/hall-wallet-strip.tsx index ad89c7d..2db73e2 100644 --- a/src/features/hall/hall-wallet-strip.tsx +++ b/src/features/hall/hall-wallet-strip.tsx @@ -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(null); const [loading, setLoading] = useState(true); const degradedWalletPollRef = useRef(null); @@ -83,7 +85,7 @@ export function HallWalletStrip() { const availableMinor = Number(balance?.available_balance ?? 0); return ( -
        +
        -

        Wallet Balance

        +

        {t("wallet.balance")}

        {loading ? ( ) : ( @@ -111,7 +113,7 @@ export function HallWalletStrip() { { - 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); + } + }; } } diff --git a/src/features/orders/ticket-item-status.tsx b/src/features/orders/ticket-item-status.tsx index 8c82708..52a3c94 100644 --- a/src/features/orders/ticket-item-status.tsx +++ b/src/features/orders/ticket-item-status.tsx @@ -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({ diff --git a/src/features/orders/ticket-order-detail-screen.tsx b/src/features/orders/ticket-order-detail-screen.tsx index a9134c8..06493f1 100644 --- a/src/features/orders/ticket-order-detail-screen.tsx +++ b/src/features/orders/ticket-order-detail-screen.tsx @@ -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 = { - first: "头奖", - second: "二奖", - third: "三奖", - starter: "特别奖", - consolation: "安慰奖", -}; - /** 界面文档 §4.8 注单详情 */ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) { + const { t } = useTranslation("player"); const [data, setData] = useState(null); const [error, setError] = useState(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 ( - - - - - - - - - + +
        + + +
        +
        ); } if (error || !data) { return ( - - - 注单详情 - {error ?? "无数据"} - - + +
        +

        {error ?? t("orders.noData")}

        - - 返回列表 - - - +
        +
        ); } 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 ( -
        - - -
        - 注单详情 - -
        - - 注单号 {data.ticket_no} · 订单 {data.order_no ?? "—"} - -
        - -
        -

        - 期号{" "} - {data.draw_no ?? "—"} -

        -

        - 下单时间{" "} - {formatLotteryInstant(data.placed_at ?? null)} -

        -

        - 号码{" "} - {data.original_number ?? "—"} -

        -

        - 玩法 {playLabelZh(data.play_code)} ( - {data.dimension ?? "—"}D) -

        -

        - 下注金额{" "} - {formatMinorAsCurrency(data.total_bet_amount, cur)} -

        -

        - 回水率{" "} - {(Number(data.rebate_rate_snapshot) * 100).toFixed(1)}% -

        -

        - 实扣金额{" "} - {formatMinorAsCurrency(data.actual_deduct_amount, cur)} -

        -
        + +
        + + +
        + {t("orders.detailTitle")} + +
        + + {t("orders.ticketNo", { ticketNo: data.ticket_no })} ·{" "} + {t("orders.orderNo", { orderNo: data.order_no ?? "—" })} + +
        + +
        +

        + {t("orders.drawNo")}{" "} + {data.draw_no ?? "—"} +

        +

        + {t("orders.placedAt")}{" "} + {formatLotteryInstant(data.placed_at ?? null)} +

        +

        + {t("orders.number")}{" "} + {data.original_number ?? "—"} +

        +

        + {t("orders.play")}{" "} + {playLabel(data.play_code, t)} ( + {data.dimension ?? "—"}D) +

        +

        + {t("orders.amount")}{" "} + {formatMinorAsCurrency(data.total_bet_amount, cur)} +

        +

        + {t("orders.rebateRate")}{" "} + {(Number(data.rebate_rate_snapshot) * 100).toFixed(1)}% +

        +

        + {t("orders.actualDeduct")}{" "} + {formatMinorAsCurrency(data.actual_deduct_amount, cur)} +

        +
        -

        赔率快照

        -

        {formatOddsSnapshot(data.odds_snapshot_json)}

        +

        {t("orders.oddsSnapshot")}

        +

        + {formatOddsSnapshot(data.odds_snapshot_json, t)} +

        {pub?.results ? (
        -

        开奖号码(本期)

        +

        {t("orders.drawNumbers")}

        {first ? (

        - 头奖{" "} + {t("orders.firstPrize")}{" "} {first} {comboHits.length > 0 ? ( - ← 命中 + + {" "} + ← {t("orders.hit")} + ) : null}

        ) : null}
        ) : ( -

        本期开奖号码尚未发布或不可展示。

        +

        {t("orders.notPublished")}

        )} {data.settlement && tierLabel ? (

        - 匹配结果:命中 {tierLabel} + {t("orders.matchWin", { tier: tierLabel })}

        - 中奖金额 {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}

        - 派彩合计 {formatMinorAsCurrency(totalWin, cur)} + {t("orders.payoutTotal", { amount: formatMinorAsCurrency(totalWin, cur) })}

        ) : data.status === "settled_lose" ? ( -

        匹配结果:未中奖

        +

        {t("orders.matchLose")}

        ) : null} {data.settled_at ? (

        - 结算时间 {formatLotteryInstant(data.settled_at)} + {t("orders.settledAt", { time: formatLotteryInstant(data.settled_at) })}

        ) : null} -
        -
        + + -
        - {data.draw_no ? ( - - 查看本期开奖 +
        + {data.draw_no ? ( + + {t("orders.viewDraw")} + + ) : null} + + {t("orders.backToOrders")} - ) : null} - - 返回我的注单 - +
        -
        +
        ); } diff --git a/src/features/orders/ticket-orders-list-screen.tsx b/src/features/orders/ticket-orders-list-screen.tsx index 6bc7c42..4e1ef36 100644 --- a/src/features/orders/ticket-orders-list-screen.tsx +++ b/src/features/orders/ticket-orders-list-screen.tsx @@ -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 ( - +

        - {drawNoFilter ? "Filtered Issue" : "Total Records"} + {drawNoFilter ? t("orders.filteredIssue") : t("orders.totalRecords")}

        {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")} ) : ( - Bet Now + {t("orders.betNow")} )}

        @@ -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")}
        ) : items.length === 0 ? (
        -

        No bet records yet.

        +

        {t("orders.empty")}

        - Submit Bet + {t("orders.submitBet")}
        ) : ( @@ -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 ?? "—"}

        - {playLabelZh(row.play_code)} · {row.original_number ?? row.play_code} + {playLabel(row.play_code, t)} · {row.original_number ?? row.play_code}

        -

        Stake

        +

        + {t("orders.stake")} +

        {formatMinorAsCurrency(row.total_bet_amount, cur)}

        -

        Deduction

        +

        + {t("orders.deduction")} +

        {formatMinorAsCurrency(row.actual_deduct_amount, cur)}

        @@ -169,7 +176,7 @@ export function TicketOrdersListScreen() {
        {totalWin > 0 && row.status === "settled_win" ? (

        - Win {formatMinorAsCurrency(totalWin, cur)} + {t("orders.win", { amount: formatMinorAsCurrency(totalWin, cur) })}

        ) : null}

        @@ -187,7 +194,7 @@ export function TicketOrdersListScreen() { disabled={loadingMore} onClick={() => loadMore()} > - {loadingMore ? "Loading..." : "Load More"} + {loadingMore ? t("actions.loading") : t("actions.loadMore")} ) : null} diff --git a/src/features/player/entry-gate.tsx b/src/features/player/entry-gate.tsx index d23a806..28fb154 100644 --- a/src/features/player/entry-gate.tsx +++ b/src/features/player/entry-gate.tsx @@ -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("loading"); - const [progress, setProgress] = useState(0); const [failureDetails, setFailureDetails] = useState([]); const [steps, setSteps] = useState(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"); diff --git a/src/features/player/player-session-bar.tsx b/src/features/player/player-session-bar.tsx index 14b2e0d..38e52df 100644 --- a/src/features/player/player-session-bar.tsx +++ b/src/features/player/player-session-bar.tsx @@ -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 (

        ] 切换 + 本人命中高亮 + Jackpot */ export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps) { + const { t } = useTranslation("player"); const [data, setData] = useState(null); const [error, setError] = useState(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 ( - - - - - - - - - + +
        + + +
        +
        ); } if (error || !data) { return ( - - - 开奖结果 - {error ?? "无数据"} - - + +
        +

        {error ?? t("results.noData")}

        - - 返回列表 - - - +
        +
        ); } @@ -143,60 +149,81 @@ export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps) myTotals.jackpot === 0; return ( -
        - + +
        + - - -
        - {data.previous_draw_no ? ( - - ‹ 上一期 - - ) : ( - - )} - - {data.draw_no} - - {data.next_draw_no ? ( - - 下一期 › - - ) : ( - - )} -
        - - 开奖时间:{" "} - {formatLotteryInstant(data.draw_time_iso ?? data.draw_time ?? null)} - -
        - - + + +
        + {data.previous_draw_no ? ( + + {t("results.previous")} + + ) : ( + + )} + + {data.draw_no} + + {data.next_draw_no ? ( + + {t("results.next")} + + ) : ( + + )} +
        + + {t("results.drawTime", { + time: formatLotteryInstant(data.draw_time_iso ?? data.draw_time ?? null), + })} + +
        + + {showMyPayout && myTotals ? (
        -

        本期我的派彩

        +

        + {t("results.myPayout")} +

        - 常规:{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}

        @@ -204,13 +231,13 @@ export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps) ) : null} {showHitOnly ? (

        - 您的注单已命中本期开奖号码中的格子;派彩完成后将显示金额汇总。 + {t("results.hitPending")}

        ) : null}

        - 如果您中奖,与注单匹配的号码将以金色高亮显示(需登录)。 + {t("results.hitHint")}

        - 查看我的中奖情况 + {t("results.viewMyWinning")}
        - - -
        +
        +
        +
        +
        ); } diff --git a/src/features/results/draw-results-list-screen.tsx b/src/features/results/draw-results-list-screen.tsx index 2222e4c..cc39e86 100644 --- a/src/features/results/draw-results-list-screen.tsx +++ b/src/features/results/draw-results-list-screen.tsx @@ -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(null); const [error, setError] = useState(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 ( - +
        -

        Business Date

        +

        + {t("results.businessDate")} +

        void fetchList()} > - Apply + {t("actions.apply")}
        @@ -82,12 +86,12 @@ export function DrawResultsListScreen() { className="mt-3 bg-[#e5002c] text-white hover:bg-[#d10028]" onClick={() => void fetchList()} > - Retry + {t("actions.retry")}
        ) : items && items.length === 0 ? (
        - No results yet. + {t("results.empty")}
        ) : (
        @@ -107,7 +111,7 @@ export function DrawResultsListScreen() {

        - Detail + {t("results.detail")}
        diff --git a/src/features/results/jackpot-results-strip.tsx b/src/features/results/jackpot-results-strip.tsx index 650ea1a..dc16316 100644 --- a/src/features/results/jackpot-results-strip.tsx +++ b/src/features/results/jackpot-results-strip.tsx @@ -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(null); const [enabled, setEnabled] = useState(false); @@ -44,7 +46,7 @@ export function JackpotResultsStrip({ return (

        - Jackpot + {t("results.jackpotLabel")}

        {formatMinorAsCurrency(minor, currencyCode.toUpperCase())} diff --git a/src/features/results/twenty-three-results-grid.tsx b/src/features/results/twenty-three-results-grid.tsx index 5bc327a..2bda543 100644 --- a/src/features/results/twenty-three-results-grid.tsx +++ b/src/features/results/twenty-three-results-grid.tsx @@ -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) => (

        - {key === "1st" ? "头奖" : key === "2nd" ? "二奖" : "三奖"} + {key === "1st" + ? t("results.grid.first") + : key === "2nd" + ? t("results.grid.second") + : t("results.grid.third")}
        {numbers[key] || "—"} @@ -54,7 +61,9 @@ export function TwentyThreeResultsGrid({
        -

        特别奖 (Starter)

        +

        + {t("results.grid.starter")} (Starter) +

        {Array.from({ length: 10 }).map((_, i) => (
        @@ -65,7 +74,9 @@ export function TwentyThreeResultsGrid({
        -

        安慰奖 (Consolation)

        +

        + {t("results.grid.consolation")} (Consolation) +

        {Array.from({ length: 10 }).map((_, i) => (
        diff --git a/src/features/wallet/wallet-logs-block.tsx b/src/features/wallet/wallet-logs-block.tsx index 417b892..468ab83 100644 --- a/src/features/wallet/wallet-logs-block.tsx +++ b/src/features/wallet/wallet-logs-block.tsx @@ -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 = { - 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 ? ( - - - 待对账 - - 以下划转主站结果未最终确认;若长时间未到账请联系客服(界面文档 §4.10 - 超时说明)。 - - - +
        +

        {t("wallet.pendingTitle")}

        +

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

        +
        {logs.pending_reconcile.map((p) => (
        - {p.type === "transfer_in" ? "转入" : "转出"}{" "} + {logTypeLabel(p.type, t)}{" "} {formatMinorAsCurrency(p.amount, p.currency_code)} - 处理中 + {t("wallet.pendingStatus")}
        ))} - - +
        +
        ) : null}
        -

        {title}

        +

        {resolvedTitle}

        - {WALLET_FLOW_FILTERS.map((f) => ( + {filters.map((f) => ( - - - ) : null} +
        + ) : null} - -
        + +
        + ); } diff --git a/src/features/wallet/wallet-screen.tsx b/src/features/wallet/wallet-screen.tsx index 2eb872d..b75c6c3 100644 --- a/src/features/wallet/wallet-screen.tsx +++ b/src/features/wallet/wallet-screen.tsx @@ -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(null); const [logs, setLogs] = useState(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 ( -
        -
        -
        -

        彩票钱包

        -
        - +
        + {error ? ( +
        +

        {error}

        +
        -
        - - 返回大厅 - -
        - - {error ? ( - - - 加载失败 - {error} - - - - - - ) : null} - - - - - - 余额 - - - - {loading ? ( - - ) : ( - <> -
        -

        彩票钱包余额

        -

        - {formatMinorAsCurrency( - balance?.balance ?? 0, - currency, - )} -

        -

        - 可用{" "} - {formatMinorAsCurrency( - balance?.available_balance ?? 0, - currency, - )} -

        -
        -
        - 主站钱包余额{" "} - - {balance?.main_balance == null - ? "—(待接入主站)" - : formatMinorAsCurrency(balance.main_balance, currency)} - -
        - - )} - -
        - -
        -
        -
        + ) : null} - -
        +
        +
        +
        + +
        +
        +

        {t("wallet.balance")}

        + {loading ? ( + + ) : ( +

        + {formatMinorAsCurrency(balance?.balance ?? 0, currency)} +

        + )} +

        + {t("wallet.available", { + amount: formatMinorAsCurrency(balance?.available_balance ?? 0, currency), + })} +

        +
        +
        +
        + +
        + + +
        + +
        + + {t("wallet.inPage")} + + + {t("wallet.outPage")} + + + {t("wallet.logs")} + +
        + + +
        + ); } diff --git a/src/features/wallet/wallet-transfer-dialogs.tsx b/src/features/wallet/wallet-transfer-dialogs.tsx index bbb9634..38b17e3 100644 --- a/src/features/wallet/wallet-transfer-dialogs.tsx +++ b/src/features/wallet/wallet-transfer-dialogs.tsx @@ -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)} > - {triggerLabel} + {resolvedTriggerLabel} - 转入资金 + {t("wallet.transferInTitle")} - 从主站钱包划入彩票钱包(最小单笔以服务端校验为准,默认约 1.00{" "} - {currency})。 + {t("wallet.dialogInDescription", { currency })} setOpen(true)} > - {triggerLabel} + {resolvedTriggerLabel} - 转出资金 + {t("wallet.transferOutTitle")} - 划回主站钱包;单笔限额以服务端校验为准。 + {t("wallet.dialogOutDescription")} Promise, + t: (key: string) => string, ): Promise { 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(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" ? ( ) : ( @@ -125,7 +128,7 @@ export function TransferInPanel({ disabled={submitting} onClick={onCancel} > - 取消 + {t("actions.cancel")}
        @@ -147,32 +150,34 @@ export function TransferInPanel({ return ( <>
        -
        +

        - 主站钱包余额:{" "} - —(待接入主站) + {t("wallet.mainBalance")}{" "} + {t("wallet.mainPending")}

        - 彩票钱包余额:{" "} + {t("wallet.lotteryBalance")}{" "} {formatMinorAsCurrency(lotteryMinor, currency)}

        - + setAmountText(ev.target.value)} disabled={submitting} autoComplete="off" + className="h-11 rounded-lg border-[#dce7f7] bg-white text-base" />

        - 转入后彩票余额(预览):{" "} - {formatMinorAsCurrency(previewAfter, currency)} + {t("wallet.afterInPreview", { + amount: formatMinorAsCurrency(previewAfter, currency), + })}

        {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(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" ? ( ) : ( @@ -271,7 +277,7 @@ export function TransferOutPanel({ disabled={submitting} onClick={onCancel} > - 取消 + {t("actions.cancel")}
        @@ -293,9 +299,9 @@ export function TransferOutPanel({ return ( <>
        -
        +

        - 彩票钱包可用:{" "} + {t("wallet.lotteryAvailable")}{" "} {formatMinorAsCurrency(availableMinor, currency)} @@ -303,7 +309,7 @@ export function TransferOutPanel({

        - +
        setAmountText(ev.target.value)} disabled={submitting} autoComplete="off" + className="h-11 rounded-lg border-[#dce7f7] bg-white text-base" />

        - 转出后彩票余额(预览):{" "} - {formatMinorAsCurrency(previewAfter, currency)} + {t("wallet.afterOutPreview", { + amount: formatMinorAsCurrency(previewAfter, currency), + })}

        {localError ? ( @@ -344,21 +353,21 @@ export function TransferInPage({ lotteryMinor, onSuccess, }: PanelBase & { lotteryMinor: number }) { + const { t } = useTranslation("player"); + return ( -
        - - - 返回钱包 - - + + - 转入资金 + {t("wallet.transferInTitle")} - 从主站钱包划入彩票钱包(最小单笔以服务端校验为准,默认约 1.00{" "} - {currency})。 + {t("wallet.transferInDescription")} @@ -372,7 +381,7 @@ export function TransferInPage({ /> -
        + ); } @@ -382,20 +391,21 @@ export function TransferOutPage({ availableMinor, onSuccess, }: PanelBase & { availableMinor: number }) { + const { t } = useTranslation("player"); + return ( -
        - - - 返回钱包 - - + + - 转出资金 + {t("wallet.transferOutTitle")} - 划回主站钱包;单笔限额以服务端校验为准。 + {t("wallet.transferOutDescription")} @@ -409,6 +419,6 @@ export function TransferOutPage({ /> -
        + ); } diff --git a/src/hooks/use-token-refresh.ts b/src/hooks/use-token-refresh.ts index 9b02416..584996e 100644 --- a/src/hooks/use-token-refresh.ts +++ b/src/hooks/use-token-refresh.ts @@ -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 分钟 diff --git a/src/hooks/use-websocket-manager.ts b/src/hooks/use-websocket-manager.ts index 35631a4..d5200f2 100644 --- a/src/hooks/use-websocket-manager.ts +++ b/src/hooks/use-websocket-manager.ts @@ -44,11 +44,11 @@ export type UseWebSocketManagerReturn = { export function useWebSocketManager(): UseWebSocketManagerReturn { const store = useNetworkConnectionStore(); const reconnectTimerRef = useRef(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(); diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 9706241..3db6bf7 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -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; diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 346877d..449a5ec 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -15,6 +15,9 @@ "pending": "Pending", "failed": "Failed" }, + "navigation": { + "notifications": "Notifications" + }, "errors": { "general": "General" } diff --git a/src/i18n/locales/en/player.json b/src/i18n/locales/en/player.json new file mode 100644 index 0000000..6005a27 --- /dev/null +++ b/src/i18n/locales/en/player.json @@ -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" + } +} diff --git a/src/i18n/locales/ne/common.json b/src/i18n/locales/ne/common.json index 5b4ce5f..e9deac9 100644 --- a/src/i18n/locales/ne/common.json +++ b/src/i18n/locales/ne/common.json @@ -15,6 +15,9 @@ "pending": "बाँकी", "failed": "असफल" }, + "navigation": { + "notifications": "सूचनाहरू" + }, "errors": { "general": "सामान्य" } diff --git a/src/i18n/locales/ne/player.json b/src/i18n/locales/ne/player.json new file mode 100644 index 0000000..5eabe18 --- /dev/null +++ b/src/i18n/locales/ne/player.json @@ -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" + } +} diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json index 379ef22..3189bca 100644 --- a/src/i18n/locales/zh/common.json +++ b/src/i18n/locales/zh/common.json @@ -15,6 +15,9 @@ "pending": "待处理", "failed": "失败" }, + "navigation": { + "notifications": "通知" + }, "errors": { "general": "通用" } diff --git a/src/i18n/locales/zh/player.json b/src/i18n/locales/zh/player.json new file mode 100644 index 0000000..11f5e11 --- /dev/null +++ b/src/i18n/locales/zh/player.json @@ -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": "位数小" + } +} diff --git a/src/lib/play-labels.ts b/src/lib/play-labels.ts index ac46588..059418d 100644 --- a/src/lib/play-labels.ts +++ b/src/lib/play-labels.ts @@ -32,3 +32,13 @@ const LABELS: Record = { 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 }); +} diff --git a/src/lib/wallet-api-error.ts b/src/lib/wallet-api-error.ts index 0b715e3..b07f188 100644 --- a/src/lib/wallet-api-error.ts +++ b/src/lib/wallet-api-error.ts @@ -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 = { @@ -41,3 +48,20 @@ const WALLET_CODE_MESSAGES: Record = { 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); +}