266 lines
10 KiB
TypeScript
266 lines
10 KiB
TypeScript
"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 { readToken } from "@/stores/admin-token";
|
||
import { authModuleMeta } from "@/modules/auth/meta";
|
||
import { useAdminSessionStore } from "@/stores/admin-session";
|
||
import { LotteryApiBizError } from "@/types/api/errors";
|
||
|
||
export function LoginForm() {
|
||
const router = useRouter();
|
||
const setBearerToken = useAdminSessionStore((s) => s.setBearerToken);
|
||
const setAdminProfile = useAdminSessionStore((s) => s.setAdminProfile);
|
||
|
||
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 (readToken()) {
|
||
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);
|
||
setAdminProfile(result.admin);
|
||
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>
|
||
);
|
||
}
|