| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| |
|
| | 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 CheckinCalendar from './personal/cards/CheckinCalendar';
|
| | 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('');
|
| | }
|
| | }
|
| |
|
| | (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) {
|
| |
|
| | }
|
| | })();
|
| |
|
| | 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);
|
| | }, [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.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 {
|
| |
|
| | 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,
|
| | }));
|
| | };
|
| |
|
| | 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} />
|
| |
|
| | {/* 签到日历 - 仅在启用时显示 */}
|
| | {status?.checkin_enabled && (
|
| | <div className='mt-4 md:mt-6'>
|
| | <CheckinCalendar
|
| | t={t}
|
| | status={status}
|
| | turnstileEnabled={turnstileEnabled}
|
| | turnstileSiteKey={turnstileSiteKey}
|
| | />
|
| | </div>
|
| | )}
|
| |
|
| | {/* 账户管理和其他设置 */}
|
| | <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;
|
| |
|