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

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

View File

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

View File

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

View File

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