提交1 bmad搭建与项目启动 - ok
This commit is contained in:
175
src/components/auth/AuthContext.tsx
Normal file
175
src/components/auth/AuthContext.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import { User, AuthState } from '../../types/auth';
|
||||
import {
|
||||
getToken,
|
||||
getUser,
|
||||
saveToken,
|
||||
saveUser,
|
||||
clearAuth,
|
||||
isTokenExpired,
|
||||
refreshAuthToken,
|
||||
generateToken,
|
||||
} from '../../lib/authStorage';
|
||||
|
||||
interface AuthContextType {
|
||||
authState: AuthState;
|
||||
login: (user: User) => void;
|
||||
logout: () => void;
|
||||
updateUser: (user: User) => void;
|
||||
checkAuth: () => boolean;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [authState, setAuthState] = useState<AuthState>({
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
token: null,
|
||||
refreshToken: null,
|
||||
});
|
||||
|
||||
// 初始化时检查登录状态
|
||||
useEffect(() => {
|
||||
const initAuth = async () => {
|
||||
const token = getToken();
|
||||
const user = getUser();
|
||||
|
||||
if (token && user) {
|
||||
// 检查token是否过期
|
||||
if (isTokenExpired()) {
|
||||
// 尝试刷新token
|
||||
const refreshed = await refreshAuthToken();
|
||||
if (refreshed) {
|
||||
setAuthState({
|
||||
isAuthenticated: true,
|
||||
user,
|
||||
token: refreshed.token,
|
||||
refreshToken: refreshed.refreshToken,
|
||||
});
|
||||
} else {
|
||||
// 刷新失败,自动使用默认账号登录
|
||||
await autoLoginWithDefaultAccount();
|
||||
}
|
||||
} else {
|
||||
setAuthState({
|
||||
isAuthenticated: true,
|
||||
user,
|
||||
token,
|
||||
refreshToken: getToken(),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// 没有登录信息,自动使用默认账号登录
|
||||
await autoLoginWithDefaultAccount();
|
||||
}
|
||||
};
|
||||
|
||||
// 自动登录默认账号
|
||||
const autoLoginWithDefaultAccount = async () => {
|
||||
// 动态导入避免循环依赖
|
||||
const { validatePasswordLogin } = await import('../../lib/authStorage');
|
||||
|
||||
// 使用默认管理员账号自动登录
|
||||
const result = await validatePasswordLogin('admin', 'admin123', 'AUTO');
|
||||
|
||||
if (result.success && result.user) {
|
||||
const newToken = generateToken();
|
||||
const newRefreshToken = generateToken();
|
||||
|
||||
saveToken(newToken, newRefreshToken);
|
||||
saveUser(result.user);
|
||||
|
||||
setAuthState({
|
||||
isAuthenticated: true,
|
||||
user: result.user,
|
||||
token: newToken,
|
||||
refreshToken: newRefreshToken,
|
||||
});
|
||||
} else {
|
||||
// 自动登录失败,显示登录页面
|
||||
setAuthState({
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
token: null,
|
||||
refreshToken: null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
initAuth();
|
||||
}, []);
|
||||
|
||||
// 定期检查token有效性
|
||||
useEffect(() => {
|
||||
if (!authState.isAuthenticated) return;
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
if (isTokenExpired()) {
|
||||
const refreshed = await refreshAuthToken();
|
||||
if (refreshed) {
|
||||
setAuthState(prev => ({
|
||||
...prev,
|
||||
token: refreshed.token,
|
||||
refreshToken: refreshed.refreshToken,
|
||||
}));
|
||||
} else {
|
||||
logout();
|
||||
}
|
||||
}
|
||||
}, 5 * 60 * 1000); // 每5分钟检查一次
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [authState.isAuthenticated]);
|
||||
|
||||
const login = (user: User) => {
|
||||
const token = generateToken();
|
||||
const refreshToken = generateToken();
|
||||
|
||||
saveToken(token, refreshToken);
|
||||
saveUser(user);
|
||||
|
||||
setAuthState({
|
||||
isAuthenticated: true,
|
||||
user,
|
||||
token,
|
||||
refreshToken,
|
||||
});
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
clearAuth();
|
||||
setAuthState({
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
token: null,
|
||||
refreshToken: null,
|
||||
});
|
||||
};
|
||||
|
||||
const updateUser = (user: User) => {
|
||||
saveUser(user);
|
||||
setAuthState(prev => ({
|
||||
...prev,
|
||||
user,
|
||||
}));
|
||||
};
|
||||
|
||||
const checkAuth = (): boolean => {
|
||||
return authState.isAuthenticated && !isTokenExpired();
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ authState, login, logout, updateUser, checkAuth }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
127
src/components/auth/CaptchaInput.tsx
Normal file
127
src/components/auth/CaptchaInput.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
|
||||
interface CaptchaInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CaptchaInput({ value, onChange, className = '' }: CaptchaInputProps) {
|
||||
const [captchaText, setCaptchaText] = useState('');
|
||||
const [captchaImage, setCaptchaImage] = useState('');
|
||||
|
||||
const generateCaptcha = () => {
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
let text = '';
|
||||
for (let i = 0; i < 4; i++) {
|
||||
text += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
setCaptchaText(text);
|
||||
generateCaptchaImage(text);
|
||||
};
|
||||
|
||||
const generateCaptchaImage = (text: string) => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 120;
|
||||
canvas.height = 40;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (!ctx) return;
|
||||
|
||||
// 背景
|
||||
ctx.fillStyle = '#f0f0f0';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// 干扰线
|
||||
for (let i = 0; i < 3; i++) {
|
||||
ctx.strokeStyle = `rgba(${Math.random() * 100}, ${Math.random() * 100}, ${Math.random() * 100}, 0.3)`;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(Math.random() * canvas.width, Math.random() * canvas.height);
|
||||
ctx.lineTo(Math.random() * canvas.width, Math.random() * canvas.height);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// 干扰点
|
||||
for (let i = 0; i < 30; i++) {
|
||||
ctx.fillStyle = `rgba(${Math.random() * 255}, ${Math.random() * 255}, ${Math.random() * 255}, 0.3)`;
|
||||
ctx.beginPath();
|
||||
ctx.arc(
|
||||
Math.random() * canvas.width,
|
||||
Math.random() * canvas.height,
|
||||
1,
|
||||
0,
|
||||
2 * Math.PI
|
||||
);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// 验证码文字
|
||||
ctx.font = 'bold 24px Arial';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const char = text[i];
|
||||
const x = 20 + i * 25;
|
||||
const y = 20 + (Math.random() - 0.5) * 6;
|
||||
const angle = (Math.random() - 0.5) * 0.4;
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(x, y);
|
||||
ctx.rotate(angle);
|
||||
|
||||
// 随机颜色
|
||||
const colors = ['#16a34a', '#2563eb', '#dc2626', '#ea580c', '#8b5cf6'];
|
||||
ctx.fillStyle = colors[Math.floor(Math.random() * colors.length)];
|
||||
|
||||
ctx.fillText(char, 0, 0);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
setCaptchaImage(canvas.toDataURL());
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
generateCaptcha();
|
||||
}, []);
|
||||
|
||||
const handleRefresh = () => {
|
||||
generateCaptcha();
|
||||
onChange('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex gap-2 ${className}`}>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="请输入验证码"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value.toUpperCase())}
|
||||
maxLength={4}
|
||||
className="flex-1"
|
||||
/>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div
|
||||
className="h-10 w-[120px] border border-gray-300 rounded cursor-pointer hover:opacity-80 transition-opacity"
|
||||
onClick={handleRefresh}
|
||||
style={{
|
||||
backgroundImage: `url(${captchaImage})`,
|
||||
backgroundSize: 'cover',
|
||||
}}
|
||||
title="点击刷新验证码"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleRefresh}
|
||||
title="刷新验证码"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
395
src/components/auth/Login.tsx
Normal file
395
src/components/auth/Login.tsx
Normal file
@@ -0,0 +1,395 @@
|
||||
import { useState } from 'react';
|
||||
import { Card } from '../ui/card';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
|
||||
import { Alert, AlertDescription } from '../ui/alert';
|
||||
import { CaptchaInput } from './CaptchaInput';
|
||||
import { useAuth } from './AuthContext';
|
||||
import { validatePasswordLogin, validatePhoneLogin, sendSmsCode } from '../../lib/authStorage';
|
||||
import {
|
||||
Lock,
|
||||
User,
|
||||
Phone,
|
||||
MessageSquare,
|
||||
Eye,
|
||||
EyeOff,
|
||||
LogIn,
|
||||
Loader2,
|
||||
Shield,
|
||||
Smartphone,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner@2.0.3';
|
||||
|
||||
interface LoginProps {
|
||||
onRegisterClick: () => void;
|
||||
}
|
||||
|
||||
export function Login({ onRegisterClick }: LoginProps) {
|
||||
const { login } = useAuth();
|
||||
const [loginType, setLoginType] = useState<'password' | 'phone'>('password');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [sendingCode, setSendingCode] = useState(false);
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// 密码登录表单
|
||||
const [passwordForm, setPasswordForm] = useState({
|
||||
username: '',
|
||||
password: '',
|
||||
captcha: '',
|
||||
});
|
||||
|
||||
// 手机号登录表单
|
||||
const [phoneForm, setPhoneForm] = useState({
|
||||
phone: '',
|
||||
code: '',
|
||||
captcha: '',
|
||||
});
|
||||
|
||||
// 发送验证码
|
||||
const handleSendCode = async () => {
|
||||
if (!phoneForm.phone) {
|
||||
toast.error('请输入手机号');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/^1[3-9]\d{9}$/.test(phoneForm.phone)) {
|
||||
toast.error('请输入正确的手机号');
|
||||
return;
|
||||
}
|
||||
|
||||
setSendingCode(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const result = await sendSmsCode(phoneForm.phone);
|
||||
if (result.success) {
|
||||
toast.success(result.message + '(测试验证码:123456)');
|
||||
// 开始倒计时
|
||||
setCountdown(60);
|
||||
const timer = setInterval(() => {
|
||||
setCountdown((prev) => {
|
||||
if (prev <= 1) {
|
||||
clearInterval(timer);
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
} else {
|
||||
setError(result.message);
|
||||
}
|
||||
} catch (err) {
|
||||
setError('发送验证码失败,请稍后重试');
|
||||
} finally {
|
||||
setSendingCode(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 密码登录
|
||||
const handlePasswordLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (!passwordForm.username || !passwordForm.password) {
|
||||
setError('请输入用户名和密码');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!passwordForm.captcha) {
|
||||
setError('请输入图形验证码');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const result = await validatePasswordLogin(
|
||||
passwordForm.username,
|
||||
passwordForm.password,
|
||||
passwordForm.captcha
|
||||
);
|
||||
|
||||
if (result.success && result.user) {
|
||||
login(result.user);
|
||||
toast.success('登录成功!');
|
||||
} else {
|
||||
setError(result.message);
|
||||
toast.error(result.message);
|
||||
}
|
||||
} catch (err) {
|
||||
setError('登录失败,请稍后重试');
|
||||
toast.error('登录失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 手机号登录
|
||||
const handlePhoneLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (!phoneForm.phone || !phoneForm.code) {
|
||||
setError('请输入手机号和验证码');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!phoneForm.captcha) {
|
||||
setError('请输入图形验证码');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const result = await validatePhoneLogin(
|
||||
phoneForm.phone,
|
||||
phoneForm.code,
|
||||
phoneForm.captcha
|
||||
);
|
||||
|
||||
if (result.success && result.user) {
|
||||
login(result.user);
|
||||
toast.success('登录成功!');
|
||||
} else {
|
||||
setError(result.message);
|
||||
toast.error(result.message);
|
||||
}
|
||||
} catch (err) {
|
||||
setError('登录失败,请稍后重试');
|
||||
toast.error('登录失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-green-50 via-blue-50 to-cyan-50 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo和标题 */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-green-600 rounded-2xl mb-4">
|
||||
<Shield className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h1 className="text-green-900 mb-2">智慧农业生产管理系统</h1>
|
||||
<p className="text-sm text-muted-foreground">安全、智能、高效的农业管理平台</p>
|
||||
</div>
|
||||
|
||||
<Card className="p-8 shadow-xl">
|
||||
<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>
|
||||
|
||||
{error && (
|
||||
<Alert className="mb-4 bg-red-50 border-red-200">
|
||||
<AlertCircle className="w-4 h-4 text-red-600" />
|
||||
<AlertDescription className="text-red-700">{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 密码登录 */}
|
||||
<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={passwordForm.username}
|
||||
onChange={(e) => setPasswordForm({ ...passwordForm, username: e.target.value })}
|
||||
className="pl-10"
|
||||
disabled={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={showPassword ? 'text' : 'password'}
|
||||
placeholder="请输入密码"
|
||||
value={passwordForm.password}
|
||||
onChange={(e) => setPasswordForm({ ...passwordForm, password: e.target.value })}
|
||||
className="pl-10 pr-10"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>图形验证码</Label>
|
||||
<CaptchaInput
|
||||
value={passwordForm.captcha}
|
||||
onChange={(value) => setPasswordForm({ ...passwordForm, captcha: value })}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full bg-green-600 hover:bg-green-700" disabled={loading}>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
登录中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LogIn className="w-4 h-4 mr-2" />
|
||||
登录
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
测试账号:admin / admin123 或 zhangsan / zhang123
|
||||
</p>
|
||||
</div>
|
||||
</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={phoneForm.phone}
|
||||
onChange={(e) => setPhoneForm({ ...phoneForm, phone: e.target.value })}
|
||||
className="pl-10"
|
||||
maxLength={11}
|
||||
disabled={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={phoneForm.code}
|
||||
onChange={(e) => setPhoneForm({ ...phoneForm, code: e.target.value })}
|
||||
className="pl-10"
|
||||
maxLength={6}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleSendCode}
|
||||
disabled={sendingCode || countdown > 0 || loading}
|
||||
className="min-w-[120px]"
|
||||
>
|
||||
{sendingCode ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : countdown > 0 ? (
|
||||
`${countdown}秒后重试`
|
||||
) : (
|
||||
'发送验证码'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>图形验证码</Label>
|
||||
<CaptchaInput
|
||||
value={phoneForm.captcha}
|
||||
onChange={(value) => setPhoneForm({ ...phoneForm, captcha: value })}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full bg-green-600 hover:bg-green-700" disabled={loading}>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
登录中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LogIn className="w-4 h-4 mr-2" />
|
||||
登录
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
测试手机号:13800138000 或 13800138001,验证码:123456
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* 注册入口 */}
|
||||
<div className="mt-6 pt-6 border-t text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
还没有账号?{' '}
|
||||
<button
|
||||
onClick={onRegisterClick}
|
||||
className="text-green-600 hover:text-green-700 hover:underline"
|
||||
>
|
||||
立即注册
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 安全提示 */}
|
||||
<div className="mt-4 p-3 bg-blue-50 rounded-lg">
|
||||
<div className="flex items-start gap-2">
|
||||
<Shield className="w-4 h-4 text-blue-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-xs text-blue-900">
|
||||
<p className="mb-1">安全保障:</p>
|
||||
<ul className="space-y-0.5">
|
||||
<li>• 登录信息加密传输</li>
|
||||
<li>• 会话自动管理,24小时有效期</li>
|
||||
<li>• 记录登录IP、设备信息用于安全审计</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 页脚 */}
|
||||
<div className="mt-6 text-center text-xs text-muted-foreground">
|
||||
<p>© 2024 智慧农业生产管理系统. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
519
src/components/auth/Register.tsx
Normal file
519
src/components/auth/Register.tsx
Normal file
@@ -0,0 +1,519 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card } from '../ui/card';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
import { Alert, AlertDescription } from '../ui/alert';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
|
||||
import { CaptchaInput } from './CaptchaInput';
|
||||
import { useAuth } from './AuthContext';
|
||||
import { registerUser, sendSmsCode, getAllEnterprises } from '../../lib/authStorage';
|
||||
import { Enterprise } from '../../types/auth';
|
||||
import {
|
||||
User,
|
||||
Lock,
|
||||
Phone,
|
||||
MessageSquare,
|
||||
Mail,
|
||||
UserPlus,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Loader2,
|
||||
Shield,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
ArrowLeft,
|
||||
Building2,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner@2.0.3';
|
||||
|
||||
interface RegisterProps {
|
||||
onBackToLogin: () => void;
|
||||
}
|
||||
|
||||
export function Register({ onBackToLogin }: RegisterProps) {
|
||||
const { login } = useAuth();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [sendingCode, setSendingCode] = useState(false);
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const [enterprises, setEnterprises] = useState<Enterprise[]>([]);
|
||||
|
||||
const [form, setForm] = useState({
|
||||
username: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
phone: '',
|
||||
code: '',
|
||||
realName: '',
|
||||
email: '',
|
||||
enterpriseId: '',
|
||||
captcha: '',
|
||||
});
|
||||
|
||||
// 加载企业列表
|
||||
useEffect(() => {
|
||||
const enterpriseList = getAllEnterprises();
|
||||
setEnterprises(enterpriseList);
|
||||
}, []);
|
||||
|
||||
const [validation, setValidation] = useState({
|
||||
username: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
phone: '',
|
||||
realName: '',
|
||||
});
|
||||
|
||||
// 验证用户名
|
||||
const validateUsername = (username: string) => {
|
||||
if (!username) return '请输入用户名';
|
||||
if (username.length < 3) return '用户名至少3个字符';
|
||||
if (username.length > 20) return '用户名最多20个字符';
|
||||
if (!/^[a-zA-Z0-9_]+$/.test(username)) return '用户名只能包含字母、数字和下划线';
|
||||
return '';
|
||||
};
|
||||
|
||||
// 验证密码
|
||||
const validatePassword = (password: string) => {
|
||||
if (!password) return '请输入密码';
|
||||
if (password.length < 6) return '密码至少6个字符';
|
||||
if (password.length > 20) return '密码最多20个字符';
|
||||
return '';
|
||||
};
|
||||
|
||||
// 验证确认密码
|
||||
const validateConfirmPassword = (confirmPassword: string, password: string) => {
|
||||
if (!confirmPassword) return '请确认密码';
|
||||
if (confirmPassword !== password) return '两次密码输入不一致';
|
||||
return '';
|
||||
};
|
||||
|
||||
// 验证手机号
|
||||
const validatePhone = (phone: string) => {
|
||||
if (!phone) return '请输入手机号';
|
||||
if (!/^1[3-9]\d{9}$/.test(phone)) return '请输入正确的手机号';
|
||||
return '';
|
||||
};
|
||||
|
||||
// 验证真实姓名
|
||||
const validateRealName = (realName: string) => {
|
||||
if (!realName) return '请输入真实姓名';
|
||||
if (realName.length < 2) return '姓名至少2个字符';
|
||||
if (realName.length > 20) return '姓名最多20个字符';
|
||||
return '';
|
||||
};
|
||||
|
||||
// 发送验证码
|
||||
const handleSendCode = async () => {
|
||||
const phoneError = validatePhone(form.phone);
|
||||
if (phoneError) {
|
||||
setValidation({ ...validation, phone: phoneError });
|
||||
toast.error(phoneError);
|
||||
return;
|
||||
}
|
||||
|
||||
setSendingCode(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const result = await sendSmsCode(form.phone);
|
||||
if (result.success) {
|
||||
toast.success(result.message + '(测试验证码:123456)');
|
||||
setCountdown(60);
|
||||
const timer = setInterval(() => {
|
||||
setCountdown((prev) => {
|
||||
if (prev <= 1) {
|
||||
clearInterval(timer);
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
} else {
|
||||
setError(result.message);
|
||||
toast.error(result.message);
|
||||
}
|
||||
} catch (err) {
|
||||
setError('发送验证码失败,请稍后重试');
|
||||
toast.error('发送验证码失败');
|
||||
} finally {
|
||||
setSendingCode(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理注册
|
||||
const handleRegister = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setSuccess('');
|
||||
|
||||
// 验证所有字段
|
||||
const usernameError = validateUsername(form.username);
|
||||
const passwordError = validatePassword(form.password);
|
||||
const confirmPasswordError = validateConfirmPassword(form.confirmPassword, form.password);
|
||||
const phoneError = validatePhone(form.phone);
|
||||
const realNameError = validateRealName(form.realName);
|
||||
|
||||
setValidation({
|
||||
username: usernameError,
|
||||
password: passwordError,
|
||||
confirmPassword: confirmPasswordError,
|
||||
phone: phoneError,
|
||||
realName: realNameError,
|
||||
});
|
||||
|
||||
if (usernameError || passwordError || confirmPasswordError || phoneError || realNameError) {
|
||||
setError('请检查表单填写是否正确');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!form.code) {
|
||||
setError('请输入短信验证码');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!form.captcha) {
|
||||
setError('请输入图形验证码');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const result = await registerUser({
|
||||
username: form.username,
|
||||
password: form.password,
|
||||
phone: form.phone,
|
||||
code: form.code,
|
||||
realName: form.realName,
|
||||
email: form.email,
|
||||
enterpriseId: form.enterpriseId,
|
||||
captcha: form.captcha,
|
||||
});
|
||||
|
||||
if (result.success && result.user) {
|
||||
setSuccess('注册成功!正在为您自动登录...');
|
||||
toast.success('注册成功!');
|
||||
|
||||
// 自动登录
|
||||
setTimeout(() => {
|
||||
login(result.user!);
|
||||
}, 1500);
|
||||
} else {
|
||||
setError(result.message);
|
||||
toast.error(result.message);
|
||||
}
|
||||
} catch (err) {
|
||||
setError('注册失败,请稍后重试');
|
||||
toast.error('注册失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-green-50 via-blue-50 to-cyan-50 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo和标题 */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-green-600 rounded-2xl mb-4">
|
||||
<Shield className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h1 className="text-green-900 mb-2">用户注册</h1>
|
||||
<p className="text-sm text-muted-foreground">创建您的智慧农业管理账号</p>
|
||||
</div>
|
||||
|
||||
<Card className="p-8 shadow-xl">
|
||||
{error && (
|
||||
<Alert className="mb-4 bg-red-50 border-red-200">
|
||||
<AlertCircle className="w-4 h-4 text-red-600" />
|
||||
<AlertDescription className="text-red-700">{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<Alert className="mb-4 bg-green-50 border-green-200">
|
||||
<CheckCircle className="w-4 h-4 text-green-600" />
|
||||
<AlertDescription className="text-green-700">{success}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleRegister} 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={form.username}
|
||||
onChange={(e) => {
|
||||
setForm({ ...form, username: e.target.value });
|
||||
setValidation({ ...validation, username: '' });
|
||||
}}
|
||||
onBlur={(e) => setValidation({ ...validation, username: validateUsername(e.target.value) })}
|
||||
className="pl-10"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
{validation.username && (
|
||||
<p className="text-xs text-red-600 mt-1">{validation.username}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 真实姓名 */}
|
||||
<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={form.realName}
|
||||
onChange={(e) => {
|
||||
setForm({ ...form, realName: e.target.value });
|
||||
setValidation({ ...validation, realName: '' });
|
||||
}}
|
||||
onBlur={(e) => setValidation({ ...validation, realName: validateRealName(e.target.value) })}
|
||||
className="pl-10"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
{validation.realName && (
|
||||
<p className="text-xs text-red-600 mt-1">{validation.realName}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 手机号 */}
|
||||
<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={form.phone}
|
||||
onChange={(e) => {
|
||||
setForm({ ...form, phone: e.target.value });
|
||||
setValidation({ ...validation, phone: '' });
|
||||
}}
|
||||
onBlur={(e) => setValidation({ ...validation, phone: validatePhone(e.target.value) })}
|
||||
className="pl-10"
|
||||
maxLength={11}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
{validation.phone && (
|
||||
<p className="text-xs text-red-600 mt-1">{validation.phone}</p>
|
||||
)}
|
||||
</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={form.code}
|
||||
onChange={(e) => setForm({ ...form, code: e.target.value })}
|
||||
className="pl-10"
|
||||
maxLength={6}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleSendCode}
|
||||
disabled={sendingCode || countdown > 0 || loading}
|
||||
className="min-w-[120px]"
|
||||
>
|
||||
{sendingCode ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : countdown > 0 ? (
|
||||
`${countdown}秒后重试`
|
||||
) : (
|
||||
'发送验证码'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 所属企业 */}
|
||||
<div>
|
||||
<Label>所属企业</Label>
|
||||
<Select
|
||||
value={form.enterpriseId}
|
||||
onValueChange={(value) => setForm({ ...form, enterpriseId: value })}
|
||||
disabled={loading}
|
||||
>
|
||||
<SelectTrigger className="mt-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 className="w-4 h-4 text-muted-foreground" />
|
||||
<SelectValue placeholder="请选择所属企业" />
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{enterprises.map((enterprise) => (
|
||||
<SelectItem key={enterprise.id} value={enterprise.id}>
|
||||
<div className="flex flex-col">
|
||||
<span>{enterprise.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{enterprise.code} · {enterprise.type}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{!form.enterpriseId && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
请选择您所在的企业或农场
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 邮箱(可选) */}
|
||||
<div>
|
||||
<Label>邮箱(可选)</Label>
|
||||
<div className="relative mt-2">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="请输入邮箱"
|
||||
value={form.email}
|
||||
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||
className="pl-10"
|
||||
disabled={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={showPassword ? 'text' : 'password'}
|
||||
placeholder="请输入密码(至少6位)"
|
||||
value={form.password}
|
||||
onChange={(e) => {
|
||||
setForm({ ...form, password: e.target.value });
|
||||
setValidation({ ...validation, password: '' });
|
||||
}}
|
||||
onBlur={(e) => setValidation({ ...validation, password: validatePassword(e.target.value) })}
|
||||
className="pl-10 pr-10"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
{validation.password && (
|
||||
<p className="text-xs text-red-600 mt-1">{validation.password}</p>
|
||||
)}
|
||||
</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={showConfirmPassword ? 'text' : 'password'}
|
||||
placeholder="请再次输入密码"
|
||||
value={form.confirmPassword}
|
||||
onChange={(e) => {
|
||||
setForm({ ...form, confirmPassword: e.target.value });
|
||||
setValidation({ ...validation, confirmPassword: '' });
|
||||
}}
|
||||
onBlur={(e) => setValidation({ ...validation, confirmPassword: validateConfirmPassword(e.target.value, form.password) })}
|
||||
className="pl-10 pr-10"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showConfirmPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
{validation.confirmPassword && (
|
||||
<p className="text-xs text-red-600 mt-1">{validation.confirmPassword}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 图形验证码 */}
|
||||
<div>
|
||||
<Label>图形验证码</Label>
|
||||
<CaptchaInput
|
||||
value={form.captcha}
|
||||
onChange={(value) => setForm({ ...form, captcha: value })}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 注册按钮 */}
|
||||
<Button type="submit" className="w-full bg-green-600 hover:bg-green-700" disabled={loading}>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
注册中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UserPlus className="w-4 h-4 mr-2" />
|
||||
立即注册
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* 返回登录 */}
|
||||
<div className="mt-6 pt-6 border-t text-center">
|
||||
<Button variant="ghost" onClick={onBackToLogin} className="text-green-600 hover:text-green-700">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
返回登录
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 提示信息 */}
|
||||
<div className="mt-4 p-3 bg-blue-50 rounded-lg">
|
||||
<div className="flex items-start gap-2">
|
||||
<Shield className="w-4 h-4 text-blue-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-xs text-blue-900">
|
||||
<p className="mb-1">注册说明:</p>
|
||||
<ul className="space-y-0.5">
|
||||
<li>• 请先选择所属企业或农场</li>
|
||||
<li>• 用户名和手机号必须唯一</li>
|
||||
<li>• 密码至少6位,建议包含字母和数字</li>
|
||||
<li>• 测试验证码:123456</li>
|
||||
<li>• 注册成功后自动登录系统</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 页脚 */}
|
||||
<div className="mt-6 text-center text-xs text-muted-foreground">
|
||||
<p>© 2024 智慧农业生产管理系统. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user