Profile.tsx 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896
  1. import React, { useState, useEffect, useRef } from 'react';
  2. import { useNavigate, useLocation } from 'react-router-dom';
  3. import { Copy, LogOut, Edit2, Mail, Phone, User, Camera, AlertCircle, CheckCircle2, Shield, Check } from 'lucide-react';
  4. import { authService, UserInfo } from '../services/authService';
  5. import { userApi, UpdateUserRequest } from '../services/userApi';
  6. import { ossApi } from '../services/ossApi';
  7. import { Loader2 } from '../icons/commonIcons';
  8. import { validateIDCard, validateRealName } from '../utils/idCardValidator';
  9. import { encryptVerificationData } from '../utils/rsaEncryption';
  10. import { copyToClipboard } from '../utils/clipboard';
  11. interface EditModalProps {
  12. isOpen: boolean;
  13. onClose: () => void;
  14. title: string;
  15. field: keyof UpdateUserRequest;
  16. currentValue: string;
  17. onSave: (value: string) => Promise<void>;
  18. placeholder?: string;
  19. type?: string;
  20. }
  21. const EditModal: React.FC<EditModalProps> = ({
  22. isOpen, onClose, title, currentValue, onSave, placeholder, type = 'text'
  23. }) => {
  24. const [value, setValue] = useState(currentValue);
  25. const [loading, setLoading] = useState(false);
  26. const [error, setError] = useState<string | null>(null);
  27. useEffect(() => { setValue(currentValue); setError(null); }, [currentValue, isOpen]);
  28. const handleSave = async () => {
  29. if (value.trim() === currentValue) { onClose(); return; }
  30. setLoading(true); setError(null);
  31. try { await onSave(value.trim()); onClose(); }
  32. catch (err) { setError(err instanceof Error ? err.message : '保存失败'); }
  33. finally { setLoading(false); }
  34. };
  35. if (!isOpen) return null;
  36. return (
  37. <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
  38. <div className="bg-white rounded-2xl shadow-2xl w-full max-w-md p-6">
  39. <h3 className="text-lg font-bold text-gray-900 mb-4">{title}</h3>
  40. {error && (
  41. <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg flex items-center gap-2">
  42. <AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0" />
  43. <p className="text-sm text-red-600">{error}</p>
  44. </div>
  45. )}
  46. <div className="mb-6">
  47. <input type={type} value={value} onChange={e => setValue(e.target.value)}
  48. placeholder={placeholder}
  49. className="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 focus:border-blue-500 transition-all"
  50. disabled={loading} />
  51. </div>
  52. <div className="flex gap-3">
  53. <button onClick={onClose} disabled={loading}
  54. className="flex-1 py-2 px-4 bg-gray-100 text-gray-700 rounded-lg font-medium hover:bg-gray-200 transition-colors disabled:opacity-50">取消</button>
  55. <button onClick={handleSave} disabled={loading || value.trim() === currentValue}
  56. className="flex-1 py-2 px-4 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors disabled:opacity-50 flex items-center justify-center gap-2">
  57. {loading ? <><Loader2 className="w-4 h-4 animate-spin" /><span>保存中...</span></> : <span>保存</span>}
  58. </button>
  59. </div>
  60. </div>
  61. </div>
  62. );
  63. };
  64. // 绑定/换绑邮箱弹窗(带验证码)
  65. interface BindEmailModalProps {
  66. isOpen: boolean;
  67. onClose: () => void;
  68. onSaved: (email: string) => void;
  69. }
  70. const BindEmailModal: React.FC<BindEmailModalProps> = ({ isOpen, onClose, onSaved }) => {
  71. const [email, setEmail] = useState('');
  72. const [code, setCode] = useState('');
  73. const [sending, setSending] = useState(false);
  74. const [countdown, setCountdown] = useState(0);
  75. const [loading, setLoading] = useState(false);
  76. const [error, setError] = useState('');
  77. useEffect(() => { if (!isOpen) { setEmail(''); setCode(''); setError(''); setCountdown(0); } }, [isOpen]);
  78. useEffect(() => {
  79. if (countdown <= 0) return;
  80. const t = setTimeout(() => setCountdown(c => c - 1), 1000);
  81. return () => clearTimeout(t);
  82. }, [countdown]);
  83. const handleSend = async () => {
  84. if (!email) { setError('请输入邮箱'); return; }
  85. const emailRe = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
  86. if (!emailRe.test(email)) { setError('邮箱格式不正确'); return; }
  87. setSending(true); setError('');
  88. try { await userApi.sendEmailCode(email, 'register'); setCountdown(60); }
  89. catch (e: any) { setError(e.message || '发送失败'); }
  90. finally { setSending(false); }
  91. };
  92. const handleSubmit = async (e: React.FormEvent) => {
  93. e.preventDefault();
  94. if (!email || !code) { setError('请填写邮箱和验证码'); return; }
  95. setLoading(true); setError('');
  96. try {
  97. await userApi.verifyEmailCode(email, code);
  98. const updatedUser = await userApi.updateCurrentUser({ email });
  99. onSaved(updatedUser.email || email);
  100. onClose();
  101. } catch (e: any) { setError(e.message || '操作失败'); }
  102. finally { setLoading(false); }
  103. };
  104. if (!isOpen) return null;
  105. return (
  106. <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
  107. <div className="bg-white rounded-2xl shadow-2xl w-full max-w-md p-6">
  108. <h3 className="text-lg font-bold text-gray-900 mb-4">绑定邮箱</h3>
  109. <form onSubmit={handleSubmit} className="space-y-4">
  110. {error && (
  111. <div className="p-3 bg-red-50 border border-red-200 rounded-lg flex items-center gap-2">
  112. <AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0" />
  113. <p className="text-sm text-red-600">{error}</p>
  114. </div>
  115. )}
  116. <div>
  117. <label className="block text-sm font-bold text-gray-700 mb-1">邮箱</label>
  118. <input type="email" value={email} onChange={e => setEmail(e.target.value)}
  119. placeholder="请输入邮箱地址" autoComplete="off"
  120. className="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" required />
  121. </div>
  122. <div>
  123. <label className="block text-sm font-bold text-gray-700 mb-1">验证码</label>
  124. <div className="flex gap-2">
  125. <input type="text" value={code} onChange={e => setCode(e.target.value)} maxLength={6}
  126. placeholder="请输入验证码" autoComplete="one-time-code"
  127. 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 />
  128. <button type="button" onClick={handleSend} disabled={sending || countdown > 0}
  129. 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">
  130. {sending ? '发送中...' : countdown > 0 ? `${countdown}s` : '获取验证码'}
  131. </button>
  132. </div>
  133. </div>
  134. <div className="flex gap-3 pt-2">
  135. <button type="button" onClick={onClose}
  136. className="flex-1 py-2.5 bg-gray-100 text-gray-700 rounded-xl font-medium hover:bg-gray-200">取消</button>
  137. <button type="submit" disabled={loading}
  138. 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">
  139. {loading ? <><Loader2 className="w-4 h-4 animate-spin" />保存中...</> : '确认绑定'}
  140. </button>
  141. </div>
  142. </form>
  143. </div>
  144. </div>
  145. );
  146. };
  147. // 绑定/换绑手机号弹窗(带验证码)
  148. interface BindPhoneModalProps {
  149. isOpen: boolean;
  150. onClose: () => void;
  151. onSaved: (phone: string) => void;
  152. }
  153. const BindPhoneModal: React.FC<BindPhoneModalProps> = ({ isOpen, onClose, onSaved }) => {
  154. const [phone, setPhone] = useState('');
  155. const [code, setCode] = useState('');
  156. const [sending, setSending] = useState(false);
  157. const [countdown, setCountdown] = useState(0);
  158. const [loading, setLoading] = useState(false);
  159. const [error, setError] = useState('');
  160. useEffect(() => { if (!isOpen) { setPhone(''); setCode(''); setError(''); setCountdown(0); } }, [isOpen]);
  161. useEffect(() => {
  162. if (countdown <= 0) return;
  163. const t = setTimeout(() => setCountdown(c => c - 1), 1000);
  164. return () => clearTimeout(t);
  165. }, [countdown]);
  166. const handleSend = async () => {
  167. if (!phone || phone.length !== 11) { setError('请输入正确的手机号'); return; }
  168. setSending(true); setError('');
  169. try { await userApi.sendSmsCode(phone, 'register'); setCountdown(60); }
  170. catch (e: any) { setError(e.message || '发送失败'); }
  171. finally { setSending(false); }
  172. };
  173. const handleSubmit = async (e: React.FormEvent) => {
  174. e.preventDefault();
  175. if (!phone || !code) { setError('请填写手机号和验证码'); return; }
  176. setLoading(true); setError('');
  177. try {
  178. // 先验证验证码
  179. await userApi.verifySmsCode(phone, code);
  180. // 再更新手机号,用服务器返回的掩码值
  181. const updatedUser = await userApi.updateCurrentUser({ phone });
  182. onSaved(updatedUser.phone || phone);
  183. onClose();
  184. } catch (e: any) { setError(e.message || '操作失败'); }
  185. finally { setLoading(false); }
  186. };
  187. if (!isOpen) return null;
  188. return (
  189. <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
  190. <div className="bg-white rounded-2xl shadow-2xl w-full max-w-md p-6">
  191. <h3 className="text-lg font-bold text-gray-900 mb-4">绑定手机号</h3>
  192. <form onSubmit={handleSubmit} className="space-y-4">
  193. {error && (
  194. <div className="p-3 bg-red-50 border border-red-200 rounded-lg flex items-center gap-2">
  195. <AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0" />
  196. <p className="text-sm text-red-600">{error}</p>
  197. </div>
  198. )}
  199. <div>
  200. <label className="block text-sm font-bold text-gray-700 mb-1">手机号</label>
  201. <input type="tel" value={phone} onChange={e => setPhone(e.target.value)} maxLength={11}
  202. placeholder="请输入手机号" autoComplete="off"
  203. className="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" required />
  204. </div>
  205. <div>
  206. <label className="block text-sm font-bold text-gray-700 mb-1">验证码</label>
  207. <div className="flex gap-2">
  208. <input type="text" value={code} onChange={e => setCode(e.target.value)} maxLength={6}
  209. placeholder="请输入验证码" autoComplete="one-time-code"
  210. 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 />
  211. <button type="button" onClick={handleSend} disabled={sending || countdown > 0}
  212. 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">
  213. {sending ? '发送中...' : countdown > 0 ? `${countdown}s` : '获取验证码'}
  214. </button>
  215. </div>
  216. </div>
  217. <div className="flex gap-3 pt-2">
  218. <button type="button" onClick={onClose}
  219. className="flex-1 py-2.5 bg-gray-100 text-gray-700 rounded-xl font-medium hover:bg-gray-200">取消</button>
  220. <button type="submit" disabled={loading}
  221. 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">
  222. {loading ? <><Loader2 className="w-4 h-4 animate-spin" />保存中...</> : '确认绑定'}
  223. </button>
  224. </div>
  225. </form>
  226. </div>
  227. </div>
  228. );
  229. };
  230. const Profile: React.FC = () => {
  231. const navigate = useNavigate();
  232. const location = useLocation();
  233. const [copied, setCopied] = useState(false);
  234. const [userInfo, setUserInfo] = useState<UserInfo | null>(null);
  235. const [loading, setLoading] = useState(true);
  236. const [error, setError] = useState<string | null>(null);
  237. const [editModal, setEditModal] = useState<{
  238. isOpen: boolean;
  239. field: keyof UpdateUserRequest;
  240. title: string;
  241. placeholder?: string;
  242. type?: string;
  243. }>({
  244. isOpen: false,
  245. field: 'nickname',
  246. title: '',
  247. });
  248. const [bindPhoneOpen, setBindPhoneOpen] = useState(false);
  249. const [bindEmailOpen, setBindEmailOpen] = useState(false);
  250. const [successMessage, setSuccessMessage] = useState<string | null>(null);
  251. const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
  252. const [avatarUploading, setAvatarUploading] = useState(false);
  253. const [verifyModalOpen, setVerifyModalOpen] = useState(false);
  254. const [verifyData, setVerifyData] = useState({ real_name: '', id_card: '' });
  255. const [verifyLoading, setVerifyLoading] = useState(false);
  256. const [verifyError, setVerifyError] = useState<string | null>(null);
  257. const avatarInputRef = useRef<HTMLInputElement>(null);
  258. useEffect(() => {
  259. loadUserInfo();
  260. }, []);
  261. // 检查URL参数,如果有 openVerify=true,自动打开认证弹窗
  262. useEffect(() => {
  263. const searchParams = new URLSearchParams(location.search);
  264. if (searchParams.get('openVerify') === 'true' && userInfo && userInfo.isVerified !== 'verified') {
  265. // 延迟一下,等页面加载完成
  266. setTimeout(() => {
  267. handleOpenVerifyModal();
  268. }, 500);
  269. }
  270. }, [location.search, userInfo]);
  271. const loadUserInfo = async () => {
  272. try {
  273. setLoading(true);
  274. setError(null);
  275. // 检查是否已登录
  276. if (!authService.isAuthenticated()) {
  277. navigate('/login');
  278. return;
  279. }
  280. // 从本地存储获取用户信息
  281. const localUserInfo = authService.getUserInfo();
  282. if (localUserInfo) {
  283. setUserInfo(localUserInfo);
  284. }
  285. // 从服务器获取最新用户信息
  286. const currentUser = await userApi.getCurrentUser();
  287. const updatedUserInfo = {
  288. id: currentUser.id,
  289. nickname: currentUser.nickname,
  290. phone: currentUser.phone || undefined,
  291. email: currentUser.email || undefined,
  292. avatar: currentUser.avatar || undefined,
  293. registrationDate: currentUser.registration_date,
  294. realName: currentUser.real_name || undefined,
  295. isVerified: currentUser.is_verified || 'unverified',
  296. verifiedAt: currentUser.verified_at || undefined,
  297. };
  298. setUserInfo(updatedUserInfo);
  299. // 更新本地存储
  300. authService.setToken(authService.getToken()!, updatedUserInfo);
  301. } catch (err) {
  302. setError(err instanceof Error ? err.message : '加载用户信息失败');
  303. // 如果是认证错误,跳转到登录页
  304. if (err instanceof Error && err.message.includes('未授权')) {
  305. navigate('/login');
  306. }
  307. } finally {
  308. setLoading(false);
  309. }
  310. };
  311. const handleEdit = (field: keyof UpdateUserRequest, title: string, placeholder?: string, type?: string) => {
  312. setEditModal({
  313. isOpen: true,
  314. field,
  315. title,
  316. placeholder,
  317. type,
  318. });
  319. };
  320. const handleSave = async (value: string) => {
  321. if (!userInfo) return;
  322. const updateData: UpdateUserRequest = {
  323. [editModal.field]: value,
  324. };
  325. const updatedUser = await userApi.updateCurrentUser(updateData);
  326. // 用服务器返回的值(已掩码)更新本地状态
  327. // phone/email 取服务器返回的掩码值,其他字段取本地输入值
  328. const serverValue = (updatedUser as Record<string, unknown>)[editModal.field];
  329. const updatedUserInfo = {
  330. ...userInfo,
  331. [editModal.field]: (serverValue !== undefined && serverValue !== null) ? serverValue : value,
  332. };
  333. setUserInfo(updatedUserInfo);
  334. // 更新本地存储
  335. authService.setToken(authService.getToken()!, updatedUserInfo);
  336. // 显示成功消息
  337. setSuccessMessage('信息更新成功');
  338. setTimeout(() => setSuccessMessage(null), 3000);
  339. };
  340. const handleLogout = async () => {
  341. console.log('[Profile] handleLogout called');
  342. authService.clearToken();
  343. navigate('/login');
  344. };
  345. const handleApplyDelete = () => {
  346. if (!userInfo) return;
  347. setDeleteConfirmOpen(true);
  348. };
  349. const handleConfirmDelete = async () => {
  350. if (!userInfo) return;
  351. setDeleteConfirmOpen(false);
  352. try {
  353. setLoading(true);
  354. await userApi.deleteCurrentUser();
  355. // 清理本地登录状态并跳转到首页
  356. authService.clearStorage();
  357. setUserInfo(null);
  358. setSuccessMessage('申请注销成功,账户已删除。');
  359. setTimeout(() => {
  360. setSuccessMessage(null);
  361. navigate('/', { replace: true });
  362. }, 1500);
  363. } catch (err) {
  364. setError(err instanceof Error ? err.message : '注销失败,请重试');
  365. } finally {
  366. setLoading(false);
  367. }
  368. };
  369. const handleCancelDelete = () => {
  370. setDeleteConfirmOpen(false);
  371. };
  372. const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
  373. const file = e.target.files?.[0];
  374. if (!file || !userInfo) return;
  375. // 验证文件类型
  376. if (!file.type.startsWith('image/')) {
  377. setError('请选择图片文件');
  378. return;
  379. }
  380. // 验证文件大小(最大5MB)
  381. if (file.size > 5 * 1024 * 1024) {
  382. setError('图片大小不能超过5MB');
  383. return;
  384. }
  385. setAvatarUploading(true);
  386. setError(null);
  387. try {
  388. // 上传到OSS
  389. const response = await ossApi.uploadFile(file, 'avatars');
  390. if (response.code === 200) {
  391. // 更新用户头像
  392. await userApi.updateCurrentUser({ avatar: response.data.url });
  393. // 更新本地状态
  394. const updatedUserInfo = { ...userInfo, avatar: response.data.url };
  395. setUserInfo(updatedUserInfo);
  396. authService.setToken(authService.getToken()!, updatedUserInfo);
  397. setSuccessMessage('头像更新成功');
  398. setTimeout(() => setSuccessMessage(null), 3000);
  399. } else {
  400. setError('上传失败,请重试');
  401. }
  402. } catch (err) {
  403. setError(err instanceof Error ? err.message : '上传失败');
  404. } finally {
  405. setAvatarUploading(false);
  406. // 清空input,允许重复选择同一文件
  407. if (avatarInputRef.current) {
  408. avatarInputRef.current.value = '';
  409. }
  410. }
  411. };
  412. const handleCopyId = () => {
  413. if (!userInfo?.id) return;
  414. copyToClipboard(userInfo.id);
  415. setCopied(true);
  416. setTimeout(() => setCopied(false), 2000);
  417. };
  418. const handleOpenVerifyModal = () => {
  419. setVerifyModalOpen(true);
  420. setVerifyError(null);
  421. setVerifyData({ real_name: '', id_card: '' });
  422. };
  423. const handleSubmitVerification = async () => {
  424. if (!verifyData.real_name.trim() || !verifyData.id_card.trim()) {
  425. setVerifyError('请填写完整信息');
  426. return;
  427. }
  428. // 验证真实姓名
  429. const nameValidation = validateRealName(verifyData.real_name);
  430. if (!nameValidation.valid) {
  431. setVerifyError(nameValidation.error);
  432. return;
  433. }
  434. // 验证身份证号
  435. const idCardValidation = validateIDCard(verifyData.id_card);
  436. if (!idCardValidation.valid) {
  437. setVerifyError(idCardValidation.error);
  438. return;
  439. }
  440. setVerifyLoading(true);
  441. setVerifyError(null);
  442. try {
  443. // 获取RSA公钥
  444. const { public_key } = await userApi.getRSAPublicKey();
  445. // 使用RSA加密数据
  446. const encryptedData = encryptVerificationData(
  447. verifyData.real_name.trim(),
  448. verifyData.id_card.trim().toUpperCase(),
  449. public_key
  450. );
  451. // 提交加密数据
  452. const updatedUser = await userApi.submitVerification(encryptedData);
  453. // 更新本地状态
  454. const updatedUserInfo = {
  455. ...userInfo!,
  456. realName: updatedUser.real_name || undefined,
  457. isVerified: updatedUser.is_verified || 'verified',
  458. verifiedAt: updatedUser.verified_at || undefined,
  459. };
  460. setUserInfo(updatedUserInfo);
  461. authService.setToken(authService.getToken()!, updatedUserInfo);
  462. setVerifyModalOpen(false);
  463. setSuccessMessage('实名认证提交成功');
  464. setTimeout(() => setSuccessMessage(null), 3000);
  465. // 清空表单
  466. setVerifyData({ real_name: '', id_card: '' });
  467. } catch (err) {
  468. setVerifyError(err instanceof Error ? err.message : '认证失败,请重试');
  469. } finally {
  470. setVerifyLoading(false);
  471. }
  472. };
  473. if (loading) {
  474. return (
  475. <div className="min-h-screen bg-gray-50 flex items-center justify-center">
  476. <div className="flex items-center gap-3">
  477. <Loader2 className="w-6 h-6 text-blue-600 animate-spin" />
  478. <span className="text-gray-600">加载用户信息中...</span>
  479. </div>
  480. </div>
  481. );
  482. }
  483. if (error) {
  484. return (
  485. <div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
  486. <div className="bg-white rounded-2xl shadow-lg p-8 max-w-md w-full text-center">
  487. <AlertCircle className="w-12 h-12 text-red-500 mx-auto mb-4" />
  488. <h2 className="text-lg font-bold text-gray-900 mb-2">加载失败</h2>
  489. <p className="text-sm text-gray-600 mb-6">{error}</p>
  490. <button
  491. onClick={loadUserInfo}
  492. className="px-6 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors"
  493. >
  494. 重试
  495. </button>
  496. </div>
  497. </div>
  498. );
  499. }
  500. if (!userInfo) {
  501. return null;
  502. }
  503. return (
  504. <div className="min-h-screen bg-gray-50 relative">
  505. {/* 成功消息 */}
  506. {successMessage && (
  507. <div className="fixed top-4 right-4 z-50 bg-green-50 border border-green-200 rounded-lg p-4 flex items-center gap-2 shadow-lg">
  508. <CheckCircle2 className="w-5 h-5 text-green-500" />
  509. <span className="text-sm text-green-700 font-medium">{successMessage}</span>
  510. </div>
  511. )}
  512. {/* 主要内容 */}
  513. <div className="flex flex-col items-center justify-start min-h-screen pt-8 pb-12 px-4">
  514. {/* 欢迎信息 */}
  515. <div className="text-center mb-6">
  516. <h1 className="text-2xl font-bold text-gray-900 tracking-tight mb-2">欢迎, {userInfo.nickname}</h1>
  517. <p className="text-sm text-gray-500">
  518. 管理自己的信息、隐私和安全,让我们更好地为您服务。
  519. </p>
  520. </div>
  521. {/* 用户信息卡片 */}
  522. <div className="w-full max-w-sm sm:max-w-xl md:max-w-2xl bg-white rounded-2xl border border-gray-100 p-4 sm:p-6 space-y-4 sm:space-y-6">
  523. {/* 账号 ID */}
  524. <div className="flex items-center justify-between py-3 border-b border-gray-200">
  525. <span className="text-sm text-gray-700 flex-shrink-0">账号 ID</span>
  526. <div className="flex items-center gap-2 min-w-0 ml-2">
  527. <span className="text-sm text-gray-900 font-mono truncate max-w-[120px] sm:max-w-none">{userInfo.id}</span>
  528. <button
  529. onClick={handleCopyId}
  530. className="p-1.5 hover:bg-gray-200 rounded transition-colors"
  531. title="复制"
  532. >
  533. <Copy className="w-4 h-4 text-gray-600" />
  534. </button>
  535. {copied && (
  536. <span className="text-xs text-green-600">已复制</span>
  537. )}
  538. </div>
  539. </div>
  540. {/* 头像 */}
  541. <div className="flex items-center justify-between py-3 border-b border-gray-200">
  542. <span className="text-sm text-gray-700">头像</span>
  543. <div className="flex items-center gap-4">
  544. {userInfo.avatar ? (
  545. <img
  546. src={userInfo.avatar}
  547. alt="头像"
  548. className="w-12 h-12 rounded-full object-cover"
  549. />
  550. ) : (
  551. <div className="w-12 h-12 rounded-full bg-gradient-to-br from-blue-400 to-blue-600 flex items-center justify-center text-white text-lg font-bold">
  552. {userInfo.nickname.charAt(0).toUpperCase()}
  553. </div>
  554. )}
  555. <input
  556. ref={avatarInputRef}
  557. type="file"
  558. accept="image/*"
  559. onChange={handleAvatarUpload}
  560. className="hidden"
  561. />
  562. <button
  563. onClick={() => avatarInputRef.current?.click()}
  564. disabled={avatarUploading}
  565. className="text-blue-600 hover:text-blue-700 text-sm font-bold transition-colors flex items-center gap-1 disabled:opacity-50"
  566. >
  567. {avatarUploading ? (
  568. <>
  569. <Loader2 className="w-4 h-4 animate-spin" />
  570. 上传中...
  571. </>
  572. ) : (
  573. <>
  574. <Camera className="w-4 h-4" />
  575. 更换
  576. </>
  577. )}
  578. </button>
  579. </div>
  580. </div>
  581. {/* 昵称 */}
  582. <div className="flex items-center justify-between py-3 border-b border-gray-200">
  583. <span className="text-sm text-gray-700">昵称</span>
  584. <div className="flex items-center gap-2">
  585. <span className="text-sm text-gray-900">{userInfo.nickname}</span>
  586. <button
  587. onClick={() => handleEdit('nickname', '修改昵称', '请输入新昵称')}
  588. className="text-blue-600 hover:text-blue-700 text-sm font-bold transition-colors flex items-center gap-1"
  589. >
  590. <Edit2 className="w-3 h-3" />
  591. 修改
  592. </button>
  593. </div>
  594. </div>
  595. {/* 手机 */}
  596. <div className="flex items-center justify-between py-3 border-b border-gray-200">
  597. <span className="text-sm text-gray-700">手机</span>
  598. <div className="flex items-center gap-2">
  599. <span className="text-sm text-gray-900">
  600. {userInfo.phone || '未绑定'}
  601. </span>
  602. <button
  603. onClick={() => setBindPhoneOpen(true)}
  604. className="text-blue-600 hover:text-blue-700 text-sm font-bold transition-colors flex items-center gap-1"
  605. >
  606. <Phone className="w-3 h-3" />
  607. {userInfo.phone ? '换绑' : '绑定'}
  608. </button>
  609. </div>
  610. </div>
  611. {/* 邮箱 */}
  612. <div className="flex items-center justify-between py-3 border-b border-gray-200">
  613. <span className="text-sm text-gray-700">邮箱</span>
  614. <div className="flex items-center gap-2">
  615. <span className="text-sm text-gray-900">
  616. {userInfo.email || '未绑定'}
  617. </span>
  618. <button
  619. onClick={() => setBindEmailOpen(true)}
  620. className="text-blue-600 hover:text-blue-700 text-sm font-bold transition-colors flex items-center gap-1"
  621. >
  622. <Mail className="w-3 h-3" />
  623. {userInfo.email ? '换绑' : '绑定'}
  624. </button>
  625. </div>
  626. </div>
  627. {/* API密钥 */}
  628. <div className="flex items-center justify-between py-3 border-b border-gray-200">
  629. <span className="text-sm text-gray-700">API密钥</span>
  630. <div className="flex items-center gap-2">
  631. <span className="text-sm text-gray-900 font-mono">
  632. {userInfo.apikey ? `${userInfo.apikey.substring(0, 8)}...${userInfo.apikey.substring(userInfo.apikey.length - 8)}` : '未设置'}
  633. </span>
  634. <button
  635. onClick={() => handleEdit('apikey', userInfo.apikey ? '更新API密钥' : '设置API密钥', '请输入API密钥')}
  636. className="text-blue-600 hover:text-blue-700 text-sm font-bold transition-colors"
  637. >
  638. {userInfo.apikey ? '更新' : '设置'}
  639. </button>
  640. </div>
  641. </div>
  642. {/* 注册时间 */}
  643. <div className="flex items-center justify-between py-3 border-b border-gray-200">
  644. <span className="text-sm text-gray-700">注册时间</span>
  645. <span className="text-sm text-gray-900">{userInfo.registrationDate}</span>
  646. </div>
  647. {/* 注销账号 */}
  648. <div className="flex items-center justify-between py-3">
  649. <span className="text-sm text-gray-700">注销账号</span>
  650. <button onClick={handleApplyDelete} className="text-red-600 hover:text-red-700 text-sm font-bold transition-colors">
  651. 申请注销
  652. </button>
  653. </div>
  654. </div>
  655. {/* 退出登录 */}
  656. <div className="mt-8">
  657. <button
  658. onClick={handleLogout}
  659. className="flex items-center gap-2 text-blue-600 hover:text-blue-700 text-sm font-bold transition-colors"
  660. >
  661. <LogOut className="w-4 h-4" />
  662. <span>退出登录</span>
  663. </button>
  664. </div>
  665. </div>
  666. {/* 顶部确认弹窗(参考图片2样式) */}
  667. {deleteConfirmOpen && (
  668. <div className="fixed top-6 left-1/2 -translate-x-1/2 z-50 w-[calc(100%-2rem)] sm:w-[560px] max-w-full p-4">
  669. <div className="bg-white border border-red-200 rounded-lg shadow-lg p-4">
  670. <div className="flex items-start gap-4">
  671. <div className="flex-shrink-0 mt-1">
  672. <AlertCircle className="w-6 h-6 text-red-500" />
  673. </div>
  674. <div className="flex-1">
  675. <div className="text-sm text-gray-900 mb-2">确认申请注销账号吗?</div>
  676. <div className="text-xs text-gray-500">账号将被永久删除,此操作不可撤销。</div>
  677. </div>
  678. <div className="flex items-center gap-2 ml-4">
  679. <button
  680. onClick={handleConfirmDelete}
  681. className="bg-black text-white px-4 py-2 rounded-md text-sm hover:opacity-95"
  682. >
  683. 确定
  684. </button>
  685. <button
  686. onClick={handleCancelDelete}
  687. className="bg-white border border-gray-200 text-gray-700 px-3 py-2 rounded-md text-sm hover:bg-gray-50"
  688. >
  689. 取消
  690. </button>
  691. </div>
  692. </div>
  693. </div>
  694. </div>
  695. )}
  696. {/* 编辑模态框 */}
  697. <EditModal
  698. isOpen={editModal.isOpen}
  699. onClose={() => setEditModal({ ...editModal, isOpen: false })}
  700. title={editModal.title}
  701. field={editModal.field}
  702. currentValue={userInfo[editModal.field as keyof UserInfo] as string || ''}
  703. onSave={handleSave}
  704. placeholder={editModal.placeholder}
  705. type={editModal.type}
  706. />
  707. {/* 实名认证模态框 */}
  708. {verifyModalOpen && (
  709. <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
  710. <div className="bg-white rounded-2xl shadow-2xl w-full max-w-md p-6">
  711. <div className="flex items-center gap-3 mb-4">
  712. <div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
  713. <Shield className="w-5 h-5 text-blue-600" />
  714. </div>
  715. <h3 className="text-lg font-bold text-gray-900">实名认证</h3>
  716. </div>
  717. <p className="text-sm text-gray-600 mb-6">
  718. 为了保障您的账号安全,请填写真实姓名和身份证号进行实名认证。
  719. </p>
  720. {verifyError && (
  721. <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg flex items-center gap-2">
  722. <AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0" />
  723. <p className="text-sm text-red-600">{verifyError}</p>
  724. </div>
  725. )}
  726. <div className="space-y-4 mb-6">
  727. <div>
  728. <label className="block text-sm font-medium text-gray-700 mb-2">
  729. 真实姓名
  730. </label>
  731. <input
  732. type="text"
  733. value={verifyData.real_name}
  734. onChange={(e) => setVerifyData({ ...verifyData, real_name: e.target.value })}
  735. placeholder="请输入真实姓名"
  736. className="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 focus:border-blue-500 transition-all"
  737. disabled={verifyLoading}
  738. />
  739. </div>
  740. <div>
  741. <label className="block text-sm font-medium text-gray-700 mb-2">
  742. 身份证号
  743. </label>
  744. <input
  745. type="text"
  746. value={verifyData.id_card}
  747. onChange={(e) => setVerifyData({ ...verifyData, id_card: e.target.value })}
  748. placeholder="请输入身份证号"
  749. maxLength={18}
  750. className="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 focus:border-blue-500 transition-all"
  751. disabled={verifyLoading}
  752. />
  753. </div>
  754. </div>
  755. <div className="bg-amber-50 border border-amber-200 rounded-lg p-3 mb-6">
  756. <p className="text-xs text-amber-800">
  757. <strong>温馨提示:</strong>您的个人信息将被严格保密,仅用于身份验证,不会用于其他用途。
  758. </p>
  759. </div>
  760. <div className="flex gap-3">
  761. <button
  762. onClick={() => setVerifyModalOpen(false)}
  763. disabled={verifyLoading}
  764. className="flex-1 py-2 px-4 bg-gray-100 text-gray-700 rounded-lg font-medium hover:bg-gray-200 transition-colors disabled:opacity-50"
  765. >
  766. 取消
  767. </button>
  768. <button
  769. onClick={handleSubmitVerification}
  770. disabled={verifyLoading || !verifyData.real_name.trim() || !verifyData.id_card.trim()}
  771. className="flex-1 py-2 px-4 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors disabled:opacity-50 flex items-center justify-center gap-2"
  772. >
  773. {verifyLoading ? (
  774. <>
  775. <Loader2 className="w-4 h-4 animate-spin" />
  776. <span>提交中...</span>
  777. </>
  778. ) : (
  779. <span>提交认证</span>
  780. )}
  781. </button>
  782. </div>
  783. </div>
  784. </div>
  785. )}
  786. {/* 绑定/换绑手机号弹窗 */}
  787. <BindPhoneModal
  788. isOpen={bindPhoneOpen}
  789. onClose={() => setBindPhoneOpen(false)}
  790. onSaved={(phone) => {
  791. const updated = { ...userInfo, phone };
  792. setUserInfo(updated);
  793. authService.setToken(authService.getToken()!, updated);
  794. setSuccessMessage('手机号绑定成功');
  795. setTimeout(() => setSuccessMessage(null), 3000);
  796. }}
  797. />
  798. {/* 绑定/换绑邮箱弹窗 */}
  799. <BindEmailModal
  800. isOpen={bindEmailOpen}
  801. onClose={() => setBindEmailOpen(false)}
  802. onSaved={(email) => {
  803. const updated = { ...userInfo, email };
  804. setUserInfo(updated);
  805. authService.setToken(authService.getToken()!, updated);
  806. setSuccessMessage('邮箱绑定成功');
  807. setTimeout(() => setSuccessMessage(null), 3000);
  808. }}
  809. />
  810. </div>
  811. );
  812. };
  813. export default Profile;