Files
new-api/web/src/components/PersonalSetting.js

1600 lines
69 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useContext, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
API,
copy,
isRoot,
isAdmin,
showError,
showInfo,
showSuccess,
getQuotaPerUnit,
renderQuota,
renderQuotaWithPrompt,
stringToColor,
onGitHubOAuthClicked,
onOIDCClicked,
onLinuxDOOAuthClicked
} from '../helpers';
import Turnstile from 'react-turnstile';
import { UserContext } from '../context/User';
import {
Avatar,
Banner,
Button,
Card,
Image,
Input,
InputNumber,
Layout,
Modal,
Space,
Tag,
Typography,
Collapsible,
Radio,
RadioGroup,
AutoComplete,
Checkbox,
Tabs,
TabPane,
} from '@douyinfe/semi-ui';
import {
IconMail,
IconLock,
IconShield,
IconUser,
IconSetting,
IconBell,
IconGithubLogo,
IconKey,
IconCreditCard,
IconLink,
IconDelete,
IconChevronDown,
IconChevronUp,
} from '@douyinfe/semi-icons';
import { SiTelegram, SiWechat, SiLinux } from 'react-icons/si';
import { Bell, Shield, Webhook, Globe, Settings, UserPlus, ShieldCheck } from 'lucide-react';
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: '',
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 [affLink, setAffLink] = useState('');
const [systemToken, setSystemToken] = useState('');
const [models, setModels] = useState([]);
const [openTransfer, setOpenTransfer] = useState(false);
const [transferAmount, setTransferAmount] = useState(0);
const [isModelsExpanded, setIsModelsExpanded] = useState(() => {
// Initialize from localStorage if available
const savedState = localStorage.getItem('modelsExpanded');
return savedState ? JSON.parse(savedState) : false;
});
const MODELS_DISPLAY_COUNT = 25; // 默认显示的模型数量
const [notificationSettings, setNotificationSettings] = useState({
warningType: 'email',
warningThreshold: 100000,
webhookUrl: '',
webhookSecret: '',
notificationEmail: '',
acceptUnsetModelRatioModel: false,
});
const [showWebhookDocs, setShowWebhookDocs] = useState(true);
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);
}
}
getUserData().then((res) => {
console.log(userState);
});
loadModels().then();
getAffLink().then();
setTransferAmount(getQuotaPerUnit());
}, []);
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,
});
}
}, [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 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 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(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 getUsername = () => {
if (userState.user) {
return userState.user.username;
} else {
return 'null';
}
};
const getAvatarText = () => {
const username = getUsername();
if (username && username.length > 0) {
// 获取前两个字符,支持中文和英文
return username.slice(0, 2).toUpperCase();
}
return 'NA';
};
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,
});
if (res.data.success) {
showSuccess(t('通知设置已更新'));
await getUserData();
} else {
showError(res.data.message);
}
} catch (error) {
showError(t('更新通知设置失败'));
}
};
return (
<div className="min-h-screen bg-gray-50">
<Layout>
<Layout.Content>
{/* 划转模态框 */}
<Modal
title={
<div className="flex items-center">
<IconCreditCard className="mr-2" />
{t('请输入要划转的数量')}
</div>
}
visible={openTransfer}
onOk={transfer}
onCancel={handleCancel}
maskClosable={false}
size={'small'}
centered={true}
>
<div className="space-y-4 py-4">
<div>
<Typography.Text strong className="block mb-2">
{t('可用额度')} {renderQuotaWithPrompt(userState?.user?.aff_quota)}
</Typography.Text>
<Input
value={userState?.user?.aff_quota}
disabled={true}
size="large"
className="!rounded-lg"
/>
</div>
<div>
<Typography.Text strong className="block mb-2">
{t('划转额度')} {renderQuotaWithPrompt(transferAmount)}{' '}
{t('最低') + renderQuota(getQuotaPerUnit())}
</Typography.Text>
<InputNumber
min={0}
value={transferAmount}
onChange={(value) => setTransferAmount(value)}
disabled={false}
size="large"
className="!rounded-lg w-full"
/>
</div>
</div>
</Modal>
<div className="flex justify-center">
<div className="w-full max-w-6xl">
{/* 主卡片容器 */}
<Card className="!rounded-2xl shadow-lg border-0">
{/* 顶部用户信息区域 */}
<Card
className="!rounded-2xl !border-0 !shadow-2xl overflow-hidden"
style={{
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 25%, #a855f7 50%, #c084fc 75%, #d8b4fe 100%)',
position: 'relative'
}}
bodyStyle={{ padding: 0 }}
>
{/* 装饰性背景元素 */}
<div className="absolute inset-0 overflow-hidden">
<div className="absolute -top-10 -right-10 w-40 h-40 bg-white opacity-5 rounded-full"></div>
<div className="absolute -bottom-16 -left-16 w-48 h-48 bg-white opacity-3 rounded-full"></div>
<div className="absolute top-1/2 right-1/4 w-24 h-24 bg-yellow-400 opacity-10 rounded-full"></div>
</div>
<div className="relative p-4 sm:p-6 md:p-8" style={{ color: 'white' }}>
<div className="flex justify-between items-start mb-4 sm:mb-6">
<div className="flex items-center flex-1 min-w-0">
<Avatar
size='large'
color={stringToColor(getUsername())}
border={{ motion: true }}
contentMotion={true}
className="mr-3 sm:mr-4 shadow-lg flex-shrink-0"
>
{getAvatarText()}
</Avatar>
<div className="flex-1 min-w-0">
<div className="text-base sm:text-lg font-semibold truncate" style={{ color: 'white' }}>
{getUsername()}
</div>
<div className="mt-1 flex flex-wrap gap-1 sm:gap-2">
{isRoot() ? (
<Tag
color='red'
size='small'
style={{
backgroundColor: 'rgba(255, 255, 255, 0.95)',
color: '#dc2626',
fontWeight: '600'
}}
className="!rounded-full"
>
{t('超级管理员')}
</Tag>
) : isAdmin() ? (
<Tag
color='orange'
size='small'
style={{
backgroundColor: 'rgba(255, 255, 255, 0.95)',
color: '#ea580c',
fontWeight: '600'
}}
className="!rounded-full"
>
{t('管理员')}
</Tag>
) : (
<Tag
color='blue'
size='small'
style={{
backgroundColor: 'rgba(255, 255, 255, 0.95)',
color: '#2563eb',
fontWeight: '600'
}}
className="!rounded-full"
>
{t('普通用户')}
</Tag>
)}
<Tag
color='green'
size='small'
className="!rounded-full"
style={{
backgroundColor: 'rgba(255, 255, 255, 0.95)',
color: '#16a34a',
fontWeight: '600'
}}
>
ID: {userState?.user?.id}
</Tag>
</div>
</div>
</div>
<div
className="w-10 h-10 sm:w-12 sm:h-12 rounded-lg flex items-center justify-center shadow-lg flex-shrink-0 ml-2"
style={{
background: `linear-gradient(135deg, ${stringToColor(getUsername())} 0%, #f59e0b 100%)`
}}
>
<IconUser size="default" style={{ color: 'white' }} />
</div>
</div>
<div className="mb-4 sm:mb-6">
<div className="text-xs sm:text-sm mb-1 sm:mb-2" style={{ color: 'rgba(255, 255, 255, 0.7)' }}>
{t('当前余额')}
</div>
<div className="text-2xl sm:text-3xl md:text-4xl font-bold tracking-wide" style={{ color: 'white' }}>
{renderQuota(userState?.user?.quota)}
</div>
</div>
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-end">
<div className="grid grid-cols-3 gap-2 sm:flex sm:space-x-6 lg:space-x-8 mb-3 sm:mb-0">
<div className="text-center sm:text-left">
<div className="text-xs" style={{ color: 'rgba(255, 255, 255, 0.6)' }}>
{t('历史消耗')}
</div>
<div className="text-xs sm:text-sm font-medium truncate" style={{ color: 'white' }}>
{renderQuota(userState?.user?.used_quota)}
</div>
</div>
<div className="text-center sm:text-left">
<div className="text-xs" style={{ color: 'rgba(255, 255, 255, 0.6)' }}>
{t('请求次数')}
</div>
<div className="text-xs sm:text-sm font-medium truncate" style={{ color: 'white' }}>
{userState.user?.request_count || 0}
</div>
</div>
<div className="text-center sm:text-left">
<div className="text-xs" style={{ color: 'rgba(255, 255, 255, 0.6)' }}>
{t('用户分组')}
</div>
<div className="text-xs sm:text-sm font-medium truncate" style={{ color: 'white' }}>
{userState?.user?.group || t('默认')}
</div>
</div>
</div>
</div>
<div className="absolute top-0 left-0 w-full h-2 bg-gradient-to-r from-yellow-400 via-orange-400 to-red-400" style={{ opacity: 0.6 }}></div>
</div>
</Card>
{/* 主内容区域 - 使用Tabs组织不同功能模块 */}
<div className="p-4">
<Tabs type='line' defaultActiveKey='models' className="modern-tabs">
{/* 模型与邀请Tab */}
<TabPane
tab={
<div className="flex items-center">
<Settings size={16} className="mr-2" />
{t('模型与邀请')}
</div>
}
itemKey='models'
>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 py-4">
{/* 可用模型部分 */}
<div className="bg-gray-50 rounded-xl">
<div className="flex items-center mb-4">
<div className="w-10 h-10 rounded-full bg-purple-50 flex items-center justify-center mr-3">
<Settings size={20} className="text-purple-500" />
</div>
<div>
<Typography.Title heading={6} className="mb-0">{t('可用模型')}</Typography.Title>
<div className="text-gray-500 text-sm">{t('点击模型名称可复制')}</div>
</div>
</div>
<div className="bg-white rounded-lg p-3">
{models.length <= MODELS_DISPLAY_COUNT ? (
<Space wrap>
{models.map((model) => (
<Tag
key={model}
color={stringToColor(model)}
onClick={() => copyText(model)}
className="cursor-pointer hover:opacity-80 transition-opacity !rounded-lg"
>
{model}
</Tag>
))}
</Space>
) : (
<>
<Collapsible isOpen={isModelsExpanded}>
<Space wrap>
{models.map((model) => (
<Tag
key={model}
color={stringToColor(model)}
onClick={() => copyText(model)}
className="cursor-pointer hover:opacity-80 transition-opacity !rounded-lg"
>
{model}
</Tag>
))}
<Tag
color='grey'
type='light'
className="cursor-pointer !rounded-lg"
onClick={() => setIsModelsExpanded(false)}
icon={<IconChevronUp />}
>
{t('收起')}
</Tag>
</Space>
</Collapsible>
{!isModelsExpanded && (
<Space wrap>
{models
.slice(0, MODELS_DISPLAY_COUNT)
.map((model) => (
<Tag
key={model}
color={stringToColor(model)}
onClick={() => copyText(model)}
className="cursor-pointer hover:opacity-80 transition-opacity !rounded-lg"
>
{model}
</Tag>
))}
<Tag
color='grey'
type='light'
className="cursor-pointer !rounded-lg"
onClick={() => setIsModelsExpanded(true)}
icon={<IconChevronDown />}
>
{t('更多')} {models.length - MODELS_DISPLAY_COUNT} {t('个模型')}
</Tag>
</Space>
)}
</>
)}
</div>
</div>
{/* 邀请信息部分 */}
<div className="bg-gray-50 rounded-xl">
<div className="flex items-center mb-4">
<div className="w-10 h-10 rounded-full bg-orange-50 flex items-center justify-center mr-3">
<IconLink size={20} className="text-orange-500" />
</div>
<div>
<Typography.Title heading={6} className="mb-0">{t('邀请信息')}</Typography.Title>
<div className="text-gray-500 text-sm">{t('管理您的邀请链接和收益')}</div>
</div>
</div>
<div className="space-y-3">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<Card
className="!rounded-2xl text-center"
bodyStyle={{ padding: '16px' }}
shadows='hover'
>
<div className="text-gray-600 text-xs font-medium">{t('待使用收益')}</div>
<div className="text-gray-900 text-lg font-bold mt-1">
{renderQuota(userState?.user?.aff_quota)}
</div>
<Button
type="primary"
theme="solid"
onClick={() => setOpenTransfer(true)}
size="small"
className="!rounded-lg !bg-blue-500 hover:!bg-blue-600 mt-2 w-full"
icon={<IconCreditCard />}
>
{t('划转')}
</Button>
</Card>
<Card
className="!rounded-2xl text-center"
bodyStyle={{ padding: '16px' }}
shadows='hover'
>
<div className="text-gray-600 text-xs font-medium">{t('总收益')}</div>
<div className="text-gray-900 text-lg font-bold mt-1">
{renderQuota(userState?.user?.aff_history_quota)}
</div>
</Card>
<Card
className="!rounded-2xl text-center"
bodyStyle={{ padding: '16px' }}
shadows='hover'
>
<div className="text-gray-600 text-xs font-medium">{t('邀请人数')}</div>
<div className="text-gray-900 text-lg font-bold mt-1">
{userState?.user?.aff_count || 0}
</div>
</Card>
</div>
<div className="bg-white rounded-lg p-3">
<Typography.Text strong className="block mb-2 text-sm">{t('邀请链接')}</Typography.Text>
<Input
value={affLink}
onClick={handleAffLinkClick}
readOnly
size="large"
className="!rounded-lg"
prefix={<IconLink />}
/>
</div>
</div>
</div>
</div>
</TabPane>
{/* 账户绑定Tab */}
<TabPane
tab={
<div className="flex items-center">
<UserPlus size={16} className="mr-2" />
{t('账户绑定')}
</div>
}
itemKey='account'
>
<div className="py-4">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* 邮箱绑定 */}
<Card
className="!rounded-xl transition-shadow"
bodyStyle={{ padding: '16px' }}
shadows='hover'
>
<div className="flex items-center justify-between">
<div className="flex items-center flex-1">
<div className="w-10 h-10 rounded-full bg-red-50 flex items-center justify-center mr-3">
<IconMail size="default" className="text-red-500" />
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-gray-900">{t('邮箱')}</div>
<div className="text-sm text-gray-500 truncate">
{userState.user && userState.user.email !== ''
? userState.user.email
: t('未绑定')}
</div>
</div>
</div>
<Button
type="primary"
theme="outline"
size="small"
onClick={() => setShowEmailBindModal(true)}
className="!rounded-lg"
>
{userState.user && userState.user.email !== ''
? t('修改绑定')
: t('绑定邮箱')}
</Button>
</div>
</Card>
{/* 微信绑定 */}
<Card
className="!rounded-xl transition-shadow"
bodyStyle={{ padding: '16px' }}
shadows='hover'
>
<div className="flex items-center justify-between">
<div className="flex items-center flex-1">
<div className="w-10 h-10 rounded-full bg-green-50 flex items-center justify-center mr-3">
<SiWechat size={20} className="text-green-500" />
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-gray-900">{t('微信')}</div>
<div className="text-sm text-gray-500 truncate">
{userState.user && userState.user.wechat_id !== ''
? t('已绑定')
: t('未绑定')}
</div>
</div>
</div>
<Button
type="primary"
theme="outline"
size="small"
disabled={!status.wechat_login}
onClick={() => setShowWeChatBindModal(true)}
className="!rounded-lg"
>
{userState.user && userState.user.wechat_id !== ''
? t('修改绑定')
: status.wechat_login
? t('绑定')
: t('未启用')}
</Button>
</div>
</Card>
{/* GitHub绑定 */}
<Card
className="!rounded-xl transition-shadow"
bodyStyle={{ padding: '16px' }}
shadows='hover'
>
<div className="flex items-center justify-between">
<div className="flex items-center flex-1">
<div className="w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center mr-3">
<IconGithubLogo size="default" className="text-gray-700" />
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-gray-900">{t('GitHub')}</div>
<div className="text-sm text-gray-500 truncate">
{userState.user && userState.user.github_id !== ''
? userState.user.github_id
: t('未绑定')}
</div>
</div>
</div>
<Button
type="primary"
theme="outline"
size="small"
onClick={() => onGitHubOAuthClicked(status.github_client_id)}
disabled={
(userState.user && userState.user.github_id !== '') ||
!status.github_oauth
}
className="!rounded-lg"
>
{status.github_oauth ? t('绑定') : t('未启用')}
</Button>
</div>
</Card>
{/* OIDC绑定 */}
<Card
className="!rounded-xl transition-shadow"
bodyStyle={{ padding: '16px' }}
shadows='hover'
>
<div className="flex items-center justify-between">
<div className="flex items-center flex-1">
<div className="w-10 h-10 rounded-full bg-indigo-50 flex items-center justify-center mr-3">
<IconShield size="default" className="text-indigo-500" />
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-gray-900">{t('OIDC')}</div>
<div className="text-sm text-gray-500 truncate">
{userState.user && userState.user.oidc_id !== ''
? userState.user.oidc_id
: t('未绑定')}
</div>
</div>
</div>
<Button
type="primary"
theme="outline"
size="small"
onClick={() => onOIDCClicked(
status.oidc_authorization_endpoint,
status.oidc_client_id,
)}
disabled={
(userState.user && userState.user.oidc_id !== '') ||
!status.oidc_enabled
}
className="!rounded-lg"
>
{status.oidc_enabled ? t('绑定') : t('未启用')}
</Button>
</div>
</Card>
{/* Telegram绑定 */}
<Card
className="!rounded-xl transition-shadow"
bodyStyle={{ padding: '16px' }}
shadows='hover'
>
<div className="flex items-center justify-between">
<div className="flex items-center flex-1">
<div className="w-10 h-10 rounded-full bg-blue-50 flex items-center justify-center mr-3">
<SiTelegram size={20} className="text-blue-500" />
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-gray-900">{t('Telegram')}</div>
<div className="text-sm text-gray-500 truncate">
{userState.user && userState.user.telegram_id !== ''
? userState.user.telegram_id
: t('未绑定')}
</div>
</div>
</div>
<div className="flex-shrink-0">
{status.telegram_oauth ? (
userState.user.telegram_id !== '' ? (
<Button disabled={true} size="small" className="!rounded-lg">
{t('已绑定')}
</Button>
) : (
<div className="scale-75">
<TelegramLoginButton
dataAuthUrl='/api/oauth/telegram/bind'
botName={status.telegram_bot_name}
/>
</div>
)
) : (
<Button disabled={true} size="small" className="!rounded-lg">
{t('未启用')}
</Button>
)}
</div>
</div>
</Card>
{/* LinuxDO绑定 */}
<Card
className="!rounded-xl transition-shadow"
bodyStyle={{ padding: '16px' }}
shadows='hover'
>
<div className="flex items-center justify-between">
<div className="flex items-center flex-1">
<div className="w-10 h-10 rounded-full bg-orange-50 flex items-center justify-center mr-3">
<SiLinux size={20} className="text-orange-500" />
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-gray-900">{t('LinuxDO')}</div>
<div className="text-sm text-gray-500 truncate">
{userState.user && userState.user.linux_do_id !== ''
? userState.user.linux_do_id
: t('未绑定')}
</div>
</div>
</div>
<Button
type="primary"
theme="outline"
size="small"
onClick={() => onLinuxDOOAuthClicked(status.linuxdo_client_id)}
disabled={
(userState.user && userState.user.linux_do_id !== '') ||
!status.linuxdo_oauth
}
className="!rounded-lg"
>
{status.linuxdo_oauth ? t('绑定') : t('未启用')}
</Button>
</div>
</Card>
</div>
</div>
</TabPane>
{/* 安全设置Tab */}
<TabPane
tab={
<div className="flex items-center">
<ShieldCheck size={16} className="mr-2" />
{t('安全设置')}
</div>
}
itemKey='security'
>
<div className="py-4">
<div className="space-y-6">
<Space vertical className='w-full'>
{/* 系统访问令牌 */}
<Card
className="!rounded-xl w-full"
bodyStyle={{ padding: '20px' }}
shadows='hover'
>
<div className="flex flex-col sm:flex-row items-start sm:justify-between gap-4">
<div className="flex items-start w-full sm:w-auto">
<div className="w-12 h-12 rounded-full bg-blue-50 flex items-center justify-center mr-4 flex-shrink-0">
<IconKey size="large" className="text-blue-500" />
</div>
<div className="flex-1">
<Typography.Title heading={6} className="mb-1">
{t('系统访问令牌')}
</Typography.Title>
<Typography.Text type="tertiary" className="text-sm">
{t('用于API调用的身份验证令牌请妥善保管')}
</Typography.Text>
{systemToken && (
<div className="mt-3">
<Input
readOnly
value={systemToken}
onClick={handleSystemTokenClick}
size="large"
className="!rounded-lg"
prefix={<IconKey />}
/>
</div>
)}
</div>
</div>
<Button
type="primary"
theme="solid"
onClick={generateAccessToken}
className="!rounded-lg !bg-blue-500 hover:!bg-blue-600 w-full sm:w-auto"
icon={<IconKey />}
>
{systemToken ? t('重新生成') : t('生成令牌')}
</Button>
</div>
</Card>
{/* 密码管理 */}
<Card
className="!rounded-xl w-full"
bodyStyle={{ padding: '20px' }}
shadows='hover'
>
<div className="flex flex-col sm:flex-row items-start sm:justify-between gap-4">
<div className="flex items-start w-full sm:w-auto">
<div className="w-12 h-12 rounded-full bg-orange-50 flex items-center justify-center mr-4 flex-shrink-0">
<IconLock size="large" className="text-orange-500" />
</div>
<div>
<Typography.Title heading={6} className="mb-1">
{t('密码管理')}
</Typography.Title>
<Typography.Text type="tertiary" className="text-sm">
{t('定期更改密码可以提高账户安全性')}
</Typography.Text>
</div>
</div>
<Button
type="primary"
theme="solid"
onClick={() => setShowChangePasswordModal(true)}
className="!rounded-lg !bg-orange-500 hover:!bg-orange-600 w-full sm:w-auto"
icon={<IconLock />}
>
{t('修改密码')}
</Button>
</div>
</Card>
{/* 危险区域 */}
<Card
className="!rounded-xl border-red-200 w-full"
bodyStyle={{ padding: '20px' }}
shadows='hover'
>
<div className="flex flex-col sm:flex-row items-start sm:justify-between gap-4">
<div className="flex items-start w-full sm:w-auto">
<div className="w-12 h-12 rounded-full bg-red-50 flex items-center justify-center mr-4 flex-shrink-0">
<IconDelete size="large" className="text-red-500" />
</div>
<div>
<Typography.Title heading={6} className="mb-1 text-red-600">
{t('删除账户')}
</Typography.Title>
<Typography.Text type="tertiary" className="text-sm">
{t('此操作不可逆,所有数据将被永久删除')}
</Typography.Text>
</div>
</div>
<Button
type="danger"
theme="solid"
onClick={() => setShowAccountDeleteModal(true)}
className="!rounded-lg w-full sm:w-auto"
icon={<IconDelete />}
>
{t('删除账户')}
</Button>
</div>
</Card>
</Space>
</div>
</div>
</TabPane>
{/* 通知设置Tab */}
<TabPane
tab={
<div className="flex items-center">
<Bell size={16} className="mr-2" />
{t('通知设置')}
</div>
}
itemKey='notification'
>
<div className="py-4">
<Tabs type='card' defaultActiveKey='notify' className="!rounded-lg">
<TabPane
tab={t('通知设置')}
itemKey='notify'
>
<div className="space-y-6">
{/* 通知方式选择 */}
<div className="bg-gray-50 rounded-xl">
<Typography.Text strong className="block mb-4 pt-4">{t('通知方式')}</Typography.Text>
<RadioGroup
value={notificationSettings.warningType}
onChange={(value) =>
handleNotificationSettingChange('warningType', value)
}
type="pureCard"
>
<Radio value='email' className="!p-4 !rounded-lg">
<div className="flex items-center">
<IconMail className="mr-2 text-blue-500" />
<div>
<div className="font-medium">{t('邮件通知')}</div>
<div className="text-sm text-gray-500">{t('通过邮件接收通知')}</div>
</div>
</div>
</Radio>
<Radio value='webhook' className="!p-4 !rounded-lg">
<div className="flex items-center">
<Webhook size={16} className="mr-2 text-green-500" />
<div>
<div className="font-medium">{t('Webhook通知')}</div>
<div className="text-sm text-gray-500">{t('通过HTTP请求接收通知')}</div>
</div>
</div>
</Radio>
</RadioGroup>
</div>
{/* Webhook设置 */}
{notificationSettings.warningType === 'webhook' && (
<div className="space-y-4">
<div className="bg-white rounded-xl">
<Typography.Text strong className="block mb-3">{t('Webhook地址')}</Typography.Text>
<Input
value={notificationSettings.webhookUrl}
onChange={(val) =>
handleNotificationSettingChange('webhookUrl', val)
}
placeholder={t('请输入Webhook地址例如: https://example.com/webhook')}
size="large"
className="!rounded-lg"
prefix={<Webhook size={16} className="m-2" />}
/>
<div className="text-gray-500 text-sm mt-2">
{t('只支持https系统将以 POST 方式发送通知,请确保地址可以接收 POST 请求')}
</div>
</div>
<div className="bg-white rounded-xl">
<Typography.Text strong className="block mb-3">{t('接口凭证(可选)')}</Typography.Text>
<Input
value={notificationSettings.webhookSecret}
onChange={(val) =>
handleNotificationSettingChange('webhookSecret', val)
}
placeholder={t('请输入密钥')}
size="large"
className="!rounded-lg"
prefix={<IconKey />}
/>
<div className="text-gray-500 text-sm mt-2">
{t('密钥将以 Bearer 方式添加到请求头中用于验证webhook请求的合法性')}
</div>
</div>
<div className="bg-yellow-50 rounded-xl">
<div className="flex items-center justify-between cursor-pointer" onClick={() => setShowWebhookDocs(!showWebhookDocs)}>
<div className="flex items-center">
<Globe size={16} className="mr-2 text-yellow-600" />
<Typography.Text strong className="text-yellow-800">
{t('Webhook请求结构')}
</Typography.Text>
</div>
{showWebhookDocs ? <IconChevronUp /> : <IconChevronDown />}
</div>
<Collapsible isOpen={showWebhookDocs}>
<pre className="mt-4 bg-gray-800 text-gray-100 rounded-lg text-sm overflow-x-auto">
{`{
"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>
</div>
</div>
)}
{/* 邮件设置 */}
{notificationSettings.warningType === 'email' && (
<div className="bg-white rounded-xl">
<Typography.Text strong className="block mb-3">{t('通知邮箱')}</Typography.Text>
<Input
value={notificationSettings.notificationEmail}
onChange={(val) =>
handleNotificationSettingChange('notificationEmail', val)
}
placeholder={t('留空则使用账号绑定的邮箱')}
size="large"
className="!rounded-lg"
prefix={<IconMail />}
/>
<div className="text-gray-500 text-sm mt-2">
{t('设置用于接收额度预警的邮箱地址,不填则使用账号绑定的邮箱')}
</div>
</div>
)}
{/* 预警阈值 */}
<div className="bg-white rounded-xl">
<Typography.Text strong className="block mb-3">
{t('额度预警阈值')} {renderQuotaWithPrompt(notificationSettings.warningThreshold)}
</Typography.Text>
<AutoComplete
value={notificationSettings.warningThreshold}
onChange={(val) =>
handleNotificationSettingChange('warningThreshold', val)
}
size="large"
className="!rounded-lg w-full max-w-xs"
placeholder={t('请输入预警额度')}
data={[
{ value: 100000, label: '0.2$' },
{ value: 500000, label: '1$' },
{ value: 1000000, label: '5$' },
{ value: 5000000, label: '10$' },
]}
prefix={<IconBell />}
/>
<div className="text-gray-500 text-sm mt-2">
{t('当剩余额度低于此数值时,系统将通过选择的方式发送通知')}
</div>
</div>
</div>
</TabPane>
<TabPane
tab={t('价格设置')}
itemKey='price'
>
<div className="py-4">
<div className="bg-white rounded-xl">
<div className="flex items-start">
<div className="w-10 h-10 rounded-full bg-orange-50 flex items-center justify-center mt-1">
<Shield size={20} className="text-orange-500" />
</div>
<div className="flex-1">
<div className="flex items-center justify-between">
<div>
<Typography.Text strong className="block mb-2">
{t('接受未设置价格模型')}
</Typography.Text>
<div className="text-gray-500 text-sm">
{t('当模型没有设置价格时仍接受调用,仅当您信任该网站时使用,可能会产生高额费用')}
</div>
</div>
<Checkbox
checked={notificationSettings.acceptUnsetModelRatioModel}
onChange={(e) =>
handleNotificationSettingChange(
'acceptUnsetModelRatioModel',
e.target.checked,
)
}
className="ml-4"
/>
</div>
</div>
</div>
</div>
</div>
</TabPane>
</Tabs>
<div className="mt-6 flex justify-end">
<Button
type='primary'
onClick={saveNotificationSettings}
size="large"
className="!rounded-lg !bg-purple-500 hover:!bg-purple-600"
icon={<IconSetting />}
>
{t('保存设置')}
</Button>
</div>
</div>
</TabPane>
</Tabs>
</div>
</Card>
</div>
</div>
</Layout.Content>
</Layout>
{/* 邮箱绑定模态框 */}
<Modal
title={
<div className="flex items-center">
<IconMail className="mr-2 text-blue-500" />
{t('绑定邮箱地址')}
</div>
}
visible={showEmailBindModal}
onCancel={() => setShowEmailBindModal(false)}
onOk={bindEmail}
size={'small'}
centered={true}
maskClosable={false}
className="modern-modal"
>
<div className="space-y-4 py-4">
<div className="flex gap-3">
<Input
placeholder={t('输入邮箱地址')}
onChange={(value) => handleInputChange('email', value)}
name='email'
type='email'
size="large"
className="!rounded-lg flex-1"
prefix={<IconMail />}
/>
<Button
onClick={sendVerificationCode}
disabled={disableButton || loading}
className="!rounded-lg"
type="primary"
theme="outline"
size='large'
>
{disableButton ? `${t('重新发送')} (${countdown})` : t('获取验证码')}
</Button>
</div>
<Input
placeholder={t('验证码')}
name='email_verification_code'
value={inputs.email_verification_code}
onChange={(value) =>
handleInputChange('email_verification_code', value)
}
size="large"
className="!rounded-lg"
prefix={<IconKey />}
/>
{turnstileEnabled && (
<div className="flex justify-center">
<Turnstile
sitekey={turnstileSiteKey}
onVerify={(token) => {
setTurnstileToken(token);
}}
/>
</div>
)}
</div>
</Modal>
{/* 微信绑定模态框 */}
<Modal
title={
<div className="flex items-center">
<SiWechat className="mr-2 text-green-500" size={20} />
{t('绑定微信账户')}
</div>
}
visible={showWeChatBindModal}
onCancel={() => setShowWeChatBindModal(false)}
footer={null}
size={'small'}
centered={true}
className="modern-modal"
>
<div className="space-y-4 py-4 text-center">
<Image src={status.wechat_qrcode} className="mx-auto" />
<div className="text-gray-600">
<p>{t('微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)')}</p>
</div>
<Input
placeholder={t('验证码')}
name='wechat_verification_code'
value={inputs.wechat_verification_code}
onChange={(v) =>
handleInputChange('wechat_verification_code', v)
}
size="large"
className="!rounded-lg"
prefix={<IconKey />}
/>
<Button
type="primary"
theme="solid"
size='large'
onClick={bindWeChat}
className="!rounded-lg w-full !bg-green-500 hover:!bg-green-600"
icon={<SiWechat size={16} />}
>
{t('绑定')}
</Button>
</div>
</Modal>
{/* 账户删除模态框 */}
<Modal
title={
<div className="flex items-center">
<IconDelete className="mr-2 text-red-500" />
{t('删除账户确认')}
</div>
}
visible={showAccountDeleteModal}
onCancel={() => setShowAccountDeleteModal(false)}
onOk={deleteAccount}
size={'small'}
centered={true}
className="modern-modal"
>
<div className="space-y-4 py-4">
<Banner
type='danger'
description={t('您正在删除自己的帐户,将清空所有数据且不可恢复')}
closeIcon={null}
className="!rounded-lg"
/>
<div>
<Typography.Text strong className="block mb-2 text-red-600">
{t('请输入您的用户名以确认删除')}
</Typography.Text>
<Input
placeholder={t('输入你的账户名{{username}}以确认删除', { username: ` ${userState?.user?.username} ` })}
name='self_account_deletion_confirmation'
value={inputs.self_account_deletion_confirmation}
onChange={(value) =>
handleInputChange('self_account_deletion_confirmation', value)
}
size="large"
className="!rounded-lg"
prefix={<IconUser />}
/>
</div>
{turnstileEnabled && (
<div className="flex justify-center">
<Turnstile
sitekey={turnstileSiteKey}
onVerify={(token) => {
setTurnstileToken(token);
}}
/>
</div>
)}
</div>
</Modal>
{/* 修改密码模态框 */}
<Modal
title={
<div className="flex items-center">
<IconLock className="mr-2 text-orange-500" />
{t('修改密码')}
</div>
}
visible={showChangePasswordModal}
onCancel={() => setShowChangePasswordModal(false)}
onOk={changePassword}
size={'small'}
centered={true}
className="modern-modal"
>
<div className="space-y-4 py-4">
<div>
<Typography.Text strong className="block mb-2">{t('原密码')}</Typography.Text>
<Input
name='original_password'
placeholder={t('请输入原密码')}
type='password'
value={inputs.original_password}
onChange={(value) =>
handleInputChange('original_password', value)
}
size="large"
className="!rounded-lg"
prefix={<IconLock />}
/>
</div>
<div>
<Typography.Text strong className="block mb-2">{t('新密码')}</Typography.Text>
<Input
name='set_new_password'
placeholder={t('请输入新密码')}
type='password'
value={inputs.set_new_password}
onChange={(value) =>
handleInputChange('set_new_password', value)
}
size="large"
className="!rounded-lg"
prefix={<IconLock />}
/>
</div>
<div>
<Typography.Text strong className="block mb-2">{t('确认新密码')}</Typography.Text>
<Input
name='set_new_password_confirmation'
placeholder={t('请再次输入新密码')}
type='password'
value={inputs.set_new_password_confirmation}
onChange={(value) =>
handleInputChange('set_new_password_confirmation', value)
}
size="large"
className="!rounded-lg"
prefix={<IconLock />}
/>
</div>
{turnstileEnabled && (
<div className="flex justify-center">
<Turnstile
sitekey={turnstileSiteKey}
onVerify={(token) => {
setTurnstileToken(token);
}}
/>
</div>
)}
</div>
</Modal>
</div>
);
};
export default PersonalSetting;