Your Name 3a6e25b5b1 feat: 添加密码重置功能
- 后端: 新增 POST /auth/reset-password 端点(邮箱+验证码+新密码)
- 后端: 新增 ResetPasswordRequest schema
- 前端: 新增 /forgot-password 页面(分步骤:输入邮箱→验证码+新密码→完成)
- 前端: 登录页添加"忘记密码?"链接

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

327 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, Suspense } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { useAuth } from '@/contexts/AuthContext'
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()
const searchParams = useSearchParams()
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',
agency: 'agency@demo.com',
brand: 'brand@demo.com',
}
const demoEmail = emailMap[role]
setEmail(demoEmail)
setPassword('demo123')
setError('')
setIsLoading(true)
const result = await login({ email: demoEmail, password: 'demo123' })
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)
}
useEffect(() => {
if (roleFromUrl && !isLoading && !autoLoginAttempted) {
setAutoLoginAttempted(true)
handleDemoLogin(roleFromUrl)
}
}, [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 credentials = loginMode === 'code'
? { email, email_code: emailCode }
: { email, password }
const result = await login(credentials)
if (result.success) {
const stored = localStorage.getItem('miaosi_user')
if (stored) {
const user = JSON.parse(stored)
switch (user.role) {
case 'creator':
router.push('/creator')
break
case 'agency':
router.push('/agency')
break
case 'brand':
router.push('/brand')
break
default:
router.push('/')
}
}
} else {
setError(result.error || '登录失败')
}
setIsLoading(false)
}
return (
<div className="min-h-screen bg-bg-page flex flex-col items-center justify-center px-6">
<div className="w-full max-w-sm space-y-8">
{/* 返回按钮 */}
<Link
href="/"
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">AI </p>
</div>
</div>
{/* 登录方式切换 */}
<div className="flex gap-4 border-b border-border-subtle">
<button
type="button"
onClick={() => { setLoginMode('password'); setError('') }}
className={`pb-3 text-sm font-medium transition-all border-b-2 ${
loginMode === 'password'
? 'border-accent-indigo text-accent-indigo'
: 'border-transparent text-text-tertiary hover:text-text-secondary'
}`}
>
</button>
<button
type="button"
onClick={() => { setLoginMode('code'); setError('') }}
className={`pb-3 text-sm font-medium transition-all border-b-2 ${
loginMode === 'code'
? 'border-accent-indigo text-accent-indigo'
: 'border-transparent text-text-tertiary hover:text-text-secondary'
}`}
>
</button>
</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="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>
{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 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"
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>
{/* 注册 + 忘记密码 */}
<div className="flex items-center justify-between text-sm">
<Link href="/forgot-password" className="text-text-tertiary hover:text-text-secondary transition-colors">
</Link>
<p className="text-text-secondary">
{' '}
<Link href="/register" className="text-accent-indigo hover:underline font-medium">
</Link>
</p>
</div>
{/* Demo 登录 */}
<div className="pt-6 border-t border-border-subtle">
<p className="text-sm text-text-tertiary text-center mb-4">Demo </p>
<div className="flex flex-col gap-3">
<button
type="button"
onClick={() => handleDemoLogin('creator')}
className="w-full p-4 text-left bg-bg-card border border-border-subtle rounded-xl hover:bg-bg-elevated transition-colors"
disabled={isLoading}
>
<div className="font-medium text-text-primary"></div>
<div className="text-sm text-text-secondary">creator@demo.com</div>
</button>
<button
type="button"
onClick={() => handleDemoLogin('agency')}
className="w-full p-4 text-left bg-bg-card border border-border-subtle rounded-xl hover:bg-bg-elevated transition-colors"
disabled={isLoading}
>
<div className="font-medium text-text-primary"></div>
<div className="text-sm text-text-secondary">agency@demo.com</div>
</button>
<button
type="button"
onClick={() => handleDemoLogin('brand')}
className="w-full p-4 text-left bg-bg-card border border-border-subtle rounded-xl hover:bg-bg-elevated transition-colors"
disabled={isLoading}
>
<div className="font-medium text-text-primary"></div>
<div className="text-sm text-text-secondary">brand@demo.com</div>
</button>
</div>
</div>
</div>
</div>
)
}
export default function LoginPage() {
return (
<Suspense fallback={
<div className="min-h-screen bg-bg-page flex items-center justify-center">
<div className="w-8 h-8 border-2 border-accent-indigo border-t-transparent rounded-full animate-spin" />
</div>
}>
<LoginForm />
</Suspense>
)
}