diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..14c8fef
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,14 @@
+# =============================================================================
+# 前端本地配置示例
+# =============================================================================
+
+# Next 开发服务代理目标:浏览器请求 /api/* 时由 Next 转发到这里。
+# 默认值已经在 next.config.ts 中兜底为 http://127.0.0.1:8000;本地 Laravel 端口不同时再改。
+LOTTERY_API_PROXY_TARGET=http://127.0.0.1:8000
+
+# 可选:如果设置此值,浏览器会绕过 Next 代理,直接请求该 API 地址。
+# 一般本地开发建议留空,让请求走同源 /api 代理,避免 CORS。
+# NEXT_PUBLIC_LOTTERY_API_BASE_URL=http://127.0.0.1:8000
+
+# 可选:入口授权失败时“返回主站重新进入”的地址。
+# NEXT_PUBLIC_MAIN_SITE_URL=http://localhost:5173
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 5ef6a52..7b8da95 100644
--- a/.gitignore
+++ b/.gitignore
@@ -32,6 +32,7 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
+!.env.example
# vercel
.vercel
diff --git a/next.config.ts b/next.config.ts
index 66e1566..2372b96 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -1,8 +1,19 @@
import type { NextConfig } from "next";
+const lotteryApiProxyTarget =
+ process.env.LOTTERY_API_PROXY_TARGET?.trim() || "http://127.0.0.1:8000";
+
const nextConfig: NextConfig = {
- /* config options here */
reactCompiler: true,
+
+ async rewrites() {
+ return [
+ {
+ source: "/api/:path*",
+ destination: `${lotteryApiProxyTarget}/api/:path*`,
+ },
+ ];
+ },
};
export default nextConfig;
diff --git a/package-lock.json b/package-lock.json
index d9a8fbd..2ddc091 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -19,7 +19,8 @@
"react-dom": "19.2.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
- "tw-animate-css": "^1.4.0"
+ "tw-animate-css": "^1.4.0",
+ "zustand": "^5.0.13"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@@ -10464,6 +10465,35 @@
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
}
+ },
+ "node_modules/zustand": {
+ "version": "5.0.13",
+ "resolved": "https://registry.npmmirror.com/zustand/-/zustand-5.0.13.tgz",
+ "integrity": "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=18.0.0",
+ "immer": ">=9.0.6",
+ "react": ">=18.0.0",
+ "use-sync-external-store": ">=1.2.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "immer": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "use-sync-external-store": {
+ "optional": true
+ }
+ }
}
}
}
diff --git a/package.json b/package.json
index 93be0b2..17f7d14 100644
--- a/package.json
+++ b/package.json
@@ -20,7 +20,8 @@
"react-dom": "19.2.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
- "tw-animate-css": "^1.4.0"
+ "tw-animate-css": "^1.4.0",
+ "zustand": "^5.0.13"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
diff --git a/src/app/(player)/(main)/hall/page.tsx b/src/app/(player)/(main)/hall/page.tsx
new file mode 100644
index 0000000..fec4ced
--- /dev/null
+++ b/src/app/(player)/(main)/hall/page.tsx
@@ -0,0 +1,24 @@
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+
+/** 下注大厅占位(§4.2 再接表格与期号) */
+export default function LotteryHallPlaceholderPage() {
+ return (
+
+
+ 下注大厅
+
+ 路由已打通;玩法表格、期号与余额将在此页迭代。
+
+
+
+ 从入口页授权成功后可刷新本页;鉴权头会从会话存储恢复。
+
+
+ );
+}
diff --git a/src/app/(player)/(main)/layout.tsx b/src/app/(player)/(main)/layout.tsx
new file mode 100644
index 0000000..942eb6f
--- /dev/null
+++ b/src/app/(player)/(main)/layout.tsx
@@ -0,0 +1,15 @@
+import { PlayerAppShell } from "@/components/layout/player-app-shell";
+import { HydratePlayerAuth } from "@/features/player/hydrate-player-auth";
+
+export default function PlayerMainLayout({
+ children,
+}: Readonly<{
+ children: React.ReactNode;
+}>) {
+ return (
+ <>
+
+ {children}
+ >
+ );
+}
diff --git a/src/app/(player)/layout.tsx b/src/app/(player)/layout.tsx
index 744671a..5fd7b57 100644
--- a/src/app/(player)/layout.tsx
+++ b/src/app/(player)/layout.tsx
@@ -1,9 +1,7 @@
-import { PlayerAppShell } from "@/components/layout/player-app-shell";
-
-export default function PlayerLayout({
+export default function PlayerRootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
- return {children};
+ return children;
}
diff --git a/src/app/(player)/page.tsx b/src/app/(player)/page.tsx
index 82d0d01..bb34605 100644
--- a/src/app/(player)/page.tsx
+++ b/src/app/(player)/page.tsx
@@ -1,23 +1,20 @@
-import {
- Card,
- CardContent,
- CardDescription,
- CardHeader,
- CardTitle,
-} from "@/components/ui/card";
+import type { ReactNode } from "react";
+import { Suspense } from "react";
-export default function PlayerHomePage() {
+import { EntryGate } from "@/features/player/entry-gate";
+
+function EntryFallback(): ReactNode {
return (
-
-
- 玩家端
-
- 基础 layout 已就绪;后续在此挂钱包、大厅等路由。
-
-
-
- 顶栏与主区域宽度由 PlayerAppShell 统一控制。
-
-
+
+ );
+}
+
+export default function EntryPage() {
+ return (
+ }>
+
+
);
}
diff --git a/src/features/player/entry-gate.tsx b/src/features/player/entry-gate.tsx
new file mode 100644
index 0000000..d83d6d9
--- /dev/null
+++ b/src/features/player/entry-gate.tsx
@@ -0,0 +1,389 @@
+"use client";
+
+import { isAxiosError } from "axios";
+import {
+ AlertTriangle,
+ Bell,
+ Check,
+ ChevronRight,
+ Languages,
+ Loader2,
+ Shield,
+} from "lucide-react";
+import { useRouter, useSearchParams } from "next/navigation";
+import type { ReactNode } from "react";
+import { useCallback, useEffect } from "react";
+
+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 { 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;
+
+function sleep(ms: number): Promise {
+ return new Promise((r) => setTimeout(r, ms));
+}
+
+function shouldRetryEntryRequest(error: unknown): boolean {
+ if (error instanceof LotteryApiBizError) {
+ return false;
+ }
+ if (isAxiosError(error)) {
+ if (error.code === "ECONNABORTED") {
+ return true;
+ }
+ if (!error.response) {
+ return true;
+ }
+ const s = error.response.status;
+ return s >= 500 || s === 429;
+ }
+ 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 {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+
+ 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 applyProgress = useCallback(
+ (doneCount: number) => {
+ setProgress(Math.round((doneCount / 3) * 100));
+ },
+ [setProgress],
+ );
+
+ const runBootstrap = useCallback(async () => {
+ resetEntryFlow();
+
+ const fromQuery = normalizeTokenInput(searchParams.get("token"));
+ const fromStorage = normalizeTokenInput(restoreBearerToken());
+ const token = fromQuery ?? fromStorage;
+
+ if (fromQuery) {
+ stripTokenFromUrl();
+ }
+
+ if (!token) {
+ setPhase("error");
+ setErrorMessage("缺少登录凭证,请从主站重新进入彩票系统。");
+ updateStep("token", "pending");
+ applyProgress(0);
+ return;
+ }
+
+ setBearerToken(token);
+
+ try {
+ updateStep("token", "done");
+ applyProgress(1);
+ updateStep("account", "active");
+
+ const profile = await withEntryRetries(() => getPlayerMe());
+ setProfile(profile);
+
+ updateStep("account", "done");
+ applyProgress(2);
+ updateStep("hall", "active");
+
+ await withEntryRetries(() => getPlayerPing());
+
+ 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 : "进入彩票系统失败,请稍后重试。",
+ );
+ }
+ }
+ }, [
+ applyProgress,
+ clearBearerToken,
+ resetEntryFlow,
+ restoreBearerToken,
+ searchParams,
+ setBearerToken,
+ setErrorMessage,
+ setPhase,
+ setProfile,
+ setProgress,
+ updateStep,
+ ]);
+
+ useEffect(() => {
+ void runBootstrap();
+ }, [runBootstrap]);
+
+ 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}
+
+
+
+
+
+ );
+}
diff --git a/src/features/player/hydrate-player-auth.tsx b/src/features/player/hydrate-player-auth.tsx
new file mode 100644
index 0000000..c88341d
--- /dev/null
+++ b/src/features/player/hydrate-player-auth.tsx
@@ -0,0 +1,18 @@
+"use client";
+
+import { useEffect } from "react";
+
+import { usePlayerSessionStore } from "@/stores/player-session-store";
+
+/** 从 sessionStorage 恢复 Bearer,避免 `/hall` 等子路由刷新后丢失鉴权头 */
+export function HydratePlayerAuth(): null {
+ const restoreBearerToken = usePlayerSessionStore(
+ (state) => state.restoreBearerToken,
+ );
+
+ useEffect(() => {
+ restoreBearerToken();
+ }, [restoreBearerToken]);
+
+ return null;
+}
diff --git a/src/lib/lottery-auth.ts b/src/lib/lottery-auth.ts
new file mode 100644
index 0000000..90279ae
--- /dev/null
+++ b/src/lib/lottery-auth.ts
@@ -0,0 +1,36 @@
+import { AxiosHeaders, type AxiosRequestConfig } from "axios";
+
+/** `Bearer ` 后的原始串:`dev:1`、JWT 等 */
+let playerBearerPayload: string | null = null;
+
+/**
+ * 设置玩家鉴权 Token(仅作用于彩票 API 请求)。
+ * 传入 `null` 或 trim 后空串则清除。
+ */
+export function setPlayerBearerToken(token: string | null): void {
+ if (token === null || token.trim() === "") {
+ playerBearerPayload = null;
+ return;
+ }
+ const t = token.trim();
+ playerBearerPayload = t.startsWith("Bearer ") ? t.slice(7).trim() : t;
+}
+
+export function getPlayerBearerTokenPayload(): string | null {
+ return playerBearerPayload;
+}
+
+export function withPlayerAuthHeader(
+ config: AxiosRequestConfig,
+): AxiosRequestConfig {
+ if (!playerBearerPayload) {
+ return config;
+ }
+ const merged: AxiosRequestConfig = { ...config };
+ const headers = AxiosHeaders.concat(
+ merged.headers as Parameters[0],
+ );
+ headers.set("Authorization", `Bearer ${playerBearerPayload}`);
+ merged.headers = headers;
+ return merged;
+}
\ No newline at end of file
diff --git a/src/lib/lottery-http.ts b/src/lib/lottery-http.ts
index 818c4fd..d3ca12b 100644
--- a/src/lib/lottery-http.ts
+++ b/src/lib/lottery-http.ts
@@ -1,52 +1,17 @@
import axios, {
- AxiosHeaders,
isAxiosError,
type AxiosRequestConfig,
type AxiosResponse,
} from "axios";
+import { withPlayerAuthHeader } from "@/lib/lottery-auth";
+import { withLotteryLocaleHeaders } from "@/lib/lottery-locale";
import {
LotteryApiBizError,
LotteryApiEnvelopeError,
} from "@/types/api/errors";
import { isApiEnvelope } from "@/types/api/envelope";
-export function setLotteryRequestLocale(locale: string | null): void {
- if (locale === null) {
- overrideLocale = null;
- return;
- }
- const p = locale.trim().toLowerCase().split("-")[0] ?? "";
- overrideLocale =
- p === "zh" || p === "en" || p === "ne" ? (p as "zh" | "en" | "ne") : null;
-}
-
-let overrideLocale: "zh" | "en" | "ne" | null = null;
-
-function requestLocale(): "zh" | "en" | "ne" {
- if (overrideLocale) {
- return overrideLocale;
- }
- if (typeof document !== "undefined") {
- const tag = document.documentElement.lang.trim().toLowerCase();
- const primary = tag.split("-")[0] ?? tag;
- if (primary === "zh" || primary === "en" || primary === "ne") {
- return primary;
- }
- }
- return "en";
-}
-
-function acceptLanguage(loc: ReturnType): string {
- if (loc === "zh") {
- return "zh-CN,zh;q=0.9,en;q=0.8";
- }
- if (loc === "ne") {
- return "ne,ne-NP;q=0.9,en;q=0.8";
- }
- return "en-US,en;q=0.9";
-}
-
const baseURL = process.env.NEXT_PUBLIC_LOTTERY_API_BASE_URL?.trim();
/**
@@ -58,21 +23,6 @@ export const lotteryHttp = axios.create({
headers: { Accept: "application/json" },
});
-function mergeLocaleHeaders(
- config: AxiosRequestConfig,
-): AxiosRequestConfig {
- const loc = requestLocale();
- const merged: AxiosRequestConfig = { ...config };
- // Axios 的 RequestConfig.headers 类型比 concat 能接受的头对象更窄
- const headers = AxiosHeaders.concat(
- merged.headers as Parameters[0],
- );
- headers.set("X-Locale", loc);
- headers.set("Accept-Language", acceptLanguage(loc));
- merged.headers = headers;
- return merged;
-}
-
/**
* 对 **payload**(通常是 `response.data`)校验信封并成功时返回 `data`;
* `code !== 0` 抛 {@link LotteryApiBizError}。
@@ -93,11 +43,11 @@ export function unwrapResponse(res: AxiosResponse): T {
}
/**
- * **第二层**:自动带语言头,用 `lotteryHttp` 发请求,再 `unwrapResponse`。
+ * **第二层**:补齐玩家鉴权与语言头,用 `lotteryHttp` 发请求,再 `unwrapResponse`。
* 是否提示用户由各页面/特性自己 `catch` 决定。
*/
export async function request(config: AxiosRequestConfig): Promise {
- const merged = mergeLocaleHeaders(config);
+ const merged = withPlayerAuthHeader(withLotteryLocaleHeaders(config));
try {
const res = await lotteryHttp.request(merged);
return unwrapResponse(res);
@@ -139,4 +89,4 @@ export const lotteryRequest = {
data?: unknown,
config?: Omit,
) => request({ ...config, url, method: "PATCH", data }),
-};
+};
\ No newline at end of file
diff --git a/src/lib/lottery-locale.ts b/src/lib/lottery-locale.ts
new file mode 100644
index 0000000..7cccb5e
--- /dev/null
+++ b/src/lib/lottery-locale.ts
@@ -0,0 +1,57 @@
+import { AxiosHeaders, type AxiosRequestConfig } from "axios";
+
+type LotteryLocale = "zh" | "en" | "ne";
+
+let overrideLocale: LotteryLocale | null = null;
+
+export function setLotteryRequestLocale(locale: string | null): void {
+ if (locale === null) {
+ overrideLocale = null;
+ return;
+ }
+ const p = locale.trim().toLowerCase().split("-")[0] ?? "";
+ overrideLocale = isLotteryLocale(p) ? p : null;
+}
+
+function isLotteryLocale(value: string): value is LotteryLocale {
+ return value === "zh" || value === "en" || value === "ne";
+}
+
+function requestLocale(): LotteryLocale {
+ if (overrideLocale) {
+ return overrideLocale;
+ }
+ if (typeof document !== "undefined") {
+ const tag = document.documentElement.lang.trim().toLowerCase();
+ const primary = tag.split("-")[0] ?? tag;
+ if (isLotteryLocale(primary)) {
+ return primary;
+ }
+ }
+ return "en";
+}
+
+function acceptLanguage(loc: LotteryLocale): string {
+ if (loc === "zh") {
+ return "zh-CN,zh;q=0.9,en;q=0.8";
+ }
+ if (loc === "ne") {
+ return "ne,ne-NP;q=0.9,en;q=0.8";
+ }
+ return "en-US,en;q=0.9";
+}
+
+export function withLotteryLocaleHeaders(
+ config: AxiosRequestConfig,
+): AxiosRequestConfig {
+ const loc = requestLocale();
+ const merged: AxiosRequestConfig = { ...config };
+ // Axios 的 RequestConfig.headers 类型比 concat 能接受的头对象更窄
+ const headers = AxiosHeaders.concat(
+ merged.headers as Parameters[0],
+ );
+ headers.set("X-Locale", loc);
+ headers.set("Accept-Language", acceptLanguage(loc));
+ merged.headers = headers;
+ return merged;
+}
\ No newline at end of file
diff --git a/src/lib/player-session.ts b/src/lib/player-session.ts
new file mode 100644
index 0000000..91627ba
--- /dev/null
+++ b/src/lib/player-session.ts
@@ -0,0 +1,26 @@
+const STORAGE_KEY = "lottery.player.bearer";
+
+/** sessionStorage:标签页关闭即清空,符合 H5 嵌入主站场景 */
+export function persistPlayerBearerToken(raw: string): void {
+ try {
+ sessionStorage.setItem(STORAGE_KEY, raw.trim());
+ } catch {
+ /* ignore quota / private mode */
+ }
+}
+
+export function readPersistedPlayerBearerToken(): string | null {
+ try {
+ return sessionStorage.getItem(STORAGE_KEY);
+ } catch {
+ return null;
+ }
+}
+
+export function clearPersistedPlayerBearerToken(): void {
+ try {
+ sessionStorage.removeItem(STORAGE_KEY);
+ } catch {
+ /* ignore */
+ }
+}
diff --git a/src/stores/player-session-store.ts b/src/stores/player-session-store.ts
new file mode 100644
index 0000000..af9e61e
--- /dev/null
+++ b/src/stores/player-session-store.ts
@@ -0,0 +1,102 @@
+import { create } from "zustand";
+
+import { setPlayerBearerToken } from "@/lib/lottery-auth";
+import {
+ clearPersistedPlayerBearerToken,
+ persistPlayerBearerToken,
+ readPersistedPlayerBearerToken,
+} from "@/lib/player-session";
+import type { PlayerMeData } from "@/types/api/player-me";
+
+export type PlayerEntryPhase = "loading" | "error" | "success";
+export type PlayerEntryStepId = "token" | "account" | "hall";
+export type PlayerEntryStepStatus = "pending" | "active" | "done";
+
+export type PlayerEntryStep = {
+ id: PlayerEntryStepId;
+ label: string;
+ status: PlayerEntryStepStatus;
+};
+
+type PlayerSessionState = {
+ bearerToken: string | null;
+ profile: PlayerMeData | null;
+ phase: PlayerEntryPhase;
+ progress: number;
+ errorMessage: string | null;
+ steps: PlayerEntryStep[];
+ setBearerToken: (token: string | null) => void;
+ restoreBearerToken: () => string | null;
+ clearBearerToken: () => void;
+ setProfile: (profile: PlayerMeData | null) => void;
+ setPhase: (phase: PlayerEntryPhase) => void;
+ setProgress: (progress: number) => void;
+ setErrorMessage: (message: string | null) => void;
+ updateStep: (id: PlayerEntryStepId, status: PlayerEntryStepStatus) => void;
+ resetEntryFlow: () => void;
+};
+
+function initialSteps(): PlayerEntryStep[] {
+ return [
+ { id: "token", label: "校验登录凭证", status: "active" },
+ { id: "account", label: "同步玩家账号", status: "pending" },
+ { id: "hall", label: "加载彩票大厅", status: "pending" },
+ ];
+}
+
+export const usePlayerSessionStore = create((set) => ({
+ bearerToken: null,
+ profile: null,
+ phase: "loading",
+ progress: 0,
+ errorMessage: null,
+ steps: initialSteps(),
+
+ setBearerToken: (token) => {
+ const normalized = token?.trim() || null;
+ setPlayerBearerToken(normalized);
+ if (normalized) {
+ persistPlayerBearerToken(normalized);
+ } else {
+ clearPersistedPlayerBearerToken();
+ }
+ set({ bearerToken: normalized });
+ },
+
+ restoreBearerToken: () => {
+ const raw = readPersistedPlayerBearerToken();
+ const normalized = raw?.trim() || null;
+ if (normalized) {
+ setPlayerBearerToken(normalized);
+ set({ bearerToken: normalized });
+ return normalized;
+ }
+ setPlayerBearerToken(null);
+ set({ bearerToken: null });
+ return null;
+ },
+
+ clearBearerToken: () => {
+ setPlayerBearerToken(null);
+ clearPersistedPlayerBearerToken();
+ set({ bearerToken: null, profile: null });
+ },
+
+ setProfile: (profile) => set({ profile }),
+ setPhase: (phase) => set({ phase }),
+ setProgress: (progress) => set({ progress: Math.max(0, Math.min(100, progress)) }),
+ setErrorMessage: (errorMessage) => set({ errorMessage }),
+ updateStep: (id, status) =>
+ set((state) => ({
+ steps: state.steps.map((step) =>
+ step.id === id ? { ...step, status } : step,
+ ),
+ })),
+ resetEntryFlow: () =>
+ set({
+ phase: "loading",
+ progress: 0,
+ errorMessage: null,
+ steps: initialSteps(),
+ }),
+}));
\ No newline at end of file