feat:添加管理员登录
This commit is contained in:
7
.env.example
Normal file
7
.env.example
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# 管理端本地配置示例:复制为 .env.local 后按需修改
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# 必填:Laravel 应用根 URL(无尾部斜杠)。axios 会请求 {此地址}/api/v1/...
|
||||||
|
# 需保证 Laravel 已允许该来源的 CORS(本地一般为 http://localhost:3000 等)。
|
||||||
|
NEXT_PUBLIC_LOTTERY_API_BASE_URL=http://127.0.0.1:8000
|
||||||
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
|
||||||
|
|||||||
37
src/api/admin-auth.ts
Normal file
37
src/api/admin-auth.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import {
|
||||||
|
hasLotteryAdminApiBaseUrl,
|
||||||
|
publicAdminRequest,
|
||||||
|
} from "@/lib/admin-http";
|
||||||
|
import type {
|
||||||
|
AdminAuthCaptchaResponse,
|
||||||
|
AdminAuthLoginRequest,
|
||||||
|
AdminAuthLoginResponse,
|
||||||
|
} from "@/types/api/admin-auth";
|
||||||
|
|
||||||
|
import { API_V1_PREFIX } from "@/api/paths";
|
||||||
|
|
||||||
|
/** `GET /api/v1/admin/auth/captcha`(无需 Token) */
|
||||||
|
export async function getAdminCaptcha(): Promise<AdminAuthCaptchaResponse | null> {
|
||||||
|
if (!hasLotteryAdminApiBaseUrl()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await publicAdminRequest<AdminAuthCaptchaResponse>({
|
||||||
|
url: `${API_V1_PREFIX}/admin/auth/captcha`,
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `POST /api/v1/admin/auth/login`(无需 Token) */
|
||||||
|
export async function postAdminLogin(
|
||||||
|
body: AdminAuthLoginRequest,
|
||||||
|
): Promise<AdminAuthLoginResponse> {
|
||||||
|
return publicAdminRequest<AdminAuthLoginResponse>({
|
||||||
|
url: `${API_V1_PREFIX}/admin/auth/login`,
|
||||||
|
method: "POST",
|
||||||
|
data: body,
|
||||||
|
});
|
||||||
|
}
|
||||||
18
src/api/admin-ping.ts
Normal file
18
src/api/admin-ping.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { adminRequest, hasLotteryAdminApiBaseUrl } from "@/lib/admin-http";
|
||||||
|
import type { AdminPingResponse } from "@/types/api/admin-ping";
|
||||||
|
|
||||||
|
import { API_V1_PREFIX } from "@/api/paths";
|
||||||
|
|
||||||
|
/** `GET /api/v1/admin/ping`(需 Bearer Token) */
|
||||||
|
export async function getAdminPing(): Promise<AdminPingResponse | null> {
|
||||||
|
if (!hasLotteryAdminApiBaseUrl()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await adminRequest.get<AdminPingResponse>(
|
||||||
|
`${API_V1_PREFIX}/admin/ping`,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/api/index.ts
Normal file
9
src/api/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export { API_V1_PREFIX } from "@/api/paths";
|
||||||
|
export { getAdminCaptcha, postAdminLogin } from "@/api/admin-auth";
|
||||||
|
export { getAdminPing } from "@/api/admin-ping";
|
||||||
|
export type {
|
||||||
|
AdminAuthCaptchaResponse,
|
||||||
|
AdminAuthLoginRequest,
|
||||||
|
AdminAuthLoginResponse,
|
||||||
|
AdminPingResponse,
|
||||||
|
} from "@/types/api";
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
import { AdminShell } from "@/components/admin/admin-shell";
|
import { AdminShell } from "@/components/admin/admin-shell";
|
||||||
|
import { AdminShellAuthGate } from "@/components/admin/admin-shell-auth-gate";
|
||||||
|
|
||||||
export default function AdminShellLayout({
|
export default function AdminShellLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return <AdminShell>{children}</AdminShell>;
|
return (
|
||||||
|
<AdminShellAuthGate>
|
||||||
|
<AdminShell>{children}</AdminShell>
|
||||||
|
</AdminShellAuthGate>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||||
import { getAdminPing } from "@/lib/admin-http";
|
import { getAdminPing } from "@/api";
|
||||||
import { dashboardModuleMeta } from "@/modules/dashboard/meta";
|
import { dashboardModuleMeta } from "@/modules/dashboard/meta";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
|||||||
@@ -1,32 +1,12 @@
|
|||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
import { authModuleMeta } from "@/modules/auth/meta";
|
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
import { AdminLoginForm } from "@/components/admin/admin-login-form";
|
||||||
|
import { authModuleMeta } from "@/modules/auth/meta";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: authModuleMeta.title,
|
title: authModuleMeta.title,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function AdminLoginPage() {
|
export default function AdminLoginPage() {
|
||||||
return (
|
return <AdminLoginForm />;
|
||||||
<div className="flex min-h-full flex-1 flex-col items-center justify-center px-4 py-16">
|
|
||||||
<div className="w-full max-w-md rounded-2xl border border-black/10 bg-white p-8 shadow-sm dark:border-white/10 dark:bg-zinc-900">
|
|
||||||
<h1 className="text-xl font-semibold text-foreground">
|
|
||||||
{authModuleMeta.title}
|
|
||||||
</h1>
|
|
||||||
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
|
|
||||||
{authModuleMeta.description}
|
|
||||||
</p>
|
|
||||||
<p className="mt-6 text-sm text-zinc-500 dark:text-zinc-400">
|
|
||||||
接入管理端鉴权后,在此放置表单与回调;当前为路由占位。
|
|
||||||
</p>
|
|
||||||
<Link
|
|
||||||
href="/admin"
|
|
||||||
className="mt-8 inline-flex text-sm font-medium text-foreground underline-offset-4 hover:underline"
|
|
||||||
>
|
|
||||||
返回工作台
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
263
src/components/admin/admin-login-form.tsx
Normal file
263
src/components/admin/admin-login-form.tsx
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ShieldCheckIcon, TriangleAlertIcon } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { isAxiosError } from "axios";
|
||||||
|
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { getAdminCaptcha, postAdminLogin } from "@/api";
|
||||||
|
import { readStoredAdminToken } from "@/lib/admin-token-local-storage";
|
||||||
|
import { authModuleMeta } from "@/modules/auth/meta";
|
||||||
|
import { useAdminSessionStore } from "@/stores/admin-session-store";
|
||||||
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
|
|
||||||
|
export function AdminLoginForm() {
|
||||||
|
const router = useRouter();
|
||||||
|
const setBearerToken = useAdminSessionStore((s) => s.setBearerToken);
|
||||||
|
|
||||||
|
const apiConfigured =
|
||||||
|
process.env.NEXT_PUBLIC_LOTTERY_API_BASE_URL?.trim() !== "" &&
|
||||||
|
process.env.NEXT_PUBLIC_LOTTERY_API_BASE_URL !== undefined;
|
||||||
|
|
||||||
|
const [account, setAccount] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [captchaCode, setCaptchaCode] = useState("");
|
||||||
|
const [captchaKey, setCaptchaKey] = useState<string | null>(null);
|
||||||
|
const [captchaSrc, setCaptchaSrc] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [loadingCaptcha, setLoadingCaptcha] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const loadCaptcha = useCallback(async () => {
|
||||||
|
if (!apiConfigured) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoadingCaptcha(true);
|
||||||
|
try {
|
||||||
|
const data = await getAdminCaptcha();
|
||||||
|
if (!data) {
|
||||||
|
toast.error("无法获取验证码,请检查接口或网络");
|
||||||
|
setCaptchaKey(null);
|
||||||
|
setCaptchaSrc(null);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCaptchaKey(data.captcha_key);
|
||||||
|
setCaptchaSrc(`data:image/svg+xml;base64,${data.image_base64}`);
|
||||||
|
setCaptchaCode("");
|
||||||
|
} finally {
|
||||||
|
setLoadingCaptcha(false);
|
||||||
|
}
|
||||||
|
}, [apiConfigured]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (readStoredAdminToken()) {
|
||||||
|
router.replace("/admin");
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const t = window.setTimeout(() => {
|
||||||
|
void loadCaptcha();
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
}, [loadCaptcha, router]);
|
||||||
|
|
||||||
|
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!apiConfigured) {
|
||||||
|
toast.error("未配置 NEXT_PUBLIC_LOTTERY_API_BASE_URL");
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!captchaKey || !captchaSrc) {
|
||||||
|
toast.error("请先刷新验证码");
|
||||||
|
void loadCaptcha();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const result = await postAdminLogin({
|
||||||
|
account: account.trim(),
|
||||||
|
password,
|
||||||
|
captcha_key: captchaKey,
|
||||||
|
captcha_code: captchaCode.trim(),
|
||||||
|
});
|
||||||
|
setBearerToken(result.token);
|
||||||
|
toast.success(`欢迎,${result.admin.nickname || result.admin.username}`);
|
||||||
|
router.replace("/admin");
|
||||||
|
router.refresh();
|
||||||
|
} catch (err) {
|
||||||
|
void loadCaptcha();
|
||||||
|
if (err instanceof LotteryApiBizError) {
|
||||||
|
toast.error(err.message);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isAxiosError(err)) {
|
||||||
|
toast.error(err.message || "网络请求失败");
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.error("登录失败");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex min-h-full flex-1 flex-col items-center justify-center overflow-hidden px-4 py-14 sm:py-20">
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_85%_50%_at_50%_-5%,rgb(59_130_246/0.09),transparent)] dark:bg-[radial-gradient(ellipse_85%_50%_at_50%_-5%,rgb(56_189_248/0.12),transparent)]"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute inset-0 bg-[linear-gradient(to_bottom,transparent_0%,var(--background)_100%)] opacity-90"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute inset-0 opacity-[0.35] dark:opacity-[0.2]"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `linear-gradient(to right, var(--border) 1px, transparent 1px),
|
||||||
|
linear-gradient(to bottom, var(--border) 1px, transparent 1px)`,
|
||||||
|
backgroundSize: "48px 48px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card className="relative w-full max-w-[420px] rounded-2xl border border-border/70 bg-card/90 shadow-2xl shadow-black/[0.06] ring-1 ring-black/[0.03] backdrop-blur-md dark:border-border/50 dark:bg-card/85 dark:shadow-black/25 dark:ring-white/[0.06]">
|
||||||
|
<CardHeader className="space-y-5 pb-2 text-center sm:px-8 sm:pt-10">
|
||||||
|
<div className="mx-auto flex size-12 items-center justify-center rounded-2xl bg-primary/8 text-primary shadow-inner ring-1 ring-primary/10 dark:bg-primary/15 dark:ring-primary/20">
|
||||||
|
<ShieldCheckIcon className="size-6" strokeWidth={1.75} aria-hidden />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<CardTitle className="text-balance text-2xl font-semibold tracking-tight">
|
||||||
|
{authModuleMeta.title}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-pretty text-sm leading-relaxed">
|
||||||
|
使用账号与密码登录;请输入图形验证码(不区分大小写)。
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<form onSubmit={onSubmit}>
|
||||||
|
<CardContent className="flex flex-col gap-5 sm:px-8">
|
||||||
|
{!apiConfigured ? (
|
||||||
|
<Alert variant="destructive" className="text-left">
|
||||||
|
<TriangleAlertIcon />
|
||||||
|
<AlertTitle>未配置 API 地址</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
请在环境中设置{" "}
|
||||||
|
<code className="rounded bg-muted px-1 py-0.5 text-xs">
|
||||||
|
NEXT_PUBLIC_LOTTERY_API_BASE_URL
|
||||||
|
</code>{" "}
|
||||||
|
(Laravel 根 URL,如 http://127.0.0.1:8000)。
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="admin-account" className="text-sm font-medium">
|
||||||
|
账号
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="admin-account"
|
||||||
|
name="account"
|
||||||
|
autoComplete="username"
|
||||||
|
value={account}
|
||||||
|
onChange={(ev) => setAccount(ev.target.value)}
|
||||||
|
placeholder="登录账号"
|
||||||
|
required
|
||||||
|
disabled={submitting}
|
||||||
|
className="h-11 transition-shadow focus-visible:ring-2 focus-visible:ring-primary/25"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="admin-password" className="text-sm font-medium">
|
||||||
|
密码
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="admin-password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={password}
|
||||||
|
onChange={(ev) => setPassword(ev.target.value)}
|
||||||
|
placeholder="密码"
|
||||||
|
required
|
||||||
|
disabled={submitting}
|
||||||
|
className="h-11 transition-shadow focus-visible:ring-2 focus-visible:ring-primary/25"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-8 flex flex-col gap-2">
|
||||||
|
<Label htmlFor="admin-captcha" className="text-sm font-medium">
|
||||||
|
验证码
|
||||||
|
</Label>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Input
|
||||||
|
id="admin-captcha"
|
||||||
|
name="captcha"
|
||||||
|
autoComplete="off"
|
||||||
|
value={captchaCode}
|
||||||
|
onChange={(ev) => setCaptchaCode(ev.target.value)}
|
||||||
|
placeholder="图中字符"
|
||||||
|
maxLength={32}
|
||||||
|
required
|
||||||
|
disabled={submitting}
|
||||||
|
className="h-11 min-w-0 flex-1 transition-shadow focus-visible:ring-2 focus-visible:ring-primary/25 sm:max-w-[12rem]"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex h-11 min-w-[156px] shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-xl border border-border/80 bg-muted/35 px-2 shadow-[inset_0_1px_2px_rgba(0,0,0,0.04)] ring-1 ring-black/[0.03] transition-[box-shadow,transform] hover:bg-muted/45 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/30 disabled:pointer-events-none disabled:opacity-50 dark:bg-muted/25 dark:shadow-[inset_0_1px_2px_rgba(0,0,0,0.2)] dark:ring-white/[0.06] dark:hover:bg-muted/35"
|
||||||
|
onClick={() => void loadCaptcha()}
|
||||||
|
disabled={
|
||||||
|
loadingCaptcha || !apiConfigured || submitting
|
||||||
|
}
|
||||||
|
aria-label={loadingCaptcha ? "加载验证码中" : "点击刷新验证码"}
|
||||||
|
>
|
||||||
|
{captchaSrc ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element -- data URL from API
|
||||||
|
<img
|
||||||
|
src={captchaSrc}
|
||||||
|
alt=""
|
||||||
|
width={160}
|
||||||
|
height={48}
|
||||||
|
className="pointer-events-none block"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="px-2 text-xs text-muted-foreground">
|
||||||
|
{loadingCaptcha ? "加载中…" : "点击获取"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="flex-col gap-0 border-t border-border/60 pb-10 pt-8 sm:px-8">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="lg"
|
||||||
|
className="h-11 w-full text-base font-medium shadow-sm"
|
||||||
|
disabled={submitting || !apiConfigured}
|
||||||
|
>
|
||||||
|
{submitting ? "登录中…" : "登录"}
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
src/components/admin/admin-shell-auth-gate.tsx
Normal file
40
src/components/admin/admin-shell-auth-gate.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect, useState, type ReactNode } from "react";
|
||||||
|
|
||||||
|
import { readStoredAdminToken } from "@/lib/admin-token-local-storage";
|
||||||
|
|
||||||
|
type AdminShellAuthGateProps = {
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 壳内路由:仅客户端可读 `localStorage` Token,无 Token 时跳转登录。
|
||||||
|
* 注意:首屏仍可能先出现服务端渲染的页面片段,再完成跳转(未用 Cookie + middleware 前无法完全避免)。
|
||||||
|
*/
|
||||||
|
export function AdminShellAuthGate({ children }: AdminShellAuthGateProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [allowed, setAllowed] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = readStoredAdminToken();
|
||||||
|
if (!token) {
|
||||||
|
router.replace("/admin/login");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
queueMicrotask(() => {
|
||||||
|
setAllowed(true);
|
||||||
|
});
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
if (!allowed) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[50vh] w-full flex-1 items-center justify-center text-sm text-muted-foreground">
|
||||||
|
正在校验登录状态…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return children;
|
||||||
|
}
|
||||||
@@ -1,19 +1,34 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import { useEffect } from "react";
|
||||||
import { ThemeProvider } from "next-themes";
|
import { ThemeProvider } from "next-themes";
|
||||||
|
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
|
import { readStoredAdminToken } from "@/lib/admin-token-local-storage";
|
||||||
|
import { useAdminSessionStore } from "@/stores/admin-session-store";
|
||||||
|
|
||||||
type ProvidersProps = {
|
type ProvidersProps = {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function AdminTokenHydrator() {
|
||||||
|
useEffect(() => {
|
||||||
|
const token = readStoredAdminToken();
|
||||||
|
if (token) {
|
||||||
|
useAdminSessionStore.getState().setBearerToken(token);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function Providers({ children }: ProvidersProps) {
|
export function Providers({ children }: ProvidersProps) {
|
||||||
return (
|
return (
|
||||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
|
<AdminTokenHydrator />
|
||||||
{children}
|
{children}
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|||||||
@@ -5,12 +5,16 @@ import axios, {
|
|||||||
} from "axios";
|
} from "axios";
|
||||||
|
|
||||||
import { withAdminAuthHeader } from "@/lib/admin-auth";
|
import { withAdminAuthHeader } from "@/lib/admin-auth";
|
||||||
import { API_V1_PREFIX } from "@/lib/paths";
|
|
||||||
import { LotteryApiBizError, LotteryApiEnvelopeError } from "@/types/api/errors";
|
import { LotteryApiBizError, LotteryApiEnvelopeError } from "@/types/api/errors";
|
||||||
import { isApiEnvelope } from "@/types/api/envelope";
|
import { isApiEnvelope } from "@/types/api/envelope";
|
||||||
|
|
||||||
const baseURL = process.env.NEXT_PUBLIC_LOTTERY_API_BASE_URL?.trim();
|
const baseURL = process.env.NEXT_PUBLIC_LOTTERY_API_BASE_URL?.trim();
|
||||||
|
|
||||||
|
/** 是否已配置后台 API 根地址(客户端/服务端均可用 `NEXT_PUBLIC_*`) */
|
||||||
|
export function hasLotteryAdminApiBaseUrl(): boolean {
|
||||||
|
return baseURL !== undefined && baseURL !== "";
|
||||||
|
}
|
||||||
|
|
||||||
export const adminHttp = axios.create({
|
export const adminHttp = axios.create({
|
||||||
baseURL: baseURL && baseURL !== "" ? baseURL : undefined,
|
baseURL: baseURL && baseURL !== "" ? baseURL : undefined,
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
@@ -31,6 +35,24 @@ export function unwrapResponse<T>(res: AxiosResponse<unknown>): T {
|
|||||||
return unwrapData<T>(res.data);
|
return unwrapData<T>(res.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 登录/验证码等:**不**附加 `Authorization`。 */
|
||||||
|
export async function publicAdminRequest<T>(
|
||||||
|
config: AxiosRequestConfig,
|
||||||
|
): Promise<T> {
|
||||||
|
try {
|
||||||
|
const res = await adminHttp.request<unknown>(config);
|
||||||
|
return unwrapResponse<T>(res);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (isAxiosError(err) && err.response?.data !== undefined) {
|
||||||
|
const body = err.response.data;
|
||||||
|
if (isApiEnvelope(body) && body.code !== 0) {
|
||||||
|
throw new LotteryApiBizError(body.msg, body.code, body.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function request<T>(config: AxiosRequestConfig): Promise<T> {
|
export async function request<T>(config: AxiosRequestConfig): Promise<T> {
|
||||||
const merged = withAdminAuthHeader(config);
|
const merged = withAdminAuthHeader(config);
|
||||||
try {
|
try {
|
||||||
@@ -74,19 +96,3 @@ export const adminRequest = {
|
|||||||
config?: Omit<AxiosRequestConfig, "url" | "method" | "data">,
|
config?: Omit<AxiosRequestConfig, "url" | "method" | "data">,
|
||||||
) => request<T>({ ...config, url, method: "PUT", data }),
|
) => request<T>({ ...config, url, method: "PUT", data }),
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AdminPingData = { scope: string };
|
|
||||||
|
|
||||||
export async function getAdminPing(): Promise<AdminPingData | null> {
|
|
||||||
if (!baseURL || baseURL === "") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const data = await adminRequest.get<AdminPingData>(
|
|
||||||
`${API_V1_PREFIX}/admin/ping`,
|
|
||||||
);
|
|
||||||
return data;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
21
src/lib/admin-token-local-storage.ts
Normal file
21
src/lib/admin-token-local-storage.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
const STORAGE_KEY = "lottery_admin_token";
|
||||||
|
|
||||||
|
export function readStoredAdminToken(): string | null {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const raw = window.localStorage.getItem(STORAGE_KEY)?.trim();
|
||||||
|
|
||||||
|
return raw && raw !== "" ? raw : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeStoredAdminToken(token: string | null): void {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (token && token.trim() !== "") {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, token.trim());
|
||||||
|
} else {
|
||||||
|
window.localStorage.removeItem(STORAGE_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
export const authModuleMeta = {
|
export const authModuleMeta = {
|
||||||
segment: "auth",
|
segment: "auth",
|
||||||
title: "登录",
|
title: "登录",
|
||||||
description: "后台登录流程(占位,与侧边栏工作台分离)。",
|
description: "账号、密码与图形验证码登录;对接 Laravel Sanctum。",
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
|
|
||||||
import { setAdminBearerToken } from "@/lib/admin-auth";
|
import { setAdminBearerToken } from "@/lib/admin-auth";
|
||||||
|
import { writeStoredAdminToken } from "@/lib/admin-token-local-storage";
|
||||||
|
|
||||||
type AdminSessionState = {
|
type AdminSessionState = {
|
||||||
/** 已与 `Authorization: Bearer …` 同步的原始片段(不带 `Bearer ` 前缀) */
|
/** 已与 `Authorization: Bearer …` 同步的原始片段(不带 `Bearer ` 前缀) */
|
||||||
@@ -15,11 +16,13 @@ export const useAdminSessionStore = create<AdminSessionState>((set) => ({
|
|||||||
setBearerToken: (token) => {
|
setBearerToken: (token) => {
|
||||||
const normalized = token?.trim() ? token.trim() : null;
|
const normalized = token?.trim() ? token.trim() : null;
|
||||||
setAdminBearerToken(normalized);
|
setAdminBearerToken(normalized);
|
||||||
|
writeStoredAdminToken(normalized);
|
||||||
set({ bearerToken: normalized });
|
set({ bearerToken: normalized });
|
||||||
},
|
},
|
||||||
|
|
||||||
clearBearerToken: () => {
|
clearBearerToken: () => {
|
||||||
setAdminBearerToken(null);
|
setAdminBearerToken(null);
|
||||||
|
writeStoredAdminToken(null);
|
||||||
set({ bearerToken: null });
|
set({ bearerToken: null });
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
25
src/types/api/admin-auth.ts
Normal file
25
src/types/api/admin-auth.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/** `GET /api/v1/admin/auth/captcha` 成功信封内的 `data` */
|
||||||
|
export type AdminAuthCaptchaResponse = {
|
||||||
|
captcha_key: string;
|
||||||
|
image_base64: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** `POST /api/v1/admin/auth/login` 请求体 */
|
||||||
|
export type AdminAuthLoginRequest = {
|
||||||
|
account: string;
|
||||||
|
password: string;
|
||||||
|
captcha_key: string;
|
||||||
|
captcha_code: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** `POST /api/v1/admin/auth/login` 成功信封内的 `data` */
|
||||||
|
export type AdminAuthLoginResponse = {
|
||||||
|
token: string;
|
||||||
|
token_type: string;
|
||||||
|
admin: {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
nickname: string;
|
||||||
|
email: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
4
src/types/api/admin-ping.ts
Normal file
4
src/types/api/admin-ping.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
/** `GET /api/v1/admin/ping` 成功信封内的 `data` */
|
||||||
|
export type AdminPingResponse = {
|
||||||
|
scope: string;
|
||||||
|
};
|
||||||
6
src/types/api/index.ts
Normal file
6
src/types/api/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export type {
|
||||||
|
AdminAuthCaptchaResponse,
|
||||||
|
AdminAuthLoginRequest,
|
||||||
|
AdminAuthLoginResponse,
|
||||||
|
} from "./admin-auth";
|
||||||
|
export type { AdminPingResponse } from "./admin-ping";
|
||||||
Reference in New Issue
Block a user