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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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