| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535 |
- /*
- Copyright (C) 2025 QuantumNous
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <https://www.gnu.org/licenses/>.
- For commercial licensing, please contact support@quantumnous.com
- */
- import React, { useContext, useEffect, useState } from 'react';
- import { useNavigate } from 'react-router-dom';
- import {
- API,
- copy,
- showError,
- showInfo,
- showSuccess,
- setStatusData,
- prepareCredentialCreationOptions,
- buildRegistrationResult,
- isPasskeySupported,
- setUserData,
- } from '../../helpers';
- import { UserContext } from '../../context/User';
- import { Modal } from '@douyinfe/semi-ui';
- import { useTranslation } from 'react-i18next';
- // 导入子组件
- import UserInfoHeader from './personal/components/UserInfoHeader';
- import AccountManagement from './personal/cards/AccountManagement';
- import NotificationSettings from './personal/cards/NotificationSettings';
- import EmailBindModal from './personal/modals/EmailBindModal';
- import WeChatBindModal from './personal/modals/WeChatBindModal';
- import AccountDeleteModal from './personal/modals/AccountDeleteModal';
- import ChangePasswordModal from './personal/modals/ChangePasswordModal';
- const PersonalSetting = () => {
- const [userState, userDispatch] = useContext(UserContext);
- let navigate = useNavigate();
- const { t } = useTranslation();
- const [inputs, setInputs] = useState({
- wechat_verification_code: '',
- email_verification_code: '',
- email: '',
- self_account_deletion_confirmation: '',
- original_password: '',
- set_new_password: '',
- set_new_password_confirmation: '',
- });
- const [status, setStatus] = useState({});
- const [showChangePasswordModal, setShowChangePasswordModal] = useState(false);
- const [showWeChatBindModal, setShowWeChatBindModal] = useState(false);
- const [showEmailBindModal, setShowEmailBindModal] = useState(false);
- const [showAccountDeleteModal, setShowAccountDeleteModal] = useState(false);
- const [turnstileEnabled, setTurnstileEnabled] = useState(false);
- const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
- const [turnstileToken, setTurnstileToken] = useState('');
- const [loading, setLoading] = useState(false);
- const [disableButton, setDisableButton] = useState(false);
- const [countdown, setCountdown] = useState(30);
- const [systemToken, setSystemToken] = useState('');
- const [passkeyStatus, setPasskeyStatus] = useState({ enabled: false });
- const [passkeyRegisterLoading, setPasskeyRegisterLoading] = useState(false);
- const [passkeyDeleteLoading, setPasskeyDeleteLoading] = useState(false);
- const [passkeySupported, setPasskeySupported] = useState(false);
- const [notificationSettings, setNotificationSettings] = useState({
- warningType: 'email',
- warningThreshold: 100000,
- webhookUrl: '',
- webhookSecret: '',
- notificationEmail: '',
- barkUrl: '',
- gotifyUrl: '',
- gotifyToken: '',
- gotifyPriority: 5,
- acceptUnsetModelRatioModel: false,
- recordIpLog: false,
- });
- useEffect(() => {
- let saved = localStorage.getItem('status');
- if (saved) {
- const parsed = JSON.parse(saved);
- setStatus(parsed);
- if (parsed.turnstile_check) {
- setTurnstileEnabled(true);
- setTurnstileSiteKey(parsed.turnstile_site_key);
- } else {
- setTurnstileEnabled(false);
- setTurnstileSiteKey('');
- }
- }
- // Always refresh status from server to avoid stale flags (e.g., admin just enabled OAuth)
- (async () => {
- try {
- const res = await API.get('/api/status');
- const { success, data } = res.data;
- if (success && data) {
- setStatus(data);
- setStatusData(data);
- if (data.turnstile_check) {
- setTurnstileEnabled(true);
- setTurnstileSiteKey(data.turnstile_site_key);
- } else {
- setTurnstileEnabled(false);
- setTurnstileSiteKey('');
- }
- }
- } catch (e) {
- // ignore and keep local status
- }
- })();
- getUserData();
- isPasskeySupported()
- .then(setPasskeySupported)
- .catch(() => setPasskeySupported(false));
- }, []);
- useEffect(() => {
- let countdownInterval = null;
- if (disableButton && countdown > 0) {
- countdownInterval = setInterval(() => {
- setCountdown(countdown - 1);
- }, 1000);
- } else if (countdown === 0) {
- setDisableButton(false);
- setCountdown(30);
- }
- return () => clearInterval(countdownInterval); // Clean up on unmount
- }, [disableButton, countdown]);
- useEffect(() => {
- if (userState?.user?.setting) {
- const settings = JSON.parse(userState.user.setting);
- setNotificationSettings({
- warningType: settings.notify_type || 'email',
- warningThreshold: settings.quota_warning_threshold || 500000,
- webhookUrl: settings.webhook_url || '',
- webhookSecret: settings.webhook_secret || '',
- notificationEmail: settings.notification_email || '',
- barkUrl: settings.bark_url || '',
- gotifyUrl: settings.gotify_url || '',
- gotifyToken: settings.gotify_token || '',
- gotifyPriority:
- settings.gotify_priority !== undefined
- ? settings.gotify_priority
- : 5,
- acceptUnsetModelRatioModel:
- settings.accept_unset_model_ratio_model || false,
- recordIpLog: settings.record_ip_log || false,
- });
- }
- }, [userState?.user?.setting]);
- const handleInputChange = (name, value) => {
- setInputs((inputs) => ({ ...inputs, [name]: value }));
- };
- const generateAccessToken = async () => {
- const res = await API.get('/api/user/token');
- const { success, message, data } = res.data;
- if (success) {
- setSystemToken(data);
- await copy(data);
- showSuccess(t('令牌已重置并已复制到剪贴板'));
- } else {
- showError(message);
- }
- };
- const loadPasskeyStatus = async () => {
- try {
- const res = await API.get('/api/user/passkey');
- const { success, data, message } = res.data;
- if (success) {
- setPasskeyStatus({
- enabled: data?.enabled || false,
- last_used_at: data?.last_used_at || null,
- backup_eligible: data?.backup_eligible || false,
- backup_state: data?.backup_state || false,
- });
- } else {
- showError(message);
- }
- } catch (error) {
- // 忽略错误,保留默认状态
- }
- };
- const handleRegisterPasskey = async () => {
- if (!passkeySupported || !window.PublicKeyCredential) {
- showInfo(t('当前设备不支持 Passkey'));
- return;
- }
- setPasskeyRegisterLoading(true);
- try {
- const beginRes = await API.post('/api/user/passkey/register/begin');
- const { success, message, data } = beginRes.data;
- if (!success) {
- showError(message || t('无法发起 Passkey 注册'));
- return;
- }
- const publicKey = prepareCredentialCreationOptions(data?.options || data?.publicKey || data);
- const credential = await navigator.credentials.create({ publicKey });
- const payload = buildRegistrationResult(credential);
- if (!payload) {
- showError(t('Passkey 注册失败,请重试'));
- return;
- }
- const finishRes = await API.post('/api/user/passkey/register/finish', payload);
- if (finishRes.data.success) {
- showSuccess(t('Passkey 注册成功'));
- await loadPasskeyStatus();
- } else {
- showError(finishRes.data.message || t('Passkey 注册失败,请重试'));
- }
- } catch (error) {
- if (error?.name === 'AbortError') {
- showInfo(t('已取消 Passkey 注册'));
- } else {
- showError(t('Passkey 注册失败,请重试'));
- }
- } finally {
- setPasskeyRegisterLoading(false);
- }
- };
- const handleRemovePasskey = async () => {
- setPasskeyDeleteLoading(true);
- try {
- const res = await API.delete('/api/user/passkey');
- const { success, message } = res.data;
- if (success) {
- showSuccess(t('Passkey 已解绑'));
- await loadPasskeyStatus();
- } else {
- showError(message || t('操作失败,请重试'));
- }
- } catch (error) {
- showError(t('操作失败,请重试'));
- } finally {
- setPasskeyDeleteLoading(false);
- }
- };
- const getUserData = async () => {
- let res = await API.get(`/api/user/self`);
- const { success, message, data } = res.data;
- if (success) {
- userDispatch({ type: 'login', payload: data });
- setUserData(data);
- await loadPasskeyStatus();
- } else {
- showError(message);
- }
- };
- const handleSystemTokenClick = async (e) => {
- e.target.select();
- await copy(e.target.value);
- showSuccess(t('系统令牌已复制到剪切板'));
- };
- const deleteAccount = async () => {
- if (inputs.self_account_deletion_confirmation !== userState.user.username) {
- showError(t('请输入你的账户名以确认删除!'));
- return;
- }
- const res = await API.delete('/api/user/self');
- const { success, message } = res.data;
- if (success) {
- showSuccess(t('账户已删除!'));
- await API.get('/api/user/logout');
- userDispatch({ type: 'logout' });
- localStorage.removeItem('user');
- navigate('/login');
- } else {
- showError(message);
- }
- };
- const bindWeChat = async () => {
- if (inputs.wechat_verification_code === '') return;
- const res = await API.get(
- `/api/oauth/wechat/bind?code=${inputs.wechat_verification_code}`,
- );
- const { success, message } = res.data;
- if (success) {
- showSuccess(t('微信账户绑定成功!'));
- setShowWeChatBindModal(false);
- } else {
- showError(message);
- }
- };
- const changePassword = async () => {
- if (inputs.original_password === '') {
- showError(t('请输入原密码!'));
- return;
- }
- if (inputs.set_new_password === '') {
- showError(t('请输入新密码!'));
- return;
- }
- if (inputs.original_password === inputs.set_new_password) {
- showError(t('新密码需要和原密码不一致!'));
- return;
- }
- if (inputs.set_new_password !== inputs.set_new_password_confirmation) {
- showError(t('两次输入的密码不一致!'));
- return;
- }
- const res = await API.put(`/api/user/self`, {
- original_password: inputs.original_password,
- password: inputs.set_new_password,
- });
- const { success, message } = res.data;
- if (success) {
- showSuccess(t('密码修改成功!'));
- setShowWeChatBindModal(false);
- } else {
- showError(message);
- }
- setShowChangePasswordModal(false);
- };
- const sendVerificationCode = async () => {
- if (inputs.email === '') {
- showError(t('请输入邮箱!'));
- return;
- }
- setDisableButton(true);
- if (turnstileEnabled && turnstileToken === '') {
- showInfo(t('请稍后几秒重试,Turnstile 正在检查用户环境!'));
- return;
- }
- setLoading(true);
- const res = await API.get(
- `/api/verification?email=${inputs.email}&turnstile=${turnstileToken}`,
- );
- const { success, message } = res.data;
- if (success) {
- showSuccess(t('验证码发送成功,请检查邮箱!'));
- } else {
- showError(message);
- }
- setLoading(false);
- };
- const bindEmail = async () => {
- if (inputs.email_verification_code === '') {
- showError(t('请输入邮箱验证码!'));
- return;
- }
- setLoading(true);
- const res = await API.get(
- `/api/oauth/email/bind?email=${inputs.email}&code=${inputs.email_verification_code}`,
- );
- const { success, message } = res.data;
- if (success) {
- showSuccess(t('邮箱账户绑定成功!'));
- setShowEmailBindModal(false);
- userState.user.email = inputs.email;
- } else {
- showError(message);
- }
- setLoading(false);
- };
- const copyText = async (text) => {
- if (await copy(text)) {
- showSuccess(t('已复制:') + text);
- } else {
- // setSearchKeyword(text);
- Modal.error({ title: t('无法复制到剪贴板,请手动复制'), content: text });
- }
- };
- const handleNotificationSettingChange = (type, value) => {
- setNotificationSettings((prev) => ({
- ...prev,
- [type]: value.target
- ? value.target.value !== undefined
- ? value.target.value
- : value.target.checked
- : value, // handle checkbox properly
- }));
- };
- const saveNotificationSettings = async () => {
- try {
- const res = await API.put('/api/user/setting', {
- notify_type: notificationSettings.warningType,
- quota_warning_threshold: parseFloat(
- notificationSettings.warningThreshold,
- ),
- webhook_url: notificationSettings.webhookUrl,
- webhook_secret: notificationSettings.webhookSecret,
- notification_email: notificationSettings.notificationEmail,
- bark_url: notificationSettings.barkUrl,
- gotify_url: notificationSettings.gotifyUrl,
- gotify_token: notificationSettings.gotifyToken,
- gotify_priority: (() => {
- const parsed = parseInt(notificationSettings.gotifyPriority);
- return isNaN(parsed) ? 5 : parsed;
- })(),
- accept_unset_model_ratio_model:
- notificationSettings.acceptUnsetModelRatioModel,
- record_ip_log: notificationSettings.recordIpLog,
- });
- if (res.data.success) {
- showSuccess(t('设置保存成功'));
- await getUserData();
- } else {
- showError(res.data.message);
- }
- } catch (error) {
- showError(t('设置保存失败'));
- }
- };
- return (
- <div className='mt-[60px]'>
- <div className='flex justify-center'>
- <div className='w-full max-w-7xl mx-auto px-2'>
- {/* 顶部用户信息区域 */}
- <UserInfoHeader t={t} userState={userState} />
- {/* 账户管理和其他设置 */}
- <div className='grid grid-cols-1 xl:grid-cols-2 items-start gap-4 md:gap-6 mt-4 md:mt-6'>
- {/* 左侧:账户管理设置 */}
- <AccountManagement
- t={t}
- userState={userState}
- status={status}
- systemToken={systemToken}
- setShowEmailBindModal={setShowEmailBindModal}
- setShowWeChatBindModal={setShowWeChatBindModal}
- generateAccessToken={generateAccessToken}
- handleSystemTokenClick={handleSystemTokenClick}
- setShowChangePasswordModal={setShowChangePasswordModal}
- setShowAccountDeleteModal={setShowAccountDeleteModal}
- passkeyStatus={passkeyStatus}
- passkeySupported={passkeySupported}
- passkeyRegisterLoading={passkeyRegisterLoading}
- passkeyDeleteLoading={passkeyDeleteLoading}
- onPasskeyRegister={handleRegisterPasskey}
- onPasskeyDelete={handleRemovePasskey}
- />
- {/* 右侧:其他设置 */}
- <NotificationSettings
- t={t}
- notificationSettings={notificationSettings}
- handleNotificationSettingChange={handleNotificationSettingChange}
- saveNotificationSettings={saveNotificationSettings}
- />
- </div>
- </div>
- </div>
- {/* 模态框组件 */}
- <EmailBindModal
- t={t}
- showEmailBindModal={showEmailBindModal}
- setShowEmailBindModal={setShowEmailBindModal}
- inputs={inputs}
- handleInputChange={handleInputChange}
- sendVerificationCode={sendVerificationCode}
- bindEmail={bindEmail}
- disableButton={disableButton}
- loading={loading}
- countdown={countdown}
- turnstileEnabled={turnstileEnabled}
- turnstileSiteKey={turnstileSiteKey}
- setTurnstileToken={setTurnstileToken}
- />
- <WeChatBindModal
- t={t}
- showWeChatBindModal={showWeChatBindModal}
- setShowWeChatBindModal={setShowWeChatBindModal}
- inputs={inputs}
- handleInputChange={handleInputChange}
- bindWeChat={bindWeChat}
- status={status}
- />
- <AccountDeleteModal
- t={t}
- showAccountDeleteModal={showAccountDeleteModal}
- setShowAccountDeleteModal={setShowAccountDeleteModal}
- inputs={inputs}
- handleInputChange={handleInputChange}
- deleteAccount={deleteAccount}
- userState={userState}
- turnstileEnabled={turnstileEnabled}
- turnstileSiteKey={turnstileSiteKey}
- setTurnstileToken={setTurnstileToken}
- />
- <ChangePasswordModal
- t={t}
- showChangePasswordModal={showChangePasswordModal}
- setShowChangePasswordModal={setShowChangePasswordModal}
- inputs={inputs}
- handleInputChange={handleInputChange}
- changePassword={changePassword}
- turnstileEnabled={turnstileEnabled}
- turnstileSiteKey={turnstileSiteKey}
- setTurnstileToken={setTurnstileToken}
- />
- </div>
- );
- };
- export default PersonalSetting;
|