Files
zclaw_openfang/admin/src/app/login/page.tsx
iven 5fdf96c3f5 chore: 提交所有工作进度 — SaaS 后端增强、Admin UI、桌面端集成
包含大量 SaaS 平台改进、Admin 管理后台更新、桌面端集成完善、
文档同步、测试文件重构等内容。为 QA 测试准备干净工作树。
2026-03-29 10:46:41 +08:00

242 lines
9.8 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 { useState, type FormEvent } from 'react'
import { useRouter } from 'next/navigation'
import { Lock, User, Loader2, Eye, EyeOff, ShieldCheck } from 'lucide-react'
import { api } from '@/lib/api-client'
import { login } from '@/lib/auth'
import { ApiRequestError } from '@/lib/api-client'
export default function LoginPage() {
const router = useRouter()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [totpCode, setTotpCode] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [needTotp, setNeedTotp] = useState(false)
const [remember, setRemember] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
async function handleSubmit(e: FormEvent) {
e.preventDefault()
setError('')
if (!username.trim()) {
setError('请输入用户名')
return
}
if (!password.trim()) {
setError('请输入密码')
return
}
setLoading(true)
try {
const res = await api.auth.login({
username: username.trim(),
password,
totp_code: totpCode.trim() || undefined,
})
login(res.token, res.account)
router.replace('/')
} catch (err) {
if (err instanceof ApiRequestError) {
const msg = err.body.message || ''
// 后端返回 "需要 TOTP" 时显示 TOTP 输入框
if (msg.includes('TOTP') || msg.includes('totp') || msg.includes('2FA') || msg.includes('验证码') || err.status === 403) {
setNeedTotp(true)
setError(msg || '请输入两步验证码')
} else {
setError(msg || '登录失败,请检查用户名和密码')
}
} else {
setError('网络错误,请稍后重试')
}
} finally {
setLoading(false)
}
}
return (
<div className="flex min-h-screen">
{/* 左侧品牌区域 */}
<div className="hidden lg:flex lg:w-1/2 relative overflow-hidden bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
{/* 装饰性背景 */}
<div className="absolute inset-0">
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-green-500/5 rounded-full blur-3xl" />
<div className="absolute bottom-1/4 right-1/4 w-64 h-64 bg-green-500/8 rounded-full blur-3xl" />
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] border border-green-500/10 rounded-full" />
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[400px] h-[400px] border border-green-500/10 rounded-full" />
</div>
{/* 品牌内容 */}
<div className="relative z-10 flex flex-col items-center justify-center w-full p-12">
<div className="text-center">
<h1 className="text-6xl font-bold tracking-tight text-foreground mb-4">
ZCLAW
</h1>
<p className="text-xl text-muted-foreground font-light">
AI Agent
</p>
<div className="mt-8 flex items-center justify-center gap-2">
<div className="h-px w-12 bg-green-500/50" />
<div className="w-2 h-2 rounded-full bg-green-500" />
<div className="h-px w-12 bg-green-500/50" />
</div>
<p className="mt-6 text-sm text-muted-foreground/60 max-w-sm">
AI API
</p>
</div>
</div>
</div>
{/* 右侧登录表单 */}
<div className="flex w-full lg:w-1/2 items-center justify-center p-8">
<div className="w-full max-w-sm space-y-8">
{/* 移动端 Logo */}
<div className="lg:hidden text-center">
<h1 className="text-4xl font-bold tracking-tight text-foreground mb-2">
ZCLAW
</h1>
<p className="text-sm text-muted-foreground">AI Agent </p>
</div>
<div>
<h2 className="text-2xl font-semibold text-foreground"></h2>
<p className="mt-2 text-sm text-muted-foreground">
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{/* 用户名 */}
<div className="space-y-2">
<label
htmlFor="username"
className="text-sm font-medium text-foreground"
>
</label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
id="username"
type="text"
placeholder="请输入用户名"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-transparent pl-10 pr-3 py-2 text-sm shadow-sm transition-colors duration-200 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
autoComplete="username"
/>
</div>
</div>
{/* 密码 */}
<div className="space-y-2">
<label
htmlFor="password"
className="text-sm font-medium text-foreground"
>
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
id="password"
type={showPassword ? 'text' : 'password'}
placeholder="请输入密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-transparent pl-10 pr-10 py-2 text-sm shadow-sm transition-colors duration-200 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
autoComplete="current-password"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors duration-200 cursor-pointer"
>
{showPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
</div>
{/* TOTP 验证码 */}
{needTotp && (
<div className="space-y-2">
<label
htmlFor="totp"
className="text-sm font-medium text-foreground"
>
</label>
<div className="relative">
<ShieldCheck className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
id="totp"
type="text"
placeholder="请输入 6 位验证码"
value={totpCode}
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
maxLength={6}
className="flex h-10 w-full rounded-md border border-input bg-transparent pl-10 pr-3 py-2 text-sm shadow-sm transition-colors duration-200 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring tracking-widest"
autoComplete="one-time-code"
inputMode="numeric"
/>
</div>
<p className="text-xs text-muted-foreground">
使 App Google Authenticator
</p>
</div>
)}
{/* 记住我 */}
<div className="flex items-center gap-2">
<input
id="remember"
type="checkbox"
checked={remember}
onChange={(e) => setRemember(e.target.checked)}
className="h-4 w-4 rounded border-input bg-transparent accent-primary cursor-pointer"
/>
<label
htmlFor="remember"
className="text-sm text-muted-foreground cursor-pointer select-none"
>
</label>
</div>
{/* 错误信息 */}
{error && (
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
{error}
</div>
)}
{/* 登录按钮 */}
<button
type="submit"
disabled={loading}
className="flex h-10 w-full items-center justify-center rounded-md bg-primary text-primary-foreground font-medium text-sm shadow-sm transition-colors duration-200 hover:bg-primary-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 cursor-pointer"
>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
'登录'
)}
</button>
</form>
</div>
</div>
</div>
)
}