Your Name d4081345f7 feat: 实现邮箱验证码注册/登录功能
- 后端: 新增验证码服务(生成/存储/验证)和邮件发送服务(开发环境控制台输出)
- 后端: 新增 POST /auth/send-code 端点,支持注册/登录/重置密码三种用途
- 后端: 注册流程要求邮箱验证码,验证通过后 is_verified=True
- 后端: 登录支持邮箱+密码 或 邮箱+验证码 两种方式
- 前端: 注册页增加验证码输入框和获取验证码按钮(60秒倒计时)
- 前端: 登录页增加密码登录/验证码登录双Tab切换
- 测试: conftest 添加 bypass_verification fixture,所有 367 测试通过

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 18:49:47 +08:00

306 lines
12 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, 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>
)
}