PersonalSetting.jsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557
  1. /*
  2. Copyright (C) 2025 QuantumNous
  3. This program is free software: you can redistribute it and/or modify
  4. it under the terms of the GNU Affero General Public License as
  5. published by the Free Software Foundation, either version 3 of the
  6. License, or (at your option) any later version.
  7. This program is distributed in the hope that it will be useful,
  8. but WITHOUT ANY WARRANTY; without even the implied warranty of
  9. MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  10. GNU Affero General Public License for more details.
  11. You should have received a copy of the GNU Affero General Public License
  12. along with this program. If not, see <https://www.gnu.org/licenses/>.
  13. For commercial licensing, please contact support@quantumnous.com
  14. */
  15. import React, { useContext, useEffect, useState } from 'react';
  16. import { useNavigate } from 'react-router-dom';
  17. import {
  18. API,
  19. copy,
  20. showError,
  21. showInfo,
  22. showSuccess,
  23. setStatusData,
  24. prepareCredentialCreationOptions,
  25. buildRegistrationResult,
  26. isPasskeySupported,
  27. setUserData,
  28. } from '../../helpers';
  29. import { UserContext } from '../../context/User';
  30. import { Modal } from '@douyinfe/semi-ui';
  31. import { useTranslation } from 'react-i18next';
  32. // 导入子组件
  33. import UserInfoHeader from './personal/components/UserInfoHeader';
  34. import AccountManagement from './personal/cards/AccountManagement';
  35. import NotificationSettings from './personal/cards/NotificationSettings';
  36. import PreferencesSettings from './personal/cards/PreferencesSettings';
  37. import CheckinCalendar from './personal/cards/CheckinCalendar';
  38. import EmailBindModal from './personal/modals/EmailBindModal';
  39. import WeChatBindModal from './personal/modals/WeChatBindModal';
  40. import AccountDeleteModal from './personal/modals/AccountDeleteModal';
  41. import ChangePasswordModal from './personal/modals/ChangePasswordModal';
  42. const PersonalSetting = () => {
  43. const [userState, userDispatch] = useContext(UserContext);
  44. let navigate = useNavigate();
  45. const { t } = useTranslation();
  46. const [inputs, setInputs] = useState({
  47. wechat_verification_code: '',
  48. email_verification_code: '',
  49. email: '',
  50. self_account_deletion_confirmation: '',
  51. original_password: '',
  52. set_new_password: '',
  53. set_new_password_confirmation: '',
  54. });
  55. const [status, setStatus] = useState({});
  56. const [showChangePasswordModal, setShowChangePasswordModal] = useState(false);
  57. const [showWeChatBindModal, setShowWeChatBindModal] = useState(false);
  58. const [showEmailBindModal, setShowEmailBindModal] = useState(false);
  59. const [showAccountDeleteModal, setShowAccountDeleteModal] = useState(false);
  60. const [turnstileEnabled, setTurnstileEnabled] = useState(false);
  61. const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
  62. const [turnstileToken, setTurnstileToken] = useState('');
  63. const [loading, setLoading] = useState(false);
  64. const [disableButton, setDisableButton] = useState(false);
  65. const [countdown, setCountdown] = useState(30);
  66. const [systemToken, setSystemToken] = useState('');
  67. const [passkeyStatus, setPasskeyStatus] = useState({ enabled: false });
  68. const [passkeyRegisterLoading, setPasskeyRegisterLoading] = useState(false);
  69. const [passkeyDeleteLoading, setPasskeyDeleteLoading] = useState(false);
  70. const [passkeySupported, setPasskeySupported] = useState(false);
  71. const [notificationSettings, setNotificationSettings] = useState({
  72. warningType: 'email',
  73. warningThreshold: 100000,
  74. webhookUrl: '',
  75. webhookSecret: '',
  76. notificationEmail: '',
  77. barkUrl: '',
  78. gotifyUrl: '',
  79. gotifyToken: '',
  80. gotifyPriority: 5,
  81. acceptUnsetModelRatioModel: false,
  82. recordIpLog: false,
  83. });
  84. useEffect(() => {
  85. let saved = localStorage.getItem('status');
  86. if (saved) {
  87. const parsed = JSON.parse(saved);
  88. setStatus(parsed);
  89. if (parsed.turnstile_check) {
  90. setTurnstileEnabled(true);
  91. setTurnstileSiteKey(parsed.turnstile_site_key);
  92. } else {
  93. setTurnstileEnabled(false);
  94. setTurnstileSiteKey('');
  95. }
  96. }
  97. // Always refresh status from server to avoid stale flags (e.g., admin just enabled OAuth)
  98. (async () => {
  99. try {
  100. const res = await API.get('/api/status');
  101. const { success, data } = res.data;
  102. if (success && data) {
  103. setStatus(data);
  104. setStatusData(data);
  105. if (data.turnstile_check) {
  106. setTurnstileEnabled(true);
  107. setTurnstileSiteKey(data.turnstile_site_key);
  108. } else {
  109. setTurnstileEnabled(false);
  110. setTurnstileSiteKey('');
  111. }
  112. }
  113. } catch (e) {
  114. // ignore and keep local status
  115. }
  116. })();
  117. getUserData();
  118. isPasskeySupported()
  119. .then(setPasskeySupported)
  120. .catch(() => setPasskeySupported(false));
  121. }, []);
  122. useEffect(() => {
  123. let countdownInterval = null;
  124. if (disableButton && countdown > 0) {
  125. countdownInterval = setInterval(() => {
  126. setCountdown(countdown - 1);
  127. }, 1000);
  128. } else if (countdown === 0) {
  129. setDisableButton(false);
  130. setCountdown(30);
  131. }
  132. return () => clearInterval(countdownInterval); // Clean up on unmount
  133. }, [disableButton, countdown]);
  134. useEffect(() => {
  135. if (userState?.user?.setting) {
  136. const settings = JSON.parse(userState.user.setting);
  137. setNotificationSettings({
  138. warningType: settings.notify_type || 'email',
  139. warningThreshold: settings.quota_warning_threshold || 500000,
  140. webhookUrl: settings.webhook_url || '',
  141. webhookSecret: settings.webhook_secret || '',
  142. notificationEmail: settings.notification_email || '',
  143. barkUrl: settings.bark_url || '',
  144. gotifyUrl: settings.gotify_url || '',
  145. gotifyToken: settings.gotify_token || '',
  146. gotifyPriority:
  147. settings.gotify_priority !== undefined ? settings.gotify_priority : 5,
  148. acceptUnsetModelRatioModel:
  149. settings.accept_unset_model_ratio_model || false,
  150. recordIpLog: settings.record_ip_log || false,
  151. });
  152. }
  153. }, [userState?.user?.setting]);
  154. const handleInputChange = (name, value) => {
  155. setInputs((inputs) => ({ ...inputs, [name]: value }));
  156. };
  157. const generateAccessToken = async () => {
  158. const res = await API.get('/api/user/token');
  159. const { success, message, data } = res.data;
  160. if (success) {
  161. setSystemToken(data);
  162. await copy(data);
  163. showSuccess(t('令牌已重置并已复制到剪贴板'));
  164. } else {
  165. showError(message);
  166. }
  167. };
  168. const loadPasskeyStatus = async () => {
  169. try {
  170. const res = await API.get('/api/user/passkey');
  171. const { success, data, message } = res.data;
  172. if (success) {
  173. setPasskeyStatus({
  174. enabled: data?.enabled || false,
  175. last_used_at: data?.last_used_at || null,
  176. backup_eligible: data?.backup_eligible || false,
  177. backup_state: data?.backup_state || false,
  178. });
  179. } else {
  180. showError(message);
  181. }
  182. } catch (error) {
  183. // 忽略错误,保留默认状态
  184. }
  185. };
  186. const handleRegisterPasskey = async () => {
  187. if (!passkeySupported || !window.PublicKeyCredential) {
  188. showInfo(t('当前设备不支持 Passkey'));
  189. return;
  190. }
  191. setPasskeyRegisterLoading(true);
  192. try {
  193. const beginRes = await API.post('/api/user/passkey/register/begin');
  194. const { success, message, data } = beginRes.data;
  195. if (!success) {
  196. showError(message || t('无法发起 Passkey 注册'));
  197. return;
  198. }
  199. const publicKey = prepareCredentialCreationOptions(
  200. data?.options || data?.publicKey || data,
  201. );
  202. const credential = await navigator.credentials.create({ publicKey });
  203. const payload = buildRegistrationResult(credential);
  204. if (!payload) {
  205. showError(t('Passkey 注册失败,请重试'));
  206. return;
  207. }
  208. const finishRes = await API.post(
  209. '/api/user/passkey/register/finish',
  210. payload,
  211. );
  212. if (finishRes.data.success) {
  213. showSuccess(t('Passkey 注册成功'));
  214. await loadPasskeyStatus();
  215. } else {
  216. showError(finishRes.data.message || t('Passkey 注册失败,请重试'));
  217. }
  218. } catch (error) {
  219. if (error?.name === 'AbortError') {
  220. showInfo(t('已取消 Passkey 注册'));
  221. } else {
  222. showError(t('Passkey 注册失败,请重试'));
  223. }
  224. } finally {
  225. setPasskeyRegisterLoading(false);
  226. }
  227. };
  228. const handleRemovePasskey = async () => {
  229. setPasskeyDeleteLoading(true);
  230. try {
  231. const res = await API.delete('/api/user/passkey');
  232. const { success, message } = res.data;
  233. if (success) {
  234. showSuccess(t('Passkey 已解绑'));
  235. await loadPasskeyStatus();
  236. } else {
  237. showError(message || t('操作失败,请重试'));
  238. }
  239. } catch (error) {
  240. showError(t('操作失败,请重试'));
  241. } finally {
  242. setPasskeyDeleteLoading(false);
  243. }
  244. };
  245. const getUserData = async () => {
  246. let res = await API.get(`/api/user/self`);
  247. const { success, message, data } = res.data;
  248. if (success) {
  249. userDispatch({ type: 'login', payload: data });
  250. setUserData(data);
  251. await loadPasskeyStatus();
  252. } else {
  253. showError(message);
  254. }
  255. };
  256. const handleSystemTokenClick = async (e) => {
  257. e.target.select();
  258. await copy(e.target.value);
  259. showSuccess(t('系统令牌已复制到剪切板'));
  260. };
  261. const deleteAccount = async () => {
  262. if (inputs.self_account_deletion_confirmation !== userState.user.username) {
  263. showError(t('请输入你的账户名以确认删除!'));
  264. return;
  265. }
  266. const res = await API.delete('/api/user/self');
  267. const { success, message } = res.data;
  268. if (success) {
  269. showSuccess(t('账户已删除!'));
  270. await API.get('/api/user/logout');
  271. userDispatch({ type: 'logout' });
  272. localStorage.removeItem('user');
  273. navigate('/login');
  274. } else {
  275. showError(message);
  276. }
  277. };
  278. const bindWeChat = async () => {
  279. if (inputs.wechat_verification_code === '') return;
  280. const res = await API.get(
  281. `/api/oauth/wechat/bind?code=${inputs.wechat_verification_code}`,
  282. );
  283. const { success, message } = res.data;
  284. if (success) {
  285. showSuccess(t('微信账户绑定成功!'));
  286. setShowWeChatBindModal(false);
  287. } else {
  288. showError(message);
  289. }
  290. };
  291. const changePassword = async () => {
  292. // if (inputs.original_password === '') {
  293. // showError(t('请输入原密码!'));
  294. // return;
  295. // }
  296. if (inputs.set_new_password === '') {
  297. showError(t('请输入新密码!'));
  298. return;
  299. }
  300. if (inputs.original_password === inputs.set_new_password) {
  301. showError(t('新密码需要和原密码不一致!'));
  302. return;
  303. }
  304. if (inputs.set_new_password !== inputs.set_new_password_confirmation) {
  305. showError(t('两次输入的密码不一致!'));
  306. return;
  307. }
  308. const res = await API.put(`/api/user/self`, {
  309. original_password: inputs.original_password,
  310. password: inputs.set_new_password,
  311. });
  312. const { success, message } = res.data;
  313. if (success) {
  314. showSuccess(t('密码修改成功!'));
  315. setShowWeChatBindModal(false);
  316. } else {
  317. showError(message);
  318. }
  319. setShowChangePasswordModal(false);
  320. };
  321. const sendVerificationCode = async () => {
  322. if (inputs.email === '') {
  323. showError(t('请输入邮箱!'));
  324. return;
  325. }
  326. setDisableButton(true);
  327. if (turnstileEnabled && turnstileToken === '') {
  328. showInfo(t('请稍后几秒重试,Turnstile 正在检查用户环境!'));
  329. return;
  330. }
  331. setLoading(true);
  332. const res = await API.get(
  333. `/api/verification?email=${inputs.email}&turnstile=${turnstileToken}`,
  334. );
  335. const { success, message } = res.data;
  336. if (success) {
  337. showSuccess(t('验证码发送成功,请检查邮箱!'));
  338. } else {
  339. showError(message);
  340. }
  341. setLoading(false);
  342. };
  343. const bindEmail = async () => {
  344. if (inputs.email_verification_code === '') {
  345. showError(t('请输入邮箱验证码!'));
  346. return;
  347. }
  348. setLoading(true);
  349. const res = await API.get(
  350. `/api/oauth/email/bind?email=${inputs.email}&code=${inputs.email_verification_code}`,
  351. );
  352. const { success, message } = res.data;
  353. if (success) {
  354. showSuccess(t('邮箱账户绑定成功!'));
  355. setShowEmailBindModal(false);
  356. userState.user.email = inputs.email;
  357. } else {
  358. showError(message);
  359. }
  360. setLoading(false);
  361. };
  362. const copyText = async (text) => {
  363. if (await copy(text)) {
  364. showSuccess(t('已复制:') + text);
  365. } else {
  366. // setSearchKeyword(text);
  367. Modal.error({ title: t('无法复制到剪贴板,请手动复制'), content: text });
  368. }
  369. };
  370. const handleNotificationSettingChange = (type, value) => {
  371. setNotificationSettings((prev) => ({
  372. ...prev,
  373. [type]: value.target
  374. ? value.target.value !== undefined
  375. ? value.target.value
  376. : value.target.checked
  377. : value, // handle checkbox properly
  378. }));
  379. };
  380. const saveNotificationSettings = async () => {
  381. try {
  382. const res = await API.put('/api/user/setting', {
  383. notify_type: notificationSettings.warningType,
  384. quota_warning_threshold: parseFloat(
  385. notificationSettings.warningThreshold,
  386. ),
  387. webhook_url: notificationSettings.webhookUrl,
  388. webhook_secret: notificationSettings.webhookSecret,
  389. notification_email: notificationSettings.notificationEmail,
  390. bark_url: notificationSettings.barkUrl,
  391. gotify_url: notificationSettings.gotifyUrl,
  392. gotify_token: notificationSettings.gotifyToken,
  393. gotify_priority: (() => {
  394. const parsed = parseInt(notificationSettings.gotifyPriority);
  395. return isNaN(parsed) ? 5 : parsed;
  396. })(),
  397. accept_unset_model_ratio_model:
  398. notificationSettings.acceptUnsetModelRatioModel,
  399. record_ip_log: notificationSettings.recordIpLog,
  400. });
  401. if (res.data.success) {
  402. showSuccess(t('设置保存成功'));
  403. await getUserData();
  404. } else {
  405. showError(res.data.message);
  406. }
  407. } catch (error) {
  408. showError(t('设置保存失败'));
  409. }
  410. };
  411. return (
  412. <div className='mt-[60px]'>
  413. <div className='flex justify-center'>
  414. <div className='w-full max-w-7xl mx-auto px-2'>
  415. {/* 顶部用户信息区域 */}
  416. <UserInfoHeader t={t} userState={userState} />
  417. {/* 签到日历 - 仅在启用时显示 */}
  418. {status?.checkin_enabled && (
  419. <div className='mt-4 md:mt-6'>
  420. <CheckinCalendar
  421. t={t}
  422. status={status}
  423. turnstileEnabled={turnstileEnabled}
  424. turnstileSiteKey={turnstileSiteKey}
  425. />
  426. </div>
  427. )}
  428. {/* 账户管理和其他设置 */}
  429. <div className='grid grid-cols-1 xl:grid-cols-2 items-start gap-4 md:gap-6 mt-4 md:mt-6'>
  430. {/* 左侧:账户管理设置 */}
  431. <div className='flex flex-col gap-4 md:gap-6'>
  432. <AccountManagement
  433. t={t}
  434. userState={userState}
  435. status={status}
  436. systemToken={systemToken}
  437. setShowEmailBindModal={setShowEmailBindModal}
  438. setShowWeChatBindModal={setShowWeChatBindModal}
  439. generateAccessToken={generateAccessToken}
  440. handleSystemTokenClick={handleSystemTokenClick}
  441. setShowChangePasswordModal={setShowChangePasswordModal}
  442. setShowAccountDeleteModal={setShowAccountDeleteModal}
  443. passkeyStatus={passkeyStatus}
  444. passkeySupported={passkeySupported}
  445. passkeyRegisterLoading={passkeyRegisterLoading}
  446. passkeyDeleteLoading={passkeyDeleteLoading}
  447. onPasskeyRegister={handleRegisterPasskey}
  448. onPasskeyDelete={handleRemovePasskey}
  449. />
  450. {/* 偏好设置(语言等) */}
  451. <PreferencesSettings t={t} />
  452. </div>
  453. {/* 右侧:其他设置 */}
  454. <NotificationSettings
  455. t={t}
  456. notificationSettings={notificationSettings}
  457. handleNotificationSettingChange={handleNotificationSettingChange}
  458. saveNotificationSettings={saveNotificationSettings}
  459. />
  460. </div>
  461. </div>
  462. </div>
  463. {/* 模态框组件 */}
  464. <EmailBindModal
  465. t={t}
  466. showEmailBindModal={showEmailBindModal}
  467. setShowEmailBindModal={setShowEmailBindModal}
  468. inputs={inputs}
  469. handleInputChange={handleInputChange}
  470. sendVerificationCode={sendVerificationCode}
  471. bindEmail={bindEmail}
  472. disableButton={disableButton}
  473. loading={loading}
  474. countdown={countdown}
  475. turnstileEnabled={turnstileEnabled}
  476. turnstileSiteKey={turnstileSiteKey}
  477. setTurnstileToken={setTurnstileToken}
  478. />
  479. <WeChatBindModal
  480. t={t}
  481. showWeChatBindModal={showWeChatBindModal}
  482. setShowWeChatBindModal={setShowWeChatBindModal}
  483. inputs={inputs}
  484. handleInputChange={handleInputChange}
  485. bindWeChat={bindWeChat}
  486. status={status}
  487. />
  488. <AccountDeleteModal
  489. t={t}
  490. showAccountDeleteModal={showAccountDeleteModal}
  491. setShowAccountDeleteModal={setShowAccountDeleteModal}
  492. inputs={inputs}
  493. handleInputChange={handleInputChange}
  494. deleteAccount={deleteAccount}
  495. userState={userState}
  496. turnstileEnabled={turnstileEnabled}
  497. turnstileSiteKey={turnstileSiteKey}
  498. setTurnstileToken={setTurnstileToken}
  499. />
  500. <ChangePasswordModal
  501. t={t}
  502. showChangePasswordModal={showChangePasswordModal}
  503. setShowChangePasswordModal={setShowChangePasswordModal}
  504. inputs={inputs}
  505. handleInputChange={handleInputChange}
  506. changePassword={changePassword}
  507. turnstileEnabled={turnstileEnabled}
  508. turnstileSiteKey={turnstileSiteKey}
  509. setTurnstileToken={setTurnstileToken}
  510. />
  511. </div>
  512. );
  513. };
  514. export default PersonalSetting;