Login.tsx 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493
  1. import { encryptPassword } from '../utils/cryptots';
  2. import React, { useState, useEffect } from 'react';
  3. import { useNavigate, useSearchParams, Link, useLocation } from 'react-router-dom';
  4. import { authService } from '../services/authService';
  5. import { userApi } from '../services/userApi';
  6. import { Mail, Lock, GraduationCap, AlertCircle, CheckCircle2, Phone } from 'lucide-react';
  7. import { User, Loader2 } from '../icons/commonIcons';
  8. import { BrandingContext } from '../App';
  9. const API_BASE = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8010';
  10. // ==================== 忘记密码弹窗 ====================
  11. type ForgotVerifyType = 'phone' | 'email';
  12. const ForgotPasswordModal: React.FC<{ onClose: () => void }> = ({ onClose }) => {
  13. const [verifyType, setVerifyType] = useState<ForgotVerifyType>('phone');
  14. const [step, setStep] = useState<'verify' | 'reset'>('verify');
  15. const [phone, setPhone] = useState('');
  16. const [smsCode, setSmsCode] = useState('');
  17. const [smsSending, setSmsSending] = useState(false);
  18. const [smsCountdown, setSmsCountdown] = useState(0);
  19. const [email, setEmail] = useState('');
  20. const [emailCode, setEmailCode] = useState('');
  21. const [emailSending, setEmailSending] = useState(false);
  22. const [emailCountdown, setEmailCountdown] = useState(0);
  23. const [newPwd, setNewPwd] = useState('');
  24. const [confirmPwd, setConfirmPwd] = useState('');
  25. const [loading, setLoading] = useState(false);
  26. const [error, setError] = useState('');
  27. const [success, setSuccess] = useState(false);
  28. useEffect(() => {
  29. if (smsCountdown <= 0) return;
  30. const t = setTimeout(() => setSmsCountdown(c => c - 1), 1000);
  31. return () => clearTimeout(t);
  32. }, [smsCountdown]);
  33. useEffect(() => {
  34. if (emailCountdown <= 0) return;
  35. const t = setTimeout(() => setEmailCountdown(c => c - 1), 1000);
  36. return () => clearTimeout(t);
  37. }, [emailCountdown]);
  38. const handleSendSms = async () => {
  39. if (!phone || phone.length !== 11) { setError('请输入正确的手机号'); return; }
  40. setSmsSending(true); setError('');
  41. try {
  42. await userApi.sendSmsCode(phone, 'reset_password');
  43. setSmsCountdown(60);
  44. } catch (e: any) { setError(e.message || '发送失败'); }
  45. finally { setSmsSending(false); }
  46. };
  47. const handleSendEmailCode = async () => {
  48. if (!email) { setError('请输入邮箱'); return; }
  49. setEmailSending(true); setError('');
  50. try {
  51. await userApi.sendEmailCode(email, 'reset_password');
  52. setEmailCountdown(60);
  53. } catch (e: any) { setError(e.message || '发送失败'); }
  54. finally { setEmailSending(false); }
  55. };
  56. const handleVerify = async (e: React.FormEvent) => {
  57. e.preventDefault();
  58. setLoading(true); setError('');
  59. try {
  60. if (verifyType === 'phone') {
  61. if (!phone || !smsCode) { setError('请填写手机号和验证码'); setLoading(false); return; }
  62. await userApi.verifySmsCode(phone, smsCode);
  63. } else {
  64. if (!email || !emailCode) { setError('请填写邮箱和验证码'); setLoading(false); return; }
  65. await userApi.verifyEmailCode(email, emailCode);
  66. }
  67. setStep('reset');
  68. } catch (e: any) { setError(e.message || '验证失败'); }
  69. finally { setLoading(false); }
  70. };
  71. const handleReset = async (e: React.FormEvent) => {
  72. e.preventDefault();
  73. if (!newPwd || !confirmPwd) { setError('请填写新密码'); return; }
  74. if (newPwd !== confirmPwd) { setError('两次密码不一致'); return; }
  75. if (newPwd.length < 6) { setError('密码至少6位'); return; }
  76. setLoading(true); setError('');
  77. try {
  78. const encrypted = encryptPassword(newPwd);
  79. if (verifyType === 'phone') {
  80. await userApi.resetPasswordByPhone(phone, smsCode, encrypted);
  81. } else {
  82. await userApi.resetPasswordByEmail(email, emailCode, encrypted);
  83. }
  84. setSuccess(true);
  85. } catch (e: any) { setError(e.message || '修改失败'); }
  86. finally { setLoading(false); }
  87. };
  88. const inputCls = 'w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-blue-500';
  89. return (
  90. <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
  91. <div className="bg-white rounded-2xl shadow-2xl w-full max-w-md p-6">
  92. <h3 className="text-lg font-bold text-gray-900 mb-4">忘记密码</h3>
  93. {success ? (
  94. <div className="text-center py-6">
  95. <CheckCircle2 className="w-12 h-12 text-green-500 mx-auto mb-3" />
  96. <p className="text-gray-700 font-medium">密码修改成功</p>
  97. <button onClick={onClose} className="mt-4 px-6 py-2 bg-blue-600 text-white rounded-lg text-sm font-bold hover:bg-blue-700">去登录</button>
  98. </div>
  99. ) : step === 'verify' ? (
  100. <>
  101. <div className="flex bg-gray-100 rounded-xl p-1 mb-4">
  102. <button type="button" onClick={() => { setVerifyType('phone'); setError(''); }}
  103. className={`flex-1 py-2 rounded-lg text-sm font-bold transition-all ${verifyType === 'phone' ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-500'}`}>
  104. 手机验证
  105. </button>
  106. <button type="button" onClick={() => { setVerifyType('email'); setError(''); }}
  107. className={`flex-1 py-2 rounded-lg text-sm font-bold transition-all ${verifyType === 'email' ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-500'}`}>
  108. 邮箱验证
  109. </button>
  110. </div>
  111. <form onSubmit={handleVerify} className="space-y-4">
  112. {error && <div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-600">{error}</div>}
  113. {verifyType === 'phone' ? (
  114. <>
  115. <div>
  116. <label className="block text-sm font-bold text-gray-700 mb-1">手机号</label>
  117. <input type="tel" value={phone} onChange={e => setPhone(e.target.value)} maxLength={11}
  118. placeholder="请输入注册时的手机号" className={inputCls} required />
  119. </div>
  120. <div>
  121. <label className="block text-sm font-bold text-gray-700 mb-1">验证码</label>
  122. <div className="flex gap-2">
  123. <input type="text" value={smsCode} onChange={e => setSmsCode(e.target.value)} maxLength={6}
  124. placeholder="请输入验证码"
  125. 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" required />
  126. <button type="button" onClick={handleSendSms} disabled={smsSending || smsCountdown > 0}
  127. 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">
  128. {smsSending ? '发送中...' : smsCountdown > 0 ? `${smsCountdown}s` : '获取验证码'}
  129. </button>
  130. </div>
  131. </div>
  132. </>
  133. ) : (
  134. <>
  135. <div>
  136. <label className="block text-sm font-bold text-gray-700 mb-1">邮箱</label>
  137. <input type="email" value={email} onChange={e => setEmail(e.target.value)}
  138. placeholder="请输入绑定的邮箱" className={inputCls} required />
  139. </div>
  140. <div>
  141. <label className="block text-sm font-bold text-gray-700 mb-1">验证码</label>
  142. <div className="flex gap-2">
  143. <input type="text" value={emailCode} onChange={e => setEmailCode(e.target.value)} maxLength={6}
  144. placeholder="请输入验证码"
  145. 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" required />
  146. <button type="button" onClick={handleSendEmailCode} disabled={emailSending || emailCountdown > 0}
  147. 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">
  148. {emailSending ? '发送中...' : emailCountdown > 0 ? `${emailCountdown}s` : '获取验证码'}
  149. </button>
  150. </div>
  151. </div>
  152. </>
  153. )}
  154. <div className="flex gap-3 pt-2">
  155. <button type="button" onClick={onClose} className="flex-1 py-2.5 bg-gray-100 text-gray-700 rounded-xl font-medium hover:bg-gray-200">取消</button>
  156. <button type="submit" disabled={loading}
  157. className="flex-1 py-2.5 bg-blue-600 text-white rounded-xl font-bold hover:bg-blue-700 disabled:opacity-50">
  158. {loading ? '验证中...' : '下一步'}
  159. </button>
  160. </div>
  161. </form>
  162. </>
  163. ) : (
  164. <form onSubmit={handleReset} className="space-y-4">
  165. {error && <div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-600">{error}</div>}
  166. <div>
  167. <label className="block text-sm font-bold text-gray-700 mb-1">新密码</label>
  168. <input type="password" value={newPwd} onChange={e => setNewPwd(e.target.value)}
  169. placeholder="请输入新密码(至少6位)" className={inputCls} required />
  170. </div>
  171. <div>
  172. <label className="block text-sm font-bold text-gray-700 mb-1">确认新密码</label>
  173. <input type="password" value={confirmPwd} onChange={e => setConfirmPwd(e.target.value)}
  174. placeholder="请再次输入新密码" className={inputCls} required />
  175. </div>
  176. <div className="flex gap-3 pt-2">
  177. <button type="button" onClick={() => { setStep('verify'); setError(''); }}
  178. className="flex-1 py-2.5 bg-gray-100 text-gray-700 rounded-xl font-medium hover:bg-gray-200">上一步</button>
  179. <button type="submit" disabled={loading}
  180. className="flex-1 py-2.5 bg-blue-600 text-white rounded-xl font-bold hover:bg-blue-700 disabled:opacity-50 flex items-center justify-center gap-2">
  181. {loading ? <><Loader2 className="w-4 h-4 animate-spin" />修改中...</> : '确认修改'}
  182. </button>
  183. </div>
  184. </form>
  185. )}
  186. </div>
  187. </div>
  188. );
  189. };
  190. // ==================== 登录页 ====================
  191. type LoginType = 'normal' | 'phone';
  192. interface LocationState {
  193. from?: { pathname: string };
  194. }
  195. const Login: React.FC = () => {
  196. const navigate = useNavigate();
  197. const location = useLocation();
  198. const branding = React.useContext(BrandingContext);
  199. const [searchParams, setSearchParams] = useSearchParams();
  200. const [username, setUsername] = useState('');
  201. const [password, setPassword] = useState('');
  202. const [phoneNum, setPhoneNum] = useState('');
  203. const [smsCode, setSmsCode] = useState('');
  204. const [smsSending, setSmsSending] = useState(false);
  205. const [smsCountdown, setSmsCountdown] = useState(0);
  206. const [loginType, setLoginType] = useState<LoginType>('normal');
  207. const [loading, setLoading] = useState(false);
  208. const [error, setError] = useState<string | null>(null);
  209. const [ssoLoading, setSsoLoading] = useState(false);
  210. const [forgotOpen, setForgotOpen] = useState(false);
  211. const from = (location.state as LocationState)?.from?.pathname || '/';
  212. useEffect(() => {
  213. if (smsCountdown <= 0) return;
  214. const timer = setTimeout(() => setSmsCountdown(c => c - 1), 1000);
  215. return () => clearTimeout(timer);
  216. }, [smsCountdown]);
  217. const handleSSOLogin = async (ssoToken: string) => {
  218. setSsoLoading(true); setError(null);
  219. try {
  220. const response = await authService.ssoLogin(ssoToken);
  221. if (response.code === 200) {
  222. setSearchParams({});
  223. navigate(from, { replace: true });
  224. } else {
  225. setError(response.message || 'SSO登录失败');
  226. setSearchParams({});
  227. }
  228. } catch (err) {
  229. setError(err instanceof Error ? err.message : 'SSO登录失败,请稍后重试');
  230. setSearchParams({});
  231. } finally { setSsoLoading(false); }
  232. };
  233. useEffect(() => {
  234. const ssoToken = searchParams.get('sso_token');
  235. if (ssoToken) handleSSOLogin(ssoToken);
  236. }, []);
  237. const handleSendSms = async () => {
  238. if (!phoneNum || phoneNum.length !== 11) { setError('请输入正确的手机号'); return; }
  239. setSmsSending(true); setError(null);
  240. try {
  241. await userApi.sendSmsCode(phoneNum, 'login');
  242. setSmsCountdown(60);
  243. } catch (e: any) { setError(e.message || '发送失败'); }
  244. finally { setSmsSending(false); }
  245. };
  246. const handleLogin = async (e: React.FormEvent) => {
  247. e.preventDefault();
  248. setError(null);
  249. setLoading(true);
  250. try {
  251. if (loginType === 'phone') {
  252. const apiResp = await userApi.loginByPhone(phoneNum, smsCode);
  253. authService.setToken(apiResp.access_token, {
  254. id: apiResp.user.id,
  255. nickname: apiResp.user.nickname,
  256. phone: apiResp.user.phone || undefined,
  257. email: apiResp.user.email || undefined,
  258. avatar: apiResp.user.avatar || undefined,
  259. registrationDate: apiResp.user.registration_date,
  260. });
  261. navigate(from, { replace: true });
  262. return;
  263. }
  264. const encryptedPassword = encryptPassword(password);
  265. const response = await authService.login(username, encryptedPassword);
  266. if (response.code === 200) {
  267. navigate(from, { replace: true });
  268. } else {
  269. setError(response.message || '登录失败,请检查账号密码');
  270. }
  271. } catch (err) {
  272. setError(err instanceof Error ? err.message : '登录失败,请稍后重试');
  273. } finally { setLoading(false); }
  274. };
  275. const tabCls = (active: boolean) =>
  276. `flex-1 py-2 px-1 rounded-lg text-sm font-bold transition-all ${active ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'}`;
  277. return (
  278. <>
  279. <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 via-white to-indigo-50 px-4">
  280. <div className="w-full max-w-md">
  281. <div className="bg-white rounded-2xl shadow-2xl border border-gray-100 p-8">
  282. <div className="text-center mb-8">
  283. <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">
  284. {branding.system_logo
  285. ? <img src={branding.system_logo} alt="logo" className="w-full h-full object-contain" />
  286. : <GraduationCap className="w-8 h-8 text-white" />}
  287. </div>
  288. <h1 className="text-2xl font-bold text-gray-900 mb-2">欢迎登录{branding.system_name}</h1>
  289. <p className="text-sm text-gray-500">{branding.system_name} AI模型服务平台</p>
  290. </div>
  291. {ssoLoading && (
  292. <div className="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-xl flex items-center gap-3">
  293. <Loader2 className="w-5 h-5 text-blue-600 animate-spin flex-shrink-0" />
  294. <div className="flex-1">
  295. <p className="text-sm font-bold text-blue-900">正在登录...</p>
  296. <p className="text-xs text-blue-600 mt-1">请稍候,正在验证您的身份</p>
  297. </div>
  298. </div>
  299. )}
  300. {!ssoLoading && (
  301. <div className="flex bg-gray-100 rounded-xl p-1 mb-6">
  302. <button type="button" onClick={() => { setLoginType('normal'); setError(null); }} className={tabCls(loginType === 'normal')}>
  303. <div className="flex items-center justify-center gap-1">
  304. <User className="w-4 h-4" /><span>账号密码</span>
  305. </div>
  306. </button>
  307. <button type="button" onClick={() => { setLoginType('phone'); setError(null); }} className={tabCls(loginType === 'phone')}>
  308. <div className="flex items-center justify-center gap-1">
  309. <Phone className="w-4 h-4" /><span>手机验证码</span>
  310. </div>
  311. </button>
  312. </div>
  313. )}
  314. {error && (
  315. <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg flex items-center gap-2">
  316. <AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0" />
  317. <p className="text-sm text-red-600">{error}</p>
  318. </div>
  319. )}
  320. {!ssoLoading && (
  321. <form onSubmit={handleLogin} className="space-y-4">
  322. {loginType === 'phone' && (
  323. <>
  324. <div>
  325. <label className="block text-sm font-bold text-gray-700 mb-2">手机号</label>
  326. <div className="relative">
  327. <Phone className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
  328. <input type="tel" value={phoneNum} onChange={e => setPhoneNum(e.target.value)}
  329. placeholder="请输入手机号" maxLength={11}
  330. 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 />
  331. </div>
  332. </div>
  333. <div>
  334. <label className="block text-sm font-bold text-gray-700 mb-2">验证码</label>
  335. <div className="flex gap-2">
  336. <input type="text" value={smsCode} onChange={e => setSmsCode(e.target.value)}
  337. placeholder="请输入验证码" maxLength={6}
  338. 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 />
  339. <button type="button" onClick={handleSendSms} disabled={smsSending || smsCountdown > 0}
  340. 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">
  341. {smsSending ? '发送中...' : smsCountdown > 0 ? `${smsCountdown}s` : '获取验证码'}
  342. </button>
  343. </div>
  344. </div>
  345. </>
  346. )}
  347. {loginType === 'normal' && (
  348. <>
  349. <div>
  350. <label className="block text-sm font-bold text-gray-700 mb-2">用户名 / 手机号</label>
  351. <div className="relative">
  352. <User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
  353. <input type="text" value={username} onChange={e => setUsername(e.target.value)}
  354. placeholder="请输入用户名或手机号"
  355. 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 />
  356. </div>
  357. </div>
  358. <div>
  359. <label className="block text-sm font-bold text-gray-700 mb-2">密码</label>
  360. <div className="relative">
  361. <Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
  362. <input type="password" value={password} onChange={e => setPassword(e.target.value)}
  363. placeholder="请输入密码"
  364. 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 />
  365. </div>
  366. </div>
  367. </>
  368. )}
  369. <div className="flex items-center justify-between text-sm">
  370. {loginType === 'normal' && (
  371. <label className="flex items-center gap-2 cursor-pointer">
  372. <input type="checkbox" className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500" />
  373. <span className="text-gray-600">记住我</span>
  374. </label>
  375. )}
  376. <div className={loginType !== 'normal' ? 'ml-auto' : ''}>
  377. <button type="button" onClick={() => setForgotOpen(true)}
  378. className="text-blue-600 hover:text-blue-700 font-medium">
  379. 忘记密码?
  380. </button>
  381. </div>
  382. </div>
  383. <button type="submit" disabled={loading}
  384. 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">
  385. {loading ? <><Loader2 className="w-5 h-5 animate-spin" /><span>登录中...</span></> : <span>登录</span>}
  386. </button>
  387. </form>
  388. )}
  389. {!ssoLoading && (
  390. <div className="mt-6">
  391. <div className="relative">
  392. <div className="absolute inset-0 flex items-center">
  393. <div className="w-full border-t border-gray-200"></div>
  394. </div>
  395. <div className="relative flex justify-center text-xs">
  396. <span className="bg-white px-4 text-gray-400">或</span>
  397. </div>
  398. </div>
  399. {/* 统一身份认证登录 */}
  400. <button
  401. type="button"
  402. onClick={async () => {
  403. try {
  404. const resp = await fetch(`${API_BASE}/api/sso/config`);
  405. const data = await resp.json();
  406. if (data.sso_enabled && data.authorize_url) {
  407. window.location.href = data.authorize_url;
  408. } else {
  409. setError('统一身份认证未启用,请联系管理员');
  410. }
  411. } catch {
  412. setError('无法连接认证服务');
  413. }
  414. }}
  415. className="mt-4 w-full py-3 bg-white border-2 border-blue-100 text-blue-700 rounded-xl font-bold hover:bg-blue-50 hover:border-blue-200 transition-all flex items-center justify-center gap-2"
  416. >
  417. <svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
  418. <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
  419. </svg>
  420. 统一身份认证登录
  421. </button>
  422. <div className="mt-4 text-center">
  423. <p className="text-sm text-gray-600">
  424. 还没有账号?{' '}
  425. <Link to="/register" className="text-blue-600 hover:text-blue-700 font-bold">立即注册</Link>
  426. </p>
  427. </div>
  428. </div>
  429. )}
  430. </div>
  431. <div className="mt-6 flex flex-col items-center gap-1 text-xs text-gray-400">
  432. <p>© 2026 {branding.system_name}</p>
  433. <div className="flex items-center justify-center gap-4">
  434. <a
  435. href="https://beian.mps.gov.cn/#/query/webSearch?code=51019002009294"
  436. target="_blank"
  437. rel="noopener noreferrer"
  438. className="flex items-center gap-1 hover:text-gray-600"
  439. >
  440. <img src="https://beian.mps.gov.cn/web/assets/logo01.6189a29f.png" alt="公安备案图标" className="h-4 w-4" />
  441. 川公网安备51019002009294号
  442. </a>
  443. <a
  444. href="https://beian.miit.gov.cn/"
  445. target="_blank"
  446. rel="noopener noreferrer"
  447. className="hover:text-gray-600"
  448. >
  449. 蜀ICP备2025168675号-3
  450. </a>
  451. </div>
  452. </div>
  453. </div>
  454. </div>
  455. {forgotOpen && <ForgotPasswordModal onClose={() => setForgotOpen(false)} />}
  456. </>
  457. );
  458. };
  459. export default Login;