feat: 增强国际化支持与安全头配置

- 在 .env.example 中新增 i18next 相关配置项以支持多语言功能
- 在 next.config.ts 中添加安全头配置以支持 iframe 嵌入
- 更新 Providers 组件以引入 i18n 配置
- 在 PlayerAppShell 中集成 LanguageSwitcher 组件以实现语言切换功能
- 优化 HallWalletStrip 组件的网络状态管理逻辑
- 更新多个组件以支持国际化文本
This commit is contained in:
2026-05-13 17:53:56 +08:00
parent c8f8f90515
commit 587a6ad66c
32 changed files with 2126 additions and 436 deletions

View File

@@ -0,0 +1,220 @@
"use client";
import { useEffect, useCallback, type ReactNode } from "react";
import { usePlayerSessionStore } from "@/stores/player-session-store";
import { setPlayerBearerToken } from "@/lib/lottery-auth";
/**
* iframe 通信桥接组件
*
* 功能:
* 1. 监听父窗口(主站)通过 postMessage 发送的 Token
* 2. 向父窗口发送心跳和状态通知
* 3. 支持在主站 iframe 内嵌入时的双向通信
*/
export function IframeBridge({ children }: { children: ReactNode }): ReactNode {
const setBearerToken = usePlayerSessionStore((state) => state.setBearerToken);
/**
* 向父窗口发送消息
*/
const sendToParent = useCallback(
(type: string, payload?: Record<string, unknown>): void => {
if (typeof window === "undefined" || window.parent === window) return;
window.parent.postMessage(
{
type: `LOTTERY_${type}`,
payload,
timestamp: Date.now(),
source: "lottery-iframe",
},
"*", // 生产环境应指定具体域名
);
},
[],
);
/**
* 通知父窗口:已准备就绪
*/
const notifyReady = useCallback((): void => {
sendToParent("READY", {
url: window.location.href,
userAgent: navigator.userAgent,
});
}, [sendToParent]);
/**
* 通知父窗口:需要新 Token
*/
const notifyTokenNeeded = useCallback((): void => {
sendToParent("TOKEN_NEEDED", {
reason: "token_expired",
});
}, [sendToParent]);
/**
* 通知父窗口Token 刷新成功
*/
const notifyTokenRefreshed = useCallback((): void => {
sendToParent("TOKEN_REFRESHED");
}, [sendToParent]);
/**
* 通知父窗口:发生错误
*/
const notifyError = useCallback(
(error: string): void => {
sendToParent("ERROR", { error });
},
[sendToParent],
);
/**
* 监听父窗口消息
*/
useEffect(() => {
if (typeof window === "undefined") return;
// 检查是否在 iframe 内
const isInIframe = window.self !== window.top;
if (!isInIframe) {
console.log("[IframeBridge] Not in iframe, skipping bridge setup");
return;
}
console.log("[IframeBridge] Setting up iframe communication");
const handleMessage = (event: MessageEvent): void => {
// 安全:验证来源域名
const allowedOrigins = [
process.env.NEXT_PUBLIC_MAIN_SITE_URL,
process.env.NEXT_PUBLIC_PARENT_ORIGIN,
"http://localhost:3000",
"http://127.0.0.1:3000",
].filter(Boolean);
if (
allowedOrigins.length > 0 &&
!allowedOrigins.includes(event.origin)
) {
console.warn("[IframeBridge] Rejected message from:", event.origin);
return;
}
const { data } = event;
if (!data || typeof data !== "object") return;
console.log("[IframeBridge] Received message:", data.type);
switch (data.type) {
// 主站发送初始化 Token
case "MAIN_INIT_TOKEN":
if (data.token) {
console.log("[IframeBridge] Received initial token");
setBearerToken(data.token);
setPlayerBearerToken(data.token);
notifyReady();
}
break;
// 主站刷新 Token
case "MAIN_REFRESH_TOKEN":
if (data.token) {
console.log("[IframeBridge] Received refreshed token");
setBearerToken(data.token);
setPlayerBearerToken(data.token);
notifyTokenRefreshed();
}
break;
// 主站通知 Token 即将过期
case "MAIN_TOKEN_EXPIRING":
console.log("[IframeBridge] Token expiring soon");
// 可以显示提示或自动刷新
break;
// 主站请求当前状态
case "MAIN_REQUEST_STATUS":
sendToParent("STATUS_RESPONSE", {
isReady: true,
currentPath: window.location.pathname,
});
break;
// 主站导航请求
case "MAIN_NAVIGATE":
if (data.path && typeof data.path === "string") {
window.history.pushState({}, "", data.path);
}
break;
default:
break;
}
};
window.addEventListener("message", handleMessage);
// 发送就绪通知
notifyReady();
// 定期发送心跳
const heartbeat = setInterval(() => {
sendToParent("HEARTBEAT", {
timestamp: Date.now(),
});
}, 30000); // 每 30 秒
return () => {
window.removeEventListener("message", handleMessage);
clearInterval(heartbeat);
};
}, [notifyReady, 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,
notifyError,
sendToParent,
};
}, [notifyError, notifyReady, notifyTokenNeeded, sendToParent]);
return children;
}
/**
* 检测当前是否在 iframe 内
*/
export function isInIframe(): boolean {
if (typeof window === "undefined") return false;
try {
return window.self !== window.top;
} catch {
return true; // 跨域时无法访问 window.top说明在 iframe 内
}
}
/**
* 获取父窗口信息
*/
export function getParentInfo(): {
isInIframe: boolean;
referrer: string;
} {
if (typeof window === "undefined") {
return { isInIframe: false, referrer: "" };
}
return {
isInIframe: isInIframe(),
referrer: document.referrer || "",
};
}

View File

@@ -0,0 +1,169 @@
"use client";
import { ChevronDown, Globe } from "lucide-react";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { normalizeLanguage, SUPPORTED_LANGUAGES, type AppLanguage } from "@/i18n";
import { cn } from "@/lib/utils";
interface LanguageSwitcherProps {
variant?: "default" | "header" | "minimal";
/** 下拉相对触发器水平对齐:`start`=左对齐(适合左上角触发器),`end`=右对齐(适合顶栏右侧) */
menuAlign?: "start" | "end";
className?: string;
showFlag?: boolean;
showLabel?: boolean;
}
export function LanguageSwitcher({
variant = "default",
menuAlign,
className,
showFlag = true,
showLabel = true,
}: LanguageSwitcherProps) {
const { i18n, t } = useTranslation("common");
const active = normalizeLanguage(i18n.language) as AppLanguage;
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const options = useMemo(
() =>
SUPPORTED_LANGUAGES.map((item) => ({
...item,
label: t(`language.${item.code}`),
short: t(`languageShort.${item.code}`),
})),
[t, i18n.language],
);
useEffect(() => {
function handleClickOutside(event: MouseEvent): void {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [isOpen]);
async function handleSelect(code: AppLanguage): Promise<void> {
await i18n.changeLanguage(code);
setIsOpen(false);
}
const currentLabel = options.find((o) => o.code === active)?.short ?? active.toUpperCase();
const currentFlag = options.find((o) => o.code === active)?.flag ?? "";
const variantStyles = {
default: {
button: "border border-white/20 bg-white/10 text-white hover:bg-white/20",
dropdown: "border border-gray-200 bg-white shadow-lg",
item: "text-gray-800 hover:bg-gray-100",
activeItem: "bg-red-50 text-red-600",
},
header: {
button: "text-white/80 hover:bg-white/10 hover:text-white",
dropdown: "border border-white/20 bg-white/95 shadow-xl backdrop-blur-sm",
item: "text-gray-800 hover:bg-white/10",
activeItem: "bg-red-500/10 text-red-600",
},
minimal: {
button: "text-current hover:bg-black/5",
dropdown: "border border-gray-200 bg-white shadow-lg",
item: "text-gray-800 hover:bg-gray-100",
activeItem: "bg-red-50 text-red-600",
},
} as const;
const styles = variantStyles[variant];
const align =
menuAlign ?? (variant === "header" || variant === "default" ? "start" : "end");
return (
<div ref={containerRef} className={cn("relative inline-block", className)}>
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className={cn(
"flex items-center gap-1.5 rounded-md px-2 py-1.5 text-sm font-medium transition-colors",
styles.button,
)}
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-label={`Language (${currentLabel})`}
>
<Globe className="size-4" aria-hidden />
{showFlag && currentFlag ? <span className="text-base">{currentFlag}</span> : null}
{showLabel ? <span>{currentLabel}</span> : null}
<ChevronDown
className={cn("size-4 transition-transform duration-200", isOpen && "rotate-180")}
aria-hidden
/>
</button>
{isOpen ? (
<div
className={cn(
"absolute z-[100] mt-1 min-w-[min(100vw-2rem,220px)] max-w-[min(100vw-2rem,280px)] rounded-lg py-1 text-gray-900 shadow-md",
align === "start" ? "left-0" : "right-0",
styles.dropdown,
)}
role="listbox"
>
<div className="max-h-[280px] overflow-y-auto">
{options.map((option) => (
<button
key={option.code}
type="button"
onClick={() => void handleSelect(option.code)}
className={cn(
"flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors",
active === option.code ? styles.activeItem : styles.item,
)}
role="option"
aria-selected={active === option.code}
>
<span className="text-lg">{option.flag}</span>
<div className="flex flex-col leading-tight">
<span className="font-medium">{option.label}</span>
<span className="text-xs opacity-60">{option.short}</span>
</div>
{active === option.code ? (
<svg
className="ml-auto size-4 text-red-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<title>{option.label}</title>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
) : null}
</button>
))}
</div>
</div>
) : null}
</div>
);
}
export function LanguageSwitcherMinimal({
className,
}: {
className?: string;
}): ReactNode {
return (
<LanguageSwitcher variant="minimal" className={className} showFlag={false} />
);
}

View File

@@ -1,6 +1,10 @@
"use client";
import Link from "next/link";
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { LanguageSwitcher } from "@/components/language-switcher";
import { NetworkStatusBanner } from "@/components/network-status-banner";
import { PlayerBottomNav } from "@/components/layout/player-bottom-nav";
import { PlayerSessionBar } from "@/features/player/player-session-bar";
@@ -17,6 +21,8 @@ type PlayerAppShellProps = {
* 这里的 NetworkStatusBanner 仅用于 WebSocket 状态显示
*/
export function PlayerAppShell({ children }: PlayerAppShellProps): ReactNode {
const { t } = useTranslation("layout");
return (
<div className="flex min-h-dvh flex-col bg-background text-foreground">
{/* WebSocket 连接状态横幅(降级模式提示) */}
@@ -27,9 +33,10 @@ export function PlayerAppShell({ children }: PlayerAppShellProps): ReactNode {
href="/hall"
className="shrink-0 text-sm font-semibold tracking-tight text-foreground no-underline hover:opacity-90"
>
Lottery
{t("brand.title")}
</Link>
<PlayerSessionBar className="min-w-0 flex-1 border-l border-border pl-2" />
<LanguageSwitcher variant="minimal" showFlag={false} />
</div>
</header>
<main className="mx-auto flex w-full max-w-lg flex-1 flex-col gap-4 px-4 pb-[calc(3.5rem+env(safe-area-inset-bottom,0px)+0.75rem)] pt-4">

View File

@@ -6,6 +6,9 @@ import { ThemeProvider } from "next-themes";
import { Toaster } from "@/components/ui/sonner";
import { ErrorProvider } from "@/components/error-provider";
import { IframeBridge } from "@/components/iframe-bridge";
import { TokenRefreshIndicator } from "@/components/token-refresh-indicator";
import "@/i18n";
type ProvidersProps = {
children: ReactNode;
@@ -15,7 +18,12 @@ export function Providers({ children }: ProvidersProps): ReactNode {
return (
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<ErrorProvider>
{children}
{/* iframe 通信桥接 - 支持主站嵌入 */}
<IframeBridge>
{children}
{/* Token 续签指示器 - 显示在右下角 */}
<TokenRefreshIndicator />
</IframeBridge>
</ErrorProvider>
<Toaster />
</ThemeProvider>

View File

@@ -0,0 +1,119 @@
"use client";
import { useState, useEffect } from "react";
import { RefreshCw, AlertCircle } from "lucide-react";
import { useTokenRefresh } from "@/hooks/use-token-refresh";
import { Button } from "@/components/ui/button";
import { ERROR_COLORS } from "@/stores/error-store";
import { cn } from "@/lib/utils";
/**
* Token 续签状态指示器组件
*
* 当 Token 即将过期或正在刷新时显示提示
*/
export function TokenRefreshIndicator(): React.ReactElement | null {
const { isTokenExpiringSoon, getTokenRemainingTime, refreshToken } =
useTokenRefresh();
const [isRefreshing, setIsRefreshing] = useState(false);
const [showWarning, setShowWarning] = useState(false);
const [remainingSeconds, setRemainingSeconds] = useState<number | null>(null);
// 检测 Token 状态
useEffect(() => {
const checkToken = (): void => {
const remaining = getTokenRemainingTime();
const expiringSoon = isTokenExpiringSoon();
if (remaining > 0 && remaining < 120000) {
// 2 分钟内显示
setShowWarning(true);
setRemainingSeconds(Math.floor(remaining / 1000));
} else {
setShowWarning(false);
setRemainingSeconds(null);
}
};
checkToken();
const interval = setInterval(checkToken, 10000); // 每 10 秒检查
return () => clearInterval(interval);
}, [getTokenRemainingTime, isTokenExpiringSoon]);
// 手动刷新
const handleRefresh = async (): Promise<void> => {
setIsRefreshing(true);
try {
await refreshToken();
} finally {
setIsRefreshing(false);
}
};
if (!showWarning) {
return null;
}
const isCritical = remainingSeconds !== null && remainingSeconds < 60;
return (
<div
className={cn(
"fixed bottom-4 right-4 z-50 flex items-center gap-3 rounded-lg px-4 py-3 shadow-lg",
"animate-in slide-in-from-bottom-4 duration-300",
)}
style={{
backgroundColor: isCritical
? `${ERROR_COLORS.error}15`
: `${ERROR_COLORS.warning}15`,
border: `1px solid ${isCritical ? ERROR_COLORS.error : ERROR_COLORS.warning}`,
}}
>
<AlertCircle
className="size-5 shrink-0"
style={{
color: isCritical ? ERROR_COLORS.error : ERROR_COLORS.warning,
}}
/>
<div className="flex flex-col">
<span
className="text-sm font-medium"
style={{
color: isCritical ? ERROR_COLORS.error : ERROR_COLORS.warning,
}}
>
{isCritical ? "登录即将失效" : "登录即将过期"}
</span>
<span className="text-xs text-muted-foreground">
{remainingSeconds !== null && (
<>
{Math.floor(remainingSeconds / 60)}:
{String(remainingSeconds % 60).padStart(2, "0")} {" "}
</>
)}
...
</span>
</div>
<Button
size="sm"
variant="outline"
className={cn("ml-2 h-8 gap-1 border-current")}
style={{
color: isCritical ? ERROR_COLORS.error : ERROR_COLORS.warning,
borderColor: isCritical ? ERROR_COLORS.error : ERROR_COLORS.warning,
}}
onClick={handleRefresh}
disabled={isRefreshing}
>
<RefreshCw
className={cn("size-3.5", isRefreshing && "animate-spin")}
/>
</Button>
</div>
);
}