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
-
-
-
+
+
+
-
-
-
-
- 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}
+
+
-
+
+ {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")}
+
+
+
+
+
+ |
+ {t("failure.table.no")}
+ |
+
+ {t("failure.table.check")}
+ |
+
+ {t("failure.table.reason")}
+ |
+
+
+
+ {failureDetails.map((detail, idx) => (
+
+ | {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=()",
+ },
+];