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

260 lines
10 KiB
TypeScript

'use client'
import { useState, useEffect, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { ShieldCheck, AlertCircle, CheckCircle2, ArrowLeft, Mail, Lock, KeyRound } from 'lucide-react'
import Link from 'next/link'
import { api } from '@/lib/api'
import { USE_MOCK } from '@/contexts/AuthContext'
type Step = 'email' | 'code' | 'done'
export default function ForgotPasswordPage() {
const router = useRouter()
const [step, setStep] = useState<Step>('email')
const [email, setEmail] = useState('')
const [emailCode, setEmailCode] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
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) {
await new Promise((resolve) => setTimeout(resolve, 500))
setCountdown(60)
setStep('code')
return
}
await api.sendEmailCode({ email, purpose: 'reset_password' })
setCountdown(60)
setStep('code')
} catch (err) {
const msg = err instanceof Error ? err.message : '发送验证码失败'
setError(msg)
} finally {
setCodeSending(false)
}
}, [email, countdown])
const handleResetPassword = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
if (!emailCode) {
setError('请输入验证码')
return
}
if (newPassword.length < 6) {
setError('密码至少 6 位')
return
}
if (newPassword !== confirmPassword) {
setError('两次密码不一致')
return
}
setIsLoading(true)
try {
if (USE_MOCK) {
await new Promise((resolve) => setTimeout(resolve, 500))
setStep('done')
return
}
await api.resetPassword({ email, email_code: emailCode, new_password: newPassword })
setStep('done')
} catch (err) {
const msg = err instanceof Error ? err.message : '重置密码失败'
setError(msg)
} finally {
setIsLoading(false)
}
}
// 成功页
if (step === 'done') {
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 text-center">
<div className="flex justify-center">
<div className="w-16 h-16 rounded-full bg-accent-green/10 flex items-center justify-center">
<CheckCircle2 className="w-8 h-8 text-accent-green" />
</div>
</div>
<div>
<h2 className="text-xl font-bold text-text-primary"></h2>
<p className="text-sm text-text-secondary mt-2">使</p>
</div>
<button
onClick={() => router.push('/login')}
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"
>
</button>
</div>
</div>
)
}
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="/login"
className="inline-flex items-center gap-2 text-text-secondary hover:text-text-primary transition-colors"
>
<ArrowLeft className="w-4 h-4" />
</Link>
{/* 标题 */}
<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">
{step === 'email' ? '输入邮箱获取验证码' : '设置新密码'}
</p>
</div>
</div>
{/* 步骤指示 */}
<div className="flex items-center gap-2">
<div className={`flex-1 h-1 rounded-full ${step === 'email' ? 'bg-accent-indigo' : 'bg-accent-indigo'}`} />
<div className={`flex-1 h-1 rounded-full ${step === 'code' ? 'bg-accent-indigo' : 'bg-border-subtle'}`} />
</div>
<form onSubmit={step === 'email' ? (e) => { e.preventDefault(); handleSendCode() } : handleResetPassword} 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>
)}
{step === 'email' ? (
<>
{/* 邮箱 */}
<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>
<button
type="submit"
disabled={codeSending || !email}
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"
>
{codeSending ? '发送中...' : '获取验证码'}
</button>
</>
) : (
<>
{/* 验证码 */}
<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="请输入 6 位验证码"
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}
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"></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={newPassword}
onChange={(e) => setNewPassword(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>
</div>
</div>
)
}