feat: 增强国际化支持与安全头配置
- 在 .env.example 中新增 i18next 相关配置项以支持多语言功能 - 在 next.config.ts 中添加安全头配置以支持 iframe 嵌入 - 更新 Providers 组件以引入 i18n 配置 - 在 PlayerAppShell 中集成 LanguageSwitcher 组件以实现语言切换功能 - 优化 HallWalletStrip 组件的网络状态管理逻辑 - 更新多个组件以支持国际化文本
This commit is contained in:
220
src/components/iframe-bridge.tsx
Normal file
220
src/components/iframe-bridge.tsx
Normal 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 || "",
|
||||
};
|
||||
}
|
||||
169
src/components/language-switcher.tsx
Normal file
169
src/components/language-switcher.tsx
Normal 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} />
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
119
src/components/token-refresh-indicator.tsx
Normal file
119
src/components/token-refresh-indicator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user