feat: 接入玩家入口与API代理
- 新增 /api 重写代理,支持 LOTTERY_API_PROXY_TARGET 配置 - 玩家首页切换为 EntryGate,并移除 layout 对 PlayerAppShell 的包裹 - 请求层拆分语言头与玩家鉴权注入逻辑,引入 zustand 依赖 - 允许提交 .env.example 供本地配置参考
This commit is contained in:
14
.env.example
Normal file
14
.env.example
Normal 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
1
.gitignore
vendored
@@ -32,6 +32,7 @@ yarn-error.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
@@ -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
32
package-lock.json
generated
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
24
src/app/(player)/(main)/hall/page.tsx
Normal file
24
src/app/(player)/(main)/hall/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
src/app/(player)/(main)/layout.tsx
Normal file
15
src/app/(player)/(main)/layout.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
389
src/features/player/entry-gate.tsx
Normal file
389
src/features/player/entry-gate.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
src/features/player/hydrate-player-auth.tsx
Normal file
18
src/features/player/hydrate-player-auth.tsx
Normal 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
36
src/lib/lottery-auth.ts
Normal 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;
|
||||
}
|
||||
@@ -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
57
src/lib/lottery-locale.ts
Normal 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
26
src/lib/player-session.ts
Normal 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 */
|
||||
}
|
||||
}
|
||||
102
src/stores/player-session-store.ts
Normal file
102
src/stores/player-session-store.ts
Normal 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(),
|
||||
}),
|
||||
}));
|
||||
Reference in New Issue
Block a user