Download web/src/components/PersonalSetting.js from Jack698/velo: direct link, hf CLI and curl.
- Browser
- Download file 49.1 kB
-
https://huggingface.co/spaces/Jack698/velo/resolve/main/web/src/components/PersonalSetting.js
- Command line
-
hf download hf://spaces/Jack698/velo/web/src/components/PersonalSetting.js
-
curl -L -o PersonalSetting.js https://huggingface.co/spaces/Jack698/velo/resolve/main/web/src/components/PersonalSetting.js
49.1 kB
| /* | |
| Copyright (c) 2025 Tethys Plex | |
| This file is part of Veloera. | |
| This program is free software: you can redistribute it and/or modify | |
| it under the terms of the GNU 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 General Public License for more details. | |
| You should have received a copy of the GNU General Public License | |
| along with this program. If not, see <https://www.gnu.org/licenses/>. | |
| */ | |
| import React, { useContext, useEffect, useState } from 'react'; | |
| import { useNavigate } from 'react-router-dom'; | |
| import { | |
| API, | |
| copy, | |
| isRoot, | |
| showError, | |
| showInfo, | |
| showSuccess, | |
| } from '../helpers'; | |
| import Turnstile from 'react-turnstile'; | |
| import { UserContext } from '../context/User'; | |
| import { | |
| onGitHubOAuthClicked, | |
| onOIDCClicked, | |
| onLinuxDOOAuthClicked, | |
| onIDCFlareOAuthClicked, | |
| } from './utils'; | |
| import { | |
| Avatar, | |
| Banner, | |
| Button, | |
| Card, | |
| Descriptions, | |
| Image, | |
| Input, | |
| InputNumber, | |
| Layout, | |
| Modal, | |
| Space, | |
| Tag, | |
| Typography, | |
| Collapsible, | |
| Select, | |
| Radio, | |
| RadioGroup, | |
| AutoComplete, | |
| Checkbox, | |
| Tabs, | |
| TabPane, | |
| Switch, | |
| } from '@douyinfe/semi-ui'; | |
| import { | |
| getQuotaPerUnit, | |
| renderQuota, | |
| renderQuotaWithPrompt, | |
| stringToColor, | |
| } from '../helpers/render'; | |
| import TelegramLoginButton from 'react-telegram-login'; | |
| import { useTranslation } from 'react-i18next'; | |
| 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: '', | |
| 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 [affLink, setAffLink] = useState(''); | |
| const [systemToken, setSystemToken] = useState(''); | |
| const [affEnabled, setAffEnabled] = useState(true); | |
| const [models, setModels] = useState([]); | |
| const [openTransfer, setOpenTransfer] = useState(false); | |
| const [transferAmount, setTransferAmount] = useState(0); | |
| const [checkInEnabled, setCheckInEnabled] = useState(false); | |
| const [checkInLoading, setCheckInLoading] = useState(false); | |
| const [canCheckIn, setCanCheckIn] = useState(true); | |
| const [isModelsExpanded, setIsModelsExpanded] = useState(() => { | |
| // Initialize from localStorage if available | |
| const savedState = localStorage.getItem('modelsExpanded'); | |
| return savedState ? JSON.parse(savedState) : false; | |
| }); | |
| const MODELS_DISPLAY_COUNT = 10; // 默认显示的模型数量 | |
| const [notificationSettings, setNotificationSettings] = useState({ | |
| warningType: 'email', | |
| warningThreshold: 100000, | |
| webhookUrl: '', | |
| webhookSecret: '', | |
| notificationEmail: '', | |
| acceptUnsetModelRatioModel: false, | |
| showIPInLogs: false, | |
| }); | |
| const [showWebhookDocs, setShowWebhookDocs] = useState(false); | |
| useEffect(() => { | |
| let status = localStorage.getItem('status'); | |
| if (status) { | |
| status = JSON.parse(status); | |
| setStatus(status); | |
| if (status.turnstile_check) { | |
| setTurnstileEnabled(true); | |
| setTurnstileSiteKey(status.turnstile_site_key); | |
| } | |
| setCheckInEnabled(status.check_in_enabled === true); | |
| setAffEnabled(status.aff_enabled === true); | |
| } | |
| getUserData().then((res) => { | |
| console.log(userState); | |
| }); | |
| loadModels().then(); | |
| if (status && status.aff_enabled === true) { | |
| getAffLink().then(); | |
| } | |
| checkUserCanCheckIn().then(); | |
| setTransferAmount(getQuotaPerUnit()); | |
| }, []); | |
| // 监听checkInEnabled状态变化,确保状态同步 | |
| useEffect(() => { | |
| if (checkInEnabled) { | |
| checkUserCanCheckIn(); | |
| } | |
| }, [checkInEnabled]); | |
| 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 || '', | |
| acceptUnsetModelRatioModel: | |
| settings.accept_unset_model_ratio_model || false, | |
| showIPInLogs: settings.show_ip_in_logs || false, | |
| }); | |
| } | |
| }, [userState?.user?.setting]); | |
| // Save models expanded state to localStorage whenever it changes | |
| useEffect(() => { | |
| localStorage.setItem('modelsExpanded', JSON.stringify(isModelsExpanded)); | |
| }, [isModelsExpanded]); | |
| 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 getAffLink = async () => { | |
| const res = await API.get('/api/user/aff'); | |
| const { success, message, data } = res.data; | |
| if (success) { | |
| let link = `${window.location.origin}/register?aff=${data}`; | |
| setAffLink(link); | |
| } else { | |
| showError(message); | |
| } | |
| }; | |
| const getUserData = async () => { | |
| let res = await API.get(`/api/user/self`); | |
| const { success, message, data } = res.data; | |
| if (success) { | |
| userDispatch({ type: 'login', payload: data }); | |
| } else { | |
| showError(message); | |
| } | |
| }; | |
| const loadModels = async () => { | |
| let res = await API.get(`/api/user/models`); | |
| const { success, message, data } = res.data; | |
| if (success) { | |
| if (data != null) { | |
| setModels(data); | |
| } | |
| } else { | |
| showError(message); | |
| } | |
| }; | |
| const handleAffLinkClick = async (e) => { | |
| e.target.select(); | |
| await copy(e.target.value); | |
| showSuccess(t('邀请链接已复制到剪切板')); | |
| }; | |
| const handleSystemTokenClick = async (e) => { | |
| e.target.select(); | |
| await copy(e.target.value); | |
| showSuccess(t('系统令牌已复制到剪切板')); | |
| }; | |
| const checkUserCanCheckIn = async () => { | |
| if (!checkInEnabled) return; | |
| try { | |
| const res = await API.get('/api/user/check_in_status'); | |
| const { success, message, data } = res.data; | |
| if (success) { | |
| setCanCheckIn(data.can_check_in); | |
| } else { | |
| showError(message); | |
| } | |
| } catch (error) { | |
| console.error('Failed to check user check-in status:', error); | |
| } | |
| }; | |
| const handleCheckIn = async () => { | |
| if (!checkInEnabled || !canCheckIn) return; | |
| if (turnstileEnabled && turnstileToken === '') { | |
| showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!'); | |
| return; | |
| } | |
| try { | |
| setCheckInLoading(true); | |
| const url = turnstileEnabled | |
| ? `/api/user/check_in?turnstile=${turnstileToken}` | |
| : '/api/user/check_in'; | |
| const res = await API.post(url); | |
| const { success, message, data } = res.data; | |
| if (success) { | |
| const quotaPerUnit = status.quota_per_unit || 500000; | |
| const dollarAmount = (data.quota / quotaPerUnit).toFixed(2); | |
| showSuccess(t('签到成功!获得 ') + `$${dollarAmount}`); | |
| setCanCheckIn(false); | |
| getUserData(); // Refresh user data to get updated quota | |
| } else { | |
| showError(message); | |
| } | |
| } catch (error) { | |
| showError(t('签到失败,请稍后再试')); | |
| console.error('Check-in failed:', error); | |
| } finally { | |
| setCheckInLoading(false); | |
| } | |
| }; | |
| 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 !== inputs.set_new_password_confirmation) { | |
| showError(t('两次输入的密码不一致!')); | |
| return; | |
| } | |
| const res = await API.put(`/api/user/self`, { | |
| password: inputs.set_new_password, | |
| }); | |
| const { success, message } = res.data; | |
| if (success) { | |
| showSuccess(t('密码修改成功!')); | |
| setShowWeChatBindModal(false); | |
| } else { | |
| showError(message); | |
| } | |
| setShowChangePasswordModal(false); | |
| }; | |
| const transfer = async () => { | |
| if (transferAmount < getQuotaPerUnit()) { | |
| showError(t('划转金额最低为') + ' ' + renderQuota(getQuotaPerUnit())); | |
| return; | |
| } | |
| const res = await API.post(`/api/user/aff_transfer`, { | |
| quota: transferAmount, | |
| }); | |
| const { success, message } = res.data; | |
| if (success) { | |
| showSuccess(message); | |
| setOpenTransfer(false); | |
| getUserData().then(); | |
| } else { | |
| showError(message); | |
| } | |
| }; | |
| const sendVerificationCode = async () => { | |
| if (inputs.email === '') { | |
| showError(t('请输入邮箱!')); | |
| return; | |
| } | |
| setDisableButton(true); | |
| if (turnstileEnabled && turnstileToken === '') { | |
| showInfo('请稍后几秒重试,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 getDisplayName = () => { | |
| if (userState.user) { | |
| return userState.user.display_name || userState.user.username; | |
| } else { | |
| return 'null'; | |
| } | |
| }; | |
| const handleCancel = () => { | |
| setOpenTransfer(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 : value, // 处理 Radio 事件对象 | |
| })); | |
| }; | |
| 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, | |
| accept_unset_model_ratio_model: | |
| notificationSettings.acceptUnsetModelRatioModel, | |
| show_ip_in_logs: notificationSettings.showIPInLogs, | |
| }); | |
| if (res.data.success) { | |
| showSuccess(t('设置已更新')); | |
| await getUserData(); | |
| } else { | |
| showError(res.data.message); | |
| } | |
| } catch (error) { | |
| showError(t('更新设置失败')); | |
| } | |
| }; | |
| return ( | |
| <div> | |
| <Layout> | |
| <Layout.Content> | |
| {affEnabled && ( | |
| <Modal | |
| title={t('请输入要划转的数量')} | |
| visible={openTransfer} | |
| onOk={transfer} | |
| onCancel={handleCancel} | |
| maskClosable={false} | |
| size={'small'} | |
| centered={true} | |
| > | |
| <div style={{ marginTop: 20 }}> | |
| <Typography.Text> | |
| {t('可用额度')} | |
| {renderQuotaWithPrompt(userState?.user?.aff_quota)} | |
| </Typography.Text> | |
| <Input | |
| style={{ marginTop: 5 }} | |
| value={userState?.user?.aff_quota} | |
| disabled={true} | |
| ></Input> | |
| </div> | |
| <div style={{ marginTop: 20 }}> | |
| <Typography.Text> | |
| {t('划转额度')} | |
| {renderQuotaWithPrompt(transferAmount)}{' '} | |
| {t('最低') + renderQuota(getQuotaPerUnit())} | |
| </Typography.Text> | |
| <div> | |
| <InputNumber | |
| min={0} | |
| style={{ marginTop: 5 }} | |
| value={transferAmount} | |
| onChange={(value) => setTransferAmount(value)} | |
| disabled={false} | |
| ></InputNumber> | |
| </div> | |
| </div> | |
| </Modal> | |
| )} | |
| <div> | |
| <Card | |
| title={ | |
| <Card.Meta | |
| avatar={ | |
| <Avatar | |
| size='default' | |
| color={stringToColor(getDisplayName())} | |
| style={{ marginRight: 4 }} | |
| > | |
| {typeof getDisplayName() === 'string' && | |
| getDisplayName().slice(0, 1)} | |
| </Avatar> | |
| } | |
| title={<Typography.Text>{getDisplayName()}</Typography.Text>} | |
| description={ | |
| isRoot() ? ( | |
| <Tag color='red'>{t('管理员')}</Tag> | |
| ) : ( | |
| <Tag color='blue'>{t('普通用户')}</Tag> | |
| ) | |
| } | |
| ></Card.Meta> | |
| } | |
| headerExtraContent={ | |
| <> | |
| <Space vertical align='start'> | |
| <Tag color='green'>{'ID: ' + userState?.user?.id}</Tag> | |
| <Tag color='blue'>{userState?.user?.group}</Tag> | |
| </Space> | |
| </> | |
| } | |
| footer={ | |
| <> | |
| <div | |
| style={{ display: 'flex', alignItems: 'center', gap: 8 }} | |
| > | |
| <Typography.Title heading={6}> | |
| {t('可用模型')} | |
| </Typography.Title> | |
| </div> | |
| <div style={{ marginTop: 10 }}> | |
| {models.length <= MODELS_DISPLAY_COUNT ? ( | |
| <Space wrap> | |
| {models.map((model) => ( | |
| <Tag | |
| key={model} | |
| color='cyan' | |
| onClick={() => { | |
| copyText(model); | |
| }} | |
| > | |
| {model} | |
| </Tag> | |
| ))} | |
| </Space> | |
| ) : ( | |
| <> | |
| <Collapsible isOpen={isModelsExpanded}> | |
| <Space wrap> | |
| {models.map((model) => ( | |
| <Tag | |
| key={model} | |
| color='cyan' | |
| onClick={() => { | |
| copyText(model); | |
| }} | |
| > | |
| {model} | |
| </Tag> | |
| ))} | |
| <Tag | |
| color='blue' | |
| type='light' | |
| style={{ cursor: 'pointer' }} | |
| onClick={() => setIsModelsExpanded(false)} | |
| > | |
| {t('收起')} | |
| </Tag> | |
| </Space> | |
| </Collapsible> | |
| {!isModelsExpanded && ( | |
| <Space wrap> | |
| {models | |
| .slice(0, MODELS_DISPLAY_COUNT) | |
| .map((model) => ( | |
| <Tag | |
| key={model} | |
| color='cyan' | |
| onClick={() => { | |
| copyText(model); | |
| }} | |
| > | |
| {model} | |
| </Tag> | |
| ))} | |
| <Tag | |
| color='blue' | |
| type='light' | |
| style={{ cursor: 'pointer' }} | |
| onClick={() => setIsModelsExpanded(true)} | |
| > | |
| {t('更多')} {models.length - MODELS_DISPLAY_COUNT}{' '} | |
| {t('个模型')} | |
| </Tag> | |
| </Space> | |
| )} | |
| </> | |
| )} | |
| </div> | |
| </> | |
| } | |
| > | |
| <Descriptions row> | |
| <Descriptions.Item itemKey={t('当前余额')}> | |
| {renderQuota(userState?.user?.quota)} | |
| </Descriptions.Item> | |
| <Descriptions.Item itemKey={t('历史消耗')}> | |
| {renderQuota(userState?.user?.used_quota)} | |
| </Descriptions.Item> | |
| <Descriptions.Item itemKey={t('请求次数')}> | |
| {userState.user?.request_count} | |
| </Descriptions.Item> | |
| </Descriptions> | |
| </Card> | |
| {checkInEnabled && ( | |
| <Card style={{ marginTop: 10 }}> | |
| <Typography.Title heading={6}>{t('每日签到')}</Typography.Title> | |
| <div style={{ marginTop: 20 }}> | |
| <Typography.Text>{t('签到可获得额外的 Token 奖励。每天只能签到一次,请按时签到哦!')}</Typography.Text> | |
| <div style={{ marginTop: 15 }}> | |
| <Button | |
| type="primary" | |
| theme="solid" | |
| size="large" | |
| onClick={handleCheckIn} | |
| loading={checkInLoading} | |
| disabled={!canCheckIn} | |
| > | |
| {canCheckIn ? t('立即签到') : t('今日已签到')} | |
| </Button> | |
| {!canCheckIn && ( | |
| <Typography.Text style={{ marginLeft: 15, color: 'var(--semi-color-success)' }}> | |
| {t('已完成今日签到,明天再来哦!')} | |
| </Typography.Text> | |
| )} | |
| </div> | |
| {turnstileEnabled && canCheckIn && ( | |
| <div style={{ marginTop: 15 }}> | |
| <Turnstile | |
| sitekey={turnstileSiteKey} | |
| onVerify={(token) => { | |
| setTurnstileToken(token); | |
| }} | |
| onExpire={() => { | |
| setTurnstileToken(''); | |
| }} | |
| /> | |
| </div> | |
| )} | |
| </div> | |
| </Card> | |
| )} | |
| {affEnabled && ( | |
| <Card | |
| style={{ marginTop: 10 }} | |
| footer={ | |
| <div> | |
| <Typography.Text>{t('邀请链接')}</Typography.Text> | |
| <Input | |
| style={{ marginTop: 10 }} | |
| value={affLink} | |
| onClick={handleAffLinkClick} | |
| readOnly | |
| /> | |
| </div> | |
| } | |
| > | |
| <Typography.Title heading={6}>{t('邀请信息')}</Typography.Title> | |
| <div style={{ marginTop: 10 }}> | |
| <Descriptions row> | |
| <Descriptions.Item itemKey={t('待使用收益')}> | |
| <span style={{ color: 'rgba(var(--semi-red-5), 1)' }}> | |
| {renderQuota(userState?.user?.aff_quota)} | |
| </span> | |
| <Button | |
| type={'secondary'} | |
| onClick={() => setOpenTransfer(true)} | |
| size={'small'} | |
| style={{ marginLeft: 10 }} | |
| > | |
| {t('划转')} | |
| </Button> | |
| </Descriptions.Item> | |
| <Descriptions.Item itemKey={t('总收益')}> | |
| {renderQuota(userState?.user?.aff_history_quota)} | |
| </Descriptions.Item> | |
| <Descriptions.Item itemKey={t('邀请人数')}> | |
| {userState?.user?.aff_count} | |
| </Descriptions.Item> | |
| </Descriptions> | |
| </div> | |
| </Card> | |
| )} | |
| <Card style={{ marginTop: 10 }}> | |
| <Typography.Title heading={6}>{t('个人信息')}</Typography.Title> | |
| <div style={{ marginTop: 20 }}> | |
| <Typography.Text strong>{t('邮箱')}</Typography.Text> | |
| <div | |
| style={{ display: 'flex', justifyContent: 'space-between' }} | |
| > | |
| <div> | |
| <Input | |
| value={ | |
| userState.user && userState.user.email !== '' | |
| ? userState.user.email | |
| : t('未绑定') | |
| } | |
| readonly={true} | |
| ></Input> | |
| </div> | |
| <div> | |
| <Button | |
| onClick={() => { | |
| setShowEmailBindModal(true); | |
| }} | |
| > | |
| {userState.user && userState.user.email !== '' | |
| ? t('修改绑定') | |
| : t('绑定邮箱')} | |
| </Button> | |
| </div> | |
| </div> | |
| </div> | |
| {status.wechat_login && ( | |
| <div style={{ marginTop: 10 }}> | |
| <Typography.Text strong>{t('微信')}</Typography.Text> | |
| <div | |
| style={{ display: 'flex', justifyContent: 'space-between' }} | |
| > | |
| <div> | |
| <Input | |
| value={ | |
| userState.user && userState.user.wechat_id !== '' | |
| ? t('已绑定') | |
| : t('未绑定') | |
| } | |
| readonly={true} | |
| ></Input> | |
| </div> | |
| <div> | |
| <Button | |
| onClick={() => { | |
| setShowWeChatBindModal(true); | |
| }} | |
| > | |
| {userState.user && userState.user.wechat_id !== '' | |
| ? t('修改绑定') | |
| : t('绑定')} | |
| </Button> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {status.github_oauth && ( | |
| <div style={{ marginTop: 10 }}> | |
| <Typography.Text strong>{t('GitHub')}</Typography.Text> | |
| <div | |
| style={{ display: 'flex', justifyContent: 'space-between' }} | |
| > | |
| <div> | |
| <Input | |
| value={ | |
| userState.user && userState.user.github_id !== '' | |
| ? userState.user.github_id | |
| : t('未绑定') | |
| } | |
| readonly={true} | |
| ></Input> | |
| </div> | |
| <div> | |
| <Button | |
| onClick={() => { | |
| onGitHubOAuthClicked(status.github_client_id); | |
| }} | |
| disabled={ | |
| userState.user && userState.user.github_id !== '' | |
| } | |
| > | |
| {t('绑定')} | |
| </Button> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {status.oidc_enabled && ( | |
| <div style={{ marginTop: 10 }}> | |
| <Typography.Text strong>{t('OIDC')}</Typography.Text> | |
| <div | |
| style={{ display: 'flex', justifyContent: 'space-between' }} | |
| > | |
| <div> | |
| <Input | |
| value={ | |
| userState.user && userState.user.oidc_id !== '' | |
| ? userState.user.oidc_id | |
| : t('未绑定') | |
| } | |
| readonly={true} | |
| ></Input> | |
| </div> | |
| <div> | |
| <Button | |
| onClick={() => { | |
| onOIDCClicked( | |
| status.oidc_authorization_endpoint, | |
| status.oidc_client_id, | |
| ); | |
| }} | |
| disabled={ | |
| userState.user && userState.user.oidc_id !== '' | |
| } | |
| > | |
| {t('绑定')} | |
| </Button> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {status.telegram_oauth && ( | |
| <div style={{ marginTop: 10 }}> | |
| <Typography.Text strong>{t('Telegram')}</Typography.Text> | |
| <div | |
| style={{ display: 'flex', justifyContent: 'space-between' }} | |
| > | |
| <div> | |
| <Input | |
| value={ | |
| userState.user && userState.user.telegram_id !== '' | |
| ? userState.user.telegram_id | |
| : t('未绑定') | |
| } | |
| readonly={true} | |
| ></Input> | |
| </div> | |
| <div> | |
| {userState.user.telegram_id !== '' ? ( | |
| <Button disabled={true}>{t('已绑定')}</Button> | |
| ) : ( | |
| <TelegramLoginButton | |
| dataAuthUrl='/api/oauth/telegram/bind' | |
| botName={status.telegram_bot_name} | |
| /> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {status.linuxdo_oauth && ( | |
| <div style={{ marginTop: 10 }}> | |
| <Typography.Text strong>{t('LinuxDO')}</Typography.Text> | |
| <div | |
| style={{ display: 'flex', justifyContent: 'space-between' }} | |
| > | |
| <div> | |
| <Input | |
| value={ | |
| userState.user && userState.user.linux_do_id !== '' | |
| ? userState.user.linux_do_id | |
| : t('未绑定') | |
| } | |
| readonly={true} | |
| ></Input> | |
| </div> | |
| <div> | |
| <Button | |
| onClick={() => { | |
| onLinuxDOOAuthClicked(status.linuxdo_client_id); | |
| }} | |
| disabled={ | |
| userState.user && userState.user.linux_do_id !== '' | |
| } | |
| > | |
| {t('绑定')} | |
| </Button> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {status.idcflare_oauth && ( | |
| <div style={{ marginTop: 10 }}> | |
| <Typography.Text strong>{t('IDC Flare')}</Typography.Text> | |
| <div | |
| style={{ display: 'flex', justifyContent: 'space-between' }} | |
| > | |
| <div> | |
| <Input | |
| value={ | |
| userState.user && userState.user.idc_flare_id !== '' | |
| ? userState.user.idc_flare_id | |
| : t('未绑定') | |
| } | |
| readonly={true} | |
| ></Input> | |
| </div> | |
| <div> | |
| <Button | |
| onClick={() => { | |
| onIDCFlareOAuthClicked(status.idcflare_client_id); | |
| }} | |
| disabled={ | |
| userState.user && userState.user.idc_flare_id !== '' | |
| } | |
| > | |
| {t('绑定')} | |
| </Button> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| <div style={{ marginTop: 10 }}> | |
| <Space> | |
| <Button onClick={generateAccessToken}> | |
| {t('生成系统访问令牌')} | |
| </Button> | |
| <Button | |
| onClick={() => { | |
| setShowChangePasswordModal(true); | |
| }} | |
| > | |
| {t('修改密码')} | |
| </Button> | |
| <Button | |
| type={'danger'} | |
| onClick={() => { | |
| setShowAccountDeleteModal(true); | |
| }} | |
| > | |
| {t('删除个人账户')} | |
| </Button> | |
| </Space> | |
| {systemToken && ( | |
| <Input | |
| readOnly | |
| value={systemToken} | |
| onClick={handleSystemTokenClick} | |
| style={{ marginTop: '10px' }} | |
| /> | |
| )} | |
| <Modal | |
| onCancel={() => setShowWeChatBindModal(false)} | |
| visible={showWeChatBindModal} | |
| size={'small'} | |
| > | |
| <Image src={status.wechat_qrcode} /> | |
| <div style={{ textAlign: 'center' }}> | |
| <p> | |
| 微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效) | |
| </p> | |
| </div> | |
| <Input | |
| placeholder='验证码' | |
| name='wechat_verification_code' | |
| value={inputs.wechat_verification_code} | |
| onChange={(v) => | |
| handleInputChange('wechat_verification_code', v) | |
| } | |
| /> | |
| <Button color='' fluid size='large' onClick={bindWeChat}> | |
| {t('绑定')} | |
| </Button> | |
| </Modal> | |
| </div> | |
| </Card> | |
| <Card style={{ marginTop: 10 }}> | |
| <Tabs type="line" defaultActiveKey="notification"> | |
| <TabPane tab={t('通知设置')} itemKey="notification"> | |
| <div style={{ marginTop: 20 }}> | |
| <Typography.Text strong>{t('通知方式')}</Typography.Text> | |
| <div style={{ marginTop: 10 }}> | |
| <RadioGroup | |
| value={notificationSettings.warningType} | |
| onChange={(value) => | |
| handleNotificationSettingChange('warningType', value) | |
| } | |
| > | |
| <Radio value='email'>{t('邮件通知')}</Radio> | |
| <Radio value='webhook'>{t('Webhook通知')}</Radio> | |
| </RadioGroup> | |
| </div> | |
| </div> | |
| {notificationSettings.warningType === 'webhook' && ( | |
| <> | |
| <div style={{ marginTop: 20 }}> | |
| <Typography.Text strong> | |
| {t('Webhook地址')} | |
| </Typography.Text> | |
| <div style={{ marginTop: 10 }}> | |
| <Input | |
| value={notificationSettings.webhookUrl} | |
| onChange={(val) => | |
| handleNotificationSettingChange('webhookUrl', val) | |
| } | |
| placeholder={t( | |
| '请输入Webhook地址,例如: https://example.com/webhook', | |
| )} | |
| /> | |
| <Typography.Text | |
| type='secondary' | |
| style={{ marginTop: 8, display: 'block' }} | |
| > | |
| {t( | |
| '只支持https,系统将以 POST 方式发送通知,请确保地址可以接收 POST 请求', | |
| )} | |
| </Typography.Text> | |
| <Typography.Text | |
| type='secondary' | |
| style={{ marginTop: 8, display: 'block' }} | |
| > | |
| <div | |
| style={{ cursor: 'pointer' }} | |
| onClick={() => | |
| setShowWebhookDocs(!showWebhookDocs) | |
| } | |
| > | |
| {t('Webhook请求结构')}{' '} | |
| {showWebhookDocs ? '▼' : '▶'} | |
| </div> | |
| <Collapsible isOpen={showWebhookDocs}> | |
| <pre | |
| style={{ | |
| marginTop: 4, | |
| background: 'var(--semi-color-fill-0)', | |
| padding: 8, | |
| borderRadius: 4, | |
| }} | |
| > | |
| {`{ | |
| "type": "quota_exceed", // 通知类型 | |
| "title": "标题", // 通知标题 | |
| "content": "通知内容", // 通知内容,支持 {{value}} 变量占位符 | |
| "values": ["值1", "值2"], // 按顺序替换content中的 {{value}} 占位符 | |
| "timestamp": 1739950503 // 时间戳 | |
| } | |
| 示例: | |
| { | |
| "type": "quota_exceed", | |
| "title": "额度预警通知", | |
| "content": "您的额度即将用尽,当前剩余额度为 {{value}}", | |
| "values": ["$0.99"], | |
| "timestamp": 1739950503 | |
| }`} | |
| </pre> | |
| </Collapsible> | |
| </Typography.Text> | |
| </div> | |
| </div> | |
| <div style={{ marginTop: 20 }}> | |
| <Typography.Text strong> | |
| {t('接口凭证(可选)')} | |
| </Typography.Text> | |
| <div style={{ marginTop: 10 }}> | |
| <Input | |
| value={notificationSettings.webhookSecret} | |
| onChange={(val) => | |
| handleNotificationSettingChange( | |
| 'webhookSecret', | |
| val, | |
| ) | |
| } | |
| placeholder={t('请输入密钥')} | |
| /> | |
| <Typography.Text | |
| type='secondary' | |
| style={{ marginTop: 8, display: 'block' }} | |
| > | |
| {t( | |
| '密钥将以 Bearer 方式添加到请求头中,用于验证webhook请求的合法性', | |
| )} | |
| </Typography.Text> | |
| <Typography.Text | |
| type='secondary' | |
| style={{ marginTop: 4, display: 'block' }} | |
| > | |
| {t('Authorization: Bearer your-secret-key')} | |
| </Typography.Text> | |
| </div> | |
| </div> | |
| </> | |
| )} | |
| {notificationSettings.warningType === 'email' && ( | |
| <div style={{ marginTop: 20 }}> | |
| <Typography.Text strong>{t('通知邮箱')}</Typography.Text> | |
| <div style={{ marginTop: 10 }}> | |
| <Input | |
| value={notificationSettings.notificationEmail} | |
| onChange={(val) => | |
| handleNotificationSettingChange( | |
| 'notificationEmail', | |
| val, | |
| ) | |
| } | |
| placeholder={t('留空则使用账号绑定的邮箱')} | |
| /> | |
| <Typography.Text | |
| type='secondary' | |
| style={{ marginTop: 8, display: 'block' }} | |
| > | |
| {t( | |
| '设置用于接收额度预警的邮箱地址,不填则使用账号绑定的邮箱', | |
| )} | |
| </Typography.Text> | |
| </div> | |
| </div> | |
| )} | |
| <div style={{ marginTop: 20 }}> | |
| <Typography.Text strong> | |
| {t('额度预警阈值')}{' '} | |
| {renderQuotaWithPrompt( | |
| notificationSettings.warningThreshold, | |
| )} | |
| </Typography.Text> | |
| <div style={{ marginTop: 10 }}> | |
| <AutoComplete | |
| value={notificationSettings.warningThreshold} | |
| onChange={(val) => | |
| handleNotificationSettingChange( | |
| 'warningThreshold', | |
| val, | |
| ) | |
| } | |
| style={{ width: 200 }} | |
| placeholder={t('请输入预警额度')} | |
| data={[ | |
| { value: 100000, label: '0.2$' }, | |
| { value: 500000, label: '1$' }, | |
| { value: 1000000, label: '5$' }, | |
| { value: 5000000, label: '10$' }, | |
| ]} | |
| /> | |
| </div> | |
| <Typography.Text | |
| type='secondary' | |
| style={{ marginTop: 10, display: 'block' }} | |
| > | |
| {t( | |
| '当剩余额度低于此数值时,系统将通过选择的方式发送通知', | |
| )} | |
| </Typography.Text> | |
| </div> | |
| </TabPane> | |
| <TabPane tab={t('价格设置')} itemKey="price"> | |
| <div style={{ marginTop: 20 }}> | |
| <Typography.Text strong>{t('接受未设置价格模型')}</Typography.Text> | |
| <div style={{ marginTop: 10 }}> | |
| <Checkbox | |
| checked={notificationSettings.acceptUnsetModelRatioModel} | |
| onChange={e => handleNotificationSettingChange('acceptUnsetModelRatioModel', e.target.checked)} | |
| > | |
| {t('接受未设置价格模型')} | |
| </Checkbox> | |
| <Typography.Text type="secondary" style={{ marginTop: 8, display: 'block' }}> | |
| {t('当模型没有设置价格时仍接受调用,仅当您信任该网站时使用,可能会产生高额费用')} | |
| </Typography.Text> | |
| </div> | |
| </div> | |
| </TabPane> | |
| <TabPane tab={t('其他设置')} itemKey="other"> | |
| <div style={{ marginTop: 20 }}> | |
| <Typography.Text strong>{t('在消费日志显示调用 IP')}</Typography.Text> | |
| <div style={{ marginTop: 10 }}> | |
| <Switch | |
| checked={notificationSettings.showIPInLogs} | |
| onChange={(checked) => | |
| handleNotificationSettingChange('showIPInLogs', checked) | |
| } | |
| /> | |
| <Typography.Text | |
| type='secondary' | |
| style={{ marginTop: 8, display: 'block' }} | |
| > | |
| {t('启用后,您的消费日志中将显示请求的 IP 地址,用于安全监控和审计')} | |
| </Typography.Text> | |
| </div> | |
| </div> | |
| </TabPane> | |
| </Tabs> | |
| <div style={{ marginTop: 20 }}> | |
| <Button type='primary' onClick={saveNotificationSettings}> | |
| {t('保存设置')} | |
| </Button> | |
| </div> | |
| </Card> | |
| <Modal | |
| onCancel={() => setShowEmailBindModal(false)} | |
| onOk={bindEmail} | |
| visible={showEmailBindModal} | |
| size={'small'} | |
| centered={true} | |
| maskClosable={false} | |
| > | |
| <Typography.Title heading={6}> | |
| {t('绑定邮箱地址')} | |
| </Typography.Title> | |
| <div | |
| style={{ | |
| marginTop: 20, | |
| display: 'flex', | |
| justifyContent: 'space-between', | |
| }} | |
| > | |
| <Input | |
| fluid | |
| placeholder='输入邮箱地址' | |
| onChange={(value) => handleInputChange('email', value)} | |
| name='email' | |
| type='email' | |
| /> | |
| <Button | |
| onClick={sendVerificationCode} | |
| disabled={disableButton || loading} | |
| > | |
| {disableButton ? `重新发送 (${countdown})` : '获取验证码'} | |
| </Button> | |
| </div> | |
| <div style={{ marginTop: 10 }}> | |
| <Input | |
| fluid | |
| placeholder='验证码' | |
| name='email_verification_code' | |
| value={inputs.email_verification_code} | |
| onChange={(value) => | |
| handleInputChange('email_verification_code', value) | |
| } | |
| /> | |
| </div> | |
| {turnstileEnabled ? ( | |
| <Turnstile | |
| sitekey={turnstileSiteKey} | |
| onVerify={(token) => { | |
| setTurnstileToken(token); | |
| }} | |
| /> | |
| ) : ( | |
| <></> | |
| )} | |
| </Modal> | |
| <Modal | |
| onCancel={() => setShowAccountDeleteModal(false)} | |
| visible={showAccountDeleteModal} | |
| size={'small'} | |
| centered={true} | |
| onOk={deleteAccount} | |
| > | |
| <div style={{ marginTop: 20 }}> | |
| <Banner | |
| type='danger' | |
| description='您正在删除自己的帐户,将清空所有数据且不可恢复' | |
| closeIcon={null} | |
| /> | |
| </div> | |
| <div style={{ marginTop: 20 }}> | |
| <Input | |
| placeholder={`输入你的账户名 ${userState?.user?.username} 以确认删除`} | |
| name='self_account_deletion_confirmation' | |
| value={inputs.self_account_deletion_confirmation} | |
| onChange={(value) => | |
| handleInputChange( | |
| 'self_account_deletion_confirmation', | |
| value, | |
| ) | |
| } | |
| /> | |
| {turnstileEnabled ? ( | |
| <Turnstile | |
| sitekey={turnstileSiteKey} | |
| onVerify={(token) => { | |
| setTurnstileToken(token); | |
| }} | |
| /> | |
| ) : ( | |
| <></> | |
| )} | |
| </div> | |
| </Modal> | |
| <Modal | |
| onCancel={() => setShowChangePasswordModal(false)} | |
| visible={showChangePasswordModal} | |
| size={'small'} | |
| centered={true} | |
| onOk={changePassword} | |
| > | |
| <div style={{ marginTop: 20 }}> | |
| <Input | |
| name='set_new_password' | |
| placeholder={t('新密码')} | |
| value={inputs.set_new_password} | |
| onChange={(value) => | |
| handleInputChange('set_new_password', value) | |
| } | |
| /> | |
| <Input | |
| style={{ marginTop: 20 }} | |
| name='set_new_password_confirmation' | |
| placeholder={t('确认新密码')} | |
| value={inputs.set_new_password_confirmation} | |
| onChange={(value) => | |
| handleInputChange('set_new_password_confirmation', value) | |
| } | |
| /> | |
| {turnstileEnabled ? ( | |
| <Turnstile | |
| sitekey={turnstileSiteKey} | |
| onVerify={(token) => { | |
| setTurnstileToken(token); | |
| }} | |
| /> | |
| ) : ( | |
| <></> | |
| )} | |
| </div> | |
| </Modal> | |
| </div> | |
| </Layout.Content> | |
| </Layout> | |
| </div> | |
| ); | |
| }; | |
| export default PersonalSetting; | |