feat: 增强国际化支持与安全头配置
- 在 .env.example 中新增 i18next 相关配置项以支持多语言功能 - 在 next.config.ts 中添加安全头配置以支持 iframe 嵌入 - 更新 Providers 组件以引入 i18n 配置 - 在 PlayerAppShell 中集成 LanguageSwitcher 组件以实现语言切换功能 - 优化 HallWalletStrip 组件的网络状态管理逻辑 - 更新多个组件以支持国际化文本
This commit is contained in:
11
.env.example
11
.env.example
@@ -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 Reverb(WebSocket)。不配则 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
|
||||
@@ -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
97
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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
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
BIN
public/entry/image2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 737 KiB |
BIN
public/entry/image3.png
Normal file
BIN
public/entry/image3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
BIN
public/logo.png
Normal file
BIN
public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
358
public/parent-integration-example.html
Normal file
358
public/parent-integration-example.html
Normal 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('已生成测试 Token(5 分钟有效期)', '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>
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
211
src/hooks/use-token-refresh.ts
Normal file
211
src/hooks/use-token-refresh.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
85
src/i18n/index.ts
Normal file
85
src/i18n/index.ts
Normal 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 → zh,ne-NP → ne,未匹配时用 fallbackLng */
|
||||
load: "languageOnly",
|
||||
|
||||
detection: {
|
||||
order: ["localStorage", "navigator"],
|
||||
caches: ["localStorage"],
|
||||
lookupLocalStorage: "i18nextLng",
|
||||
},
|
||||
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
|
||||
react: {
|
||||
useSuspense: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default i18n;
|
||||
21
src/i18n/locales/en/common.json
Normal file
21
src/i18n/locales/en/common.json
Normal 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"
|
||||
}
|
||||
}
|
||||
65
src/i18n/locales/en/entry.json
Normal file
65
src/i18n/locales/en/entry.json
Normal 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 couldn’t 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"
|
||||
}
|
||||
}
|
||||
5
src/i18n/locales/en/layout.json
Normal file
5
src/i18n/locales/en/layout.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"brand": {
|
||||
"title": "Lottery"
|
||||
}
|
||||
}
|
||||
21
src/i18n/locales/ne/common.json
Normal file
21
src/i18n/locales/ne/common.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"language": {
|
||||
"en": "English",
|
||||
"ne": "नेपाली",
|
||||
"zh": "中文"
|
||||
},
|
||||
"languageShort": {
|
||||
"en": "EN",
|
||||
"ne": "NE",
|
||||
"zh": "ZH"
|
||||
},
|
||||
"status": {
|
||||
"done": "पूरा",
|
||||
"inProgress": "जारी",
|
||||
"pending": "बाँकी",
|
||||
"failed": "असफल"
|
||||
},
|
||||
"errors": {
|
||||
"general": "सामान्य"
|
||||
}
|
||||
}
|
||||
65
src/i18n/locales/ne/entry.json
Normal file
65
src/i18n/locales/ne/entry.json
Normal 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": "प्रणालीमा खाता फेला परेन"
|
||||
}
|
||||
}
|
||||
5
src/i18n/locales/ne/layout.json
Normal file
5
src/i18n/locales/ne/layout.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"brand": {
|
||||
"title": "लटरी"
|
||||
}
|
||||
}
|
||||
21
src/i18n/locales/zh/common.json
Normal file
21
src/i18n/locales/zh/common.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"language": {
|
||||
"en": "English",
|
||||
"ne": "नेपाली",
|
||||
"zh": "中文"
|
||||
},
|
||||
"languageShort": {
|
||||
"en": "EN",
|
||||
"ne": "NE",
|
||||
"zh": "ZH"
|
||||
},
|
||||
"status": {
|
||||
"done": "完成",
|
||||
"inProgress": "进行中",
|
||||
"pending": "待处理",
|
||||
"failed": "失败"
|
||||
},
|
||||
"errors": {
|
||||
"general": "通用"
|
||||
}
|
||||
}
|
||||
65
src/i18n/locales/zh/entry.json
Normal file
65
src/i18n/locales/zh/entry.json
Normal 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": "系统中未找到该账号"
|
||||
}
|
||||
}
|
||||
5
src/i18n/locales/zh/layout.json
Normal file
5
src/i18n/locales/zh/layout.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"brand": {
|
||||
"title": "彩票"
|
||||
}
|
||||
}
|
||||
109
src/lib/csp-config.ts
Normal file
109
src/lib/csp-config.ts
Normal 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=()",
|
||||
},
|
||||
];
|
||||
Reference in New Issue
Block a user