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 files (can opt-in for committing if needed)
|
||||||
.env*
|
.env*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|||||||
@@ -1,8 +1,19 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const lotteryApiProxyTarget =
|
||||||
|
process.env.LOTTERY_API_PROXY_TARGET?.trim() || "http://127.0.0.1:8000";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
|
||||||
reactCompiler: true,
|
reactCompiler: true,
|
||||||
|
|
||||||
|
async rewrites() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: "/api/:path*",
|
||||||
|
destination: `${lotteryApiProxyTarget}/api/:path*`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
32
package-lock.json
generated
32
package-lock.json
generated
@@ -19,7 +19,8 @@
|
|||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tw-animate-css": "^1.4.0"
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"zustand": "^5.0.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
@@ -10464,6 +10465,35 @@
|
|||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"zod": "^3.25.0 || ^4.0.0"
|
"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",
|
"react-dom": "19.2.4",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tw-animate-css": "^1.4.0"
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"zustand": "^5.0.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@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 PlayerRootLayout({
|
||||||
|
|
||||||
export default function PlayerLayout({
|
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return <PlayerAppShell>{children}</PlayerAppShell>;
|
return children;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,20 @@
|
|||||||
import {
|
import type { ReactNode } from "react";
|
||||||
Card,
|
import { Suspense } from "react";
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
|
|
||||||
export default function PlayerHomePage() {
|
import { EntryGate } from "@/features/player/entry-gate";
|
||||||
|
|
||||||
|
function EntryFallback(): ReactNode {
|
||||||
return (
|
return (
|
||||||
<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">
|
||||||
<CardHeader>
|
<p>加载入口页…</p>
|
||||||
<CardTitle>玩家端</CardTitle>
|
</div>
|
||||||
<CardDescription>
|
);
|
||||||
基础 layout 已就绪;后续在此挂钱包、大厅等路由。
|
}
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
export default function EntryPage() {
|
||||||
<CardContent className="text-sm text-muted-foreground">
|
return (
|
||||||
顶栏与主区域宽度由 PlayerAppShell 统一控制。
|
<Suspense fallback={<EntryFallback />}>
|
||||||
</CardContent>
|
<EntryGate />
|
||||||
</Card>
|
</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, {
|
import axios, {
|
||||||
AxiosHeaders,
|
|
||||||
isAxiosError,
|
isAxiosError,
|
||||||
type AxiosRequestConfig,
|
type AxiosRequestConfig,
|
||||||
type AxiosResponse,
|
type AxiosResponse,
|
||||||
} from "axios";
|
} from "axios";
|
||||||
|
|
||||||
|
import { withPlayerAuthHeader } from "@/lib/lottery-auth";
|
||||||
|
import { withLotteryLocaleHeaders } from "@/lib/lottery-locale";
|
||||||
import {
|
import {
|
||||||
LotteryApiBizError,
|
LotteryApiBizError,
|
||||||
LotteryApiEnvelopeError,
|
LotteryApiEnvelopeError,
|
||||||
} from "@/types/api/errors";
|
} from "@/types/api/errors";
|
||||||
import { isApiEnvelope } from "@/types/api/envelope";
|
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();
|
const baseURL = process.env.NEXT_PUBLIC_LOTTERY_API_BASE_URL?.trim();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -58,21 +23,6 @@ export const lotteryHttp = axios.create({
|
|||||||
headers: { Accept: "application/json" },
|
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`;
|
* 对 **payload**(通常是 `response.data`)校验信封并成功时返回 `data`;
|
||||||
* `code !== 0` 抛 {@link LotteryApiBizError}。
|
* `code !== 0` 抛 {@link LotteryApiBizError}。
|
||||||
@@ -93,11 +43,11 @@ export function unwrapResponse<T>(res: AxiosResponse<unknown>): T {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* **第二层**:自动带语言头,用 `lotteryHttp` 发请求,再 `unwrapResponse`。
|
* **第二层**:补齐玩家鉴权与语言头,用 `lotteryHttp` 发请求,再 `unwrapResponse`。
|
||||||
* 是否提示用户由各页面/特性自己 `catch` 决定。
|
* 是否提示用户由各页面/特性自己 `catch` 决定。
|
||||||
*/
|
*/
|
||||||
export async function request<T>(config: AxiosRequestConfig): Promise<T> {
|
export async function request<T>(config: AxiosRequestConfig): Promise<T> {
|
||||||
const merged = mergeLocaleHeaders(config);
|
const merged = withPlayerAuthHeader(withLotteryLocaleHeaders(config));
|
||||||
try {
|
try {
|
||||||
const res = await lotteryHttp.request<unknown>(merged);
|
const res = await lotteryHttp.request<unknown>(merged);
|
||||||
return unwrapResponse<T>(res);
|
return unwrapResponse<T>(res);
|
||||||
|
|||||||
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