Files
lotteryAdmin/src/components/admin/login-form.tsx

266 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}