- 后端: 新增验证码服务(生成/存储/验证)和邮件发送服务(开发环境控制台输出) - 后端: 新增 POST /auth/send-code 端点,支持注册/登录/重置密码三种用途 - 后端: 注册流程要求邮箱验证码,验证通过后 is_verified=True - 后端: 登录支持邮箱+密码 或 邮箱+验证码 两种方式 - 前端: 注册页增加验证码输入框和获取验证码按钮(60秒倒计时) - 前端: 登录页增加密码登录/验证码登录双Tab切换 - 测试: conftest 添加 bypass_verification fixture,所有 367 测试通过 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
306 lines
12 KiB
TypeScript
306 lines
12 KiB
TypeScript
'use client'
|
||
|
||
import { useState, useEffect, useCallback } from 'react'
|
||
import { useRouter } from 'next/navigation'
|
||
import { useAuth } from '@/contexts/AuthContext'
|
||
import { ShieldCheck, AlertCircle, ArrowLeft, Mail, Lock, User, Phone, KeyRound } from 'lucide-react'
|
||
import Link from 'next/link'
|
||
import type { UserRole } from '@/lib/api'
|
||
import { api } from '@/lib/api'
|
||
import { USE_MOCK } from '@/contexts/AuthContext'
|
||
|
||
const roleOptions: { value: UserRole; label: string; desc: string }[] = [
|
||
{ value: 'brand', label: '品牌方', desc: '创建项目、管理代理商、配置审核规则' },
|
||
{ value: 'agency', label: '代理商', desc: '管理达人、分配任务、审核内容' },
|
||
{ value: 'creator', label: '达人', desc: '上传脚本和视频、查看审核结果' },
|
||
]
|
||
|
||
export default function RegisterPage() {
|
||
const router = useRouter()
|
||
const { register } = useAuth()
|
||
const [name, setName] = useState('')
|
||
const [email, setEmail] = useState('')
|
||
const [phone, setPhone] = useState('')
|
||
const [password, setPassword] = useState('')
|
||
const [confirmPassword, setConfirmPassword] = useState('')
|
||
const [emailCode, setEmailCode] = useState('')
|
||
const [role, setRole] = useState<UserRole>('creator')
|
||
const [error, setError] = useState('')
|
||
const [isLoading, setIsLoading] = useState(false)
|
||
const [codeSending, setCodeSending] = useState(false)
|
||
const [countdown, setCountdown] = useState(0)
|
||
|
||
// 倒计时
|
||
useEffect(() => {
|
||
if (countdown <= 0) return
|
||
const timer = setTimeout(() => setCountdown(countdown - 1), 1000)
|
||
return () => clearTimeout(timer)
|
||
}, [countdown])
|
||
|
||
const handleSendCode = useCallback(async () => {
|
||
if (!email) {
|
||
setError('请先输入邮箱')
|
||
return
|
||
}
|
||
if (countdown > 0) return
|
||
|
||
setError('')
|
||
setCodeSending(true)
|
||
|
||
try {
|
||
if (USE_MOCK) {
|
||
// Mock: 模拟发送
|
||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||
setCountdown(60)
|
||
return
|
||
}
|
||
|
||
await api.sendEmailCode({ email, purpose: 'register' })
|
||
setCountdown(60)
|
||
} catch (err) {
|
||
const error = err instanceof Error ? err.message : '发送验证码失败'
|
||
setError(error)
|
||
} finally {
|
||
setCodeSending(false)
|
||
}
|
||
}, [email, countdown])
|
||
|
||
const handleSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault()
|
||
setError('')
|
||
|
||
if (!name.trim()) {
|
||
setError('请输入用户名')
|
||
return
|
||
}
|
||
if (!email) {
|
||
setError('请填写邮箱')
|
||
return
|
||
}
|
||
if (!emailCode && !USE_MOCK) {
|
||
setError('请输入验证码')
|
||
return
|
||
}
|
||
if (password.length < 6) {
|
||
setError('密码至少 6 位')
|
||
return
|
||
}
|
||
if (password !== confirmPassword) {
|
||
setError('两次密码不一致')
|
||
return
|
||
}
|
||
|
||
setIsLoading(true)
|
||
|
||
const result = await register({
|
||
name: name.trim(),
|
||
email,
|
||
phone: phone || undefined,
|
||
password,
|
||
role,
|
||
email_code: emailCode || '000000',
|
||
})
|
||
|
||
if (result.success) {
|
||
switch (role) {
|
||
case 'creator':
|
||
router.push('/creator')
|
||
break
|
||
case 'agency':
|
||
router.push('/agency')
|
||
break
|
||
case 'brand':
|
||
router.push('/brand')
|
||
break
|
||
}
|
||
} else {
|
||
setError(result.error || '注册失败')
|
||
}
|
||
|
||
setIsLoading(false)
|
||
}
|
||
|
||
return (
|
||
<div className="min-h-screen bg-bg-page flex flex-col items-center justify-center px-6 py-12">
|
||
<div className="w-full max-w-sm space-y-8">
|
||
{/* 返回 */}
|
||
<Link
|
||
href="/login"
|
||
className="inline-flex items-center gap-2 text-text-secondary hover:text-text-primary transition-colors"
|
||
>
|
||
<ArrowLeft className="w-4 h-4" />
|
||
返回登录
|
||
</Link>
|
||
|
||
{/* Logo */}
|
||
<div className="flex items-center gap-3">
|
||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-accent-indigo to-[#4F46E5] flex items-center justify-center shadow-[0px_8px_24px_-4px_rgba(99,102,241,0.4)]">
|
||
<ShieldCheck className="w-7 h-7 text-white" />
|
||
</div>
|
||
<div>
|
||
<span className="text-2xl font-bold text-text-primary">注册账号</span>
|
||
<p className="text-sm text-text-secondary">加入秒思智能审核平台</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 注册表单 */}
|
||
<form onSubmit={handleSubmit} className="space-y-5">
|
||
{error && (
|
||
<div className="flex items-center gap-2 p-3 bg-accent-coral/10 text-accent-coral rounded-lg text-sm">
|
||
<AlertCircle size={16} />
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
{/* 角色选择 */}
|
||
<div className="space-y-2">
|
||
<label className="block text-sm font-medium text-text-primary">选择角色</label>
|
||
<div className="grid grid-cols-3 gap-2">
|
||
{roleOptions.map((opt) => (
|
||
<button
|
||
key={opt.value}
|
||
type="button"
|
||
onClick={() => setRole(opt.value)}
|
||
className={`p-3 rounded-xl border text-center transition-all ${
|
||
role === opt.value
|
||
? 'border-accent-indigo bg-accent-indigo/10 text-accent-indigo'
|
||
: 'border-border-subtle bg-bg-card text-text-secondary hover:bg-bg-elevated'
|
||
}`}
|
||
>
|
||
<div className="font-medium text-sm">{opt.label}</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
<p className="text-xs text-text-tertiary">
|
||
{roleOptions.find((o) => o.value === role)?.desc}
|
||
</p>
|
||
</div>
|
||
|
||
{/* 用户名 */}
|
||
<div className="space-y-2">
|
||
<label className="block text-sm font-medium text-text-primary">用户名</label>
|
||
<div className="relative">
|
||
<User className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-text-tertiary" />
|
||
<input
|
||
type="text"
|
||
placeholder="请输入用户名"
|
||
value={name}
|
||
onChange={(e) => setName(e.target.value)}
|
||
className="w-full pl-12 pr-4 py-3.5 bg-bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder-text-tertiary focus:outline-none focus:ring-2 focus:ring-accent-indigo focus:border-transparent transition-all"
|
||
required
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 邮箱 */}
|
||
<div className="space-y-2">
|
||
<label className="block text-sm font-medium text-text-primary">邮箱</label>
|
||
<div className="relative">
|
||
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-text-tertiary" />
|
||
<input
|
||
type="email"
|
||
placeholder="请输入邮箱"
|
||
value={email}
|
||
onChange={(e) => setEmail(e.target.value)}
|
||
className="w-full pl-12 pr-4 py-3.5 bg-bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder-text-tertiary focus:outline-none focus:ring-2 focus:ring-accent-indigo focus:border-transparent transition-all"
|
||
required
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 验证码 */}
|
||
<div className="space-y-2">
|
||
<label className="block text-sm font-medium text-text-primary">邮箱验证码</label>
|
||
<div className="flex gap-3">
|
||
<div className="relative flex-1">
|
||
<KeyRound className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-text-tertiary" />
|
||
<input
|
||
type="text"
|
||
placeholder="请输入验证码"
|
||
value={emailCode}
|
||
onChange={(e) => setEmailCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||
maxLength={6}
|
||
className="w-full pl-12 pr-4 py-3.5 bg-bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder-text-tertiary focus:outline-none focus:ring-2 focus:ring-accent-indigo focus:border-transparent transition-all"
|
||
/>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={handleSendCode}
|
||
disabled={codeSending || countdown > 0 || !email}
|
||
className="px-4 py-3.5 rounded-xl bg-accent-indigo/10 text-accent-indigo font-medium text-sm whitespace-nowrap hover:bg-accent-indigo/20 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
{codeSending ? '发送中...' : countdown > 0 ? `${countdown}s` : '获取验证码'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 手机号 */}
|
||
<div className="space-y-2">
|
||
<label className="block text-sm font-medium text-text-primary">
|
||
手机号 <span className="text-text-tertiary font-normal">(选填)</span>
|
||
</label>
|
||
<div className="relative">
|
||
<Phone className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-text-tertiary" />
|
||
<input
|
||
type="tel"
|
||
placeholder="请输入手机号"
|
||
value={phone}
|
||
onChange={(e) => setPhone(e.target.value)}
|
||
className="w-full pl-12 pr-4 py-3.5 bg-bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder-text-tertiary focus:outline-none focus:ring-2 focus:ring-accent-indigo focus:border-transparent transition-all"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 密码 */}
|
||
<div className="space-y-2">
|
||
<label className="block text-sm font-medium text-text-primary">密码</label>
|
||
<div className="relative">
|
||
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-text-tertiary" />
|
||
<input
|
||
type="password"
|
||
placeholder="至少 6 位密码"
|
||
value={password}
|
||
onChange={(e) => setPassword(e.target.value)}
|
||
className="w-full pl-12 pr-4 py-3.5 bg-bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder-text-tertiary focus:outline-none focus:ring-2 focus:ring-accent-indigo focus:border-transparent transition-all"
|
||
required
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 确认密码 */}
|
||
<div className="space-y-2">
|
||
<label className="block text-sm font-medium text-text-primary">确认密码</label>
|
||
<div className="relative">
|
||
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-text-tertiary" />
|
||
<input
|
||
type="password"
|
||
placeholder="请再次输入密码"
|
||
value={confirmPassword}
|
||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||
className="w-full pl-12 pr-4 py-3.5 bg-bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder-text-tertiary focus:outline-none focus:ring-2 focus:ring-accent-indigo focus:border-transparent transition-all"
|
||
required
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
type="submit"
|
||
disabled={isLoading}
|
||
className="w-full py-3.5 rounded-xl bg-gradient-to-r from-accent-indigo to-[#4F46E5] text-white font-semibold text-base shadow-[0px_8px_24px_-4px_rgba(99,102,241,0.4)] hover:opacity-90 transition-opacity disabled:opacity-50"
|
||
>
|
||
{isLoading ? '注册中...' : '注册'}
|
||
</button>
|
||
</form>
|
||
|
||
{/* 底部链接 */}
|
||
<p className="text-center text-sm text-text-secondary">
|
||
已有账号?{' '}
|
||
<Link href="/login" className="text-accent-indigo hover:underline font-medium">
|
||
立即登录
|
||
</Link>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|