feat: 接入玩家入口与API代理

- 新增 /api 重写代理,支持 LOTTERY_API_PROXY_TARGET 配置
- 玩家首页切换为 EntryGate,并移除 layout 对 PlayerAppShell 的包裹
- 请求层拆分语言头与玩家鉴权注入逻辑,引入 zustand 依赖
- 允许提交 .env.example 供本地配置参考
This commit is contained in:
2026-05-09 10:17:39 +08:00
parent 7bed43ac96
commit 14c297fe1a
16 changed files with 750 additions and 81 deletions

14
.env.example Normal file
View File

@@ -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

1
.gitignore vendored
View File

@@ -32,6 +32,7 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
!.env.example
# vercel
.vercel

View File

@@ -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;

32
package-lock.json generated
View File

@@ -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
}
}
}
}
}

View File

@@ -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",

View File

@@ -0,0 +1,24 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
/** 下注大厅占位§4.2 再接表格与期号) */
export default function LotteryHallPlaceholderPage() {
return (
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
</CardDescription>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
</CardContent>
</Card>
);
}

View File

@@ -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 (
<>
<HydratePlayerAuth />
<PlayerAppShell>{children}</PlayerAppShell>
</>
);
}

View File

@@ -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 <PlayerAppShell>{children}</PlayerAppShell>;
return children;
}

View File

@@ -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 (
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
layout
</CardDescription>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
PlayerAppShell
</CardContent>
</Card>
<div className="flex min-h-dvh flex-col items-center justify-center bg-gradient-to-b from-red-800 to-red-950 px-4 text-sm text-white/90">
<p></p>
</div>
);
}
export default function EntryPage() {
return (
<Suspense fallback={<EntryFallback />}>
<EntryGate />
</Suspense>
);
}

View File

@@ -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<void> {
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<T>(fn: () => Promise<T>): Promise<T> {
let last: unknown;
for (let i = 0; i < RETRY_ATTEMPTS; i++) {
try {
return await fn();
} catch (e) {
last = e;
if (!shouldRetryEntryRequest(e) || i === RETRY_ATTEMPTS - 1) {
throw e;
}
await sleep(RETRY_DELAY_MS);
}
}
throw last;
}
function normalizeTokenInput(raw: string | null): string | null {
if (raw === null) {
return null;
}
const t = decodeURIComponent(raw).trim();
return t === "" ? null : t;
}
function stripTokenFromUrl(): void {
if (typeof window === "undefined") {
return;
}
const url = new URL(window.location.href);
if (!url.searchParams.has("token")) {
return;
}
url.searchParams.delete("token");
const qs = url.searchParams.toString();
window.history.replaceState(
{},
"",
`${url.pathname}${qs ? `?${qs}` : ""}${url.hash}`,
);
}
export function EntryGate(): ReactNode {
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 (
<div className="flex min-h-dvh flex-col bg-gradient-to-b from-red-800 via-red-900 to-red-950 text-white">
<header className="flex h-14 shrink-0 items-center justify-between px-4 sm:h-16">
<span className="text-lg font-bold tracking-tight">Lottery</span>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="gap-1 text-white hover:bg-white/10 hover:text-white"
type="button"
disabled
aria-label="语言(即将支持)"
>
<Languages className="size-4 opacity-80" />
<span className="text-xs opacity-90">EN</span>
</Button>
<Button
variant="ghost"
size="icon-sm"
className="text-white hover:bg-white/10"
type="button"
disabled
aria-label="通知(即将支持)"
>
<Bell className="size-4 opacity-80" />
</Button>
</div>
</header>
<div className="flex min-h-0 flex-1 flex-col justify-center px-4 py-6 sm:py-8">
<div className="mx-auto w-full max-w-lg">
<p className="mb-6 text-center text-sm text-white/85">
Play smart. Win more.
</p>
{phase === "loading" || phase === "success" ? (
<Card className="border-0 shadow-xl">
<CardHeader className="text-center">
<CardTitle className="text-foreground">
{phase === "success"
? "授权成功"
: "正在进入彩票系统"}
</CardTitle>
<CardDescription>
{phase === "success"
? "即将跳转至下注大厅"
: "请稍候,正在连接服务器"}
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-6">
<div>
<div className="mb-2 flex justify-between text-xs text-muted-foreground">
<span></span>
<span>{progress}%</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-[width] duration-500 ease-out"
style={{ width: `${progress}%` }}
/>
</div>
</div>
<ul className="flex flex-col gap-3">
{steps.map((s) => (
<li
key={s.id}
className="flex items-center gap-3 text-sm text-foreground"
>
{s.status === "done" ? (
<Check
aria-hidden
className="size-4 shrink-0 text-green-600"
/>
) : s.status === "active" ? (
<Loader2
aria-hidden
className="size-4 shrink-0 animate-spin text-primary"
/>
) : (
<span
aria-hidden
className="size-4 shrink-0 rounded-full border border-muted-foreground/40"
/>
)}
<span className="flex-1">{s.label}</span>
<span className="text-xs text-muted-foreground">
{s.status === "done"
? "完成"
: s.status === "active"
? "进行中"
: "等待"}
</span>
</li>
))}
</ul>
</CardContent>
{phase === "success" ? (
<CardFooter className="flex flex-col gap-2">
<Button
className="w-full"
type="button"
onClick={() => router.push("/hall")}
>
<ChevronRight data-icon="inline-end" />
</Button>
</CardFooter>
) : null}
</Card>
) : null}
{phase === "error" ? (
<Card className="border-destructive/30 bg-destructive/5 shadow-xl">
<CardHeader className="text-center">
<div className="mx-auto mb-2 flex size-12 items-center justify-center rounded-full bg-destructive/15">
<AlertTriangle className="size-7 text-destructive" />
</div>
<CardTitle className="text-destructive"></CardTitle>
<CardDescription className="text-destructive/90">
{errorMessage}
</CardDescription>
</CardHeader>
<CardContent className="rounded-lg border border-border bg-card p-3 text-left text-foreground">
<p className="mb-2 text-xs font-medium text-muted-foreground">
</p>
<ul className="flex flex-col gap-1 text-xs text-muted-foreground">
<li> Token </li>
<li> </li>
<li> </li>
</ul>
</CardContent>
<CardFooter className="flex flex-col gap-2">
{MAIN_SITE_URL ? (
<a
href={MAIN_SITE_URL}
className={cn(
buttonVariants({ variant: "destructive" }),
"w-full justify-center no-underline",
)}
>
</a>
) : (
<Button
variant="destructive"
className="w-full"
type="button"
disabled
title="未配置 NEXT_PUBLIC_MAIN_SITE_URL"
>
</Button>
)}
<Button
variant="outline"
className="w-full bg-transparent text-foreground"
type="button"
onClick={() => {
resetEntryFlow();
void runBootstrap();
}}
>
</Button>
</CardFooter>
</Card>
) : null}
</div>
</div>
<footer className="flex h-14 shrink-0 items-center justify-center gap-2 px-4 text-xs text-white/70 sm:h-16">
<Shield className="size-3.5 shrink-0 opacity-80" />
<span>Secure · Trusted · Authorized access</span>
</footer>
</div>
);
}

View File

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

36
src/lib/lottery-auth.ts Normal file
View File

@@ -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<typeof AxiosHeaders.concat>[0],
);
headers.set("Authorization", `Bearer ${playerBearerPayload}`);
merged.headers = headers;
return merged;
}

View File

@@ -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<typeof requestLocale>): 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<typeof AxiosHeaders.concat>[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<T>(res: AxiosResponse<unknown>): T {
}
/**
* **第二层**自动带语言头,用 `lotteryHttp` 发请求,再 `unwrapResponse`。
* **第二层**补齐玩家鉴权与语言头,用 `lotteryHttp` 发请求,再 `unwrapResponse`。
* 是否提示用户由各页面/特性自己 `catch` 决定。
*/
export async function request<T>(config: AxiosRequestConfig): Promise<T> {
const merged = mergeLocaleHeaders(config);
const merged = withPlayerAuthHeader(withLotteryLocaleHeaders(config));
try {
const res = await lotteryHttp.request<unknown>(merged);
return unwrapResponse<T>(res);
@@ -139,4 +89,4 @@ export const lotteryRequest = {
data?: unknown,
config?: Omit<AxiosRequestConfig, "url" | "method" | "data">,
) => request<T>({ ...config, url, method: "PATCH", data }),
};
};

57
src/lib/lottery-locale.ts Normal file
View File

@@ -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<typeof AxiosHeaders.concat>[0],
);
headers.set("X-Locale", loc);
headers.set("Accept-Language", acceptLanguage(loc));
merged.headers = headers;
return merged;
}

26
src/lib/player-session.ts Normal file
View File

@@ -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 */
}
}

View File

@@ -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<PlayerSessionState>((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(),
}),
}));