mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-04-19 00:07:27 +00:00
Replace the legacy boolean “DisplayInCurrencyEnabled” with an injected, type-safe
configuration `general_setting.quota_display_type`, and wire it through the
backend and frontend.
Backend
- Add `QuotaDisplayType` to `operation_setting.GeneralSetting` with injected
registration via `config.GlobalConfig.Register("general_setting", ...)`.
Helpers: `IsCurrencyDisplay()`, `IsCNYDisplay()`, `GetQuotaDisplayType()`.
- Expose `quota_display_type` in `/api/status` and keep legacy
`display_in_currency` for backward compatibility.
- Logger: update `LogQuota` and `FormatQuota` to support USD/CNY/TOKENS. When
CNY is selected, convert using `operation_setting.USDExchangeRate`.
- Controllers:
- `billing`: compute subscription/usage amounts based on the selected type
(USD: divide by `QuotaPerUnit`; CNY: USD→CNY; TOKENS: keep raw tokens).
- `topup` / `topup_stripe`: treat inputs as “amount” for USD/CNY and as
token-count for TOKENS; adjust min topup and pay money accordingly.
- `misc`: include `quota_display_type` in status payload.
- Compatibility: in `model/option.UpdateOption`, map updates to
`DisplayInCurrencyEnabled` → `general_setting.quota_display_type`
(true→USD, false→TOKENS). Keep exporting the legacy key in `OptionMap`.
Frontend
- Settings: replace the “display in currency” switch with a Select
(`general_setting.quota_display_type`) offering USD / CNY / Tokens.
Provide fallback mapping from legacy `DisplayInCurrencyEnabled`.
- Persist `quota_display_type` to localStorage (keep `display_in_currency`
for legacy components).
- Rendering helpers: base all quota/price rendering on `quota_display_type`;
use `usd_exchange_rate` for CNY symbol/values.
- Pricing page: default view currency follows site display type (USD/CNY),
while TOKENS mode still allows per-view currency toggling when needed.
Notes
- No database migrations required.
- Legacy clients remain functional via compatibility fields.
525 lines
20 KiB
JavaScript
525 lines
20 KiB
JavaScript
/*
|
||
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 from 'react';
|
||
import {
|
||
Button,
|
||
Card,
|
||
Input,
|
||
Space,
|
||
Typography,
|
||
Avatar,
|
||
Tabs,
|
||
TabPane,
|
||
Popover,
|
||
Modal,
|
||
} from '@douyinfe/semi-ui';
|
||
import {
|
||
IconMail,
|
||
IconShield,
|
||
IconGithubLogo,
|
||
IconKey,
|
||
IconLock,
|
||
IconDelete,
|
||
} from '@douyinfe/semi-icons';
|
||
import { SiTelegram, SiWechat, SiLinux } from 'react-icons/si';
|
||
import { UserPlus, ShieldCheck } from 'lucide-react';
|
||
import TelegramLoginButton from 'react-telegram-login';
|
||
import {
|
||
onGitHubOAuthClicked,
|
||
onOIDCClicked,
|
||
onLinuxDOOAuthClicked,
|
||
} from '../../../../helpers';
|
||
import TwoFASetting from '../components/TwoFASetting';
|
||
|
||
const AccountManagement = ({
|
||
t,
|
||
userState,
|
||
status,
|
||
systemToken,
|
||
setShowEmailBindModal,
|
||
setShowWeChatBindModal,
|
||
generateAccessToken,
|
||
handleSystemTokenClick,
|
||
setShowChangePasswordModal,
|
||
setShowAccountDeleteModal,
|
||
}) => {
|
||
const renderAccountInfo = (accountId, label) => {
|
||
if (!accountId || accountId === '') {
|
||
return <span className='text-gray-500'>{t('未绑定')}</span>;
|
||
}
|
||
|
||
const popContent = (
|
||
<div className='text-xs p-2'>
|
||
<Typography.Paragraph copyable={{ content: accountId }}>
|
||
{accountId}
|
||
</Typography.Paragraph>
|
||
{label ? (
|
||
<div className='mt-1 text-[11px] text-gray-500'>{label}</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
<Popover content={popContent} position='top' trigger='hover'>
|
||
<span className='block max-w-full truncate text-gray-600 hover:text-blue-600 cursor-pointer'>
|
||
{accountId}
|
||
</span>
|
||
</Popover>
|
||
);
|
||
};
|
||
const isBound = (accountId) => Boolean(accountId);
|
||
const [showTelegramBindModal, setShowTelegramBindModal] =
|
||
React.useState(false);
|
||
|
||
return (
|
||
<Card className='!rounded-2xl'>
|
||
{/* 卡片头部 */}
|
||
<div className='flex items-center mb-4'>
|
||
<Avatar size='small' color='teal' className='mr-3 shadow-md'>
|
||
<UserPlus size={16} />
|
||
</Avatar>
|
||
<div>
|
||
<Typography.Text className='text-lg font-medium'>
|
||
{t('账户管理')}
|
||
</Typography.Text>
|
||
<div className='text-xs text-gray-600'>
|
||
{t('账户绑定、安全设置和身份验证')}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<Tabs type='card' defaultActiveKey='binding'>
|
||
{/* 账户绑定 Tab */}
|
||
<TabPane
|
||
tab={
|
||
<div className='flex items-center'>
|
||
<UserPlus size={16} className='mr-2' />
|
||
{t('账户绑定')}
|
||
</div>
|
||
}
|
||
itemKey='binding'
|
||
>
|
||
<div className='py-4'>
|
||
<div className='grid grid-cols-1 lg:grid-cols-2 gap-4'>
|
||
{/* 邮箱绑定 */}
|
||
<Card className='!rounded-xl'>
|
||
<div className='flex items-center justify-between gap-3'>
|
||
<div className='flex items-center flex-1 min-w-0'>
|
||
<div className='w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3 flex-shrink-0'>
|
||
<IconMail
|
||
size='default'
|
||
className='text-slate-600 dark:text-slate-300'
|
||
/>
|
||
</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'>
|
||
{renderAccountInfo(
|
||
userState.user?.email,
|
||
t('邮箱地址'),
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className='flex-shrink-0'>
|
||
<Button
|
||
type='primary'
|
||
theme='outline'
|
||
size='small'
|
||
onClick={() => setShowEmailBindModal(true)}
|
||
>
|
||
{isBound(userState.user?.email)
|
||
? t('修改绑定')
|
||
: t('绑定')}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
|
||
{/* 微信绑定 */}
|
||
<Card className='!rounded-xl'>
|
||
<div className='flex items-center justify-between gap-3'>
|
||
<div className='flex items-center flex-1 min-w-0'>
|
||
<div className='w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3 flex-shrink-0'>
|
||
<SiWechat
|
||
size={20}
|
||
className='text-slate-600 dark:text-slate-300'
|
||
/>
|
||
</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'>
|
||
{!status.wechat_login
|
||
? t('未启用')
|
||
: isBound(userState.user?.wechat_id)
|
||
? t('已绑定')
|
||
: t('未绑定')}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className='flex-shrink-0'>
|
||
<Button
|
||
type='primary'
|
||
theme='outline'
|
||
size='small'
|
||
disabled={!status.wechat_login}
|
||
onClick={() => setShowWeChatBindModal(true)}
|
||
>
|
||
{isBound(userState.user?.wechat_id)
|
||
? t('修改绑定')
|
||
: status.wechat_login
|
||
? t('绑定')
|
||
: t('未启用')}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
|
||
{/* GitHub绑定 */}
|
||
<Card className='!rounded-xl'>
|
||
<div className='flex items-center justify-between gap-3'>
|
||
<div className='flex items-center flex-1 min-w-0'>
|
||
<div className='w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3 flex-shrink-0'>
|
||
<IconGithubLogo
|
||
size='default'
|
||
className='text-slate-600 dark:text-slate-300'
|
||
/>
|
||
</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'>
|
||
{renderAccountInfo(
|
||
userState.user?.github_id,
|
||
t('GitHub ID'),
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className='flex-shrink-0'>
|
||
<Button
|
||
type='primary'
|
||
theme='outline'
|
||
size='small'
|
||
onClick={() =>
|
||
onGitHubOAuthClicked(status.github_client_id)
|
||
}
|
||
disabled={
|
||
isBound(userState.user?.github_id) ||
|
||
!status.github_oauth
|
||
}
|
||
>
|
||
{status.github_oauth ? t('绑定') : t('未启用')}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
|
||
{/* OIDC绑定 */}
|
||
<Card className='!rounded-xl'>
|
||
<div className='flex items-center justify-between gap-3'>
|
||
<div className='flex items-center flex-1 min-w-0'>
|
||
<div className='w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3 flex-shrink-0'>
|
||
<IconShield
|
||
size='default'
|
||
className='text-slate-600 dark:text-slate-300'
|
||
/>
|
||
</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'>
|
||
{renderAccountInfo(
|
||
userState.user?.oidc_id,
|
||
t('OIDC ID'),
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className='flex-shrink-0'>
|
||
<Button
|
||
type='primary'
|
||
theme='outline'
|
||
size='small'
|
||
onClick={() =>
|
||
onOIDCClicked(
|
||
status.oidc_authorization_endpoint,
|
||
status.oidc_client_id,
|
||
)
|
||
}
|
||
disabled={
|
||
isBound(userState.user?.oidc_id) || !status.oidc_enabled
|
||
}
|
||
>
|
||
{status.oidc_enabled ? t('绑定') : t('未启用')}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
|
||
{/* Telegram绑定 */}
|
||
<Card className='!rounded-xl'>
|
||
<div className='flex items-center justify-between gap-3'>
|
||
<div className='flex items-center flex-1 min-w-0'>
|
||
<div className='w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3 flex-shrink-0'>
|
||
<SiTelegram
|
||
size={20}
|
||
className='text-slate-600 dark:text-slate-300'
|
||
/>
|
||
</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'>
|
||
{renderAccountInfo(
|
||
userState.user?.telegram_id,
|
||
t('Telegram ID'),
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className='flex-shrink-0'>
|
||
{status.telegram_oauth ? (
|
||
isBound(userState.user?.telegram_id) ? (
|
||
<Button
|
||
disabled
|
||
size='small'
|
||
type='primary'
|
||
theme='outline'
|
||
>
|
||
{t('已绑定')}
|
||
</Button>
|
||
) : (
|
||
<Button
|
||
type='primary'
|
||
theme='outline'
|
||
size='small'
|
||
onClick={() => setShowTelegramBindModal(true)}
|
||
>
|
||
{t('绑定')}
|
||
</Button>
|
||
)
|
||
) : (
|
||
<Button
|
||
disabled
|
||
size='small'
|
||
type='primary'
|
||
theme='outline'
|
||
>
|
||
{t('未启用')}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
<Modal
|
||
title={t('绑定 Telegram')}
|
||
visible={showTelegramBindModal}
|
||
onCancel={() => setShowTelegramBindModal(false)}
|
||
footer={null}
|
||
>
|
||
<div className='my-3 text-sm text-gray-600'>
|
||
{t('点击下方按钮通过 Telegram 完成绑定')}
|
||
</div>
|
||
<div className='flex justify-center'>
|
||
<div className='scale-90'>
|
||
<TelegramLoginButton
|
||
dataAuthUrl='/api/oauth/telegram/bind'
|
||
botName={status.telegram_bot_name}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
|
||
{/* LinuxDO绑定 */}
|
||
<Card className='!rounded-xl'>
|
||
<div className='flex items-center justify-between gap-3'>
|
||
<div className='flex items-center flex-1 min-w-0'>
|
||
<div className='w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3 flex-shrink-0'>
|
||
<SiLinux
|
||
size={20}
|
||
className='text-slate-600 dark:text-slate-300'
|
||
/>
|
||
</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'>
|
||
{renderAccountInfo(
|
||
userState.user?.linux_do_id,
|
||
t('LinuxDO ID'),
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className='flex-shrink-0'>
|
||
<Button
|
||
type='primary'
|
||
theme='outline'
|
||
size='small'
|
||
onClick={() =>
|
||
onLinuxDOOAuthClicked(status.linuxdo_client_id)
|
||
}
|
||
disabled={
|
||
isBound(userState.user?.linux_do_id) ||
|
||
!status.linuxdo_oauth
|
||
}
|
||
>
|
||
{status.linuxdo_oauth ? t('绑定') : t('未启用')}
|
||
</Button>
|
||
</div>
|
||
</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'>
|
||
<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-slate-100 flex items-center justify-center mr-4 flex-shrink-0'>
|
||
<IconKey size='large' className='text-slate-600' />
|
||
</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'
|
||
prefix={<IconKey />}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<Button
|
||
type='primary'
|
||
theme='solid'
|
||
onClick={generateAccessToken}
|
||
className='!bg-slate-600 hover:!bg-slate-700 w-full sm:w-auto'
|
||
icon={<IconKey />}
|
||
>
|
||
{systemToken ? t('重新生成') : t('生成令牌')}
|
||
</Button>
|
||
</div>
|
||
</Card>
|
||
|
||
{/* 密码管理 */}
|
||
<Card className='!rounded-xl w-full'>
|
||
<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-slate-100 flex items-center justify-center mr-4 flex-shrink-0'>
|
||
<IconLock size='large' className='text-slate-600' />
|
||
</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='!bg-slate-600 hover:!bg-slate-700 w-full sm:w-auto'
|
||
icon={<IconLock />}
|
||
>
|
||
{t('修改密码')}
|
||
</Button>
|
||
</div>
|
||
</Card>
|
||
|
||
{/* 两步验证设置 */}
|
||
<TwoFASetting t={t} />
|
||
|
||
{/* 危险区域 */}
|
||
<Card className='!rounded-xl w-full'>
|
||
<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-slate-100 flex items-center justify-center mr-4 flex-shrink-0'>
|
||
<IconDelete size='large' className='text-slate-600' />
|
||
</div>
|
||
<div>
|
||
<Typography.Title
|
||
heading={6}
|
||
className='mb-1 text-slate-700'
|
||
>
|
||
{t('删除账户')}
|
||
</Typography.Title>
|
||
<Typography.Text type='tertiary' className='text-sm'>
|
||
{t('此操作不可逆,所有数据将被永久删除')}
|
||
</Typography.Text>
|
||
</div>
|
||
</div>
|
||
<Button
|
||
type='danger'
|
||
theme='solid'
|
||
onClick={() => setShowAccountDeleteModal(true)}
|
||
className='w-full sm:w-auto !bg-slate-500 hover:!bg-slate-600'
|
||
icon={<IconDelete />}
|
||
>
|
||
{t('删除账户')}
|
||
</Button>
|
||
</div>
|
||
</Card>
|
||
</Space>
|
||
</div>
|
||
</div>
|
||
</TabPane>
|
||
</Tabs>
|
||
</Card>
|
||
);
|
||
};
|
||
|
||
export default AccountManagement;
|