feat:添加管理员登录
This commit is contained in:
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;
|
||||
}
|
||||
Reference in New Issue
Block a user