feat(页面): 实现认证相关页面UI
- 实现登录页面,支持邮箱验证码登录 - 实现注册页面,支持邮箱验证注册 - 实现重置密码页面 - 统一认证页面布局和样式
This commit is contained in:
parent
b0b912274f
commit
abcea67980
73
src/app/(auth)/layout.tsx
Normal file
73
src/app/(auth)/layout.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { BrandIcon } from '@/components/ui/BrandIcon';
|
||||
import { ThemeToggle } from '@/components/ui/ThemeToggle';
|
||||
|
||||
interface AuthLayoutProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
// 页面配置
|
||||
const pageConfig: Record<string, { title: string; subtitle: string }> = {
|
||||
'/login': {
|
||||
title: '登录账号',
|
||||
subtitle: '欢迎回来',
|
||||
},
|
||||
'/register': {
|
||||
title: '创建账号',
|
||||
subtitle: '注册开始使用 LionCode',
|
||||
},
|
||||
'/reset-password': {
|
||||
title: '重置密码',
|
||||
subtitle: '找回您的账号',
|
||||
},
|
||||
};
|
||||
|
||||
export default function AuthLayout({ children }: AuthLayoutProps) {
|
||||
const pathname = usePathname();
|
||||
const config = pageConfig[pathname] || pageConfig['/login'];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--color-bg-secondary)] flex flex-col">
|
||||
{/* 顶部导航栏 */}
|
||||
<header className="h-16 px-6 flex items-center justify-between">
|
||||
<Link href="/" className="flex items-center gap-2 group">
|
||||
<BrandIcon size="sm" />
|
||||
<span className="text-lg font-semibold text-[var(--color-text-primary)] group-hover:text-[var(--color-primary)] transition-colors">
|
||||
LionCode
|
||||
</span>
|
||||
</Link>
|
||||
<ThemeToggle />
|
||||
</header>
|
||||
|
||||
{/* 主内容区 */}
|
||||
<main className="flex-1 flex flex-col items-center justify-center px-4 py-8">
|
||||
{/* 品牌标识和标题 */}
|
||||
<div className="text-center mb-8">
|
||||
<BrandIcon size="xl" className="mx-auto mb-4" />
|
||||
<h1 className="text-2xl font-bold text-[var(--color-text-primary)]">
|
||||
{config.title}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-[var(--color-text-tertiary)]">
|
||||
{config.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 表单卡片 */}
|
||||
<div className="w-full max-w-[480px]">
|
||||
<div className="bg-[var(--color-bg-primary)] rounded-2xl shadow-lg border border-[var(--color-border-light)] p-8">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* 页脚 */}
|
||||
<footer className="py-6 text-center text-xs text-[var(--color-text-tertiary)]">
|
||||
<p>Powered by LionCode</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
272
src/app/(auth)/login/page.tsx
Normal file
272
src/app/(auth)/login/page.tsx
Normal file
@ -0,0 +1,272 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, Suspense } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Mail, Lock, Eye, EyeOff, Loader2, KeyRound } from 'lucide-react';
|
||||
import { useAuth } from '@/providers/AuthProvider';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toast } from '@/components/ui/Toast';
|
||||
|
||||
type LoginType = 'password' | 'code';
|
||||
|
||||
function LoginForm() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { login, sendCode } = useAuth();
|
||||
|
||||
const [loginType, setLoginType] = useState<LoginType>('password');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sendingCode, setSendingCode] = useState(false);
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const redirect = searchParams.get('redirect') || '/';
|
||||
|
||||
// 倒计时
|
||||
useEffect(() => {
|
||||
if (countdown > 0) {
|
||||
const timer = setTimeout(() => setCountdown(countdown - 1), 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [countdown]);
|
||||
|
||||
// 发送验证码
|
||||
const handleSendCode = useCallback(async () => {
|
||||
if (!email) {
|
||||
setError('请输入邮箱');
|
||||
return;
|
||||
}
|
||||
|
||||
setSendingCode(true);
|
||||
setError('');
|
||||
|
||||
const result = await sendCode(email, 'login');
|
||||
|
||||
if (result.success) {
|
||||
setCountdown(60);
|
||||
toast.success('验证码已发送', {
|
||||
description: `验证码已发送到 ${email},请查收`,
|
||||
});
|
||||
} else {
|
||||
setError(result.error || '发送验证码失败');
|
||||
toast.error('发送失败', {
|
||||
description: result.error || '发送验证码失败',
|
||||
});
|
||||
}
|
||||
|
||||
setSendingCode(false);
|
||||
}, [email, sendCode]);
|
||||
|
||||
// 登录
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (!email) {
|
||||
setError('请输入邮箱');
|
||||
return;
|
||||
}
|
||||
|
||||
if (loginType === 'password' && !password) {
|
||||
setError('请输入密码');
|
||||
return;
|
||||
}
|
||||
|
||||
if (loginType === 'code' && !code) {
|
||||
setError('请输入验证码');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
const result = await login(
|
||||
email,
|
||||
loginType === 'password' ? password : undefined,
|
||||
loginType === 'code' ? code : undefined,
|
||||
loginType
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.error || '登录失败');
|
||||
setLoading(false);
|
||||
} else {
|
||||
router.push(redirect);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 登录方式切换 */}
|
||||
<div className="flex gap-2 p-1 bg-[var(--color-bg-secondary)] rounded-lg mb-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLoginType('password')}
|
||||
className={cn(
|
||||
'flex-1 py-2 text-sm font-medium rounded-md transition-colors',
|
||||
loginType === 'password'
|
||||
? 'bg-[var(--color-bg-primary)] text-[var(--color-text-primary)] shadow-sm'
|
||||
: 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]'
|
||||
)}
|
||||
>
|
||||
密码登录
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLoginType('code')}
|
||||
className={cn(
|
||||
'flex-1 py-2 text-sm font-medium rounded-md transition-colors',
|
||||
loginType === 'code'
|
||||
? 'bg-[var(--color-bg-primary)] text-[var(--color-text-primary)] shadow-sm'
|
||||
: 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]'
|
||||
)}
|
||||
>
|
||||
验证码登录
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 表单 */}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* 邮箱 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--color-text-secondary)] mb-2">
|
||||
邮箱地址
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-[var(--color-text-tertiary)]" />
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="请输入邮箱"
|
||||
className="w-full h-12 pl-10 pr-4 rounded-lg border border-[var(--color-border)] bg-[var(--color-bg-primary)] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-transparent transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 密码 */}
|
||||
{loginType === 'password' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--color-text-secondary)] mb-2">
|
||||
密码
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-[var(--color-text-tertiary)]" />
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="请输入密码"
|
||||
className="w-full h-12 pl-10 pr-12 rounded-lg border border-[var(--color-border)] bg-[var(--color-bg-primary)] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-transparent transition-all"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 验证码 */}
|
||||
{loginType === 'code' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--color-text-secondary)] mb-2">
|
||||
验证码
|
||||
</label>
|
||||
<div className="flex gap-3">
|
||||
<div className="relative flex-1">
|
||||
<KeyRound className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-[var(--color-text-tertiary)]" />
|
||||
<input
|
||||
type="text"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
placeholder="请输入6位验证码"
|
||||
maxLength={6}
|
||||
className="w-full h-12 pl-10 pr-4 rounded-lg border border-[var(--color-border)] bg-[var(--color-bg-primary)] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-transparent transition-all"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSendCode}
|
||||
disabled={sendingCode || countdown > 0 || !email}
|
||||
className={cn(
|
||||
'px-4 h-12 rounded-lg text-sm font-medium whitespace-nowrap transition-colors',
|
||||
sendingCode || countdown > 0 || !email
|
||||
? 'bg-[var(--color-bg-tertiary)] text-[var(--color-text-tertiary)] cursor-not-allowed'
|
||||
: 'bg-[var(--color-primary)] text-white hover:opacity-90'
|
||||
)}
|
||||
>
|
||||
{sendingCode ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : countdown > 0 ? (
|
||||
`${countdown}s`
|
||||
) : (
|
||||
'获取验证码'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 登录按钮 */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full h-12 rounded-lg bg-[var(--color-primary)] text-white font-medium hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading && <Loader2 className="w-5 h-5 animate-spin" />}
|
||||
{loading ? '登录中...' : '登录'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* 底部链接 */}
|
||||
<div className="mt-6 text-center space-y-2">
|
||||
<Link
|
||||
href="/reset-password"
|
||||
className="text-sm text-[var(--color-text-tertiary)] hover:text-[var(--color-primary)] transition-colors"
|
||||
>
|
||||
忘记密码?
|
||||
</Link>
|
||||
<p className="text-sm text-[var(--color-text-tertiary)]">
|
||||
没有账号?
|
||||
<Link
|
||||
href="/register"
|
||||
className="ml-1 text-[var(--color-primary)] hover:underline font-medium"
|
||||
>
|
||||
立即注册
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoginLoading() {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-[var(--color-primary)]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<Suspense fallback={<LoginLoading />}>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
282
src/app/(auth)/register/page.tsx
Normal file
282
src/app/(auth)/register/page.tsx
Normal file
@ -0,0 +1,282 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Mail, Lock, Eye, EyeOff, Loader2, KeyRound, User } from 'lucide-react';
|
||||
import { useAuth } from '@/providers/AuthProvider';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toast } from '@/components/ui/Toast';
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter();
|
||||
const { register, sendCode } = useAuth();
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
const [nickname, setNickname] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sendingCode, setSendingCode] = useState(false);
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// 倒计时
|
||||
useEffect(() => {
|
||||
if (countdown > 0) {
|
||||
const timer = setTimeout(() => setCountdown(countdown - 1), 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [countdown]);
|
||||
|
||||
// 发送验证码
|
||||
const handleSendCode = useCallback(async () => {
|
||||
if (!email) {
|
||||
setError('请输入邮箱');
|
||||
return;
|
||||
}
|
||||
|
||||
setSendingCode(true);
|
||||
setError('');
|
||||
|
||||
const result = await sendCode(email, 'register');
|
||||
|
||||
if (result.success) {
|
||||
setCountdown(60);
|
||||
toast.success('验证码已发送', {
|
||||
description: `验证码已发送到 ${email},请查收`,
|
||||
});
|
||||
} else {
|
||||
setError(result.error || '发送验证码失败');
|
||||
toast.error('发送失败', {
|
||||
description: result.error || '发送验证码失败',
|
||||
});
|
||||
}
|
||||
|
||||
setSendingCode(false);
|
||||
}, [email, sendCode]);
|
||||
|
||||
// 注册
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (!email) {
|
||||
setError('请输入邮箱');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!nickname) {
|
||||
setError('请输入昵称');
|
||||
return;
|
||||
}
|
||||
|
||||
if (nickname.length < 2 || nickname.length > 20) {
|
||||
setError('昵称长度需要在2-20个字符之间');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
setError('请输入验证码');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
setError('请输入密码');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
setError('密码长度不能少于8位');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/[a-zA-Z]/.test(password)) {
|
||||
setError('密码必须包含字母');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/\d/.test(password)) {
|
||||
setError('密码必须包含数字');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('两次输入的密码不一致');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
const result = await register(email, password, confirmPassword, nickname, code);
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.error || '注册失败');
|
||||
setLoading(false);
|
||||
} else {
|
||||
router.push('/');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 表单 */}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* 邮箱 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--color-text-secondary)] mb-2">
|
||||
邮箱地址
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-[var(--color-text-tertiary)]" />
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="请输入邮箱"
|
||||
className="w-full h-12 pl-10 pr-4 rounded-lg border border-[var(--color-border)] bg-[var(--color-bg-primary)] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-transparent transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 验证码 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--color-text-secondary)] mb-2">
|
||||
邮箱验证码
|
||||
</label>
|
||||
<div className="flex gap-3">
|
||||
<div className="relative flex-1">
|
||||
<KeyRound className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-[var(--color-text-tertiary)]" />
|
||||
<input
|
||||
type="text"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
placeholder="请输入6位验证码"
|
||||
maxLength={6}
|
||||
className="w-full h-12 pl-10 pr-4 rounded-lg border border-[var(--color-border)] bg-[var(--color-bg-primary)] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-transparent transition-all"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSendCode}
|
||||
disabled={sendingCode || countdown > 0 || !email}
|
||||
className={cn(
|
||||
'px-4 h-12 rounded-lg text-sm font-medium whitespace-nowrap transition-colors',
|
||||
sendingCode || countdown > 0 || !email
|
||||
? 'bg-[var(--color-bg-tertiary)] text-[var(--color-text-tertiary)] cursor-not-allowed'
|
||||
: 'bg-[var(--color-primary)] text-white hover:opacity-90'
|
||||
)}
|
||||
>
|
||||
{sendingCode ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : countdown > 0 ? (
|
||||
`${countdown}s`
|
||||
) : (
|
||||
'获取验证码'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 昵称 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--color-text-secondary)] mb-2">
|
||||
昵称
|
||||
</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-[var(--color-text-tertiary)]" />
|
||||
<input
|
||||
type="text"
|
||||
value={nickname}
|
||||
onChange={(e) => setNickname(e.target.value)}
|
||||
placeholder="请输入昵称(2-20个字符)"
|
||||
maxLength={20}
|
||||
className="w-full h-12 pl-10 pr-4 rounded-lg border border-[var(--color-border)] bg-[var(--color-bg-primary)] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-transparent transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 密码 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--color-text-secondary)] mb-2">
|
||||
设置密码
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-[var(--color-text-tertiary)]" />
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="8位以上,包含字母和数字"
|
||||
className="w-full h-12 pl-10 pr-12 rounded-lg border border-[var(--color-border)] bg-[var(--color-bg-primary)] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-transparent transition-all"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 确认密码 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--color-text-secondary)] mb-2">
|
||||
确认密码
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-[var(--color-text-tertiary)]" />
|
||||
<input
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="请再次输入密码"
|
||||
className="w-full h-12 pl-10 pr-12 rounded-lg border border-[var(--color-border)] bg-[var(--color-bg-primary)] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-transparent transition-all"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]"
|
||||
>
|
||||
{showConfirmPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 注册按钮 */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full h-12 rounded-lg bg-[var(--color-primary)] text-white font-medium hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading && <Loader2 className="w-5 h-5 animate-spin" />}
|
||||
{loading ? '注册中...' : '注册'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* 底部链接 */}
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-sm text-[var(--color-text-tertiary)]">
|
||||
已有账号?
|
||||
<Link
|
||||
href="/login"
|
||||
className="ml-1 text-[var(--color-primary)] hover:underline font-medium"
|
||||
>
|
||||
立即登录
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
309
src/app/(auth)/reset-password/page.tsx
Normal file
309
src/app/(auth)/reset-password/page.tsx
Normal file
@ -0,0 +1,309 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Mail, Lock, Eye, EyeOff, Loader2, KeyRound, ArrowLeft } from 'lucide-react';
|
||||
import { useAuth } from '@/providers/AuthProvider';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toast } from '@/components/ui/Toast';
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
const router = useRouter();
|
||||
const { sendCode, resetPassword } = useAuth();
|
||||
|
||||
const [step, setStep] = useState<'email' | 'reset'>('email');
|
||||
const [email, setEmail] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sendingCode, setSendingCode] = useState(false);
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
|
||||
// 倒计时
|
||||
useEffect(() => {
|
||||
if (countdown > 0) {
|
||||
const timer = setTimeout(() => setCountdown(countdown - 1), 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [countdown]);
|
||||
|
||||
// 发送验证码
|
||||
const handleSendCode = useCallback(async () => {
|
||||
if (!email) {
|
||||
setError('请输入邮箱');
|
||||
return;
|
||||
}
|
||||
|
||||
setSendingCode(true);
|
||||
setError('');
|
||||
|
||||
const result = await sendCode(email, 'reset');
|
||||
|
||||
if (result.success) {
|
||||
setCountdown(60);
|
||||
setStep('reset');
|
||||
toast.success('验证码已发送', {
|
||||
description: `验证码已发送到 ${email},请查收`,
|
||||
});
|
||||
} else {
|
||||
setError(result.error || '发送验证码失败');
|
||||
toast.error('发送失败', {
|
||||
description: result.error || '发送验证码失败',
|
||||
});
|
||||
}
|
||||
|
||||
setSendingCode(false);
|
||||
}, [email, sendCode]);
|
||||
|
||||
// 重置密码
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setSuccess('');
|
||||
|
||||
if (!code) {
|
||||
setError('请输入验证码');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!newPassword) {
|
||||
setError('请输入新密码');
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
setError('密码长度不能少于8位');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/[a-zA-Z]/.test(newPassword)) {
|
||||
setError('密码必须包含字母');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/\d/.test(newPassword)) {
|
||||
setError('密码必须包含数字');
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError('两次输入的密码不一致');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
const result = await resetPassword(email, code, newPassword, confirmPassword);
|
||||
|
||||
if (result.success) {
|
||||
toast.success('密码重置成功', {
|
||||
description: '即将跳转到登录页...',
|
||||
});
|
||||
setTimeout(() => {
|
||||
router.push('/login');
|
||||
}, 2000);
|
||||
} else {
|
||||
setError(result.error || '重置密码失败');
|
||||
toast.error('重置失败', {
|
||||
description: result.error || '重置密码失败',
|
||||
});
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 返回按钮 */}
|
||||
{step === 'reset' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep('email')}
|
||||
className="flex items-center gap-1 text-sm text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] mb-4"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
返回
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 步骤提示 */}
|
||||
<p className="text-sm text-[var(--color-text-tertiary)] text-center mb-6">
|
||||
{step === 'email' ? '请输入您的邮箱地址' : '请输入验证码和新密码'}
|
||||
</p>
|
||||
|
||||
{/* 第一步:输入邮箱 */}
|
||||
{step === 'email' && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--color-text-secondary)] mb-2">
|
||||
邮箱地址
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-[var(--color-text-tertiary)]" />
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="请输入注册时的邮箱"
|
||||
className="w-full h-12 pl-10 pr-4 rounded-lg border border-[var(--color-border)] bg-[var(--color-bg-primary)] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-transparent transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 发送验证码按钮 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSendCode}
|
||||
disabled={sendingCode || !email}
|
||||
className="w-full h-12 rounded-lg bg-[var(--color-primary)] text-white font-medium hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
{sendingCode && <Loader2 className="w-5 h-5 animate-spin" />}
|
||||
{sendingCode ? '发送中...' : '发送验证码'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 第二步:输入验证码和新密码 */}
|
||||
{step === 'reset' && (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* 验证码 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--color-text-secondary)] mb-2">
|
||||
验证码
|
||||
</label>
|
||||
<div className="flex gap-3">
|
||||
<div className="relative flex-1">
|
||||
<KeyRound className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-[var(--color-text-tertiary)]" />
|
||||
<input
|
||||
type="text"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
placeholder="请输入6位验证码"
|
||||
maxLength={6}
|
||||
className="w-full h-12 pl-10 pr-4 rounded-lg border border-[var(--color-border)] bg-[var(--color-bg-primary)] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-transparent transition-all"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSendCode}
|
||||
disabled={sendingCode || countdown > 0}
|
||||
className={cn(
|
||||
'px-4 h-12 rounded-lg text-sm font-medium whitespace-nowrap transition-colors',
|
||||
sendingCode || countdown > 0
|
||||
? 'bg-[var(--color-bg-tertiary)] text-[var(--color-text-tertiary)] cursor-not-allowed'
|
||||
: 'bg-[var(--color-primary)] text-white hover:opacity-90'
|
||||
)}
|
||||
>
|
||||
{sendingCode ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : countdown > 0 ? (
|
||||
`${countdown}s`
|
||||
) : (
|
||||
'重新发送'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 新密码 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--color-text-secondary)] mb-2">
|
||||
新密码
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-[var(--color-text-tertiary)]" />
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
placeholder="8位以上,包含字母和数字"
|
||||
className="w-full h-12 pl-10 pr-12 rounded-lg border border-[var(--color-border)] bg-[var(--color-bg-primary)] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-transparent transition-all"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 确认密码 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--color-text-secondary)] mb-2">
|
||||
确认密码
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-[var(--color-text-tertiary)]" />
|
||||
<input
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="请再次输入新密码"
|
||||
className="w-full h-12 pl-10 pr-12 rounded-lg border border-[var(--color-border)] bg-[var(--color-bg-primary)] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-transparent transition-all"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]"
|
||||
>
|
||||
{showConfirmPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 成功提示 */}
|
||||
{success && (
|
||||
<div className="p-3 rounded-lg bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400 text-sm">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 重置密码按钮 */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full h-12 rounded-lg bg-[var(--color-primary)] text-white font-medium hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading && <Loader2 className="w-5 h-5 animate-spin" />}
|
||||
{loading ? '重置中...' : '重置密码'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* 底部链接 */}
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-sm text-[var(--color-text-tertiary)]">
|
||||
想起密码了?
|
||||
<Link
|
||||
href="/login"
|
||||
className="ml-1 text-[var(--color-primary)] hover:underline font-medium"
|
||||
>
|
||||
返回登录
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user