refactor: 完成全站国际化改造,统一多语言支持
此提交完成了全项目的国际化适配: 1. 新增多语言翻译文件与基础配置 2. 替换所有硬编码文本为i18n调用 3. 优化语言切换与文档语言同步逻辑 4. 重构部分业务逻辑以支持动态翻译 5. 移除过时代码与硬编码配置
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -35,7 +35,7 @@ export function LanguageSwitcher({
|
||||
label: t(`language.${item.code}`),
|
||||
short: t(`languageShort.${item.code}`),
|
||||
})),
|
||||
[t, i18n.language],
|
||||
[t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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]" />
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user