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,211 @@
import { useCallback, useEffect, useRef } from "react";
import { usePlayerSessionStore } from "@/stores/player-session-store";
import { useErrorStore } from "@/stores/error-store";
/** Token 刷新间隔毫秒5 分钟 - 30 秒缓冲 = 4.5 分钟 */
const TOKEN_REFRESH_INTERVAL = 4.5 * 60 * 1000;
/** Token 过期前警告阈值(毫秒) */
const TOKEN_WARNING_THRESHOLD = 60 * 1000; // 1 分钟
/** 最大重试次数 */
const MAX_RETRY = 3;
/**
* Token 自动续签 Hook
*
* 功能:
* 1. 监听主站 postMessage 发送的新 Token
* 2. 自动检测 Token 过期时间并在过期前静默续签
* 3. 支持手动触发刷新
* 4. 刷新失败时提示用户返回主站
*/
export function useTokenRefresh(): {
/** 手动触发 Token 刷新 */
refreshToken: () => Promise<void>;
/** 当前 Token 剩余有效时间(毫秒),-1 表示未知 */
getTokenRemainingTime: () => number;
/** 是否即将过期 */
isTokenExpiringSoon: () => boolean;
} {
const bearerToken = usePlayerSessionStore((state) => state.bearerToken);
const setBearerToken = usePlayerSessionStore((state) => state.setBearerToken);
const setServerError = useErrorStore((state) => state.setServerError);
const clearServerError = useErrorStore((state) => state.clearServerError);
const refreshTimerRef = useRef<NodeJS.Timeout | null>(null);
const retryCountRef = useRef(0);
/**
* 解析 JWT 的 exp 字段
* @returns exp 时间戳(秒),解析失败返回 null
*/
const parseTokenExp = useCallback((token: string | null): number | null => {
if (!token) return null;
try {
// JWT 格式header.payload.signature
const parts = token.split(".");
if (parts.length !== 3) return null;
// Base64 解码 payload
const payload = JSON.parse(atob(parts[1]));
return payload.exp ?? null;
} catch {
return null;
}
}, []);
/**
* 获取 Token 剩余有效时间
* @returns 剩余毫秒数,-1 表示未知
*/
const getTokenRemainingTime = useCallback((): number => {
const exp = parseTokenExp(bearerToken);
if (!exp) return -1;
const now = Math.floor(Date.now() / 1000);
return Math.max(0, (exp - now) * 1000);
}, [bearerToken, parseTokenExp]);
/**
* 检查 Token 是否即将过期1 分钟内)
*/
const isTokenExpiringSoon = useCallback((): boolean => {
const remaining = getTokenRemainingTime();
return remaining > 0 && remaining < TOKEN_WARNING_THRESHOLD;
}, [getTokenRemainingTime]);
/**
* 请求主站刷新 Token
* 通过 postMessage 向父窗口(主站)发送刷新请求
*/
const requestParentRefresh = useCallback((): void => {
if (typeof window === "undefined") return;
// 向主站请求新 Token
window.parent.postMessage(
{
type: "LOTTERY_TOKEN_REFRESH_REQUEST",
timestamp: Date.now(),
},
"*", // 或指定主站域名
);
}, []);
/**
* 手动触发 Token 刷新
*/
const refreshToken = useCallback(async (): Promise<void> => {
if (retryCountRef.current >= MAX_RETRY) {
setServerError(true, "Token 刷新失败,请返回主站重新进入");
return;
}
clearServerError();
retryCountRef.current++;
// 向主站请求新 Token
requestParentRefresh();
// 等待主站响应(通过 postMessage
// 实际逻辑在下面的 useEffect 中处理
}, [clearServerError, requestParentRefresh, setServerError]);
/**
* 监听主站 postMessage 发送的新 Token
*/
useEffect(() => {
if (typeof window === "undefined") return;
const handleMessage = (event: MessageEvent): void => {
// 安全检查:验证来源
const allowedOrigins = [
process.env.NEXT_PUBLIC_MAIN_SITE_URL,
// 开发环境允许本地
"http://localhost:3000",
"http://127.0.0.1:3000",
].filter(Boolean);
if (
allowedOrigins.length > 0 &&
!allowedOrigins.includes(event.origin)
) {
console.warn("[TokenRefresh] Ignored message from unknown origin:", event.origin);
return;
}
const { data } = event;
if (!data || typeof data !== "object") return;
// 处理主站发送的新 Token
if (data.type === "LOTTERY_TOKEN_REFRESH_RESPONSE" && data.token) {
console.log("[TokenRefresh] Received new token from parent");
setBearerToken(data.token);
retryCountRef.current = 0; // 重置重试计数
}
// 处理主站通知 Token 即将过期
if (data.type === "LOTTERY_TOKEN_EXPIRING_WARNING") {
console.log("[TokenRefresh] Token expiring warning from parent");
// 可以在这里显示提示或自动刷新
}
};
window.addEventListener("message", handleMessage);
return () => window.removeEventListener("message", handleMessage);
}, [setBearerToken]);
/**
* 自动刷新逻辑
*/
useEffect(() => {
if (!bearerToken) {
if (refreshTimerRef.current) {
clearTimeout(refreshTimerRef.current);
refreshTimerRef.current = null;
}
return;
}
const exp = parseTokenExp(bearerToken);
if (!exp) return;
const now = Date.now();
const expMs = exp * 1000;
const remaining = expMs - now;
// 如果已经过期,立即请求刷新
if (remaining <= 0) {
console.warn("[TokenRefresh] Token already expired, requesting refresh");
requestParentRefresh();
return;
}
// 在过期前 30 秒刷新
const refreshDelay = Math.max(0, remaining - TOKEN_WARNING_THRESHOLD);
console.log(
`[TokenRefresh] Token expires in ${Math.floor(remaining / 1000)}s, ` +
`will refresh in ${Math.floor(refreshDelay / 1000)}s`,
);
refreshTimerRef.current = setTimeout(() => {
console.log("[TokenRefresh] Auto-refreshing token");
requestParentRefresh();
}, refreshDelay);
return () => {
if (refreshTimerRef.current) {
clearTimeout(refreshTimerRef.current);
}
};
}, [bearerToken, parseTokenExp, requestParentRefresh]);
return {
refreshToken,
getTokenRemainingTime,
isTokenExpiringSoon,
};
}