Profile.tsx 35 KB

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