403 lines
14 KiB
TypeScript
403 lines
14 KiB
TypeScript
/**
|
||
* filekorolheader: 登录表单组件 - 用户密码登录和手机号登录功能
|
||
* 功能:密码登录、手机号登录、表单验证、验证码处理
|
||
* 路径:/login/components/LoginForm.tsx
|
||
* 规范:遵循crop-x/docs/开发项目规范.md,使用shadcn/ui语义化样式,支持暗色主题
|
||
*/
|
||
|
||
'use client';
|
||
|
||
import React, { useEffect, useState } from 'react';
|
||
import { Card } from '@/components/ui/card';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Input } from '@/components/ui/input';
|
||
import { Label } from '@/components/ui/label';
|
||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||
import { CaptchaInput } from '@/components/auth/CaptchaInput';
|
||
import {
|
||
Lock,
|
||
User,
|
||
Phone,
|
||
MessageSquare,
|
||
Eye,
|
||
EyeOff,
|
||
LogIn,
|
||
Loader2,
|
||
Smartphone,
|
||
} from 'lucide-react';
|
||
import { toast } from 'sonner';
|
||
import { useAuth } from '@/components/auth/AuthContext';
|
||
import { authReducer, initialAuthState } from './authReducer';
|
||
import { loginApiV1AuthLoginPost } from '@/lib/api/sdk.gen';
|
||
import type { CaptchaResponse } from '@/lib/api/types.gen';
|
||
import {PERSONAL_CELTRAL_PAGE} from "@/config/constants"
|
||
interface LoginFormProps {
|
||
onRegisterClick: () => void;
|
||
}
|
||
|
||
export function LoginForm({ onRegisterClick }: LoginFormProps) {
|
||
const { login } = useAuth();
|
||
const [state, dispatch] = React.useReducer(authReducer, initialAuthState);
|
||
const [loginType, setLoginType] = useState<'password' | 'phone'>('password');
|
||
const [passwordCaptchaData, setPasswordCaptchaData] = useState<CaptchaResponse | null>(null);
|
||
|
||
// 倒计时效果
|
||
useEffect(() => {
|
||
if (state.countdown > 0) {
|
||
const timer = setTimeout(() => {
|
||
dispatch({ type: 'SET_COUNTDOWN', payload: state.countdown - 1 });
|
||
}, 1000);
|
||
return () => clearTimeout(timer);
|
||
}
|
||
}, [state.countdown]);
|
||
|
||
// 发送验证码
|
||
const handleSendCode = async () => {
|
||
if (!state.phoneForm.phone) {
|
||
toast.error('请输入手机号');
|
||
return;
|
||
}
|
||
|
||
if (!/^1[3-9]\d{9}$/.test(state.phoneForm.phone)) {
|
||
toast.error('请输入正确的手机号');
|
||
return;
|
||
}
|
||
|
||
dispatch({ type: 'SET_SENDING_CODE', payload: true });
|
||
dispatch({ type: 'SET_ERROR', payload: '' });
|
||
|
||
try {
|
||
// 模拟发送验证码
|
||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||
toast.success('验证码发送成功!(测试验证码:123456)');
|
||
dispatch({ type: 'SET_COUNTDOWN', payload: 60 });
|
||
} catch (err) {
|
||
dispatch({ type: 'SET_ERROR', payload: '发送验证码失败,请稍后重试' });
|
||
toast.error('发送验证码失败');
|
||
} finally {
|
||
dispatch({ type: 'SET_SENDING_CODE', payload: false });
|
||
}
|
||
};
|
||
|
||
// 密码登录
|
||
const handlePasswordLogin = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
dispatch({ type: 'SET_ERROR', payload: '' });
|
||
|
||
if (!state.passwordForm.username || !state.passwordForm.password) {
|
||
dispatch({ type: 'SET_ERROR', payload: '请输入用户名和密码' });
|
||
toast.error('请输入用户名和密码');
|
||
return;
|
||
}
|
||
|
||
if (!state.passwordForm.captcha) {
|
||
dispatch({ type: 'SET_ERROR', payload: '请输入图形验证码' });
|
||
toast.error('请输入图形验证码');
|
||
return;
|
||
}
|
||
|
||
if (!passwordCaptchaData?.captcha_id) {
|
||
dispatch({ type: 'SET_ERROR', payload: '验证码数据未获取,请刷新验证码' });
|
||
toast.error('验证码数据未获取,请刷新验证码');
|
||
return;
|
||
}
|
||
|
||
dispatch({ type: 'SET_LOADING', payload: true });
|
||
|
||
try {
|
||
const response = await loginApiV1AuthLoginPost({
|
||
body: {
|
||
identifier: state.passwordForm.username,
|
||
password: state.passwordForm.password,
|
||
captcha_id: passwordCaptchaData.captcha_id,
|
||
captcha_text: state.passwordForm.captcha,
|
||
},
|
||
});
|
||
|
||
if (response.data) {
|
||
// 登录成功,提取用户信息
|
||
const userData = {
|
||
id: response.data.user_id || '1',
|
||
username: state.passwordForm.username,
|
||
realName: response.data.real_name || state.passwordForm.username,
|
||
phone: response.data.phone || '',
|
||
email: response.data.email || '',
|
||
role: response.data.role || 'user',
|
||
permissions: response.data.permissions || [],
|
||
enterpriseId: response.data.enterprise_id || '',
|
||
enterpriseName: response.data.enterprise_name || '',
|
||
createdAt: response.data.created_at || new Date().toISOString(),
|
||
// 重要:存储token到用户对象中
|
||
token: response.data.access_token || response.data.token || null,
|
||
refreshToken:response.data.refresh_token || ''
|
||
};
|
||
|
||
// 打印登录成功日志
|
||
console.log('🎉 登录成功!', {
|
||
user: userData,
|
||
apiResponse: response.data,
|
||
timestamp: new Date().toISOString()
|
||
});
|
||
// 验证token是否正确存储
|
||
if (userData.token) {
|
||
console.log('🔑 Token已存储:', userData.token.substring(0, 20) + '...');
|
||
} else {
|
||
console.warn('⚠️ 未找到token,请检查API响应格式');
|
||
}
|
||
|
||
login(userData);
|
||
toast.success('登录成功!正在跳转...');
|
||
// 跳转到个人中心页面
|
||
window.location.href = PERSONAL_CELTRAL_PAGE;
|
||
} else {
|
||
dispatch({ type: 'SET_ERROR', payload: '登录失败,请检查用户名和密码' });
|
||
toast.error('登录失败,请检查用户名和密码');
|
||
}
|
||
} catch (error: unknown) {
|
||
console.error('????:', error);
|
||
const apiMessage =
|
||
typeof error === 'object' && error !== null && 'response' in error
|
||
? (error as { response?: { data?: { message?: string } } }).response?.data?.message
|
||
: undefined;
|
||
const fallbackMessage = error instanceof Error ? error.message : undefined;
|
||
const errorMessage = apiMessage || fallbackMessage || '??????????';
|
||
dispatch({ type: 'SET_ERROR', payload: errorMessage });
|
||
toast.error(errorMessage);
|
||
} finally {
|
||
dispatch({ type: 'SET_LOADING', payload: false });
|
||
}
|
||
};
|
||
|
||
// 手机号登录
|
||
const handlePhoneLogin = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
dispatch({ type: 'SET_ERROR', payload: '' });
|
||
|
||
if (!state.phoneForm.phone || !state.phoneForm.code) {
|
||
dispatch({ type: 'SET_ERROR', payload: '请输入手机号和验证码' });
|
||
return;
|
||
}
|
||
|
||
if (!state.phoneForm.captcha) {
|
||
dispatch({ type: 'SET_ERROR', payload: '请输入图形验证码' });
|
||
return;
|
||
}
|
||
|
||
dispatch({ type: 'SET_LOADING', payload: true });
|
||
|
||
try {
|
||
// 模拟手机号登录
|
||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||
|
||
if (state.phoneForm.code === '123456') {
|
||
login({
|
||
id: '2',
|
||
username: 'user_' + state.phoneForm.phone.slice(-4),
|
||
realName: '用户',
|
||
phone: state.phoneForm.phone,
|
||
email: '',
|
||
role: 'user',
|
||
permissions: [],
|
||
enterpriseId: '',
|
||
enterpriseName: '',
|
||
createdAt: new Date().toISOString(),
|
||
});
|
||
toast.success('登录成功!');
|
||
window.location.href = '/';
|
||
} else {
|
||
dispatch({ type: 'SET_ERROR', payload: '验证码错误' });
|
||
toast.error('验证码错误');
|
||
}
|
||
} catch (error) {
|
||
console.error('???????:', error);
|
||
dispatch({ type: 'SET_ERROR', payload: '??????????' });
|
||
toast.error('????');
|
||
} finally {
|
||
dispatch({ type: 'SET_LOADING', payload: false });
|
||
}
|
||
};
|
||
|
||
return (
|
||
<Card className="p-8 shadow-2xl bg-white/95 backdrop-blur-md border-white/20">
|
||
<Tabs value={loginType} onValueChange={(v) => setLoginType(v as 'password' | 'phone')}>
|
||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||
<TabsTrigger value="password" className="flex items-center gap-2">
|
||
<Lock className="w-4 h-4" />
|
||
密码登录
|
||
</TabsTrigger>
|
||
<TabsTrigger value="phone" className="flex items-center gap-2">
|
||
<Smartphone className="w-4 h-4" />
|
||
手机登录
|
||
</TabsTrigger>
|
||
</TabsList>
|
||
|
||
{state.error && (
|
||
<div className="mb-4 p-3 bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 rounded-lg">
|
||
<p className="text-sm text-red-700 dark:text-red-300">{state.error}</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* 密码登录 */}
|
||
<TabsContent value="password">
|
||
<form onSubmit={handlePasswordLogin} className="space-y-4">
|
||
<div>
|
||
<Label>用户名</Label>
|
||
<div className="relative mt-2">
|
||
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||
<Input
|
||
type="text"
|
||
placeholder="请输入用户名"
|
||
value={state.passwordForm.username}
|
||
onChange={(e) => dispatch({ type: 'UPDATE_PASSWORD_FORM', payload: { username: e.target.value } })}
|
||
className="pl-10"
|
||
disabled={state.loading}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<Label>密码</Label>
|
||
<div className="relative mt-2">
|
||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||
<Input
|
||
type={state.showPassword ? 'text' : 'password'}
|
||
placeholder="请输入密码"
|
||
value={state.passwordForm.password}
|
||
onChange={(e) => dispatch({ type: 'UPDATE_PASSWORD_FORM', payload: { password: e.target.value } })}
|
||
className="pl-10 pr-10"
|
||
disabled={state.loading}
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => dispatch({ type: 'TOGGLE_PASSWORD_VISIBILITY' })}
|
||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||
>
|
||
{state.showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<Label>图形验证码</Label>
|
||
<CaptchaInput
|
||
value={state.passwordForm.captcha}
|
||
onChange={(value) => dispatch({ type: 'UPDATE_PASSWORD_FORM', payload: { captcha: value } })}
|
||
onCaptchaChange={(captchaData) => setPasswordCaptchaData(captchaData)}
|
||
instanceId="password-login"
|
||
className="mt-2"
|
||
/>
|
||
</div>
|
||
|
||
<Button type="submit" className="w-full bg-green-600 hover:bg-green-700" disabled={state.loading}>
|
||
{state.loading ? (
|
||
<>
|
||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||
登录中...
|
||
</>
|
||
) : (
|
||
<>
|
||
<LogIn className="w-4 h-4 mr-2" />
|
||
登录
|
||
</>
|
||
)}
|
||
</Button>
|
||
</form>
|
||
|
||
</TabsContent>
|
||
|
||
{/* 手机号登录 */}
|
||
<TabsContent value="phone">
|
||
<form onSubmit={handlePhoneLogin} className="space-y-4">
|
||
<div>
|
||
<Label>手机号</Label>
|
||
<div className="relative mt-2">
|
||
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||
<Input
|
||
type="tel"
|
||
placeholder="请输入手机号"
|
||
value={state.phoneForm.phone}
|
||
onChange={(e) => dispatch({ type: 'UPDATE_PHONE_FORM', payload: { phone: e.target.value } })}
|
||
className="pl-10"
|
||
maxLength={11}
|
||
disabled={state.loading}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<Label>短信验证码</Label>
|
||
<div className="flex gap-2 mt-2">
|
||
<div className="relative flex-1">
|
||
<MessageSquare className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||
<Input
|
||
type="text"
|
||
placeholder="请输入验证码"
|
||
value={state.phoneForm.code}
|
||
onChange={(e) => dispatch({ type: 'UPDATE_PHONE_FORM', payload: { code: e.target.value } })}
|
||
className="pl-10"
|
||
maxLength={6}
|
||
disabled={state.loading}
|
||
/>
|
||
</div>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
onClick={handleSendCode}
|
||
disabled={state.sendingCode || state.countdown > 0 || state.loading}
|
||
className="min-w-[120px]"
|
||
>
|
||
{state.sendingCode ? (
|
||
<Loader2 className="w-4 h-4 animate-spin" />
|
||
) : state.countdown > 0 ? (
|
||
`${state.countdown}秒后重试`
|
||
) : (
|
||
'发送验证码'
|
||
)}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<Label>图形验证码</Label>
|
||
<CaptchaInput
|
||
value={state.phoneForm.captcha}
|
||
onChange={(value) => dispatch({ type: 'UPDATE_PHONE_FORM', payload: { captcha: value } })}
|
||
instanceId="phone-login"
|
||
className="mt-2"
|
||
/>
|
||
</div>
|
||
|
||
<Button type="submit" className="w-full bg-green-600 hover:bg-green-700" disabled={state.loading}>
|
||
{state.loading ? (
|
||
<>
|
||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||
登录中...
|
||
</>
|
||
) : (
|
||
<>
|
||
<LogIn className="w-4 h-4 mr-2" />
|
||
登录
|
||
</>
|
||
)}
|
||
</Button>
|
||
</form>
|
||
|
||
</TabsContent>
|
||
</Tabs>
|
||
|
||
{/* 注册跳转 */}
|
||
<div className="mt-6 text-center">
|
||
<p className="text-sm text-muted-foreground">
|
||
还没有账号?
|
||
<button
|
||
type="button"
|
||
onClick={onRegisterClick}
|
||
className="ml-1 text-green-600 hover:text-green-700 font-medium"
|
||
>
|
||
立即注册
|
||
</button>
|
||
</p>
|
||
</div>
|
||
</Card>
|
||
);
|
||
} |