Register.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323
  1. import { encryptPassword } from '../utils/cryptots';
  2. import React, { useState, useEffect } from 'react';
  3. import { useNavigate, Link } from 'react-router-dom';
  4. import { authService, RegisterRequest } from '../services/authService';
  5. import { userApi } from '../services/userApi';
  6. import { Mail, Lock, User, Phone, GraduationCap, AlertCircle, CheckCircle2 } from 'lucide-react';
  7. import { Loader2 } from '../icons/commonIcons';
  8. import PasswordStrengthIndicator from '../components/PasswordStrengthIndicator';
  9. import { BrandingContext } from '../App';
  10. const Register: React.FC = () => {
  11. const navigate = useNavigate();
  12. const branding = React.useContext(BrandingContext);
  13. const [formData, setFormData] = useState<RegisterRequest>({
  14. username: '',
  15. password: '',
  16. email: '',
  17. phone: '',
  18. nickname: '',
  19. });
  20. // 手机验证码
  21. const [smsCode, setSmsCode] = useState('');
  22. const [smsSending, setSmsSending] = useState(false);
  23. const [smsCountdown, setSmsCountdown] = useState(0);
  24. // 邮箱验证码
  25. const [emailCode, setEmailCode] = useState('');
  26. const [emailSending, setEmailSending] = useState(false);
  27. const [emailCountdown, setEmailCountdown] = useState(0);
  28. const [emailVerified, setEmailVerified] = useState(false);
  29. const [confirmPassword, setConfirmPassword] = useState('');
  30. const [loading, setLoading] = useState(false);
  31. const [error, setError] = useState<string | null>(null);
  32. const [success, setSuccess] = useState(false);
  33. const [autoLoginLoading, setAutoLoginLoading] = useState(false);
  34. const [passwordMeetsRequirements, setPasswordMeetsRequirements] = useState(false);
  35. useEffect(() => {
  36. if (smsCountdown <= 0) return;
  37. const timer = setTimeout(() => setSmsCountdown(c => c - 1), 1000);
  38. return () => clearTimeout(timer);
  39. }, [smsCountdown]);
  40. useEffect(() => {
  41. if (emailCountdown <= 0) return;
  42. const timer = setTimeout(() => setEmailCountdown(c => c - 1), 1000);
  43. return () => clearTimeout(timer);
  44. }, [emailCountdown]);
  45. // 邮箱变化时重置验证状态
  46. useEffect(() => {
  47. setEmailVerified(false);
  48. setEmailCode('');
  49. }, [formData.email]);
  50. const handleInputChange = (field: keyof RegisterRequest, value: string) => {
  51. setFormData(prev => ({ ...prev, [field]: value }));
  52. setError(null);
  53. };
  54. const handleSendSms = async () => {
  55. if (!formData.phone || formData.phone.length !== 11) {
  56. setError('请先填写正确的手机号');
  57. return;
  58. }
  59. setSmsSending(true); setError(null);
  60. try {
  61. await userApi.sendSmsCode(formData.phone, 'register');
  62. setSmsCountdown(60);
  63. } catch (e: any) {
  64. setError(e.message || '发送失败');
  65. } finally { setSmsSending(false); }
  66. };
  67. const handleSendEmailCode = async () => {
  68. if (!formData.email) { setError('请先填写邮箱'); return; }
  69. const emailRe = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
  70. if (!emailRe.test(formData.email)) { setError('邮箱格式不正确'); return; }
  71. setEmailSending(true); setError(null);
  72. try {
  73. await userApi.sendEmailCode(formData.email, 'register');
  74. setEmailCountdown(60);
  75. } catch (e: any) {
  76. setError(e.message || '发送失败');
  77. } finally { setEmailSending(false); }
  78. };
  79. const validateForm = (): boolean => {
  80. if (!formData.username.trim()) { setError('请输入用户名'); return false; }
  81. if (formData.username.length < 3) { setError('用户名至少需要3个字符'); return false; }
  82. if (!formData.password) { setError('请输入密码'); return false; }
  83. if (formData.password.length < 6) { setError('密码至少需要6个字符'); return false; }
  84. if (!passwordMeetsRequirements) { setError('密码强度太弱,请至少包含字母和数字两种字符'); return false; }
  85. if (formData.password !== confirmPassword) { setError('两次输入的密码不一致'); return false; }
  86. if (!formData.phone) { setError('请输入手机号'); return false; }
  87. if (formData.phone.length !== 11) { setError('请输入正确的11位手机号'); return false; }
  88. if (!smsCode) { setError('请输入手机验证码'); return false; }
  89. // 填了邮箱必须验证
  90. if (formData.email) {
  91. if (!emailCode) { setError('请输入邮箱验证码'); return false; }
  92. }
  93. return true;
  94. };
  95. const handleRegister = async (e: React.FormEvent) => {
  96. e.preventDefault();
  97. setError(null);
  98. if (!validateForm()) return;
  99. setLoading(true);
  100. try {
  101. const encryptedPassword = encryptPassword(formData.password);
  102. const registerData: RegisterRequest = {
  103. username: formData.username,
  104. password: encryptedPassword,
  105. nickname: formData.nickname || formData.username,
  106. phone: formData.phone,
  107. sms_code: smsCode,
  108. ...(formData.email ? { email: formData.email, email_code: emailCode } : {}),
  109. };
  110. const response = await authService.register(registerData);
  111. if (response.code === 200) {
  112. setSuccess(true);
  113. } else {
  114. setError(response.message || '注册失败,请稍后重试');
  115. }
  116. } catch (err) {
  117. setError(err instanceof Error ? err.message : '注册失败,请稍后重试');
  118. } finally { setLoading(false); }
  119. };
  120. const handleAutoLogin = async () => {
  121. setAutoLoginLoading(true);
  122. try {
  123. const encryptedPassword = encryptPassword(formData.password);
  124. const loginResponse = await authService.login(formData.username, encryptedPassword);
  125. if (loginResponse.code === 200) { navigate('/'); } else { navigate('/login'); }
  126. } catch { navigate('/login'); }
  127. finally { setAutoLoginLoading(false); }
  128. };
  129. if (success) {
  130. return (
  131. <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 via-white to-indigo-50 px-4">
  132. <div className="w-full max-w-md">
  133. <div className="bg-white rounded-2xl shadow-2xl border border-gray-100 p-8 text-center">
  134. <CheckCircle2 className="w-16 h-16 text-green-500 mx-auto mb-6" />
  135. <h1 className="text-2xl font-bold text-gray-900 mb-2">注册成功!</h1>
  136. <p className="text-sm text-gray-500 mb-6">您的账号已创建成功</p>
  137. <button onClick={handleAutoLogin} disabled={autoLoginLoading}
  138. className="inline-block px-6 py-2 bg-blue-600 text-white rounded-lg font-bold hover:bg-blue-700 transition-colors disabled:opacity-50">
  139. {autoLoginLoading ? <span className="flex items-center gap-2"><Loader2 className="w-4 h-4 animate-spin" />登录中...</span> : '立即登录'}
  140. </button>
  141. </div>
  142. </div>
  143. </div>
  144. );
  145. }
  146. const showEmailCodeInput = !!formData.email && /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(formData.email);
  147. return (
  148. <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 via-white to-indigo-50 px-4 py-8">
  149. <div className="w-full max-w-md">
  150. <div className="bg-white rounded-2xl shadow-2xl border border-gray-100 p-8">
  151. <div className="text-center mb-8">
  152. <div className="inline-flex items-center justify-center w-16 h-16 bg-gradient-to-r from-blue-600 to-indigo-600 rounded-2xl mb-4 overflow-hidden">
  153. {branding.system_logo
  154. ? <img src={branding.system_logo} alt="logo" className="w-full h-full object-contain" />
  155. : <GraduationCap className="w-8 h-8 text-white" />}
  156. </div>
  157. <h1 className="text-2xl font-bold text-gray-900 mb-2">注册{branding.system_name}</h1>
  158. <p className="text-sm text-gray-500">创建您的账号</p>
  159. </div>
  160. {error && (
  161. <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg flex items-center gap-2">
  162. <AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0" />
  163. <p className="text-sm text-red-600">{error}</p>
  164. </div>
  165. )}
  166. <form onSubmit={handleRegister} className="space-y-4">
  167. {/* 用户名 */}
  168. <div>
  169. <label className="block text-sm font-bold text-gray-700 mb-2">用户名 <span className="text-red-500">*</span></label>
  170. <div className="relative">
  171. <User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
  172. <input type="text" value={formData.username} onChange={e => handleInputChange('username', e.target.value)}
  173. placeholder="请输入用户名(至少3个字符)"
  174. className="w-full pl-10 pr-4 py-3 bg-gray-50 border border-gray-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all" required />
  175. </div>
  176. </div>
  177. {/* 手机号(必填) */}
  178. <div>
  179. <label className="block text-sm font-bold text-gray-700 mb-2">手机号 <span className="text-red-500">*</span></label>
  180. <div className="relative">
  181. <Phone className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
  182. <input type="tel" value={formData.phone} onChange={e => handleInputChange('phone', e.target.value)}
  183. placeholder="请输入手机号" maxLength={11}
  184. className="w-full pl-10 pr-4 py-3 bg-gray-50 border border-gray-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all" required />
  185. </div>
  186. </div>
  187. {/* 手机验证码 */}
  188. <div>
  189. <label className="block text-sm font-bold text-gray-700 mb-2">手机验证码 <span className="text-red-500">*</span></label>
  190. <div className="flex gap-2">
  191. <input type="text" value={smsCode} onChange={e => setSmsCode(e.target.value)}
  192. placeholder="请输入验证码" maxLength={6}
  193. className="flex-1 px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all" required />
  194. <button type="button" onClick={handleSendSms} disabled={smsSending || smsCountdown > 0}
  195. className="px-4 py-3 bg-blue-600 text-white rounded-xl text-sm font-bold hover:bg-blue-700 disabled:opacity-50 whitespace-nowrap">
  196. {smsSending ? '发送中...' : smsCountdown > 0 ? `${smsCountdown}s` : '获取验证码'}
  197. </button>
  198. </div>
  199. </div>
  200. {/* 邮箱(可选) */}
  201. <div>
  202. <label className="block text-sm font-bold text-gray-700 mb-2">
  203. 邮箱 <span className="text-gray-400 font-normal text-xs">(可选)</span>
  204. </label>
  205. <div className="relative">
  206. <Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
  207. <input type="email" value={formData.email} onChange={e => handleInputChange('email', e.target.value)}
  208. placeholder="请输入邮箱(可选)"
  209. className="w-full pl-10 pr-4 py-3 bg-gray-50 border border-gray-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all" />
  210. </div>
  211. <p className="mt-1.5 text-xs text-gray-400 flex items-center gap-1">
  212. <Mail className="w-3 h-3 flex-shrink-0" />
  213. 绑定邮箱后可通过邮箱验证码找回密码
  214. </p>
  215. </div>
  216. {/* 邮箱验证码(仅当邮箱格式正确时显示) */}
  217. {showEmailCodeInput && (
  218. <div>
  219. <label className="block text-sm font-bold text-gray-700 mb-2">
  220. 邮箱验证码 <span className="text-red-500">*</span>
  221. </label>
  222. <div className="flex gap-2">
  223. <input type="text" value={emailCode} onChange={e => { setEmailCode(e.target.value); setEmailVerified(false); }}
  224. placeholder="请输入邮箱验证码" maxLength={6}
  225. className="flex-1 px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all" />
  226. <button type="button" onClick={handleSendEmailCode} disabled={emailSending || emailCountdown > 0}
  227. className="px-4 py-3 bg-blue-600 text-white rounded-xl text-sm font-bold hover:bg-blue-700 disabled:opacity-50 whitespace-nowrap">
  228. {emailSending ? '发送中...' : emailCountdown > 0 ? `${emailCountdown}s` : '获取验证码'}
  229. </button>
  230. </div>
  231. <p className="mt-1 text-xs text-gray-400">验证码已发送至 {formData.email}</p>
  232. </div>
  233. )}
  234. {/* 昵称 */}
  235. <div>
  236. <label className="block text-sm font-bold text-gray-700 mb-2">昵称 <span className="text-gray-400 font-normal text-xs">(可选)</span></label>
  237. <div className="relative">
  238. <User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
  239. <input type="text" value={formData.nickname} onChange={e => handleInputChange('nickname', e.target.value)}
  240. placeholder="请输入昵称(默认为用户名)"
  241. className="w-full pl-10 pr-4 py-3 bg-gray-50 border border-gray-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all" />
  242. </div>
  243. </div>
  244. {/* 密码 */}
  245. <div>
  246. <label className="block text-sm font-bold text-gray-700 mb-2">密码 <span className="text-red-500">*</span></label>
  247. <div className="relative">
  248. <Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
  249. <input type="password" value={formData.password} onChange={e => handleInputChange('password', e.target.value)}
  250. placeholder="请输入密码(至少6个字符)"
  251. className="w-full pl-10 pr-4 py-3 bg-gray-50 border border-gray-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all" required />
  252. </div>
  253. {formData.password && (
  254. <div className="mt-2">
  255. <PasswordStrengthIndicator password={formData.password} onStrengthChange={setPasswordMeetsRequirements} />
  256. </div>
  257. )}
  258. </div>
  259. {/* 确认密码 */}
  260. <div>
  261. <label className="block text-sm font-bold text-gray-700 mb-2">确认密码 <span className="text-red-500">*</span></label>
  262. <div className="relative">
  263. <Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
  264. <input type="password" value={confirmPassword} onChange={e => { setConfirmPassword(e.target.value); setError(null); }}
  265. placeholder="请再次输入密码"
  266. className="w-full pl-10 pr-4 py-3 bg-gray-50 border border-gray-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all" required />
  267. </div>
  268. </div>
  269. <div className="flex items-start gap-2 text-sm">
  270. <input type="checkbox" className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500 mt-0.5" required />
  271. <label className="text-gray-600">
  272. 我已阅读并同意{' '}
  273. <a href="#/user-agreement" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:text-blue-700 font-medium">用户协议</a>
  274. {' '}和{' '}
  275. <a href="#/privacy-policy" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:text-blue-700 font-medium">隐私政策</a>
  276. </label>
  277. </div>
  278. <button type="submit" disabled={loading}
  279. className="w-full py-3 bg-gradient-to-r from-blue-600 to-indigo-600 text-white rounded-xl font-bold shadow-lg hover:shadow-xl hover:from-blue-700 hover:to-indigo-700 transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2">
  280. {loading ? <><Loader2 className="w-5 h-5 animate-spin" /><span>注册中...</span></> : <span>立即注册</span>}
  281. </button>
  282. </form>
  283. <div className="mt-6 text-center">
  284. <p className="text-sm text-gray-600">
  285. 已有账号?{' '}
  286. <Link to="/login" className="text-blue-600 hover:text-blue-700 font-bold">立即登录</Link>
  287. </p>
  288. </div>
  289. </div>
  290. </div>
  291. </div>
  292. );
  293. };
  294. export default Register;