195 lines
8.4 KiB
TypeScript
195 lines
8.4 KiB
TypeScript
"use client"
|
|
|
|
import type { FormEvent } from "react"
|
|
import { useEffect, useMemo, useState } from "react"
|
|
import {
|
|
AlertCircle,
|
|
ArrowRight,
|
|
CheckCircle2,
|
|
Eye,
|
|
EyeOff,
|
|
LockKeyhole,
|
|
UserRound,
|
|
} from "lucide-react"
|
|
import { AnimatedLoginCharacters, type LoginCharacterMood } from "@/components/login/animated-login-characters"
|
|
import { OasisCanvas } from "@/components/login/oasis-canvas"
|
|
|
|
type LoginStatus = "idle" | "loading" | "success"
|
|
|
|
export default function LoginPage() {
|
|
const [username, setUsername] = useState("")
|
|
const [password, setPassword] = useState("")
|
|
const [remember, setRemember] = useState(true)
|
|
const [showPassword, setShowPassword] = useState(false)
|
|
const [activeField, setActiveField] = useState<"username" | "password" | null>(null)
|
|
const [error, setError] = useState("")
|
|
const [status, setStatus] = useState<LoginStatus>("idle")
|
|
const [eyeOffset, setEyeOffset] = useState({ x: 0, y: 0 })
|
|
|
|
useEffect(() => {
|
|
const onPointerMove = (event: PointerEvent) => {
|
|
const centerX = window.innerWidth / 2
|
|
const centerY = window.innerHeight / 2
|
|
const nextX = Math.max(-1, Math.min(1, (event.clientX - centerX) / centerX))
|
|
const nextY = Math.max(-1, Math.min(1, (event.clientY - centerY) / centerY))
|
|
setEyeOffset({ x: nextX * 8, y: nextY * 5.5 })
|
|
}
|
|
window.addEventListener("pointermove", onPointerMove)
|
|
return () => window.removeEventListener("pointermove", onPointerMove)
|
|
}, [])
|
|
|
|
const disabled = status === "loading" || status === "success"
|
|
|
|
const mood: LoginCharacterMood = useMemo(() => {
|
|
if (status === "success") return "success"
|
|
if (error) return "error"
|
|
if (showPassword && activeField === "password") return "peek"
|
|
if (activeField || username || password) return "typing"
|
|
return "idle"
|
|
}, [activeField, error, password, showPassword, status, username])
|
|
|
|
async function onSubmit(event: FormEvent<HTMLFormElement>) {
|
|
event.preventDefault()
|
|
setError("")
|
|
if (!username.trim() || !password) {
|
|
setError("请输入访问账号和访问密钥")
|
|
return
|
|
}
|
|
setStatus("loading")
|
|
try {
|
|
const res = await fetch("/api/auth/login", {
|
|
method: "POST",
|
|
credentials: "include",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ username, password, remember }),
|
|
})
|
|
if (!res.ok) {
|
|
let message = "访问账号或密钥不正确"
|
|
try {
|
|
const data = await res.json()
|
|
message = data?.detail || data?.error || message
|
|
} catch {
|
|
// keep default message
|
|
}
|
|
throw new Error(message)
|
|
}
|
|
setStatus("success")
|
|
window.setTimeout(() => {
|
|
window.location.href = "/"
|
|
}, 420)
|
|
} catch (err) {
|
|
setStatus("idle")
|
|
setError(err instanceof Error ? err.message : "验证失败,请稍后再试")
|
|
}
|
|
}
|
|
|
|
return (
|
|
<main className="login-page login-page--oasis login-page--source relative min-h-screen overflow-hidden text-white">
|
|
<OasisCanvas />
|
|
<div className="login-oasis-shade" />
|
|
<div className="login-source-overlay">
|
|
<section className="login-source-character-panel" aria-hidden="true">
|
|
<AnimatedLoginCharacters mood={mood} eyeOffset={eyeOffset} />
|
|
</section>
|
|
|
|
<section className="login-auth-panel login-source-auth-panel flex items-center rounded-[8px]">
|
|
<form className="w-full" onSubmit={onSubmit}>
|
|
<div className="mb-4">
|
|
<div className="login-auth-icon inline-flex h-9 w-9 items-center justify-center rounded-[8px] text-white">
|
|
<LockKeyhole className="h-5 w-5" />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
<label className="block">
|
|
<span className="sr-only">访问账号</span>
|
|
<span className="flex h-11 items-center gap-3 rounded-[8px] border border-white/10 bg-black/25 px-3 text-white transition focus-within:border-[#d6b36a] focus-within:bg-black/35 focus-within:ring-2 focus-within:ring-[#d6b36a]/25">
|
|
<UserRound className="h-4 w-4 text-white/45" />
|
|
<input
|
|
className="h-full min-w-0 flex-1 bg-transparent text-base text-white outline-none placeholder:text-white/30"
|
|
value={username}
|
|
disabled={disabled}
|
|
autoComplete="username"
|
|
placeholder="账号"
|
|
onFocus={() => setActiveField("username")}
|
|
onBlur={() => setActiveField(null)}
|
|
onChange={(event) => {
|
|
setUsername(event.target.value)
|
|
if (error) setError("")
|
|
}}
|
|
/>
|
|
</span>
|
|
</label>
|
|
|
|
<label className="block">
|
|
<span className="sr-only">访问密钥</span>
|
|
<span className="flex h-11 items-center gap-3 rounded-[8px] border border-white/10 bg-black/25 px-3 text-white transition focus-within:border-[#d6b36a] focus-within:bg-black/35 focus-within:ring-2 focus-within:ring-[#d6b36a]/25">
|
|
<LockKeyhole className="h-4 w-4 text-white/45" />
|
|
<input
|
|
className="h-full min-w-0 flex-1 bg-transparent text-base text-white outline-none placeholder:text-white/30"
|
|
value={password}
|
|
disabled={disabled}
|
|
type={showPassword ? "text" : "password"}
|
|
autoComplete="current-password"
|
|
placeholder="密钥"
|
|
onFocus={() => setActiveField("password")}
|
|
onBlur={() => setActiveField(null)}
|
|
onChange={(event) => {
|
|
setPassword(event.target.value)
|
|
if (error) setError("")
|
|
}}
|
|
/>
|
|
<button
|
|
className="grid h-9 w-9 place-items-center rounded-[8px] text-white/55 transition hover:bg-white/10 hover:text-white focus:outline-none focus:ring-2 focus:ring-[#d6b36a]/45 disabled:opacity-50"
|
|
type="button"
|
|
disabled={disabled}
|
|
aria-label={showPassword ? "隐藏密码" : "显示密码"}
|
|
onMouseDown={(event) => event.preventDefault()}
|
|
onClick={() => setShowPassword((value) => !value)}
|
|
>
|
|
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
|
</button>
|
|
</span>
|
|
</label>
|
|
</div>
|
|
|
|
<label className="mt-3 flex cursor-pointer items-center gap-2 text-sm text-white/60">
|
|
<input
|
|
className="h-4 w-4 rounded border-white/20 bg-black/30 accent-[#c89b3c]"
|
|
type="checkbox"
|
|
checked={remember}
|
|
disabled={disabled}
|
|
onChange={(event) => setRemember(event.target.checked)}
|
|
/>
|
|
<span>保持会话</span>
|
|
</label>
|
|
|
|
<div className="mt-4 min-h-9" aria-live="polite">
|
|
{error ? (
|
|
<div className="flex items-start gap-2 rounded-[8px] border border-red-400/30 bg-red-500/10 px-3 py-2 text-sm text-red-100">
|
|
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
|
<span>{error}</span>
|
|
</div>
|
|
) : status === "success" ? (
|
|
<div className="flex items-start gap-2 rounded-[8px] border border-emerald-300/30 bg-emerald-400/10 px-3 py-2 text-sm text-emerald-100">
|
|
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" />
|
|
<span>正在进入</span>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<button
|
|
className="mt-1 flex h-11 w-full items-center justify-center gap-2 rounded-[8px] bg-white px-4 text-base font-semibold text-black shadow-xl shadow-black/25 transition hover:bg-[#f5efe3] focus:outline-none focus:ring-2 focus:ring-[#d6b36a]/60 disabled:cursor-wait disabled:opacity-70"
|
|
type="submit"
|
|
disabled={disabled}
|
|
>
|
|
<span>{status === "loading" ? "验证中" : status === "success" ? "进入中" : "登录"}</span>
|
|
<ArrowRight className="h-4 w-4" />
|
|
</button>
|
|
</form>
|
|
</section>
|
|
</div>
|
|
</main>
|
|
)
|
|
}
|