276 lines
13 KiB
TypeScript
276 lines
13 KiB
TypeScript
"use client"
|
||
|
||
import type { CSSProperties, FormEvent } from "react"
|
||
import { useEffect, useMemo, useState } from "react"
|
||
import {
|
||
AlertCircle,
|
||
ArrowRight,
|
||
CheckCircle2,
|
||
Eye,
|
||
EyeOff,
|
||
LockKeyhole,
|
||
ShieldCheck,
|
||
Sparkles,
|
||
UserRound,
|
||
} from "lucide-react"
|
||
|
||
type LoginStatus = "idle" | "loading" | "success"
|
||
type LoginMood = "idle" | "typing" | "peek" | "error" | "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 * 5, y: nextY * 3 })
|
||
}
|
||
window.addEventListener("pointermove", onPointerMove)
|
||
return () => window.removeEventListener("pointermove", onPointerMove)
|
||
}, [])
|
||
|
||
const mood: LoginMood = 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"
|
||
data-mood={mood}
|
||
style={
|
||
{
|
||
"--eye-x": `${eyeOffset.x}px`,
|
||
"--eye-y": `${eyeOffset.y}px`,
|
||
} as CSSProperties
|
||
}
|
||
>
|
||
<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/10 bg-black/35 p-6 shadow-2xl shadow-black/35 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-center justify-between gap-3">
|
||
<div className="flex items-center gap-3">
|
||
<div className="grid h-11 w-11 place-items-center rounded-[8px] border border-white/10 bg-white text-black">
|
||
<Sparkles className="h-5 w-5" />
|
||
</div>
|
||
<div>
|
||
<p className="text-sm text-white/55">SKG Marketing Studio</p>
|
||
<h1 className="text-3xl font-semibold leading-tight text-white sm:text-4xl">营销内容工作台</h1>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-2 rounded-[8px] border border-emerald-300/25 bg-emerald-300/10 px-3 py-2 text-sm text-emerald-100">
|
||
<ShieldCheck className="h-4 w-4" />
|
||
<span>生产入口已保护</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="login-character-stage" aria-hidden="true">
|
||
<div className="login-stage-grid" />
|
||
<div className="login-characters-container">
|
||
<div className="login-figure login-figure--purple">
|
||
<span className="login-eyes login-eyes--purple">
|
||
<span className="login-eye" />
|
||
<span className="login-eye" />
|
||
</span>
|
||
<span className="login-mouth login-mouth--purple" />
|
||
</div>
|
||
<div className="login-figure login-figure--black">
|
||
<span className="login-eyes login-eyes--black">
|
||
<span className="login-eye login-eye--small" />
|
||
<span className="login-eye login-eye--small" />
|
||
</span>
|
||
</div>
|
||
<div className="login-figure login-figure--orange">
|
||
<span className="login-eyes login-eyes--orange">
|
||
<span className="login-pupil" />
|
||
<span className="login-pupil" />
|
||
</span>
|
||
<span className="login-mouth login-mouth--orange" />
|
||
</div>
|
||
<div className="login-figure login-figure--yellow">
|
||
<span className="login-eyes login-eyes--yellow">
|
||
<span className="login-pupil" />
|
||
<span className="login-pupil" />
|
||
</span>
|
||
<span className="login-yellow-mouth">
|
||
<svg width="80" height="20" viewBox="0 0 80 20">
|
||
<path d="M0 10 Q10 10, 20 10 Q30 10, 40 10 Q50 10, 60 10 Q70 10, 80 10" />
|
||
</svg>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid gap-3 sm:grid-cols-3">
|
||
{[
|
||
["Visual", "素材"],
|
||
["Audio", "声音"],
|
||
["Video", "成片"],
|
||
].map(([label, value]) => (
|
||
<div key={label} className="rounded-[8px] border border-white/10 bg-white/[0.06] px-4 py-3">
|
||
<p className="text-xs text-white/45">{label}</p>
|
||
<p className="mt-1 text-lg font-semibold text-white">{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>
|
||
)
|
||
}
|