PersonalSetting.jsx 17 KB

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