diff --git a/.env.example b/.env.example index b5a3b81..7ef3261 100644 --- a/.env.example +++ b/.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 \ No newline at end of file +# 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 \ No newline at end of file diff --git a/next.config.ts b/next.config.ts index 2372b96..224128a 100644 --- a/next.config.ts +++ b/next.config.ts @@ -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 [ { diff --git a/package-lock.json b/package-lock.json index a669e2d..ca773df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 38ec61b..f9cad64 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/entry/image1.png b/public/entry/image1.png new file mode 100644 index 0000000..241cf4e Binary files /dev/null and b/public/entry/image1.png differ diff --git a/public/entry/image2.png b/public/entry/image2.png new file mode 100644 index 0000000..c397792 Binary files /dev/null and b/public/entry/image2.png differ diff --git a/public/entry/image3.png b/public/entry/image3.png new file mode 100644 index 0000000..aa01a60 Binary files /dev/null and b/public/entry/image3.png differ diff --git a/public/logo.png b/public/logo.png new file mode 100644 index 0000000..494e884 Binary files /dev/null and b/public/logo.png differ diff --git a/public/parent-integration-example.html b/public/parent-integration-example.html new file mode 100644 index 0000000..d6b2680 --- /dev/null +++ b/public/parent-integration-example.html @@ -0,0 +1,358 @@ + + + + + + 主站集成示例 - 彩票 iframe 嵌入 + + + +
+

🎰 彩票系统 - 主站 iframe 集成示例

+ +
+

控制面板

+ +
+ + +
+ +
+ + +
+ +
+ + + + +
+ + +
+ +
+

彩票系统 iframe

+ +
+ +
+
等待初始化...
+
+
+ + + + diff --git a/src/components/iframe-bridge.tsx b/src/components/iframe-bridge.tsx new file mode 100644 index 0000000..ae0c979 --- /dev/null +++ b/src/components/iframe-bridge.tsx @@ -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): 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).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 || "", + }; +} diff --git a/src/components/language-switcher.tsx b/src/components/language-switcher.tsx new file mode 100644 index 0000000..601918d --- /dev/null +++ b/src/components/language-switcher.tsx @@ -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(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 { + 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 ( +
+ + + {isOpen ? ( +
+
+ {options.map((option) => ( + + ))} +
+
+ ) : null} +
+ ); +} + +export function LanguageSwitcherMinimal({ + className, +}: { + className?: string; +}): ReactNode { + return ( + + ); +} diff --git a/src/components/layout/player-app-shell.tsx b/src/components/layout/player-app-shell.tsx index 221bde0..0278657 100644 --- a/src/components/layout/player-app-shell.tsx +++ b/src/components/layout/player-app-shell.tsx @@ -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 (
{/* 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")} +
diff --git a/src/components/providers.tsx b/src/components/providers.tsx index 9dad5fc..61ac75f 100644 --- a/src/components/providers.tsx +++ b/src/components/providers.tsx @@ -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 ( - {children} + {/* iframe 通信桥接 - 支持主站嵌入 */} + + {children} + {/* Token 续签指示器 - 显示在右下角 */} + + diff --git a/src/components/token-refresh-indicator.tsx b/src/components/token-refresh-indicator.tsx new file mode 100644 index 0000000..585315a --- /dev/null +++ b/src/components/token-refresh-indicator.tsx @@ -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(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 => { + setIsRefreshing(true); + try { + await refreshToken(); + } finally { + setIsRefreshing(false); + } + }; + + if (!showWarning) { + return null; + } + + const isCritical = remainingSeconds !== null && remainingSeconds < 60; + + return ( +
+ + +
+ + {isCritical ? "登录即将失效" : "登录即将过期"} + + + {remainingSeconds !== null && ( + <> + 剩余 {Math.floor(remainingSeconds / 60)}: + {String(remainingSeconds % 60).padStart(2, "0")} {" "} + + )} + 正在自动续签... + +
+ + +
+ ); +} diff --git a/src/features/hall/hall-wallet-strip.tsx b/src/features/hall/hall-wallet-strip.tsx index afcb358..1bd98e3 100644 --- a/src/features/hall/hall-wallet-strip.tsx +++ b/src/features/hall/hall-wallet-strip.tsx @@ -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(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(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); diff --git a/src/features/player/entry-gate.tsx b/src/features/player/entry-gate.tsx index e40c503..28d74bb 100644 --- a/src/features/player/entry-gate.tsx +++ b/src/features/player/entry-gate.tsx @@ -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 { 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(fn: () => Promise): Promise { - 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("loading"); + const [progress, setProgress] = useState(0); + const [failureDetails, setFailureDetails] = useState([]); + const [steps, setSteps] = useState(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> = { + 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 ( -
-
- Lottery -
- - +
+
+
+ {t("header.backgroundAlt")} +
-
-
-
-

- Play smart. Win more. -

- - {phase === "loading" || phase === "success" ? ( - - - - {phase === "success" - ? "授权成功" - : "正在进入彩票系统"} - - - {phase === "success" - ? "即将跳转至下注大厅" - : "请稍候,正在连接服务器"} - - - -
-
- 进度 - {progress}% -
-
-
-
-
-
    - {steps.map((s) => ( -
  • - {s.status === "done" ? ( - - ) : s.status === "active" ? ( - - ) : ( - - )} - {s.label} - - {s.status === "done" - ? "完成" - : s.status === "active" - ? "进行中" - : "等待"} - -
  • - ))} -
- - {phase === "success" ? ( - - - - ) : null} - - ) : null} - - {phase === "error" ? ( - - -
- -
- 授权失败 - - {errorMessage} - -
- -

- 常见原因 -

-
    -
  • • Token 无效或已过期
  • -
  • • 账号未授权或未建档
  • -
  • • 会话校验失败
  • -
-
- - {MAIN_SITE_URL ? ( - - 返回主站重新进入 - - ) : ( - - )} - - -
- ) : null} +
+
-
- - Secure · Trusted · Authorized access -
+
+ {phase === "loading" ? ( +
+
+
+ +
+ {t("loading.title")} +
+ +
+
+ {t("loading.progress")} + {progress}% +
+
+
+
+
+ +
+ {steps.map((step) => ( +
+
+ {step.status === "done" ? ( + + ) : null} + {step.status === "in-progress" ? ( + + ) : null} + {step.status === "pending" ? ( +
+ ) : null} + {step.status === "error" ? ( + + ) : null} +
+
+
+ + {t(`steps.${step.id}.title`)} + + +
+

+ {t(`steps.${step.id}.description`)} +

+
+
+ ))} +
+
+ ) : null} + + {phase === "failed" ? ( +
+
+
+ +
+

{t("failure.title")}

+

{t("failure.subtitle")}

+
+ + {failureDetails.length > 0 ? ( +
+
+ + {t("failure.detailsTitle")} + +
+ + + + + + + + + + {failureDetails.map((detail, idx) => ( + + + + + + ))} + +
+ {t("failure.table.no")} + + {t("failure.table.check")} + + {t("failure.table.reason")} +
{idx + 1} + {detail.code ?? tc("errors.general")} + + {detail.detailKey + ? t(detail.detailKey) + : (detail.fallbackMessage ?? t("errors.unknown"))} +
+
+ ) : null} + + +
+ ) : null} + + {phase === "success" ? ( +
+
+
+ +
+

+ {t("success.title")} +

+

{t("success.subtitle")}

+
+ +
+ {steps.map((step) => ( +
+
+ +
+ + {t(`steps.${step.id}.title`)} + + {t("success.doneLabel")} + +
+ ))} +
+ + +
+ ) : null} +
+ +
+ + {t("footer.secure")} +
); } + +function EntryStatusBadge({ status }: { status: EntryStepStatus }) { + const { t } = useTranslation("common"); + + if (status === "done") { + return ( + + {t("status.done")} + + + ); + } + if (status === "in-progress") { + return ( + + {t("status.inProgress")} + + + ); + } + if (status === "pending") { + return ( + + {t("status.pending")} + + ); + } + return ( + + {t("status.failed")} + + ); +} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index f1f7287..381b04a 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -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"; diff --git a/src/hooks/use-token-refresh.ts b/src/hooks/use-token-refresh.ts new file mode 100644 index 0000000..9b02416 --- /dev/null +++ b/src/hooks/use-token-refresh.ts @@ -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; + /** 当前 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(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 => { + 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, + }; +} diff --git a/src/hooks/use-websocket-manager.ts b/src/hooks/use-websocket-manager.ts index 6be99ec..35631a4 100644 --- a/src/hooks/use-websocket-manager.ts +++ b/src/hooks/use-websocket-manager.ts @@ -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, diff --git a/src/i18n/.gitkeep b/src/i18n/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/i18n/index.ts b/src/i18n/index.ts new file mode 100644 index 0000000..9706241 --- /dev/null +++ b/src/i18n/index.ts @@ -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> +>; + +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; diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json new file mode 100644 index 0000000..346877d --- /dev/null +++ b/src/i18n/locales/en/common.json @@ -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" + } +} diff --git a/src/i18n/locales/en/entry.json b/src/i18n/locales/en/entry.json new file mode 100644 index 0000000..0711ea8 --- /dev/null +++ b/src/i18n/locales/en/entry.json @@ -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" + } +} diff --git a/src/i18n/locales/en/layout.json b/src/i18n/locales/en/layout.json new file mode 100644 index 0000000..e33d6a3 --- /dev/null +++ b/src/i18n/locales/en/layout.json @@ -0,0 +1,5 @@ +{ + "brand": { + "title": "Lottery" + } +} diff --git a/src/i18n/locales/ne/common.json b/src/i18n/locales/ne/common.json new file mode 100644 index 0000000..5b4ce5f --- /dev/null +++ b/src/i18n/locales/ne/common.json @@ -0,0 +1,21 @@ +{ + "language": { + "en": "English", + "ne": "नेपाली", + "zh": "中文" + }, + "languageShort": { + "en": "EN", + "ne": "NE", + "zh": "ZH" + }, + "status": { + "done": "पूरा", + "inProgress": "जारी", + "pending": "बाँकी", + "failed": "असफल" + }, + "errors": { + "general": "सामान्य" + } +} diff --git a/src/i18n/locales/ne/entry.json b/src/i18n/locales/ne/entry.json new file mode 100644 index 0000000..ad7abec --- /dev/null +++ b/src/i18n/locales/ne/entry.json @@ -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": "प्रणालीमा खाता फेला परेन" + } +} diff --git a/src/i18n/locales/ne/layout.json b/src/i18n/locales/ne/layout.json new file mode 100644 index 0000000..2fe6c16 --- /dev/null +++ b/src/i18n/locales/ne/layout.json @@ -0,0 +1,5 @@ +{ + "brand": { + "title": "लटरी" + } +} diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json new file mode 100644 index 0000000..379ef22 --- /dev/null +++ b/src/i18n/locales/zh/common.json @@ -0,0 +1,21 @@ +{ + "language": { + "en": "English", + "ne": "नेपाली", + "zh": "中文" + }, + "languageShort": { + "en": "EN", + "ne": "NE", + "zh": "ZH" + }, + "status": { + "done": "完成", + "inProgress": "进行中", + "pending": "待处理", + "failed": "失败" + }, + "errors": { + "general": "通用" + } +} diff --git a/src/i18n/locales/zh/entry.json b/src/i18n/locales/zh/entry.json new file mode 100644 index 0000000..2b154db --- /dev/null +++ b/src/i18n/locales/zh/entry.json @@ -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": "系统中未找到该账号" + } +} diff --git a/src/i18n/locales/zh/layout.json b/src/i18n/locales/zh/layout.json new file mode 100644 index 0000000..2bba1b1 --- /dev/null +++ b/src/i18n/locales/zh/layout.json @@ -0,0 +1,5 @@ +{ + "brand": { + "title": "彩票" + } +} diff --git a/src/lib/.gitkeep b/src/lib/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/lib/csp-config.ts b/src/lib/csp-config.ts new file mode 100644 index 0000000..20289d4 --- /dev/null +++ b/src/lib/csp-config.ts @@ -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 = { + // 默认只允许同源 + "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=()", + }, +];