21 changed files with 1515 additions and 381 deletions
@ -1,80 +1,396 @@ |
|||
import { useState } from 'react'; |
|||
import { useState, useEffect } from 'react'; |
|||
import { DEFAULT_CREDENTIALS } from '@/constants/auth'; |
|||
import { useAuth } from '@/contexts/AuthContext'; |
|||
import { useNavigate } from 'react-router-dom'; |
|||
import { apiService } from '@/services/apiService'; |
|||
import { toast } from '@/components/ui/use-toast'; |
|||
|
|||
interface LoginFormProps { |
|||
onSubmit: (username: string, password: string, rememberMe: boolean) => Promise<void>; |
|||
onSubmit: (username: string, password: string, rememberMe: boolean, loginType: 'account' | 'email', verificationCode?: string, captchaId?: string, captchaCode?: string) => Promise<void>; |
|||
} |
|||
|
|||
export function LoginForm({ onSubmit }: LoginFormProps) { |
|||
const [username, setUsername] = useState(DEFAULT_CREDENTIALS.username); |
|||
const [loginType, setLoginType] = useState<'account' | 'email'>('account'); |
|||
const [account, setAccount] = useState(DEFAULT_CREDENTIALS.username); |
|||
const [email, setEmail] = useState(''); |
|||
const [password, setPassword] = useState(DEFAULT_CREDENTIALS.password); |
|||
const [verificationCode, setVerificationCode] = useState(''); |
|||
const [rememberMe, setRememberMe] = useState(false); |
|||
const { isLoading, error } = useAuth(); |
|||
const navigate = useNavigate(); |
|||
const [captchaImage, setCaptchaImage] = useState<string>(''); |
|||
const [captchaId, setCaptchaId] = useState<string>(''); |
|||
const [captchaAvailable, setCaptchaAvailable] = useState(false); |
|||
const [captchaCode, setCaptchaCode] = useState(''); |
|||
|
|||
// 获取验证码
|
|||
const fetchCaptcha = async () => { |
|||
try { |
|||
setCaptchaAvailable(false); |
|||
const result = await apiService.getCaptcha(); |
|||
if (result.success && result.data) { |
|||
setCaptchaImage(result.data.imageBase64); |
|||
setCaptchaId(result.data.captchaId); |
|||
setCaptchaAvailable(true); |
|||
} else { |
|||
setCaptchaImage(''); |
|||
setCaptchaId(''); |
|||
setCaptchaAvailable(false); |
|||
toast({ |
|||
title: '获取验证码失败', |
|||
description: result.message || '请点击验证码图片重试', |
|||
variant: 'destructive', |
|||
duration: 3000, |
|||
}); |
|||
} |
|||
} catch (error) { |
|||
setCaptchaImage(''); |
|||
setCaptchaId(''); |
|||
setCaptchaAvailable(false); |
|||
toast({ |
|||
title: '获取验证码失败', |
|||
description: '请点击验证码图片重试', |
|||
variant: 'destructive', |
|||
duration: 3000, |
|||
}); |
|||
} |
|||
}; |
|||
|
|||
// 组件加载时获取验证码
|
|||
useEffect(() => { |
|||
fetchCaptcha(); |
|||
}, []); |
|||
|
|||
const handleSubmit = async (e: React.FormEvent) => { |
|||
e.preventDefault(); |
|||
await onSubmit(username, password, rememberMe); |
|||
await onSubmit( |
|||
loginType === 'account' ? account : email, |
|||
password, |
|||
rememberMe, |
|||
loginType, |
|||
loginType === 'email' ? verificationCode : undefined, |
|||
captchaId, |
|||
captchaCode |
|||
); |
|||
}; |
|||
|
|||
const handleSendVerificationCode = async () => { |
|||
if (!captchaAvailable || !captchaCode) { |
|||
toast({ |
|||
title: '请先完成验证码验证', |
|||
description: '请输入图片验证码', |
|||
variant: 'destructive', |
|||
duration: 3000, |
|||
}); |
|||
return; |
|||
} |
|||
// TODO: 实现发送验证码的逻辑
|
|||
console.log('发送验证码到邮箱:', email); |
|||
}; |
|||
|
|||
return ( |
|||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}> |
|||
<div className="space-y-4"> |
|||
<div> |
|||
<label htmlFor="username" className="block text-sm font-medium"> |
|||
用户名/邮箱 |
|||
</label> |
|||
<input |
|||
id="username" |
|||
type="text" |
|||
required |
|||
value={username} |
|||
onChange={(e) => setUsername(e.target.value)} |
|||
className="mt-1 block w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" |
|||
placeholder="请输入用户名或邮箱" |
|||
disabled={isLoading} |
|||
/> |
|||
</div> |
|||
<div> |
|||
<label htmlFor="password" className="block text-sm font-medium"> |
|||
密码 |
|||
</label> |
|||
<input |
|||
id="password" |
|||
type="password" |
|||
required |
|||
value={password} |
|||
onChange={(e) => setPassword(e.target.value)} |
|||
className="mt-1 block w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" |
|||
placeholder="请输入密码" |
|||
disabled={isLoading} |
|||
/> |
|||
<div className="space-y-6"> |
|||
{/* Tab 导航 */} |
|||
<div className="border-b border-gray-200"> |
|||
<nav className="-mb-px flex space-x-8" aria-label="Tabs"> |
|||
<button |
|||
type="button" |
|||
onClick={() => setLoginType('account')} |
|||
className={` |
|||
relative whitespace-nowrap py-4 px-1 text-sm font-medium transition-all duration-200 |
|||
${loginType === 'account' |
|||
? 'text-primary' |
|||
: 'text-gray-500 hover:text-gray-700' |
|||
} |
|||
`}
|
|||
> |
|||
账号密码登录 |
|||
{loginType === 'account' && ( |
|||
<span className="absolute bottom-0 left-0 w-full h-0.5 bg-primary transform transition-transform duration-200"></span> |
|||
)} |
|||
</button> |
|||
<button |
|||
type="button" |
|||
onClick={() => setLoginType('email')} |
|||
className={` |
|||
relative whitespace-nowrap py-4 px-1 text-sm font-medium transition-all duration-200 |
|||
${loginType === 'email' |
|||
? 'text-primary' |
|||
: 'text-gray-500 hover:text-gray-700' |
|||
} |
|||
`}
|
|||
> |
|||
邮箱验证码登录 |
|||
{loginType === 'email' && ( |
|||
<span className="absolute bottom-0 left-0 w-full h-0.5 bg-primary transform transition-transform duration-200"></span> |
|||
)} |
|||
</button> |
|||
</nav> |
|||
</div> |
|||
<div className="flex items-center"> |
|||
<input |
|||
id="rememberMe" |
|||
type="checkbox" |
|||
checked={rememberMe} |
|||
onChange={(e) => setRememberMe(e.target.checked)} |
|||
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary" |
|||
disabled={isLoading} |
|||
/> |
|||
<label htmlFor="rememberMe" className="ml-2 block text-sm text-gray-900"> |
|||
记住我 |
|||
</label> |
|||
</div> |
|||
{typeof error === 'string' && error && ( |
|||
<div className="text-sm text-red-500"> |
|||
{error} |
|||
|
|||
{/* 表单内容 */} |
|||
<div className="pt-4"> |
|||
<div className="space-y-5"> |
|||
<div className="flex items-center group"> |
|||
<label htmlFor="username" className="w-12 text-sm font-medium text-gray-700 group-focus-within:text-primary transition-colors duration-200 text-right pr-4 flex items-center justify-end"> |
|||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> |
|||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /> |
|||
</svg> |
|||
</label> |
|||
<div className="flex-1 relative group/input"> |
|||
{loginType === 'account' ? ( |
|||
<input |
|||
id="username" |
|||
type="text" |
|||
required |
|||
value={account} |
|||
onChange={(e) => setAccount(e.target.value)} |
|||
className="block w-full rounded-lg border-gray-300 shadow-sm transition-all duration-200 |
|||
focus:border-primary focus:ring-1 focus:ring-primary/10 |
|||
placeholder:text-gray-400 sm:text-sm h-[38px] pr-20 |
|||
group-focus-within/input:border-primary/50 group-focus-within/input:shadow-primary/5" |
|||
placeholder="请输入账号" |
|||
disabled={isLoading} |
|||
/> |
|||
) : ( |
|||
<div className="relative"> |
|||
<input |
|||
id="email" |
|||
type="text" |
|||
required |
|||
value={email} |
|||
onChange={(e) => { |
|||
// 只允许输入 @ 符号前的部分
|
|||
const value = e.target.value.replace(/@.*$/, ''); |
|||
setEmail(value); |
|||
}} |
|||
className="block w-full rounded-lg border-gray-300 shadow-sm transition-all duration-200 |
|||
focus:border-primary focus:ring-1 focus:ring-primary/10 |
|||
placeholder:text-gray-400 sm:text-sm h-[38px] pr-24 |
|||
group-focus-within/input:border-primary/50 group-focus-within/input:shadow-primary/5" |
|||
placeholder="请输入QQ号" |
|||
disabled={isLoading} |
|||
/> |
|||
<div className="absolute right-0 inset-y-0 flex items-center pr-3 pointer-events-none"> |
|||
<span className="text-gray-500 text-sm">@qq.com</span> |
|||
</div> |
|||
</div> |
|||
)} |
|||
{loginType === 'account' && ( |
|||
<> |
|||
<div className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none |
|||
transition-opacity duration-200 group-focus-within/input:opacity-0"> |
|||
<span className="text-gray-400 text-xs bg-gradient-to-r from-gray-400 to-gray-500 bg-clip-text text-transparent"> |
|||
账号 |
|||
</span> |
|||
</div> |
|||
<div className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none |
|||
opacity-0 transition-opacity duration-200 group-focus-within/input:opacity-100"> |
|||
<span className="text-primary/60 text-xs"> |
|||
请输入3-50位账号 |
|||
</span> |
|||
</div> |
|||
</> |
|||
)} |
|||
</div> |
|||
</div> |
|||
|
|||
{loginType === 'account' ? ( |
|||
<div className="flex items-center group"> |
|||
<label htmlFor="password" className="w-12 text-sm font-medium text-gray-700 group-focus-within:text-primary transition-colors duration-200 text-right pr-4 flex items-center justify-end"> |
|||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> |
|||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /> |
|||
</svg> |
|||
</label> |
|||
<div className="flex-1 relative group/input"> |
|||
<input |
|||
id="password" |
|||
type="password" |
|||
required |
|||
value={password} |
|||
onChange={(e) => setPassword(e.target.value)} |
|||
className="block w-full rounded-lg border-gray-300 shadow-sm transition-all duration-200 |
|||
focus:border-primary focus:ring-1 focus:ring-primary/10 |
|||
placeholder:text-gray-400 sm:text-sm h-[38px] pr-20 |
|||
group-focus-within/input:border-primary/50 group-focus-within/input:shadow-primary/5" |
|||
placeholder="请输入密码" |
|||
disabled={isLoading} |
|||
/> |
|||
<div className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none |
|||
transition-opacity duration-200 group-focus-within/input:opacity-0"> |
|||
<span className="text-gray-400 text-xs bg-gradient-to-r from-gray-400 to-gray-500 bg-clip-text text-transparent"> |
|||
密码 |
|||
</span> |
|||
</div> |
|||
<div className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none |
|||
opacity-0 transition-opacity duration-200 group-focus-within/input:opacity-100"> |
|||
<span className="text-primary/60 text-xs"> |
|||
请输入6-20位密码 |
|||
</span> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
) : ( |
|||
<> |
|||
<div className="flex items-center group"> |
|||
<label htmlFor="captcha" className="w-12 text-sm font-medium text-gray-700 group-focus-within:text-primary transition-colors duration-200 text-right pr-4 flex items-center justify-end"> |
|||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> |
|||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /> |
|||
</svg> |
|||
</label> |
|||
<div className="flex-1"> |
|||
<div className="flex space-x-2"> |
|||
<input |
|||
id="captcha" |
|||
type="text" |
|||
required |
|||
value={captchaCode} |
|||
onChange={(e) => setCaptchaCode(e.target.value)} |
|||
className="block w-full rounded-lg border-gray-300 shadow-sm transition-all duration-200 |
|||
focus:border-primary focus:ring-1 focus:ring-primary/10 |
|||
placeholder:text-gray-400 sm:text-sm h-[38px]" |
|||
placeholder={captchaAvailable ? "请输入图片验证码" : "请等待验证码加载"} |
|||
disabled={isLoading || !captchaAvailable} |
|||
/> |
|||
<div> |
|||
{captchaImage ? ( |
|||
<img |
|||
src={`data:image/png;base64,${captchaImage}`} |
|||
alt="验证码" |
|||
className="h-[38px] min-w-[80px] max-w-full cursor-pointer rounded-lg" |
|||
onClick={fetchCaptcha} |
|||
title="点击刷新验证码" |
|||
/> |
|||
) : ( |
|||
<div className="flex items-center h-[38px] text-xs text-red-500"> |
|||
验证码加载失败 |
|||
<button |
|||
type="button" |
|||
className="ml-2 text-blue-500 underline" |
|||
onClick={fetchCaptcha} |
|||
> |
|||
重试 |
|||
</button> |
|||
</div> |
|||
)} |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<div className="flex items-center group"> |
|||
<label htmlFor="verificationCode" className="w-12 text-sm font-medium text-gray-700 group-focus-within:text-primary transition-colors duration-200 text-right pr-4 flex items-center justify-end"> |
|||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> |
|||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /> |
|||
</svg> |
|||
</label> |
|||
<div className="flex-1"> |
|||
<div className="relative"> |
|||
<input |
|||
id="verificationCode" |
|||
type="text" |
|||
required |
|||
value={verificationCode} |
|||
onChange={(e) => setVerificationCode(e.target.value)} |
|||
className="block w-full rounded-lg border-gray-300 transition-all duration-200 |
|||
focus:border-primary focus:ring-1 focus:ring-primary/10 |
|||
placeholder:text-gray-400 sm:text-sm h-[38px] pr-24" |
|||
placeholder="请输入邮箱验证码" |
|||
disabled={isLoading} |
|||
/> |
|||
<button |
|||
type="button" |
|||
onClick={handleSendVerificationCode} |
|||
className="absolute right-2 top-1/2 -translate-y-1/2 inline-flex items-center justify-center |
|||
whitespace-nowrap px-2 text-sm font-medium text-primary hover:text-primary/80 |
|||
transition-all duration-200 focus:outline-none disabled:opacity-50" |
|||
disabled={isLoading || !captchaAvailable || !captchaCode} |
|||
> |
|||
获取验证码 |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</> |
|||
)} |
|||
|
|||
{loginType === 'account' && ( |
|||
<div className="flex items-center"> |
|||
<div className="w-20"></div> |
|||
<div className="flex-1 flex items-center justify-between"> |
|||
<label className="flex items-center space-x-2"> |
|||
<input |
|||
type="checkbox" |
|||
checked={rememberMe} |
|||
onChange={(e) => setRememberMe(e.target.checked)} |
|||
className="rounded border-gray-300 text-primary focus:ring-primary" |
|||
/> |
|||
<span className="text-sm text-gray-600">记住我</span> |
|||
</label> |
|||
<button |
|||
type="button" |
|||
onClick={() => navigate('/forgot-password')} |
|||
className="text-sm text-primary hover:text-primary/80 font-medium transition-all duration-200 hover:scale-105 active:scale-95" |
|||
> |
|||
忘记密码? |
|||
</button> |
|||
</div> |
|||
</div> |
|||
)} |
|||
|
|||
{typeof error === 'string' && error && ( |
|||
<div className="flex items-center"> |
|||
<div className="w-20"></div> |
|||
<div className="flex-1"> |
|||
<div className="rounded-lg bg-red-50 p-4 border border-red-100"> |
|||
<div className="flex"> |
|||
<div className="flex-shrink-0"> |
|||
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor"> |
|||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" /> |
|||
</svg> |
|||
</div> |
|||
<div className="ml-3"> |
|||
<h3 className="text-sm font-medium text-red-800"> |
|||
{error} |
|||
</h3> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
)} |
|||
</div> |
|||
)} |
|||
</div> |
|||
</div> |
|||
|
|||
<button |
|||
type="submit" |
|||
className="w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50" |
|||
disabled={isLoading} |
|||
className="w-full rounded-xl bg-gradient-to-r from-primary via-primary/95 to-primary/90 |
|||
px-6 py-3 text-sm font-semibold text-white |
|||
shadow-lg shadow-primary/20 hover:shadow-xl hover:shadow-primary/30 |
|||
hover:from-primary/95 hover:via-primary/90 hover:to-primary/85 |
|||
active:from-primary/90 active:via-primary/85 active:to-primary/80 |
|||
focus:outline-none focus:ring-2 focus:ring-primary/20 focus:ring-offset-2 |
|||
disabled:opacity-50 disabled:cursor-not-allowed |
|||
transition-all duration-300 ease-in-out transform hover:-translate-y-0.5" |
|||
disabled={isLoading || (loginType === 'email' && !captchaAvailable)} |
|||
> |
|||
{isLoading ? '登录中...' : '登录'} |
|||
{isLoading ? ( |
|||
<span className="flex items-center justify-center"> |
|||
<svg className="animate-spin -ml-1 mr-2 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> |
|||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> |
|||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> |
|||
</svg> |
|||
登录中... |
|||
</span> |
|||
) : ( |
|||
<span className="flex items-center justify-center"> |
|||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> |
|||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"></path> |
|||
</svg> |
|||
登录 |
|||
</span> |
|||
)} |
|||
</button> |
|||
</form> |
|||
); |
|||
|
@ -0,0 +1,52 @@ |
|||
import { motion, HTMLMotionProps } from 'framer-motion'; |
|||
import { ReactNode } from 'react'; |
|||
|
|||
interface AnimatedContainerProps extends HTMLMotionProps<"div"> { |
|||
children: ReactNode; |
|||
delay?: number; |
|||
className?: string; |
|||
animationType?: 'fadeIn' | 'slideUp' | 'scale'; |
|||
} |
|||
|
|||
const animations = { |
|||
fadeIn: { |
|||
initial: { opacity: 0 }, |
|||
animate: { opacity: 1 }, |
|||
transition: { duration: 0.5 } |
|||
}, |
|||
slideUp: { |
|||
initial: { opacity: 0, y: 20 }, |
|||
animate: { opacity: 1, y: 0 }, |
|||
transition: { duration: 0.5 } |
|||
}, |
|||
scale: { |
|||
initial: { opacity: 0, scale: 0.95 }, |
|||
animate: { opacity: 1, scale: 1 }, |
|||
transition: { duration: 0.5 } |
|||
} |
|||
}; |
|||
|
|||
export function AnimatedContainer({ |
|||
children, |
|||
delay = 0, |
|||
className = '', |
|||
animationType = 'slideUp', |
|||
...props |
|||
}: AnimatedContainerProps) { |
|||
const selectedAnimation = animations[animationType]; |
|||
|
|||
return ( |
|||
<motion.div |
|||
className={className} |
|||
initial={selectedAnimation.initial} |
|||
animate={selectedAnimation.animate} |
|||
transition={{ |
|||
...selectedAnimation.transition, |
|||
delay |
|||
}} |
|||
{...props} |
|||
> |
|||
{children} |
|||
</motion.div> |
|||
); |
|||
} |
@ -0,0 +1,93 @@ |
|||
import React from 'react'; |
|||
|
|||
interface StatusSwitchProps { |
|||
checked: boolean; |
|||
onChange: () => void; |
|||
activeText?: string; |
|||
inactiveText?: string; |
|||
disabled?: boolean; |
|||
} |
|||
|
|||
const SWITCH_WIDTH = 72; |
|||
const SWITCH_HEIGHT = 28; |
|||
const THUMB_SIZE = 22; |
|||
|
|||
const StatusSwitch: React.FC<StatusSwitchProps> = ({ |
|||
checked, |
|||
onChange, |
|||
activeText = '正常', |
|||
inactiveText = '禁用', |
|||
disabled = false, |
|||
}) => { |
|||
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => { |
|||
if (disabled) { |
|||
e.preventDefault(); |
|||
return; |
|||
} |
|||
console.log('switch click'); |
|||
onChange(); |
|||
}; |
|||
|
|||
return ( |
|||
<div |
|||
style={{ |
|||
width: SWITCH_WIDTH, |
|||
height: SWITCH_HEIGHT, |
|||
borderRadius: SWITCH_HEIGHT / 2, |
|||
background: checked ? '#409eff' : '#d9d9d9', |
|||
cursor: disabled ? 'not-allowed' : 'pointer', |
|||
opacity: disabled ? 0.5 : 1, |
|||
userSelect: 'none', |
|||
transition: 'background 0.2s', |
|||
border: checked ? '1px solid #409eff' : '1px solid #d9d9d9', |
|||
position: 'relative', |
|||
display: 'flex', |
|||
alignItems: 'center', |
|||
boxSizing: 'border-box', |
|||
fontWeight: 500, |
|||
padding: 0, |
|||
}} |
|||
onClick={handleClick} |
|||
> |
|||
{/* 文字 */} |
|||
<span |
|||
style={{ |
|||
color: checked ? '#fff' : '#888', |
|||
fontSize: 13, |
|||
letterSpacing: 1, |
|||
position: 'absolute', |
|||
left: checked ? 12 : undefined, |
|||
right: !checked ? 12 : undefined, |
|||
transition: 'left 0.2s, right 0.2s, color 0.2s', |
|||
minWidth: 28, |
|||
textAlign: checked ? 'left' : 'right', |
|||
pointerEvents: 'none', |
|||
whiteSpace: 'nowrap', |
|||
zIndex: 2, |
|||
}} |
|||
> |
|||
{checked ? activeText : inactiveText} |
|||
</span> |
|||
{/* 圆点 */} |
|||
<div |
|||
style={{ |
|||
width: THUMB_SIZE, |
|||
height: THUMB_SIZE, |
|||
borderRadius: '50%', |
|||
background: '#fff', |
|||
boxShadow: '0 2px 8px rgba(0,0,0,0.10)', |
|||
position: 'absolute', |
|||
top: (SWITCH_HEIGHT - THUMB_SIZE) / 2-1, |
|||
left: checked |
|||
? SWITCH_WIDTH - THUMB_SIZE - 4 |
|||
: 4, |
|||
transition: 'left 0.2s cubic-bezier(.4,0,.2,1)', |
|||
border: '1px solid #f0f0f0', |
|||
zIndex: 3, |
|||
}} |
|||
/> |
|||
</div> |
|||
); |
|||
}; |
|||
|
|||
export default StatusSwitch; |
@ -0,0 +1,378 @@ |
|||
import { useState, useEffect } from 'react'; |
|||
import { useNavigate } from 'react-router-dom'; |
|||
import { apiService } from '@/services/apiService'; |
|||
import { toast } from '@/components/ui/use-toast'; |
|||
|
|||
export function ForgotPasswordPage() { |
|||
const navigate = useNavigate(); |
|||
const [email, setEmail] = useState(''); |
|||
const [verificationCode, setVerificationCode] = useState(''); |
|||
const [newPassword, setNewPassword] = useState(''); |
|||
const [confirmPassword, setConfirmPassword] = useState(''); |
|||
const [isLoading, setIsLoading] = useState(false); |
|||
const [captchaImage, setCaptchaImage] = useState<string>(''); |
|||
const [captchaId, setCaptchaId] = useState<string>(''); |
|||
const [captchaCode, setCaptchaCode] = useState(''); |
|||
const [captchaAvailable, setCaptchaAvailable] = useState(false); |
|||
|
|||
// 获取验证码
|
|||
const fetchCaptcha = async () => { |
|||
try { |
|||
setCaptchaAvailable(false); |
|||
const result = await apiService.getCaptcha(); |
|||
if (result.success && result.data) { |
|||
setCaptchaImage(result.data.imageBase64); |
|||
setCaptchaId(result.data.captchaId); |
|||
setCaptchaAvailable(true); |
|||
} else { |
|||
setCaptchaImage(''); |
|||
setCaptchaId(''); |
|||
setCaptchaAvailable(false); |
|||
toast({ |
|||
title: '获取验证码失败', |
|||
description: result.message || '请点击验证码图片重试', |
|||
variant: 'destructive', |
|||
duration: 3000, |
|||
}); |
|||
} |
|||
} catch (error) { |
|||
setCaptchaImage(''); |
|||
setCaptchaId(''); |
|||
setCaptchaAvailable(false); |
|||
toast({ |
|||
title: '获取验证码失败', |
|||
description: '请点击验证码图片重试', |
|||
variant: 'destructive', |
|||
duration: 3000, |
|||
}); |
|||
} |
|||
}; |
|||
|
|||
// 组件加载时获取验证码
|
|||
useEffect(() => { |
|||
fetchCaptcha(); |
|||
}, []); |
|||
|
|||
const handleSendVerificationCode = async () => { |
|||
if (!captchaAvailable || !captchaCode) { |
|||
toast({ |
|||
title: '请先完成验证码验证', |
|||
description: '请输入图片验证码', |
|||
variant: 'destructive', |
|||
duration: 3000, |
|||
}); |
|||
return; |
|||
} |
|||
|
|||
if (!email) { |
|||
toast({ |
|||
title: '请输入邮箱', |
|||
description: '请输入您的QQ邮箱', |
|||
variant: 'destructive', |
|||
duration: 3000, |
|||
}); |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
setIsLoading(true); |
|||
const result = await apiService.sendResetPasswordEmail(email, captchaId, captchaCode); |
|||
if (result.success) { |
|||
toast({ |
|||
title: '验证码已发送', |
|||
description: '请查收您的邮箱', |
|||
duration: 3000, |
|||
}); |
|||
} else { |
|||
toast({ |
|||
title: '发送失败', |
|||
description: result.message || '请稍后重试', |
|||
variant: 'destructive', |
|||
duration: 3000, |
|||
}); |
|||
} |
|||
} catch (error) { |
|||
toast({ |
|||
title: '发送失败', |
|||
description: '请稍后重试', |
|||
variant: 'destructive', |
|||
duration: 3000, |
|||
}); |
|||
} finally { |
|||
setIsLoading(false); |
|||
} |
|||
}; |
|||
|
|||
const handleSubmit = async (e: React.FormEvent) => { |
|||
e.preventDefault(); |
|||
|
|||
if (!email || !verificationCode || !newPassword || !confirmPassword) { |
|||
toast({ |
|||
title: '请填写完整信息', |
|||
description: '请填写所有必填项', |
|||
variant: 'destructive', |
|||
duration: 3000, |
|||
}); |
|||
return; |
|||
} |
|||
|
|||
if (newPassword !== confirmPassword) { |
|||
toast({ |
|||
title: '密码不一致', |
|||
description: '两次输入的密码不一致', |
|||
variant: 'destructive', |
|||
duration: 3000, |
|||
}); |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
setIsLoading(true); |
|||
const result = await apiService.resetPassword(email, verificationCode, newPassword); |
|||
if (result.success) { |
|||
toast({ |
|||
title: '密码重置成功', |
|||
description: '请使用新密码登录', |
|||
duration: 3000, |
|||
}); |
|||
navigate('/login'); |
|||
} else { |
|||
toast({ |
|||
title: '重置失败', |
|||
description: result.message || '请稍后重试', |
|||
variant: 'destructive', |
|||
duration: 3000, |
|||
}); |
|||
} |
|||
} catch (error) { |
|||
toast({ |
|||
title: '重置失败', |
|||
description: '请稍后重试', |
|||
variant: 'destructive', |
|||
duration: 3000, |
|||
}); |
|||
} finally { |
|||
setIsLoading(false); |
|||
} |
|||
}; |
|||
|
|||
return ( |
|||
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-white to-gray-100 flex items-start justify-center pt-20 sm:pt-32 p-4 sm:p-8 relative overflow-hidden"> |
|||
{/* 背景装饰 */} |
|||
<div className="absolute inset-0 overflow-hidden pointer-events-none"> |
|||
<div className="absolute -top-40 -right-40 w-80 h-80 bg-primary/5 rounded-full blur-3xl" /> |
|||
<div className="absolute -bottom-40 -left-40 w-80 h-80 bg-primary/5 rounded-full blur-3xl" /> |
|||
</div> |
|||
|
|||
<div className="w-full max-w-xl mx-auto relative z-10"> |
|||
<div className="text-center mb-10"> |
|||
<h1 className="text-4xl font-bold text-gray-900 mb-3 bg-clip-text text-transparent bg-gradient-to-r from-gray-900 to-gray-700">重置密码</h1> |
|||
<p className="text-lg text-gray-600">请输入您的QQ邮箱,我们将发送验证码</p> |
|||
</div> |
|||
|
|||
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl p-8 sm:p-10 space-y-8 border border-gray-100"> |
|||
<form onSubmit={handleSubmit} className="space-y-6"> |
|||
<div className="space-y-5"> |
|||
{/* 邮箱输入 */} |
|||
<div className="flex items-center group"> |
|||
<label htmlFor="email" className="w-12 text-sm font-medium text-gray-700 group-focus-within:text-primary transition-colors duration-200 text-right pr-4 flex items-center justify-end"> |
|||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> |
|||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /> |
|||
</svg> |
|||
</label> |
|||
<div className="flex-1 relative"> |
|||
<input |
|||
id="email" |
|||
type="text" |
|||
required |
|||
value={email} |
|||
onChange={(e) => { |
|||
const value = e.target.value.replace(/@.*$/, ''); |
|||
setEmail(value); |
|||
}} |
|||
className="block w-full rounded-lg border-gray-300 shadow-sm transition-all duration-200 |
|||
focus:border-primary focus:ring-1 focus:ring-primary/10 |
|||
placeholder:text-gray-400 sm:text-sm h-[38px] pr-24" |
|||
placeholder="请输入QQ号" |
|||
disabled={isLoading} |
|||
/> |
|||
<div className="absolute right-0 inset-y-0 flex items-center pr-3 pointer-events-none"> |
|||
<span className="text-gray-500 text-sm">@qq.com</span> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
{/* 图片验证码 */} |
|||
<div className="flex items-center group"> |
|||
<label htmlFor="captcha" className="w-12 text-sm font-medium text-gray-700 group-focus-within:text-primary transition-colors duration-200 text-right pr-4 flex items-center justify-end"> |
|||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> |
|||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /> |
|||
</svg> |
|||
</label> |
|||
<div className="flex-1"> |
|||
<div className="flex space-x-2"> |
|||
<input |
|||
id="captcha" |
|||
type="text" |
|||
required |
|||
value={captchaCode} |
|||
onChange={(e) => setCaptchaCode(e.target.value)} |
|||
className="block w-full rounded-lg border-gray-300 shadow-sm transition-all duration-200 |
|||
focus:border-primary focus:ring-1 focus:ring-primary/10 |
|||
placeholder:text-gray-400 sm:text-sm h-[38px]" |
|||
placeholder={captchaAvailable ? "请输入图片验证码" : "请等待验证码加载"} |
|||
disabled={isLoading || !captchaAvailable} |
|||
/> |
|||
<div> |
|||
{captchaImage ? ( |
|||
<img |
|||
src={`data:image/png;base64,${captchaImage}`} |
|||
alt="验证码" |
|||
className="h-[38px] min-w-[80px] max-w-full cursor-pointer rounded-lg" |
|||
onClick={fetchCaptcha} |
|||
title="点击刷新验证码" |
|||
/> |
|||
) : ( |
|||
<div className="flex items-center h-[38px] text-xs text-red-500"> |
|||
验证码加载失败 |
|||
<button |
|||
type="button" |
|||
className="ml-2 text-blue-500 underline" |
|||
onClick={fetchCaptcha} |
|||
> |
|||
重试 |
|||
</button> |
|||
</div> |
|||
)} |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
{/* 邮箱验证码 */} |
|||
<div className="flex items-center group"> |
|||
<label htmlFor="verificationCode" className="w-12 text-sm font-medium text-gray-700 group-focus-within:text-primary transition-colors duration-200 text-right pr-4 flex items-center justify-end"> |
|||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> |
|||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /> |
|||
</svg> |
|||
</label> |
|||
<div className="flex-1"> |
|||
<div className="relative"> |
|||
<input |
|||
id="verificationCode" |
|||
type="text" |
|||
required |
|||
value={verificationCode} |
|||
onChange={(e) => setVerificationCode(e.target.value)} |
|||
className="block w-full rounded-lg border-gray-300 transition-all duration-200 |
|||
focus:border-primary focus:ring-1 focus:ring-primary/10 |
|||
placeholder:text-gray-400 sm:text-sm h-[38px] pr-24" |
|||
placeholder="请输入邮箱验证码" |
|||
disabled={isLoading} |
|||
/> |
|||
<button |
|||
type="button" |
|||
onClick={handleSendVerificationCode} |
|||
className="absolute right-2 top-1/2 -translate-y-1/2 inline-flex items-center justify-center |
|||
whitespace-nowrap px-2 text-sm font-medium text-primary hover:text-primary/80 |
|||
transition-all duration-200 focus:outline-none disabled:opacity-50" |
|||
disabled={isLoading || !captchaAvailable || !captchaCode} |
|||
> |
|||
获取验证码 |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
{/* 新密码 */} |
|||
<div className="flex items-center group"> |
|||
<label htmlFor="newPassword" className="w-12 text-sm font-medium text-gray-700 group-focus-within:text-primary transition-colors duration-200 text-right pr-4 flex items-center justify-end"> |
|||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> |
|||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /> |
|||
</svg> |
|||
</label> |
|||
<div className="flex-1 relative"> |
|||
<input |
|||
id="newPassword" |
|||
type="password" |
|||
required |
|||
value={newPassword} |
|||
onChange={(e) => setNewPassword(e.target.value)} |
|||
className="block w-full rounded-lg border-gray-300 shadow-sm transition-all duration-200 |
|||
focus:border-primary focus:ring-1 focus:ring-primary/10 |
|||
placeholder:text-gray-400 sm:text-sm h-[38px]" |
|||
placeholder="请输入新密码" |
|||
disabled={isLoading} |
|||
/> |
|||
</div> |
|||
</div> |
|||
|
|||
{/* 确认密码 */} |
|||
<div className="flex items-center group"> |
|||
<label htmlFor="confirmPassword" className="w-12 text-sm font-medium text-gray-700 group-focus-within:text-primary transition-colors duration-200 text-right pr-4 flex items-center justify-end"> |
|||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> |
|||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /> |
|||
</svg> |
|||
</label> |
|||
<div className="flex-1 relative"> |
|||
<input |
|||
id="confirmPassword" |
|||
type="password" |
|||
required |
|||
value={confirmPassword} |
|||
onChange={(e) => setConfirmPassword(e.target.value)} |
|||
className="block w-full rounded-lg border-gray-300 shadow-sm transition-all duration-200 |
|||
focus:border-primary focus:ring-1 focus:ring-primary/10 |
|||
placeholder:text-gray-400 sm:text-sm h-[38px]" |
|||
placeholder="请确认新密码" |
|||
disabled={isLoading} |
|||
/> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<button |
|||
type="submit" |
|||
className="w-full rounded-xl bg-gradient-to-r from-primary via-primary/95 to-primary/90 |
|||
px-6 py-3 text-sm font-semibold text-white |
|||
shadow-lg shadow-primary/20 hover:shadow-xl hover:shadow-primary/30 |
|||
hover:from-primary/95 hover:via-primary/90 hover:to-primary/85 |
|||
active:from-primary/90 active:via-primary/85 active:to-primary/80 |
|||
focus:outline-none focus:ring-2 focus:ring-primary/20 focus:ring-offset-2 |
|||
disabled:opacity-50 disabled:cursor-not-allowed |
|||
transition-all duration-300 ease-in-out transform hover:-translate-y-0.5" |
|||
disabled={isLoading} |
|||
> |
|||
{isLoading ? ( |
|||
<span className="flex items-center justify-center"> |
|||
<svg className="animate-spin -ml-1 mr-2 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> |
|||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> |
|||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> |
|||
</svg> |
|||
处理中... |
|||
</span> |
|||
) : ( |
|||
<span className="flex items-center justify-center"> |
|||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> |
|||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" /> |
|||
</svg> |
|||
重置密码 |
|||
</span> |
|||
)} |
|||
</button> |
|||
</form> |
|||
|
|||
<div className="text-center text-base text-gray-600"> |
|||
<span>记起密码了?</span> |
|||
<button |
|||
onClick={() => navigate('/login')} |
|||
className="ml-2 text-primary hover:text-primary/80 font-medium transition-all duration-200 hover:scale-105 active:scale-95" |
|||
> |
|||
返回登录 |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
); |
|||
} |
@ -1,5 +0,0 @@ |
|||
import { Outlet } from 'react-router-dom'; |
|||
|
|||
export default function UserManagePage() { |
|||
return <Outlet />; |
|||
} |
Loading…
Reference in new issue