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

@@ -14,4 +14,13 @@ LOTTERY_API_PROXY_TARGET=http://127.0.0.1:8000
# NEXT_PUBLIC_LOTTERY_PLAY_CURRENCY=NPR
# 可选:入口授权失败时“返回主站重新进入”的地址。
# NEXT_PUBLIC_MAIN_SITE_URL=http://localhost:5173
# NEXT_PUBLIC_MAIN_SITE_URL=http://localhost:5173
# -----------------------------------------------------------------------------
# Laravel ReverbWebSocket。不配则 Echo 为空,会一直显示「降级模式 / 轮询」。
# Laravel 终端另开:`php artisan reverb:start`
# -----------------------------------------------------------------------------
# NEXT_PUBLIC_REVERB_APP_KEY=与 lotterLaravel .env 的 REVERB_APP_KEY 一致
# NEXT_PUBLIC_REVERB_HOST=127.0.0.1
# NEXT_PUBLIC_REVERB_PORT=8080
# NEXT_PUBLIC_REVERB_SCHEME=http

View File

@@ -1,11 +1,23 @@
import type { NextConfig } from "next";
import { securityHeaders } from "./src/lib/csp-config";
const lotteryApiProxyTarget =
process.env.LOTTERY_API_PROXY_TARGET?.trim() || "http://127.0.0.1:8000";
const nextConfig: NextConfig = {
reactCompiler: true,
// 安全头配置 - 支持 iframe 嵌入
async headers() {
return [
{
source: "/:path*",
headers: securityHeaders,
},
];
},
async rewrites() {
return [
{

97
package-lock.json generated
View File

@@ -12,6 +12,9 @@
"axios": "^1.16.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"i18next": "^26.1.0",
"i18next-browser-languagedetector": "^8.2.1",
"i18next-http-backend": "^4.0.0",
"laravel-echo": "^2.3.4",
"lucide-react": "^1.14.0",
"next": "16.2.6",
@@ -19,6 +22,7 @@
"pusher-js": "^8.5.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-i18next": "^17.0.7",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
@@ -6012,6 +6016,15 @@
"node": ">=16.9.0"
}
},
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://mirrors.cloud.tencent.com/npm/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"license": "MIT",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.1.tgz",
@@ -6057,6 +6070,52 @@
"node": ">=18.18.0"
}
},
"node_modules/i18next": {
"version": "26.1.0",
"resolved": "https://mirrors.cloud.tencent.com/npm/i18next/-/i18next-26.1.0.tgz",
"integrity": "sha512-dIU6td04DvQuIqVst5S9g0GviTmhZ0DYD4b9ociVGJmuCa5vZ2de/t+Enf4olvj87mF8Y2lwjNQBwC9QZsvzKQ==",
"funding": [
{
"type": "individual",
"url": "https://www.locize.com/i18next"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
},
{
"type": "individual",
"url": "https://www.locize.com"
}
],
"license": "MIT",
"peerDependencies": {
"typescript": "^5 || ^6"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/i18next-browser-languagedetector": {
"version": "8.2.1",
"resolved": "https://mirrors.cloud.tencent.com/npm/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz",
"integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.23.2"
}
},
"node_modules/i18next-http-backend": {
"version": "4.0.0",
"resolved": "https://mirrors.cloud.tencent.com/npm/i18next-http-backend/-/i18next-http-backend-4.0.0.tgz",
"integrity": "sha512-EgSjO3Q1G6f2Q5oy7u9mmxuesE0oSfzAD97NFBjC8EmkK4guBSYLljM0Fng3DarMWIIkU70jfo4+mUzmyVISTA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.7.2.tgz",
@@ -8485,6 +8544,33 @@
"react": "^19.2.4"
}
},
"node_modules/react-i18next": {
"version": "17.0.7",
"resolved": "https://mirrors.cloud.tencent.com/npm/react-i18next/-/react-i18next-17.0.7.tgz",
"integrity": "sha512-rwtPXsb/zwzDafN+gytcjF5YnqGQQIRmCQ6DctBC1VSipRB8GD/MWEVrFP42vjMyuYydxWxM8CZRt+yiNuuoHg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.2",
"html-parse-stringify": "^3.0.1",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"i18next": ">= 26.0.10",
"react": ">= 16.8.0",
"typescript": "^5 || ^6"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmmirror.com/react-is/-/react-is-16.13.1.tgz",
@@ -9962,7 +10048,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -10177,6 +10263,15 @@
"node": ">= 0.8"
}
},
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://mirrors.cloud.tencent.com/npm/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/web-streams-polyfill": {
"version": "3.3.3",
"resolved": "https://registry.npmmirror.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",

View File

@@ -13,6 +13,9 @@
"axios": "^1.16.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"i18next": "^26.1.0",
"i18next-browser-languagedetector": "^8.2.1",
"i18next-http-backend": "^4.0.0",
"laravel-echo": "^2.3.4",
"lucide-react": "^1.14.0",
"next": "16.2.6",
@@ -20,6 +23,7 @@
"pusher-js": "^8.5.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-i18next": "^17.0.7",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",

BIN
public/entry/image1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
public/entry/image2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 737 KiB

BIN
public/entry/image3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View File

@@ -0,0 +1,358 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>主站集成示例 - 彩票 iframe 嵌入</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f5f5;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
h1 {
margin-bottom: 20px;
color: #333;
}
.controls {
background: white;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.control-group {
margin-bottom: 15px;
}
label {
display: block;
margin-bottom: 5px;
font-weight: 500;
color: #555;
}
input[type="text"] {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
button {
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background 0.2s;
}
.btn-primary {
background: #52c41a;
color: white;
}
.btn-primary:hover {
background: #389e0d;
}
.btn-warning {
background: #faad14;
color: white;
}
.btn-warning:hover {
background: #d48806;
}
.btn-danger {
background: #ff4d4f;
color: white;
}
.btn-danger:hover {
background: #cf1322;
}
.iframe-container {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
iframe {
width: 100%;
height: 800px;
border: 1px solid #ddd;
border-radius: 4px;
}
.status {
margin-top: 10px;
padding: 10px;
border-radius: 4px;
font-size: 14px;
}
.status-success {
background: #f6ffed;
border: 1px solid #b7eb8f;
color: #389e0d;
}
.status-error {
background: #fff2f0;
border: 1px solid #ffccc7;
color: #cf1322;
}
.log {
margin-top: 20px;
background: #1e1e1e;
color: #d4d4d4;
padding: 15px;
border-radius: 4px;
font-family: 'Courier New', monospace;
font-size: 13px;
max-height: 300px;
overflow-y: auto;
}
.log-entry {
margin: 5px 0;
padding: 5px;
border-left: 3px solid #52c41a;
padding-left: 10px;
}
.log-entry.error {
border-left-color: #ff4d4f;
}
.log-entry.warn {
border-left-color: #faad14;
}
</style>
</head>
<body>
<div class="container">
<h1>🎰 彩票系统 - 主站 iframe 集成示例</h1>
<div class="controls">
<h3>控制面板</h3>
<div class="control-group">
<label>JWT Token模拟从主站获取</label>
<input type="text" id="tokenInput" placeholder="输入 JWT Token 或点击生成测试 Token">
</div>
<div class="control-group">
<label>彩票系统地址:</label>
<input type="text" id="lotteryUrl" value="http://localhost:3000/hall?token=">
</div>
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
<button class="btn-primary" onclick="initIframe()">初始化 iframe</button>
<button class="btn-warning" onclick="refreshToken()">刷新 Token</button>
<button class="btn-danger" onclick="expireToken()">模拟 Token 过期</button>
<button class="btn-primary" onclick="generateTestToken()">生成测试 Token</button>
</div>
<div id="status" class="status" style="display: none;"></div>
</div>
<div class="iframe-container">
<h3>彩票系统 iframe</h3>
<iframe id="lotteryFrame" src="" sandbox="allow-scripts allow-same-origin allow-forms allow-popups"></iframe>
</div>
<div class="log" id="log">
<div class="log-entry">等待初始化...</div>
</div>
</div>
<script>
const lotteryOrigin = 'http://localhost:3000';
let currentToken = null;
let tokenExpiryTime = null;
// 日志输出
function log(message, type = 'info') {
const logEl = document.getElementById('log');
const entry = document.createElement('div');
entry.className = `log-entry ${type}`;
entry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
logEl.appendChild(entry);
logEl.scrollTop = logEl.scrollHeight;
console.log(message);
}
// 显示状态
function showStatus(message, isError = false) {
const statusEl = document.getElementById('status');
statusEl.textContent = message;
statusEl.className = `status ${isError ? 'status-error' : 'status-success'}`;
statusEl.style.display = 'block';
}
// 生成测试 JWT Token实际生产环境由主站后端生成
function generateTestToken() {
// 这是一个示例 JWT 结构,实际需要主站用密钥签名
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
const now = Math.floor(Date.now() / 1000);
const payload = btoa(JSON.stringify({
site_code: 'main_site',
site_player_id: 'player_12345',
iat: now,
exp: now + 300, // 5 分钟过期
sub: 'lottery_player'
}));
const signature = btoa('dummy_signature_replace_in_production');
const token = `${header}.${payload}.${signature}`;
document.getElementById('tokenInput').value = token;
log('已生成测试 Token5 分钟有效期)', 'warn');
}
// 初始化 iframe
function initIframe() {
const token = document.getElementById('tokenInput').value.trim();
const baseUrl = document.getElementById('lotteryUrl').value.trim();
if (!token) {
showStatus('请先输入或生成 Token', true);
return;
}
currentToken = token;
tokenExpiryTime = Date.now() + 5 * 60 * 1000; // 5 分钟后过期
const iframe = document.getElementById('lotteryFrame');
const url = baseUrl + encodeURIComponent(token);
iframe.src = url;
log(`iframe 已初始化Token 将在 5 分钟后过期`);
showStatus('iframe 初始化成功');
// 监听 iframe 消息
window.addEventListener('message', handleIframeMessage);
}
// 处理 iframe 消息
function handleIframeMessage(event) {
// 安全验证:检查来源
if (!event.origin.includes('localhost') && !event.origin.includes('lottery')) {
log(`拒绝来自未知来源的消息: ${event.origin}`, 'error');
return;
}
const { data } = event;
if (!data || typeof data !== 'object') return;
log(`收到消息: ${data.type}`);
switch (data.type) {
case 'LOTTERY_READY':
log('彩票系统已就绪', 'success');
// 发送初始 Token
sendToIframe('MAIN_INIT_TOKEN', { token: currentToken });
break;
case 'LOTTERY_HEARTBEAT':
// 心跳响应,可以更新连接状态
break;
case 'LOTTERY_TOKEN_NEEDED':
log('彩票系统请求新 Token', 'warn');
// 这里应该调用主站后端获取新 Token
refreshTokenFromBackend();
break;
case 'LOTTERY_TOKEN_REFRESHED':
log('Token 刷新成功', 'success');
break;
case 'LOTTERY_ERROR':
log(`彩票系统错误: ${data.payload?.error}`, 'error');
break;
default:
log(`未知消息类型: ${data.type}`);
}
}
// 向 iframe 发送消息
function sendToIframe(type, payload) {
const iframe = document.getElementById('lotteryFrame');
if (!iframe || !iframe.contentWindow) {
log('iframe 未加载,无法发送消息', 'error');
return;
}
iframe.contentWindow.postMessage({
type,
payload,
timestamp: Date.now()
}, lotteryOrigin);
log(`已发送消息: ${type}`);
}
// 刷新 Token模拟主站后端调用
function refreshToken() {
if (!currentToken) {
showStatus('请先初始化 iframe', true);
return;
}
// 模拟从后端获取新 Token
generateTestToken();
const newToken = document.getElementById('tokenInput').value.trim();
currentToken = newToken;
tokenExpiryTime = Date.now() + 5 * 60 * 1000;
// 通知 iframe 新 Token
sendToIframe('MAIN_REFRESH_TOKEN', { token: newToken });
showStatus('Token 已刷新');
}
// 模拟从后端刷新 Token
function refreshTokenFromBackend() {
log('正在从后端获取新 Token...');
// 实际实现:调用主站后端 API 获取新 JWT
setTimeout(() => {
refreshToken();
}, 1000);
}
// 模拟 Token 过期
function expireToken() {
if (!currentToken) {
showStatus('请先初始化 iframe', true);
return;
}
// 通知 iframe Token 即将过期
sendToIframe('MAIN_TOKEN_EXPIRING', {
expiresIn: 10, // 10 秒后过期
});
log('已发送 Token 过期警告');
// 10 秒后使 Token 失效
setTimeout(() => {
currentToken = 'expired_token';
log('Token 已过期', 'error');
}, 10000);
}
// 自动检测 Token 过期
setInterval(() => {
if (tokenExpiryTime && Date.now() > tokenExpiryTime - 60000) {
// Token 将在 1 分钟内过期,自动刷新
log('Token 即将过期,自动刷新...', 'warn');
refreshTokenFromBackend();
tokenExpiryTime = null; // 防止重复刷新
}
}, 30000); // 每 30 秒检查一次
// 初始化提示
log('页面加载完成,点击"生成测试 Token"开始');
</script>
</body>
</html>

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>
);
}

View File

@@ -2,7 +2,7 @@
import { Wallet } from "lucide-react";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { getWalletBalance } from "@/api/wallet";
import { buttonVariants } from "@/components/ui/button";
@@ -27,18 +27,10 @@ export function HallWalletStrip() {
const [balance, setBalance] = useState<WalletBalanceData | null>(null);
const [loading, setLoading] = useState(true);
// 网络连接状态(用于降级模式下的轮询)
const mode = useNetworkConnectionStore((s) => s.mode);
const walletPollingIntervalId = useNetworkConnectionStore(
(s) => s.walletPollingIntervalId,
);
const setWalletPollingIntervalId = useNetworkConnectionStore(
(s) => s.setWalletPollingIntervalId,
);
const setWalletPollingExpiryAt = useNetworkConnectionStore(
(s) => s.setWalletPollingExpiryAt,
);
const clearWalletPolling = useNetworkConnectionStore((s) => s.clearWalletPolling);
/** 降级模式下的本地兜底轮询(勿写入全局 walletPollingIntervalId避免与 useWebSocketManager 互相覆盖/触发 effect 死循环) */
const degradedWalletPollRef = useRef<number | null>(null);
const currency = useMemo(
() =>
@@ -72,49 +64,31 @@ export function HallWalletStrip() {
return () => window.removeEventListener("lottery-wallet-refresh", onRefresh);
}, [refresh]);
// 监听钱包轮询状态变化(由下注或开奖结果触发)
// 降级模式下本地兜底轮询60s与 WebSocket 管理器里的 *_wallet* 全局 timer 隔离
useEffect(() => {
// 如果有活跃的轮询计时器,监听它并执行刷新
if (walletPollingIntervalId) {
// 轮询已在全局管理中设置,这里只需监听轮询触发的事件
const handlePollingRefresh = () => void refresh();
window.addEventListener("lottery-wallet-refresh", handlePollingRefresh);
return () => {
window.removeEventListener("lottery-wallet-refresh", handlePollingRefresh);
};
}
}, [walletPollingIntervalId, refresh]);
// 降级模式下的定期刷新(作为兜底)
useEffect(() => {
// 只有在降级模式下才启动兜底轮询
if (mode !== "polling" && mode !== "offline") {
if (degradedWalletPollRef.current !== null) {
window.clearInterval(degradedWalletPollRef.current);
degradedWalletPollRef.current = null;
}
return;
}
// 如果已经有活跃的轮询计时器,不重复设置
if (walletPollingIntervalId) {
if (degradedWalletPollRef.current !== null) {
return;
}
// 设置兜底轮询60秒一次避免过于频繁
const intervalId = window.setInterval(() => {
degradedWalletPollRef.current = window.setInterval(() => {
void refresh();
}, 60_000);
setWalletPollingIntervalId(intervalId);
return () => {
window.clearInterval(intervalId);
clearWalletPolling();
if (degradedWalletPollRef.current !== null) {
window.clearInterval(degradedWalletPollRef.current);
degradedWalletPollRef.current = null;
}
};
}, [
mode,
walletPollingIntervalId,
refresh,
setWalletPollingIntervalId,
clearWalletPolling,
]);
}, [mode, refresh]);
const lotteryMinor = Number(balance?.balance ?? 0);
const availableMinor = Number(balance?.available_balance ?? 0);

View File

@@ -2,41 +2,60 @@
import { isAxiosError } from "axios";
import {
AlertCircle,
AlertTriangle,
Bell,
Check,
CheckCircle2,
ChevronRight,
Languages,
Globe,
Loader2,
Shield,
ShieldCheck,
} from "lucide-react";
import Image from "next/image";
import { useRouter, useSearchParams } from "next/navigation";
import type { ReactNode } from "react";
import { useCallback, useEffect } from "react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { getPlayerMe, getPlayerPing } from "@/api/player";
import { Button, buttonVariants } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { LanguageSwitcher } from "@/components/language-switcher";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { usePlayerSessionStore } from "@/stores/player-session-store";
import { LotteryApiBizError } from "@/types/api/errors";
const MAIN_SITE_URL = process.env.NEXT_PUBLIC_MAIN_SITE_URL?.trim() ?? "";
const RETRY_ATTEMPTS = 3;
const RETRY_DELAY_MS = 2000;
type EntryStepId = "token" | "account" | "hall";
type EntryStepStatus = "pending" | "in-progress" | "done" | "error";
type EntryStep = {
id: EntryStepId;
status: EntryStepStatus;
};
type Phase = "loading" | "success" | "failed";
type FailureRow = {
code?: string;
/** `entry` 命名空间下的 key例如 `errors.noTokenDetail` */
detailKey?: string;
/** 服务端或动态错误兜底 */
fallbackMessage?: string;
};
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
function initialSteps(): EntryStep[] {
return [
{ id: "token", status: "pending" },
{ id: "account", status: "pending" },
{ id: "hall", status: "pending" },
];
}
function shouldRetryEntryRequest(error: unknown): boolean {
if (error instanceof LotteryApiBizError) {
return false;
@@ -48,347 +67,401 @@ function shouldRetryEntryRequest(error: unknown): boolean {
if (!error.response) {
return true;
}
const s = error.response.status;
return s >= 500 || s === 429;
if (error.response.status >= 500) {
return true;
}
}
return false;
}
async function withEntryRetries<T>(fn: () => Promise<T>): Promise<T> {
let last: unknown;
for (let i = 0; i < RETRY_ATTEMPTS; i++) {
try {
return await fn();
} catch (e) {
last = e;
if (!shouldRetryEntryRequest(e) || i === RETRY_ATTEMPTS - 1) {
throw e;
}
await sleep(RETRY_DELAY_MS);
}
}
throw last;
}
function normalizeTokenInput(raw: string | null): string | null {
if (raw === null) {
return null;
}
const t = decodeURIComponent(raw).trim();
return t === "" ? null : t;
}
function stripTokenFromUrl(): void {
if (typeof window === "undefined") {
return;
}
const url = new URL(window.location.href);
if (!url.searchParams.has("token")) {
return;
}
url.searchParams.delete("token");
const qs = url.searchParams.toString();
window.history.replaceState(
{},
"",
`${url.pathname}${qs ? `?${qs}` : ""}${url.hash}`,
);
}
export function EntryGate(): ReactNode {
export function EntryGate() {
const router = useRouter();
const searchParams = useSearchParams();
const { t } = useTranslation("entry");
const { t: tc } = useTranslation("common");
const phase = usePlayerSessionStore((state) => state.phase);
const progress = usePlayerSessionStore((state) => state.progress);
const errorMessage = usePlayerSessionStore((state) => state.errorMessage);
const steps = usePlayerSessionStore((state) => state.steps);
const setBearerToken = usePlayerSessionStore((state) => state.setBearerToken);
const restoreBearerToken = usePlayerSessionStore(
(state) => state.restoreBearerToken,
);
const clearBearerToken = usePlayerSessionStore(
(state) => state.clearBearerToken,
);
const setProfile = usePlayerSessionStore((state) => state.setProfile);
const setPhase = usePlayerSessionStore((state) => state.setPhase);
const setProgress = usePlayerSessionStore((state) => state.setProgress);
const setErrorMessage = usePlayerSessionStore(
(state) => state.setErrorMessage,
);
const updateStep = usePlayerSessionStore((state) => state.updateStep);
const resetEntryFlow = usePlayerSessionStore((state) => state.resetEntryFlow);
const tokenFromUrl = searchParams.get("token") ?? "";
const applyProgress = useCallback(
(doneCount: number) => {
setProgress(Math.round((doneCount / 3) * 100));
},
[setProgress],
);
const { bearerToken, setBearerToken, setProfile, clearBearerToken } =
usePlayerSessionStore();
const runBootstrap = useCallback(async () => {
resetEntryFlow();
const [phase, setPhase] = useState<Phase>("loading");
const [progress, setProgress] = useState(0);
const [failureDetails, setFailureDetails] = useState<FailureRow[]>([]);
const [steps, setSteps] = useState<EntryStep[]>(initialSteps());
const fromQuery = normalizeTokenInput(searchParams.get("token"));
const fromStorage = normalizeTokenInput(restoreBearerToken());
const token = fromQuery ?? fromStorage;
const effectiveToken = tokenFromUrl || bearerToken;
if (fromQuery) {
stripTokenFromUrl();
}
const updateStep = useCallback((stepId: EntryStepId, status: EntryStepStatus) => {
setSteps((prev) => prev.map((s) => (s.id === stepId ? { ...s, status } : s)));
}, []);
if (!token) {
setPhase("error");
const sessionFlag = searchParams.get("session");
setErrorMessage(
sessionFlag === "expired"
? "登录已失效,请从主站重新进入彩票系统。"
: "缺少登录凭证,请从主站重新进入彩票系统。",
);
updateStep("token", "pending");
applyProgress(0);
const calculateProgress = useCallback((currentSteps: EntryStep[]) => {
const doneCount = currentSteps.filter((s) => s.status === "done").length;
const inProgressCount = currentSteps.filter((s) => s.status === "in-progress").length;
return Math.round(((doneCount + inProgressCount * 0.5) / currentSteps.length) * 100);
}, []);
useEffect(() => {
setProgress(calculateProgress(steps));
}, [steps, calculateProgress]);
const handleRetry = useCallback(() => {
setPhase("loading");
setFailureDetails([]);
setSteps(initialSteps());
}, []);
const doEntry = useCallback(async () => {
if (!effectiveToken) {
setPhase("failed");
setFailureDetails([{ code: "NO_TOKEN", detailKey: "errors.noTokenDetail" }]);
return;
}
setBearerToken(token);
if (tokenFromUrl) {
setBearerToken(tokenFromUrl);
}
try {
updateStep("token", "done");
applyProgress(1);
updateStep("account", "active");
setSteps((prev) =>
prev.map((s) => (s.id === "token" ? { ...s, status: "in-progress" } : s)),
);
const profile = await withEntryRetries(() => getPlayerMe());
setProfile(profile);
await sleep(500);
updateStep("account", "done");
applyProgress(2);
updateStep("hall", "active");
let lastError: unknown = null;
await withEntryRetries(() => getPlayerPing());
for (let attempt = 1; attempt <= RETRY_ATTEMPTS; attempt++) {
try {
const [me] = await Promise.all([getPlayerMe(), sleep(300)]);
updateStep("hall", "done");
applyProgress(3);
setProgress(100);
setPhase("success");
} catch (e) {
const authFailure =
e instanceof LotteryApiBizError ||
(isAxiosError(e) && e.response?.status === 401);
if (authFailure) {
clearBearerToken();
}
setPhase("error");
if (e instanceof LotteryApiBizError) {
setErrorMessage(
e.code === 8001 || e.code === 8002
? "授权已失效,请从主站重新进入。"
: e.message,
);
} else if (isAxiosError(e) && !e.response) {
setErrorMessage(
`网络异常,已重试 ${RETRY_ATTEMPTS} 次仍失败,请稍后再试。`,
);
} else if (isAxiosError(e)) {
setErrorMessage(e.message || "请求失败,请稍后重试。");
} else {
setErrorMessage(
e instanceof Error ? e.message : "进入彩票系统失败,请稍后重试。",
updateStep("token", "done");
updateStep("account", "done");
setSteps((prev) =>
prev.map((s) => (s.id === "hall" ? { ...s, status: "in-progress" } : s)),
);
await Promise.all([getPlayerPing(), sleep(300)]);
updateStep("hall", "done");
setProfile(me);
setPhase("success");
await sleep(600);
router.replace("/hall");
return;
} catch (err) {
lastError = err;
if (err instanceof LotteryApiBizError) {
updateStep("token", "error");
setPhase("failed");
const details: FailureRow[] = [];
if (typeof err.code === "number") {
const keyByCode: Partial<Record<number, string>> = {
401: "errors.http401",
403: "errors.http403",
404: "errors.http404",
};
const dk = keyByCode[err.code];
details.push({
code: String(err.code),
...(dk ? { detailKey: dk } : {}),
fallbackMessage:
typeof err.message === "string" ? err.message : undefined,
});
}
const rows =
details.length > 0
? details
: [
{
fallbackMessage: err.message ?? t("errors.unknown"),
},
];
setFailureDetails(rows);
clearBearerToken();
return;
}
if (!shouldRetryEntryRequest(err)) {
updateStep("token", "error");
setPhase("failed");
setFailureDetails([{ code: "NETWORK_ERROR", detailKey: "errors.networkDetail" }]);
return;
}
if (attempt < RETRY_ATTEMPTS) {
await sleep(RETRY_DELAY_MS);
}
}
}
updateStep("token", "error");
setPhase("failed");
setFailureDetails([
{ code: "MAX_RETRIES", detailKey: "errors.maxRetriesDetail" },
{
fallbackMessage:
lastError instanceof Error ? lastError.message : t("errors.tryLater"),
},
]);
}, [
applyProgress,
clearBearerToken,
resetEntryFlow,
restoreBearerToken,
searchParams,
effectiveToken,
tokenFromUrl,
setBearerToken,
setErrorMessage,
setPhase,
setProfile,
setProgress,
clearBearerToken,
router,
updateStep,
t,
]);
useEffect(() => {
void runBootstrap();
}, [runBootstrap]);
const tmr = window.setTimeout(() => {
void doEntry();
}, 300);
return () => window.clearTimeout(tmr);
}, [doEntry]);
return (
<div className="flex min-h-dvh flex-col bg-gradient-to-b from-red-800 via-red-900 to-red-950 text-white">
<header className="flex h-14 shrink-0 items-center justify-between px-4 sm:h-16">
<span className="text-lg font-bold tracking-tight">Lottery</span>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="gap-1 text-white hover:bg-white/10 hover:text-white"
type="button"
disabled
aria-label="语言(即将支持)"
>
<Languages className="size-4 opacity-80" />
<span className="text-xs opacity-90">EN</span>
</Button>
<Button
variant="ghost"
size="icon-sm"
className="text-white hover:bg-white/10"
type="button"
disabled
aria-label="通知(即将支持)"
>
<Bell className="size-4 opacity-80" />
</Button>
<div className="relative flex min-h-dvh flex-col bg-white">
<div className="relative h-[45vh] min-h-[320px] bg-red-600">
<div className="pointer-events-none absolute inset-0 overflow-hidden">
<Image
src="/entry/image1.png"
alt={t("header.backgroundAlt")}
fill
className="object-cover object-center"
priority
/>
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-red-600/20 to-red-600/80" />
</div>
</header>
<div className="flex min-h-0 flex-1 flex-col justify-center px-4 py-6 sm:py-8">
<div className="mx-auto w-full max-w-lg">
<p className="mb-6 text-center text-sm text-white/85">
Play smart. Win more.
</p>
{phase === "loading" || phase === "success" ? (
<Card className="border-0 shadow-xl">
<CardHeader className="text-center">
<CardTitle className="text-foreground">
{phase === "success"
? "授权成功"
: "正在进入彩票系统"}
</CardTitle>
<CardDescription>
{phase === "success"
? "即将跳转至下注大厅"
: "请稍候,正在连接服务器"}
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-6">
<div>
<div className="mb-2 flex justify-between text-xs text-muted-foreground">
<span></span>
<span>{progress}%</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-[width] duration-500 ease-out"
style={{ width: `${progress}%` }}
/>
</div>
</div>
<ul className="flex flex-col gap-3">
{steps.map((s) => (
<li
key={s.id}
className="flex items-center gap-3 text-sm text-foreground"
>
{s.status === "done" ? (
<Check
aria-hidden
className="size-4 shrink-0 text-green-600"
/>
) : s.status === "active" ? (
<Loader2
aria-hidden
className="size-4 shrink-0 animate-spin text-primary"
/>
) : (
<span
aria-hidden
className="size-4 shrink-0 rounded-full border border-muted-foreground/40"
/>
)}
<span className="flex-1">{s.label}</span>
<span className="text-xs text-muted-foreground">
{s.status === "done"
? "完成"
: s.status === "active"
? "进行中"
: "等待"}
</span>
</li>
))}
</ul>
</CardContent>
{phase === "success" ? (
<CardFooter className="flex flex-col gap-2">
<Button
className="w-full"
type="button"
onClick={() => router.push("/hall")}
>
<ChevronRight data-icon="inline-end" />
</Button>
</CardFooter>
) : null}
</Card>
) : null}
{phase === "error" ? (
<Card className="border-destructive/30 bg-destructive/5 shadow-xl">
<CardHeader className="text-center">
<div className="mx-auto mb-2 flex size-12 items-center justify-center rounded-full bg-destructive/15">
<AlertTriangle className="size-7 text-destructive" />
</div>
<CardTitle className="text-destructive"></CardTitle>
<CardDescription className="text-destructive/90">
{errorMessage}
</CardDescription>
</CardHeader>
<CardContent className="rounded-lg border border-border bg-card p-3 text-left text-foreground">
<p className="mb-2 text-xs font-medium text-muted-foreground">
</p>
<ul className="flex flex-col gap-1 text-xs text-muted-foreground">
<li> Token </li>
<li> </li>
<li> </li>
</ul>
</CardContent>
<CardFooter className="flex flex-col gap-2">
{MAIN_SITE_URL ? (
<a
href={MAIN_SITE_URL}
className={cn(
buttonVariants({ variant: "destructive" }),
"w-full justify-center no-underline",
)}
>
</a>
) : (
<Button
variant="destructive"
className="w-full"
type="button"
disabled
title="未配置 NEXT_PUBLIC_MAIN_SITE_URL"
>
</Button>
)}
<Button
variant="outline"
className="w-full bg-transparent text-foreground"
type="button"
onClick={() => {
resetEntryFlow();
void runBootstrap();
}}
>
</Button>
</CardFooter>
</Card>
) : null}
<div className="absolute left-0 right-0 top-0 z-20 flex items-center px-4 py-3">
<LanguageSwitcher variant="header" showFlag={false} />
</div>
</div>
<footer className="flex h-14 shrink-0 items-center justify-center gap-2 px-4 text-xs text-white/70 sm:h-16">
<Shield className="size-3.5 shrink-0 opacity-80" />
<span>Secure · Trusted · Authorized access</span>
</footer>
<div className="flex flex-1 flex-col px-4 py-6">
{phase === "loading" ? (
<div className="mx-auto w-full max-w-md">
<div className="mb-6 flex items-center gap-2">
<div className="flex size-8 items-center justify-center rounded bg-red-600 text-white">
<Globe className="size-5" aria-hidden />
</div>
<span className="font-medium text-gray-800">{t("loading.title")}</span>
</div>
<div className="mb-6">
<div className="mb-2 flex justify-between text-xs text-gray-500">
<span>{t("loading.progress")}</span>
<span className="font-medium text-red-600">{progress}%</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-gray-200">
<div
className="h-full rounded-full bg-red-600 transition-all duration-500"
style={{ width: `${progress}%` }}
/>
</div>
</div>
<div className="space-y-4">
{steps.map((step) => (
<div key={step.id} className="flex items-start gap-3">
<div
className={cn(
"flex size-8 shrink-0 items-center justify-center rounded-full border-2",
step.status === "done" &&
"border-green-500 bg-green-500 text-white",
step.status === "in-progress" &&
"border-blue-600 bg-blue-600 text-white",
step.status === "pending" &&
"border-gray-300 bg-gray-100 text-gray-400",
step.status === "error" && "border-red-500 bg-red-500 text-white",
)}
>
{step.status === "done" ? (
<CheckCircle2 className="size-4" aria-hidden />
) : null}
{step.status === "in-progress" ? (
<Loader2 className="size-4 animate-spin" aria-hidden />
) : null}
{step.status === "pending" ? (
<div className="size-2 rounded-full bg-gray-400" aria-hidden />
) : null}
{step.status === "error" ? (
<AlertCircle className="size-4" aria-hidden />
) : null}
</div>
<div className="flex-1">
<div className="flex items-center justify-between">
<span
className={cn(
"font-medium",
step.status === "done" && "text-green-600",
step.status === "in-progress" && "text-blue-600",
step.status === "pending" && "text-gray-600",
step.status === "error" && "text-red-600",
)}
>
{t(`steps.${step.id}.title`)}
</span>
<EntryStatusBadge status={step.status} />
</div>
<p className="text-xs text-gray-500">
{t(`steps.${step.id}.description`)}
</p>
</div>
</div>
))}
</div>
</div>
) : null}
{phase === "failed" ? (
<div className="mx-auto w-full max-w-md">
<div className="mb-6 text-center">
<div className="mx-auto mb-4 flex size-16 items-center justify-center rounded-full bg-red-100 text-red-600">
<AlertTriangle className="size-8" aria-hidden />
</div>
<h2 className="mb-1 text-xl font-bold text-red-600">{t("failure.title")}</h2>
<p className="text-sm text-gray-600">{t("failure.subtitle")}</p>
</div>
{failureDetails.length > 0 ? (
<div className="mb-6 overflow-hidden rounded-lg border border-red-200 bg-red-50">
<div className="border-b border-red-200 bg-red-100 px-4 py-2">
<span className="text-sm font-medium text-red-800">
{t("failure.detailsTitle")}
</span>
</div>
<table className="w-full text-sm">
<thead className="bg-red-100/50 text-xs">
<tr>
<th className="px-3 py-2 text-left font-medium text-red-700">
{t("failure.table.no")}
</th>
<th className="px-3 py-2 text-left font-medium text-red-700">
{t("failure.table.check")}
</th>
<th className="px-3 py-2 text-left font-medium text-red-700">
{t("failure.table.reason")}
</th>
</tr>
</thead>
<tbody>
{failureDetails.map((detail, idx) => (
<tr key={`${detail.code}-${idx}`} className="border-t border-red-100">
<td className="px-3 py-2 text-gray-600">{idx + 1}</td>
<td className="px-3 py-2 text-gray-800">
{detail.code ?? tc("errors.general")}
</td>
<td className="px-3 py-2 text-gray-600">
{detail.detailKey
? t(detail.detailKey)
: (detail.fallbackMessage ?? t("errors.unknown"))}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
<Button
onClick={handleRetry}
className="w-full gap-2 bg-red-600 text-white hover:bg-red-700"
size="lg"
type="button"
>
<Loader2 className="size-4" aria-hidden />
{t("failure.reenter")}
</Button>
</div>
) : null}
{phase === "success" ? (
<div className="mx-auto w-full max-w-md text-center">
<div className="mb-6">
<div className="mx-auto mb-4 flex size-16 items-center justify-center rounded-full bg-green-100 text-green-600">
<CheckCircle2 className="size-8" aria-hidden />
</div>
<h2 className="mb-1 text-xl font-bold text-green-600">
{t("success.title")}
</h2>
<p className="text-sm text-gray-600">{t("success.subtitle")}</p>
</div>
<div className="mb-6 space-y-3">
{steps.map((step) => (
<div key={step.id} className="flex items-center gap-3">
<div className="flex size-6 items-center justify-center rounded-full bg-green-500 text-white">
<CheckCircle2 className="size-4" aria-hidden />
</div>
<span className="flex-1 text-left font-medium text-gray-700">
{t(`steps.${step.id}.title`)}
</span>
<span className="text-xs text-green-600">{t("success.doneLabel")}</span>
<CheckCircle2 className="size-4 text-green-500" aria-hidden />
</div>
))}
</div>
<Button
onClick={() => router.push("/hall")}
className="w-full gap-2 bg-blue-600 text-white hover:bg-blue-700"
size="lg"
type="button"
>
{t("success.continue")}
<ChevronRight className="size-4" aria-hidden />
</Button>
</div>
) : null}
</div>
<div className="flex items-center justify-center gap-2 py-4 text-xs text-gray-500">
<ShieldCheck className="size-4 text-red-500" aria-hidden />
<span>{t("footer.secure")}</span>
</div>
</div>
);
}
function EntryStatusBadge({ status }: { status: EntryStepStatus }) {
const { t } = useTranslation("common");
if (status === "done") {
return (
<span className="flex items-center gap-1 rounded bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700">
{t("status.done")}
<CheckCircle2 className="size-3" aria-hidden />
</span>
);
}
if (status === "in-progress") {
return (
<span className="flex items-center gap-1 rounded bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700">
{t("status.inProgress")}
<Loader2 className="size-3 animate-spin" aria-hidden />
</span>
);
}
if (status === "pending") {
return (
<span className="rounded bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-500">
{t("status.pending")}
</span>
);
}
return (
<span className="rounded bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700">
{t("status.failed")}
</span>
);
}

View File

@@ -1,18 +1,5 @@
// WebSocket Connection Management
export {
useWebSocketManager,
type UseWebSocketManagerReturn,
} from "./use-websocket-manager";
// Wallet Polling
export {
useWalletPolling,
triggerWalletPollingAfterBet,
type UseWalletPollingReturn,
} from "./use-wallet-polling";
// Network Status
export {
useNetworkStatus,
useIsOffline,
} from "./use-network-status";
// Hooks 导出
export { useNetworkStatus, useIsOffline } from "./use-network-status";
export { useTokenRefresh } from "./use-token-refresh";
export { useWalletPolling, triggerWalletPollingAfterBet } from "./use-wallet-polling";
export { useWebSocketManager } from "./use-websocket-manager";

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,
};
}

View File

@@ -4,7 +4,7 @@ import { useCallback, useEffect, useRef } from "react";
import { getDrawCurrent } from "@/api/draw";
import { getWalletBalance } from "@/api/wallet";
import { getLotteryEcho, disconnectLotteryEcho } from "@/lib/lottery-echo";
import { getLotteryEcho } from "@/lib/lottery-echo";
import {
useNetworkConnectionStore,
type NetworkMode,
@@ -50,9 +50,6 @@ export function useWebSocketManager(): UseWebSocketManagerReturn {
isWebSocketConnected,
isReconnecting,
reconnectAttempts,
drawPollingIntervalId,
walletPollingIntervalId,
walletPollingExpiryAt,
setWebSocketConnected,
setReconnecting,
incrementReconnectAttempts,
@@ -60,9 +57,6 @@ export function useWebSocketManager(): UseWebSocketManagerReturn {
setLastDisconnectedAt,
switchToPollingMode,
switchToWebSocketMode,
setDrawPollingIntervalId,
setWalletPollingIntervalId,
setWalletPollingExpiryAt,
clearWalletPolling,
} = store;
@@ -86,72 +80,56 @@ export function useWebSocketManager(): UseWebSocketManagerReturn {
}
}, []);
// 开始画作数据轮询30秒间隔
// 画作轮询:用 getState() 读/写 timer id避免 callback 依赖 id → effect含卸载清理连环重跑导致「Maximum update depth」
const startDrawPolling = useCallback(() => {
// 先停止现有的轮询
if (drawPollingIntervalId) {
window.clearInterval(drawPollingIntervalId);
const s = useNetworkConnectionStore.getState();
const prevId = s.drawPollingIntervalId;
if (prevId !== null) {
window.clearInterval(prevId);
}
// 立即执行一次
void refreshDraw();
// 设置轮询
const intervalId = window.setInterval(() => {
void refreshDraw();
}, POLLING_INTERVAL_MS);
setDrawPollingIntervalId(intervalId);
}, [drawPollingIntervalId, refreshDraw, setDrawPollingIntervalId]);
s.setDrawPollingIntervalId(intervalId);
}, [refreshDraw]);
// 停止画作数据轮询
const stopDrawPolling = useCallback(() => {
if (drawPollingIntervalId) {
window.clearInterval(drawPollingIntervalId);
setDrawPollingIntervalId(null);
}
}, [drawPollingIntervalId, setDrawPollingIntervalId]);
// 开始钱包轮询
// 钱包轮询
const startWalletPolling = useCallback(
(options?: { limitedDuration?: boolean }) => {
const { limitedDuration = false } = options ?? {};
const s0 = useNetworkConnectionStore.getState();
// 先停止现有的轮询
if (walletPollingIntervalId) {
window.clearInterval(walletPollingIntervalId);
const prevWalletId = s0.walletPollingIntervalId;
if (prevWalletId !== null) {
window.clearInterval(prevWalletId);
}
// 立即执行一次
void refreshWallet();
// 设置轮询
const intervalId = window.setInterval(() => {
// 检查限时轮询是否过期
if (limitedDuration && walletPollingExpiryAt) {
if (Date.now() > walletPollingExpiryAt) {
clearWalletPolling();
return;
}
const s = useNetworkConnectionStore.getState();
if (
limitedDuration &&
s.walletPollingExpiryAt !== null &&
Date.now() > s.walletPollingExpiryAt
) {
s.clearWalletPolling();
return;
}
void refreshWallet();
}, POLLING_INTERVAL_MS);
setWalletPollingIntervalId(intervalId);
s0.setWalletPollingIntervalId(intervalId);
// 设置限时轮询的过期时间
if (limitedDuration) {
setWalletPollingExpiryAt(Date.now() + WALLET_POLLING_DURATION_MS);
s0.setWalletPollingExpiryAt(Date.now() + WALLET_POLLING_DURATION_MS);
}
},
[
walletPollingIntervalId,
walletPollingExpiryAt,
refreshWallet,
setWalletPollingIntervalId,
setWalletPollingExpiryAt,
clearWalletPolling,
],
[refreshWallet],
);
// 停止钱包轮询
@@ -329,16 +307,15 @@ export function useWebSocketManager(): UseWebSocketManagerReturn {
switchToPollingMode,
]);
// 清理函数
// 仅挂载卸载时清理;勿依赖 stop*(否则每次 setInterval id 变化都会触发清理 → setState → 无线循环)
useEffect(() => {
return () => {
stopDrawPolling();
stopWalletPolling();
if (reconnectTimerRef.current) {
useNetworkConnectionStore.getState().clearAllPollingIntervals();
if (reconnectTimerRef.current !== null) {
window.clearTimeout(reconnectTimerRef.current);
}
};
}, [stopDrawPolling, stopWalletPolling]);
}, []);
return {
mode,

View File

85
src/i18n/index.ts Normal file
View File

@@ -0,0 +1,85 @@
import i18n from "i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import { initReactI18next } from "react-i18next";
import enCommon from "./locales/en/common.json";
import enEntry from "./locales/en/entry.json";
import enLayout from "./locales/en/layout.json";
import neCommon from "./locales/ne/common.json";
import neEntry from "./locales/ne/entry.json";
import neLayout from "./locales/ne/layout.json";
import zhCommon from "./locales/zh/common.json";
import zhEntry from "./locales/zh/entry.json";
import zhLayout from "./locales/zh/layout.json";
/** 对齐后端与产品:尼泊尔语 / 英语 / 中文(简体) */
export const SUPPORTED_LANGUAGES = [
{ code: "en" as const, flag: "🇺🇸" },
{ code: "ne" as const, flag: "🇳🇵" },
{ code: "zh" as const, flag: "🇨🇳" },
];
export type AppLanguage = (typeof SUPPORTED_LANGUAGES)[number]["code"];
export const DEFAULT_LANGUAGE: AppLanguage = "en";
const namespaces = ["common", "entry", "layout"] as const;
const resources = {
en: {
common: enCommon,
entry: enEntry,
layout: enLayout,
},
ne: {
common: neCommon,
entry: neEntry,
layout: neLayout,
},
zh: {
common: zhCommon,
entry: zhEntry,
layout: zhLayout,
},
} satisfies Record<
AppLanguage,
Record<(typeof namespaces)[number], Record<string, unknown>>
>;
export function normalizeLanguage(lang: string | undefined): AppLanguage {
const base = lang?.split("-")[0]?.toLowerCase();
if (base === "ne") return "ne";
if (base === "zh") return "zh";
return "en";
}
if (!i18n.isInitialized) {
void i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources,
fallbackLng: DEFAULT_LANGUAGE,
supportedLngs: ["en", "ne", "zh"],
defaultNS: "common",
ns: [...namespaces],
/** zh-CN → zhne-NP → ne未匹配时用 fallbackLng */
load: "languageOnly",
detection: {
order: ["localStorage", "navigator"],
caches: ["localStorage"],
lookupLocalStorage: "i18nextLng",
},
interpolation: {
escapeValue: false,
},
react: {
useSuspense: false,
},
});
}
export default i18n;

View File

@@ -0,0 +1,21 @@
{
"language": {
"en": "English",
"ne": "नेपाली",
"zh": "中文"
},
"languageShort": {
"en": "EN",
"ne": "NE",
"zh": "ZH"
},
"status": {
"done": "Done",
"inProgress": "In progress",
"pending": "Pending",
"failed": "Failed"
},
"errors": {
"general": "General"
}
}

View File

@@ -0,0 +1,65 @@
{
"header": {
"backgroundAlt": "Header background"
},
"loading": {
"title": "Loading lottery hall",
"progress": "Progress"
},
"steps": {
"token": {
"title": "Checking token",
"description": "Verifying your session securely."
},
"account": {
"title": "Creating account",
"description": "Setting up your account."
},
"hall": {
"title": "Loading lottery hall",
"description": "Preparing the lottery hall."
}
},
"messages": {
"initializing": "Initializing…",
"retrying": "Retrying…",
"verifying": "Verifying session…",
"connectingHall": "Connecting to lottery hall…",
"ready": "Ready",
"retryProgress": "Retrying ({{current}}/{{total}})…"
},
"failure": {
"title": "Authorization failed",
"subtitle": "We couldnt authorize your session. Please try again.",
"detailsTitle": "Failure details",
"table": {
"no": "No.",
"check": "Check",
"reason": "Reason"
},
"reenter": "Re-enter"
},
"success": {
"title": "Authorization successful!",
"subtitle": "Your session has been verified successfully.",
"doneLabel": "Done",
"continue": "Continue to lottery hall"
},
"footer": {
"secure": "Secure. Trusted. Authorized access."
},
"errors": {
"noToken": "No authorization token found",
"noTokenDetail": "Please return to the main site and try again.",
"authFailed": "Authorization failed",
"unknown": "Unknown error",
"network": "Network error occurred",
"networkDetail": "Please check your internet connection and try again.",
"maxRetries": "Unable to connect after multiple attempts",
"maxRetriesDetail": "The server may be temporarily unavailable.",
"tryLater": "Please try again later",
"http401": "Token is invalid or expired",
"http403": "Account has been blocked",
"http404": "Account not found in the system"
}
}

View File

@@ -0,0 +1,5 @@
{
"brand": {
"title": "Lottery"
}
}

View File

@@ -0,0 +1,21 @@
{
"language": {
"en": "English",
"ne": "नेपाली",
"zh": "中文"
},
"languageShort": {
"en": "EN",
"ne": "NE",
"zh": "ZH"
},
"status": {
"done": "पूरा",
"inProgress": "जारी",
"pending": "बाँकी",
"failed": "असफल"
},
"errors": {
"general": "सामान्य"
}
}

View File

@@ -0,0 +1,65 @@
{
"header": {
"backgroundAlt": "हेडर पृष्ठभूमि"
},
"loading": {
"title": "लटरी हल लोड हुँदैछ",
"progress": "प्रगति"
},
"steps": {
"token": {
"title": "टोकन जाँच गर्दै",
"description": "तपाईंको सत्र सुरक्षित रूपमा प्रमाणित गर्दै।"
},
"account": {
"title": "खाता सिर्जना गर्दै",
"description": "तपाईंको खाता सेट अप गर्दै।"
},
"hall": {
"title": "लटरी हल लोड गर्दै",
"description": "लटरी हल तयार गर्दै।"
}
},
"messages": {
"initializing": "सुरु गर्दै…",
"retrying": "पुन: प्रयास गर्दै…",
"verifying": "सत्र प्रमाणित गर्दै…",
"connectingHall": "लटरी हलमा जोडिँदै…",
"ready": "तयार",
"retryProgress": "पुन: प्रयास ({{current}}/{{total}})…"
},
"failure": {
"title": "प्राधिकरण असफल",
"subtitle": "हामीले तपाईंको सत्र प्राधिकृत गर्न सकेनौं। कृपया फेरि प्रयास गर्नुहोस्।",
"detailsTitle": "विफलताको विवरण",
"table": {
"no": "क्र.सं.",
"check": "जाँच",
"reason": "कारण"
},
"reenter": "पुन: प्रवेश"
},
"success": {
"title": "प्राधिकरण सफल!",
"subtitle": "तपाईंको सत्र सफलतापूर्वक प्रमाणित भयो।",
"doneLabel": "पूरा",
"continue": "लटरी हलमा जानुहोस्"
},
"footer": {
"secure": "सुरक्षित। विश्वसनीय। अधिकृत पहुँच।"
},
"errors": {
"noToken": "कुनै प्राधिकरण टोकन फेला परेन",
"noTokenDetail": "कृपया मुख्य साइटमा फर्कनुहोस् र फेरि प्रयास गर्नुहोस्।",
"authFailed": "प्राधिकरण असफल",
"unknown": "अज्ञात त्रुटि",
"network": "नेटवर्क त्रुटि भयो",
"networkDetail": "कृपया आफ्नो इन्टरनेट जाँच गर्नुहोस् र फेरि प्रयास गर्नुहोस्।",
"maxRetries": "धेरै पटक पछि पनि जडान हुन सकेन",
"maxRetriesDetail": "सर्भर अस्थायी रूपमा अनुपलब्ध हुन सक्छ।",
"tryLater": "कृपया पछि प्रयास गर्नुहोस्",
"http401": "टोकन अमान्य वा म्याद सकियो",
"http403": "खाता रोकिएको छ",
"http404": "प्रणालीमा खाता फेला परेन"
}
}

View File

@@ -0,0 +1,5 @@
{
"brand": {
"title": "लटरी"
}
}

View File

@@ -0,0 +1,21 @@
{
"language": {
"en": "English",
"ne": "नेपाली",
"zh": "中文"
},
"languageShort": {
"en": "EN",
"ne": "NE",
"zh": "ZH"
},
"status": {
"done": "完成",
"inProgress": "进行中",
"pending": "待处理",
"failed": "失败"
},
"errors": {
"general": "通用"
}
}

View File

@@ -0,0 +1,65 @@
{
"header": {
"backgroundAlt": "页头背景"
},
"loading": {
"title": "正在进入彩票大厅",
"progress": "进度"
},
"steps": {
"token": {
"title": "校验登录凭证",
"description": "正在安全验证您的会话。"
},
"account": {
"title": "创建/同步账号",
"description": "正在为您设置账号。"
},
"hall": {
"title": "加载彩票大厅",
"description": "正在准备彩票大厅。"
}
},
"messages": {
"initializing": "正在初始化…",
"retrying": "正在重试…",
"verifying": "正在校验会话…",
"connectingHall": "正在连接彩票大厅…",
"ready": "就绪",
"retryProgress": "正在重试({{current}}/{{total}})…"
},
"failure": {
"title": "授权失败",
"subtitle": "无法完成授权,请重试。",
"detailsTitle": "失败详情",
"table": {
"no": "序号",
"check": "检查项",
"reason": "原因"
},
"reenter": "重新进入"
},
"success": {
"title": "授权成功!",
"subtitle": "您的会话已通过验证。",
"doneLabel": "完成",
"continue": "进入彩票大厅"
},
"footer": {
"secure": "安全 · 可信 · 授权访问"
},
"errors": {
"noToken": "未发现授权令牌",
"noTokenDetail": "请返回主站后重试。",
"authFailed": "授权失败",
"unknown": "未知错误",
"network": "网络异常",
"networkDetail": "请检查网络连接后重试。",
"maxRetries": "多次重试仍无法连接",
"maxRetriesDetail": "服务器可能暂时不可用。",
"tryLater": "请稍后再试",
"http401": "令牌无效或已过期",
"http403": "账号已被封禁",
"http404": "系统中未找到该账号"
}
}

View File

@@ -0,0 +1,5 @@
{
"brand": {
"title": "彩票"
}
}

View File

109
src/lib/csp-config.ts Normal file
View File

@@ -0,0 +1,109 @@
/**
* Content Security Policy (CSP) 配置
*
* 支持 iframe 嵌入场景,允许主站加载彩票系统
*/
// 允许的主站来源
const ALLOWED_PARENT_ORIGINS: string[] = [
process.env.NEXT_PUBLIC_MAIN_SITE_URL,
process.env.NEXT_PUBLIC_PARENT_ORIGIN,
// 开发环境
"http://localhost:3001",
"http://127.0.0.1:3001",
// 生产环境应从环境变量读取
].filter((o): o is string => Boolean(o));
/**
* 生成 CSP 指令字符串
*/
export function generateCSP(): string {
const directives: Record<string, string[]> = {
// 默认只允许同源
"default-src": ["'self'"],
// 脚本允许同源和内联Next.js 需要)
"script-src": ["'self'", "'unsafe-inline'", "'unsafe-eval'"],
// 样式允许同源和内联
"style-src": ["'self'", "'unsafe-inline'"],
// 图片允许同源、data URL 和 blob
"img-src": ["'self'", "data:", "blob:"],
// 字体允许同源
"font-src": ["'self'"],
// 连接允许同源和 API 域名
"connect-src": [
"'self'",
process.env.NEXT_PUBLIC_API_URL || "",
// WebSocket 连接
"ws:",
"wss:",
].filter(Boolean),
// 媒体允许同源和 blob
"media-src": ["'self'", "blob:"],
// 对象不允许
"object-src": ["'none'"],
// 框架允许同源和指定父站
"frame-src": ["'self'", ...ALLOWED_PARENT_ORIGINS],
// 允许被嵌入到指定父站
"frame-ancestors": ["'self'", ...ALLOWED_PARENT_ORIGINS],
// 表单提交允许同源
"form-action": ["'self'"],
// 不升级 HTTPS
"upgrade-insecure-requests": [],
};
// 构建 CSP 字符串
return Object.entries(directives)
.map(([key, values]) => {
if (values.length === 0) return key;
return `${key} ${values.join(" ")}`;
})
.join("; ");
}
/**
* 检测是否允许被 iframe 嵌入
* @param parentOrigin 父窗口来源
*/
export function isAllowedParent(parentOrigin: string): boolean {
if (ALLOWED_PARENT_ORIGINS.length === 0) return true; // 未配置时允许所有
return ALLOWED_PARENT_ORIGINS.some(
(origin) => origin && parentOrigin.startsWith(origin),
);
}
/**
* 安全头配置(用于 next.config.ts
*/
export const securityHeaders = [
{
key: "Content-Security-Policy",
value: generateCSP(),
},
{
key: "X-Frame-Options",
value: "SAMEORIGIN", // 允许同源,通过 CSP frame-ancestors 控制跨域
},
{
key: "X-Content-Type-Options",
value: "nosniff",
},
{
key: "Referrer-Policy",
value: "strict-origin-when-cross-origin",
},
{
key: "Permissions-Policy",
value: "camera=(), microphone=(), geolocation=()",
},
];