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>
This commit is contained in:
Your Name
2026-02-09 18:49:47 +08:00
co-authored by Claude Opus 4.6
parent 864af19011
commit d4081345f7
18 changed files with 592 additions and 89 deletions
+116 -16
View File
@@ -1,10 +1,14 @@
'use client'
import { useState, useEffect, Suspense } from 'react'
import { useState, useEffect, useCallback, Suspense } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { useAuth } from '@/contexts/AuthContext'
import { ShieldCheck, AlertCircle, ArrowLeft, Mail, Lock } from 'lucide-react'
import { ShieldCheck, AlertCircle, ArrowLeft, Mail, Lock, KeyRound } from 'lucide-react'
import Link from 'next/link'
import { api } from '@/lib/api'
import { USE_MOCK } from '@/contexts/AuthContext'
type LoginMode = 'password' | 'code'
function LoginForm() {
const router = useRouter()
@@ -12,13 +16,24 @@ function LoginForm() {
const { login } = useAuth()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [emailCode, setEmailCode] = useState('')
const [loginMode, setLoginMode] = useState<LoginMode>('password')
const [error, setError] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [autoLoginAttempted, setAutoLoginAttempted] = useState(false)
const [codeSending, setCodeSending] = useState(false)
const [countdown, setCountdown] = useState(0)
// 如果 URL 有 role 参数,自动触发 demo 登录
const roleFromUrl = searchParams.get('role') as 'creator' | 'agency' | 'brand' | null
// 倒计时
useEffect(() => {
if (countdown <= 0) return
const timer = setTimeout(() => setCountdown(countdown - 1), 1000)
return () => clearTimeout(timer)
}, [countdown])
const handleDemoLogin = async (role: 'creator' | 'agency' | 'brand') => {
const emailMap = {
creator: 'creator@demo.com',
@@ -59,12 +74,43 @@ function LoginForm() {
}
}, [roleFromUrl])
const handleSendCode = useCallback(async () => {
if (!email) {
setError('请先输入邮箱')
return
}
if (countdown > 0) return
setError('')
setCodeSending(true)
try {
if (USE_MOCK) {
await new Promise((resolve) => setTimeout(resolve, 500))
setCountdown(60)
return
}
await api.sendEmailCode({ email, purpose: 'login' })
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('')
setIsLoading(true)
const result = await login({ email, password })
const credentials = loginMode === 'code'
? { email, email_code: emailCode }
: { email, password }
const result = await login(credentials)
if (result.success) {
const stored = localStorage.getItem('miaosi_user')
@@ -114,6 +160,32 @@ function LoginForm() {
</div>
</div>
{/* 登录方式切换 */}
<div className="flex bg-bg-elevated rounded-xl p-1 border border-border-subtle">
<button
type="button"
onClick={() => { setLoginMode('password'); setError('') }}
className={`flex-1 py-2.5 rounded-lg text-sm font-medium transition-all ${
loginMode === 'password'
? 'bg-white text-text-primary shadow-sm'
: 'text-text-tertiary hover:text-text-secondary'
}`}
>
</button>
<button
type="button"
onClick={() => { setLoginMode('code'); setError('') }}
className={`flex-1 py-2.5 rounded-lg text-sm font-medium transition-all ${
loginMode === 'code'
? 'bg-white text-text-primary shadow-sm'
: 'text-text-tertiary hover:text-text-secondary'
}`}
>
</button>
</div>
{/* 登录表单 */}
<form onSubmit={handleSubmit} className="space-y-5">
{error && (
@@ -138,20 +210,48 @@ function LoginForm() {
</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={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
/>
{loginMode === 'password' ? (
<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={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>
) : (
<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"
required
/>
</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>
)}
<button
type="submit"
+77 -5
View File
@@ -1,11 +1,13 @@
'use client'
import { useState } from 'react'
import { useState, useEffect, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { useAuth } from '@/contexts/AuthContext'
import { ShieldCheck, AlertCircle, ArrowLeft, Mail, Lock, User, Phone } from 'lucide-react'
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: '创建项目、管理代理商、配置审核规则' },
@@ -21,9 +23,47 @@ export default function RegisterPage() {
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()
@@ -33,8 +73,12 @@ export default function RegisterPage() {
setError('请输入用户名')
return
}
if (!email && !phone) {
setError('请填写邮箱或手机号')
if (!email) {
setError('请填写邮箱')
return
}
if (!emailCode && !USE_MOCK) {
setError('请输入验证码')
return
}
if (password.length < 6) {
@@ -50,10 +94,11 @@ export default function RegisterPage() {
const result = await register({
name: name.trim(),
email: email || undefined,
email,
phone: phone || undefined,
password,
role,
email_code: emailCode || '000000',
})
if (result.success) {
@@ -158,10 +203,37 @@ export default function RegisterPage() {
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">
+4 -1
View File
@@ -94,7 +94,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
if (!mockUser) {
return { success: false, error: '用户不存在' }
}
if (mockUser.password !== credentials.password) {
// 验证码登录或密码登录
if (credentials.email_code) {
// 验证码登录 mock: 任何验证码都通过
} else if (mockUser.password !== credentials.password) {
return { success: false, error: '密码错误' }
}
const { password: _, ...userWithoutPassword } = mockUser
+21 -2
View File
@@ -93,15 +93,26 @@ export interface LoginRequest {
email?: string
phone?: string
password?: string
sms_code?: string
email_code?: string
}
export interface RegisterRequest {
email?: string
email: string
phone?: string
password: string
name: string
role: UserRole
email_code: string
}
export interface SendEmailCodeRequest {
email: string
purpose: 'register' | 'login' | 'reset_password'
}
export interface SendEmailCodeResponse {
message: string
expires_in: number
}
export interface LoginResponse {
@@ -257,6 +268,14 @@ class ApiClient {
// ==================== 认证 ====================
/**
* 发送邮箱验证码
*/
async sendEmailCode(data: SendEmailCodeRequest): Promise<SendEmailCodeResponse> {
const response = await this.client.post<SendEmailCodeResponse>('/auth/send-code', data)
return response.data
}
/**
* 用户注册
*/