Files
20260512-skg-tk/web/app/login/page.tsx
2026-05-15 16:49:50 +08:00

247 lines
11 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 type { FormEvent } from "react"
import { useEffect, useMemo, useState } from "react"
import {
AlertCircle,
ArrowRight,
CheckCircle2,
Eye,
EyeOff,
LockKeyhole,
ShieldCheck,
Sparkles,
UserRound,
} from "lucide-react"
import { AnimatedLoginCharacters, type LoginCharacterMood } from "@/components/login/animated-login-characters"
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 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])
const disabled = status === "loading" || status === "success"
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 relative min-h-screen overflow-hidden px-5 py-6 text-white sm:px-8 lg:px-10"
>
<div className="relative z-10 mx-auto flex min-h-[calc(100vh-3rem)] w-full max-w-7xl items-center">
<div className="grid w-full gap-5 lg:grid-cols-[minmax(0,1.08fr)_minmax(380px,460px)] lg:items-stretch">
<section className="login-hero order-2 relative min-h-[470px] overflow-hidden rounded-[8px] border border-white/55 bg-[#f3f4f6] p-6 text-[#282828] shadow-2xl shadow-black/20 sm:p-8 lg:order-1 lg:min-h-[620px]">
<div className="relative z-10 flex h-full flex-col justify-between gap-8">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<div className="login-brand-mark">
<span className="login-brand-mark__logo">SKG</span>
<span className="login-brand-mark__sub"></span>
</div>
<div className="mt-5 flex items-center gap-3">
<div className="grid h-11 w-11 place-items-center rounded-[8px] bg-[#282828] text-white">
<Sparkles className="h-5 w-5" />
</div>
<div>
<p className="text-sm text-[#666]">SKG Marketing Studio</p>
<h1 className="text-2xl font-semibold leading-tight text-[#111] sm:text-4xl"></h1>
</div>
</div>
</div>
<div className="login-store-pill">
<ShieldCheck className="h-4 w-4" />
<span></span>
</div>
</div>
<div className="login-product-ribbon" aria-hidden="true">
<span>Neck Relief</span>
<span>Visual Assets</span>
<span>Product Video</span>
<span>Daily Care</span>
</div>
<AnimatedLoginCharacters mood={mood} eyeOffset={eyeOffset} />
<div className="grid gap-3 sm:grid-cols-3">
{[
["Shop by Need", "素材"],
["Recovery", "声音"],
["Self-Care", "成片"],
].map(([label, value]) => (
<div key={label} className="login-skg-tile rounded-[8px] px-4 py-3">
<p className="text-xs text-[#757575]">{label}</p>
<p className="mt-1 text-lg font-semibold text-[#111]">{value}</p>
</div>
))}
</div>
</div>
</section>
<section className="order-1 flex min-h-[470px] items-center rounded-[8px] border border-white/10 bg-[#10121d]/95 p-5 shadow-2xl shadow-black/40 sm:p-8 lg:order-2 lg:min-h-[620px]">
<form className="w-full" onSubmit={onSubmit}>
<div className="mb-8">
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-[8px] bg-[#6c3ff5] text-white shadow-lg shadow-[#6c3ff5]/35">
<LockKeyhole className="h-5 w-5" />
</div>
<h2 className="text-2xl font-semibold text-white"></h2>
<p className="mt-2 text-sm leading-6 text-white/55"> SKG </p>
</div>
<div className="space-y-4">
<label className="block">
<span className="mb-2 block text-sm font-medium text-white/70"></span>
<span className="flex h-12 items-center gap-3 rounded-[8px] border border-white/10 bg-black/25 px-3 text-white transition focus-within:border-[#8d6cff] focus-within:bg-black/35 focus-within:ring-2 focus-within:ring-[#8d6cff]/30">
<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="mb-2 block text-sm font-medium text-white/70"></span>
<span className="flex h-12 items-center gap-3 rounded-[8px] border border-white/10 bg-black/25 px-3 text-white transition focus-within:border-[#8d6cff] focus-within:bg-black/35 focus-within:ring-2 focus-within:ring-[#8d6cff]/30">
<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-[#8d6cff]/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>
<div className="mt-4 flex items-center justify-between gap-4">
<label className="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-[#6c3ff5]"
type="checkbox"
checked={remember}
disabled={disabled}
onChange={(event) => setRemember(event.target.checked)}
/>
<span></span>
</label>
<span className="text-xs text-white/35">marketing.skg.com</span>
</div>
<div className="mt-5 min-h-11" 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-2 flex h-12 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-[#f2f0ff] focus:outline-none focus:ring-2 focus:ring-[#8d6cff]/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>
</div>
</main>
)
}