151 lines
5.2 KiB
TypeScript
151 lines
5.2 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useMemo, useRef, useState } from "react";
|
||
import type { FormEvent } from "react";
|
||
import Image from "next/image";
|
||
import { Loader2, LogIn, RefreshCw } from "lucide-react";
|
||
import { pulseFeedback, revealChildren, runScopedMotion } from "@/lib/ui/motion";
|
||
|
||
export function AuthLoginPanel({
|
||
next,
|
||
configured,
|
||
message,
|
||
missing
|
||
}: {
|
||
next: string;
|
||
configured: boolean;
|
||
message?: string | null;
|
||
missing?: string[];
|
||
}) {
|
||
const [username, setUsername] = useState("");
|
||
const [password, setPassword] = useState("");
|
||
const [code, setCode] = useState("");
|
||
const [randomStr, setRandomStr] = useState("");
|
||
const [submitting, setSubmitting] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const screenRef = useRef<HTMLDivElement | null>(null);
|
||
const feedbackRef = useRef<HTMLDivElement | null>(null);
|
||
const hasMissingConfig = !configured && Boolean(missing?.length);
|
||
|
||
useEffect(() => {
|
||
return runScopedMotion(screenRef, (scope) => revealChildren(scope));
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
pulseFeedback(feedbackRef.current);
|
||
}, [error, message, missing?.join(",")]);
|
||
|
||
const captchaSrc = useMemo(() => {
|
||
if (!randomStr) return "";
|
||
return `/api/auth/captcha?randomStr=${encodeURIComponent(randomStr)}`;
|
||
}, [randomStr]);
|
||
|
||
function refreshCaptcha() {
|
||
setCode("");
|
||
setRandomStr(crypto.randomUUID());
|
||
}
|
||
|
||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||
event.preventDefault();
|
||
if (!configured || submitting) return;
|
||
setSubmitting(true);
|
||
setError(null);
|
||
try {
|
||
const payload: Record<string, string> = { username, password, next };
|
||
if (code.trim() && randomStr) {
|
||
payload.code = code.trim();
|
||
payload.randomStr = randomStr;
|
||
}
|
||
const response = await fetch("/api/auth/password", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
const result = await response.json().catch(() => ({})) as { error?: string; redirectTo?: string };
|
||
if (!response.ok) throw new Error(result.error || "登录失败");
|
||
window.location.assign(result.redirectTo || next || "/create");
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : String(err));
|
||
refreshCaptcha();
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="auth-page" ref={screenRef}>
|
||
<section className="auth-brand-panel" aria-label="智念AIGC平台" data-animate>
|
||
<Image className="auth-brand-logo" src="/logo/zhinian-logo.png" alt="" width={168} height={38} priority />
|
||
<h1>智念AIGC平台</h1>
|
||
</section>
|
||
|
||
<section className="panel auth-panel" data-animate>
|
||
<h2>账户登录</h2>
|
||
|
||
{message || hasMissingConfig || error ? (
|
||
<div ref={feedbackRef}>
|
||
{message ? <div className="callout" role="alert">{message}</div> : null}
|
||
{hasMissingConfig ? (
|
||
<div className="callout" role="alert">
|
||
缺少配置:{missing?.join("、")}
|
||
</div>
|
||
) : null}
|
||
{error ? <div className="callout" role="alert">{error}</div> : null}
|
||
</div>
|
||
) : null}
|
||
|
||
<form className="auth-password-form" onSubmit={submit}>
|
||
<label className="field" data-animate>
|
||
<span>账号</span>
|
||
<input
|
||
autoComplete="username"
|
||
value={username}
|
||
onChange={(event) => setUsername(event.target.value)}
|
||
disabled={!configured || submitting}
|
||
placeholder="请输入账号"
|
||
/>
|
||
</label>
|
||
<label className="field" data-animate>
|
||
<span>密码</span>
|
||
<input
|
||
autoComplete="current-password"
|
||
type="password"
|
||
value={password}
|
||
onChange={(event) => setPassword(event.target.value)}
|
||
disabled={!configured || submitting}
|
||
placeholder="请输入密码"
|
||
/>
|
||
</label>
|
||
<label className="field" data-animate>
|
||
<span>验证码</span>
|
||
<div className="auth-captcha-row">
|
||
<input
|
||
autoComplete="off"
|
||
value={code}
|
||
onChange={(event) => setCode(event.target.value)}
|
||
disabled={!configured || submitting}
|
||
placeholder="需要时填写"
|
||
/>
|
||
<button
|
||
className="auth-captcha-button"
|
||
type="button"
|
||
onClick={refreshCaptcha}
|
||
disabled={!configured || submitting}
|
||
aria-label="刷新验证码"
|
||
title="刷新验证码"
|
||
>
|
||
{captchaSrc ? <img src={captchaSrc} alt="验证码" /> : <RefreshCw size={18} />}
|
||
</button>
|
||
</div>
|
||
</label>
|
||
|
||
<button className="button primary auth-submit" type="submit" disabled={!configured || submitting || !username.trim() || !password} data-animate>
|
||
{submitting ? <Loader2 className="spin" size={18} /> : <LogIn size={18} />}
|
||
登录
|
||
</button>
|
||
</form>
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|