feat:添加管理员登录

This commit is contained in:
2026-05-09 11:16:00 +08:00
parent 56951c0383
commit cda7824eb2
19 changed files with 484 additions and 44 deletions

7
.env.example Normal file
View 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
View File

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

View File

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

View File

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

View File

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

View 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>
);
}

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

View File

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

View File

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

View 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);
}
}

View File

@@ -1,5 +1,5 @@
export const authModuleMeta = { export const authModuleMeta = {
segment: "auth", segment: "auth",
title: "登录", title: "登录",
description: "后台登录流程(占位,与侧边栏工作台分离)。", description: "账号、密码与图形验证码登录;对接 Laravel Sanctum。",
} as const; } as const;

View File

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

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

View File

@@ -0,0 +1,4 @@
/** `GET /api/v1/admin/ping` 成功信封内的 `data` */
export type AdminPingResponse = {
scope: string;
};

6
src/types/api/index.ts Normal file
View File

@@ -0,0 +1,6 @@
export type {
AdminAuthCaptchaResponse,
AdminAuthLoginRequest,
AdminAuthLoginResponse,
} from "./admin-auth";
export type { AdminPingResponse } from "./admin-ping";