mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-03-30 04:03:18 +00:00
🎨 chore(web): apply ESLint and Prettier auto-fixes (baseline)
- Ran: bun run eslint:fix && bun run lint:fix - Inserted AGPL license header via eslint-plugin-header - Enforced no-multiple-empty-lines and other lint rules - Formatted code using Prettier v3 (@so1ve/prettier-config) - No functional changes; formatting-only baseline across JS/JSX files
This commit is contained in:
@@ -1,34 +1,42 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2021: true, node: true },
|
||||
parserOptions: { ecmaVersion: 2020, sourceType: 'module', ecmaFeatures: { jsx: true } },
|
||||
parserOptions: {
|
||||
ecmaVersion: 2020,
|
||||
sourceType: 'module',
|
||||
ecmaFeatures: { jsx: true },
|
||||
},
|
||||
plugins: ['header', 'react-hooks'],
|
||||
overrides: [
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
rules: {
|
||||
'header/header': [2, 'block', [
|
||||
'',
|
||||
'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',
|
||||
''
|
||||
]],
|
||||
'no-multiple-empty-lines': ['error', { max: 1 }]
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
'header/header': [
|
||||
2,
|
||||
'block',
|
||||
[
|
||||
'',
|
||||
'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',
|
||||
'',
|
||||
],
|
||||
],
|
||||
'no-multiple-empty-lines': ['error', { max: 1 }],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
<!doctype html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="/logo.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
<meta
|
||||
name="description"
|
||||
content="OpenAI 接口聚合管理,支持多种渠道包括 Azure,可用于二次分发管理 key,仅单可执行文件,已打包好 Docker 镜像,一键部署,开箱即用"
|
||||
/>
|
||||
<title>New API</title>
|
||||
</head>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="/logo.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
<meta name="description" content="OpenAI 接口聚合管理,支持多种渠道包括 Azure,可用于二次分发管理 key,仅单可执行文件,已打包好 Docker 镜像,一键部署,开箱即用" />
|
||||
<title>New API</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/index.jsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/index.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -22,4 +22,4 @@ export default {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
@@ -73,10 +73,7 @@ function App() {
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/forbidden'
|
||||
element={<Forbidden />}
|
||||
/>
|
||||
<Route path='/forbidden' element={<Forbidden />} />
|
||||
<Route
|
||||
path='/console/models'
|
||||
element={
|
||||
|
||||
@@ -31,17 +31,10 @@ import {
|
||||
setUserData,
|
||||
onGitHubOAuthClicked,
|
||||
onOIDCClicked,
|
||||
onLinuxDOOAuthClicked
|
||||
onLinuxDOOAuthClicked,
|
||||
} from '../../helpers';
|
||||
import Turnstile from 'react-turnstile';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Form,
|
||||
Icon,
|
||||
Modal,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { Button, Card, Divider, Form, Icon, Modal } from '@douyinfe/semi-ui';
|
||||
import Title from '@douyinfe/semi-ui/lib/es/typography/title';
|
||||
import Text from '@douyinfe/semi-ui/lib/es/typography/text';
|
||||
import TelegramLoginButton from 'react-telegram-login';
|
||||
@@ -77,7 +70,8 @@ const LoginForm = () => {
|
||||
const [emailLoginLoading, setEmailLoginLoading] = useState(false);
|
||||
const [loginLoading, setLoginLoading] = useState(false);
|
||||
const [resetPasswordLoading, setResetPasswordLoading] = useState(false);
|
||||
const [otherLoginOptionsLoading, setOtherLoginOptionsLoading] = useState(false);
|
||||
const [otherLoginOptionsLoading, setOtherLoginOptionsLoading] =
|
||||
useState(false);
|
||||
const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false);
|
||||
const [showTwoFA, setShowTwoFA] = useState(false);
|
||||
|
||||
@@ -247,10 +241,7 @@ const LoginForm = () => {
|
||||
const handleOIDCClick = () => {
|
||||
setOidcLoading(true);
|
||||
try {
|
||||
onOIDCClicked(
|
||||
status.oidc_authorization_endpoint,
|
||||
status.oidc_client_id
|
||||
);
|
||||
onOIDCClicked(status.oidc_authorization_endpoint, status.oidc_client_id);
|
||||
} finally {
|
||||
// 由于重定向,这里不会执行到,但为了完整性添加
|
||||
setTimeout(() => setOidcLoading(false), 3000);
|
||||
@@ -306,73 +297,87 @@ const LoginForm = () => {
|
||||
|
||||
const renderOAuthOptions = () => {
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="flex items-center justify-center mb-6 gap-2">
|
||||
<img src={logo} alt="Logo" className="h-10 rounded-full" />
|
||||
<Title heading={3} className='!text-gray-800'>{systemName}</Title>
|
||||
<div className='flex flex-col items-center'>
|
||||
<div className='w-full max-w-md'>
|
||||
<div className='flex items-center justify-center mb-6 gap-2'>
|
||||
<img src={logo} alt='Logo' className='h-10 rounded-full' />
|
||||
<Title heading={3} className='!text-gray-800'>
|
||||
{systemName}
|
||||
</Title>
|
||||
</div>
|
||||
|
||||
<Card className="border-0 !rounded-2xl overflow-hidden">
|
||||
<div className="flex justify-center pt-6 pb-2">
|
||||
<Title heading={3} className="text-gray-800 dark:text-gray-200">{t('登 录')}</Title>
|
||||
<Card className='border-0 !rounded-2xl overflow-hidden'>
|
||||
<div className='flex justify-center pt-6 pb-2'>
|
||||
<Title heading={3} className='text-gray-800 dark:text-gray-200'>
|
||||
{t('登 录')}
|
||||
</Title>
|
||||
</div>
|
||||
<div className="px-2 py-8">
|
||||
<div className="space-y-3">
|
||||
<div className='px-2 py-8'>
|
||||
<div className='space-y-3'>
|
||||
{status.wechat_login && (
|
||||
<Button
|
||||
theme='outline'
|
||||
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors"
|
||||
type="tertiary"
|
||||
icon={<Icon svg={<WeChatIcon />} style={{ color: '#07C160' }} />}
|
||||
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
|
||||
type='tertiary'
|
||||
icon={
|
||||
<Icon svg={<WeChatIcon />} style={{ color: '#07C160' }} />
|
||||
}
|
||||
onClick={onWeChatLoginClicked}
|
||||
loading={wechatLoading}
|
||||
>
|
||||
<span className="ml-3">{t('使用 微信 继续')}</span>
|
||||
<span className='ml-3'>{t('使用 微信 继续')}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{status.github_oauth && (
|
||||
<Button
|
||||
theme='outline'
|
||||
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors"
|
||||
type="tertiary"
|
||||
icon={<IconGithubLogo size="large" />}
|
||||
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
|
||||
type='tertiary'
|
||||
icon={<IconGithubLogo size='large' />}
|
||||
onClick={handleGitHubClick}
|
||||
loading={githubLoading}
|
||||
>
|
||||
<span className="ml-3">{t('使用 GitHub 继续')}</span>
|
||||
<span className='ml-3'>{t('使用 GitHub 继续')}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{status.oidc_enabled && (
|
||||
<Button
|
||||
theme='outline'
|
||||
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors"
|
||||
type="tertiary"
|
||||
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
|
||||
type='tertiary'
|
||||
icon={<OIDCIcon style={{ color: '#1877F2' }} />}
|
||||
onClick={handleOIDCClick}
|
||||
loading={oidcLoading}
|
||||
>
|
||||
<span className="ml-3">{t('使用 OIDC 继续')}</span>
|
||||
<span className='ml-3'>{t('使用 OIDC 继续')}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{status.linuxdo_oauth && (
|
||||
<Button
|
||||
theme='outline'
|
||||
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors"
|
||||
type="tertiary"
|
||||
icon={<LinuxDoIcon style={{ color: '#E95420', width: '20px', height: '20px' }} />}
|
||||
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
|
||||
type='tertiary'
|
||||
icon={
|
||||
<LinuxDoIcon
|
||||
style={{
|
||||
color: '#E95420',
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
onClick={handleLinuxDOClick}
|
||||
loading={linuxdoLoading}
|
||||
>
|
||||
<span className="ml-3">{t('使用 LinuxDO 继续')}</span>
|
||||
<span className='ml-3'>{t('使用 LinuxDO 继续')}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{status.telegram_oauth && (
|
||||
<div className="flex justify-center my-2">
|
||||
<div className='flex justify-center my-2'>
|
||||
<TelegramLoginButton
|
||||
dataOnauth={onTelegramLoginClicked}
|
||||
botName={status.telegram_bot_name}
|
||||
@@ -385,24 +390,24 @@ const LoginForm = () => {
|
||||
</Divider>
|
||||
|
||||
<Button
|
||||
theme="solid"
|
||||
type="primary"
|
||||
className="w-full h-12 flex items-center justify-center bg-black text-white !rounded-full hover:bg-gray-800 transition-colors"
|
||||
icon={<IconMail size="large" />}
|
||||
theme='solid'
|
||||
type='primary'
|
||||
className='w-full h-12 flex items-center justify-center bg-black text-white !rounded-full hover:bg-gray-800 transition-colors'
|
||||
icon={<IconMail size='large' />}
|
||||
onClick={handleEmailLoginClick}
|
||||
loading={emailLoginLoading}
|
||||
>
|
||||
<span className="ml-3">{t('使用 邮箱或用户名 登录')}</span>
|
||||
<span className='ml-3'>{t('使用 邮箱或用户名 登录')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!status.self_use_mode_enabled && (
|
||||
<div className="mt-6 text-center text-sm">
|
||||
<div className='mt-6 text-center text-sm'>
|
||||
<Text>
|
||||
{t('没有账户?')}{' '}
|
||||
<Link
|
||||
to="/register"
|
||||
className="text-blue-600 hover:text-blue-800 font-medium"
|
||||
to='/register'
|
||||
className='text-blue-600 hover:text-blue-800 font-medium'
|
||||
>
|
||||
{t('注册')}
|
||||
</Link>
|
||||
@@ -418,44 +423,46 @@ const LoginForm = () => {
|
||||
|
||||
const renderEmailLoginForm = () => {
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="flex items-center justify-center mb-6 gap-2">
|
||||
<img src={logo} alt="Logo" className="h-10 rounded-full" />
|
||||
<div className='flex flex-col items-center'>
|
||||
<div className='w-full max-w-md'>
|
||||
<div className='flex items-center justify-center mb-6 gap-2'>
|
||||
<img src={logo} alt='Logo' className='h-10 rounded-full' />
|
||||
<Title heading={3}>{systemName}</Title>
|
||||
</div>
|
||||
|
||||
<Card className="border-0 !rounded-2xl overflow-hidden">
|
||||
<div className="flex justify-center pt-6 pb-2">
|
||||
<Title heading={3} className="text-gray-800 dark:text-gray-200">{t('登 录')}</Title>
|
||||
<Card className='border-0 !rounded-2xl overflow-hidden'>
|
||||
<div className='flex justify-center pt-6 pb-2'>
|
||||
<Title heading={3} className='text-gray-800 dark:text-gray-200'>
|
||||
{t('登 录')}
|
||||
</Title>
|
||||
</div>
|
||||
<div className="px-2 py-8">
|
||||
<Form className="space-y-3">
|
||||
<div className='px-2 py-8'>
|
||||
<Form className='space-y-3'>
|
||||
<Form.Input
|
||||
field="username"
|
||||
field='username'
|
||||
label={t('用户名或邮箱')}
|
||||
placeholder={t('请输入您的用户名或邮箱地址')}
|
||||
name="username"
|
||||
name='username'
|
||||
onChange={(value) => handleChange('username', value)}
|
||||
prefix={<IconMail />}
|
||||
/>
|
||||
|
||||
<Form.Input
|
||||
field="password"
|
||||
field='password'
|
||||
label={t('密码')}
|
||||
placeholder={t('请输入您的密码')}
|
||||
name="password"
|
||||
mode="password"
|
||||
name='password'
|
||||
mode='password'
|
||||
onChange={(value) => handleChange('password', value)}
|
||||
prefix={<IconLock />}
|
||||
/>
|
||||
|
||||
<div className="space-y-2 pt-2">
|
||||
<div className='space-y-2 pt-2'>
|
||||
<Button
|
||||
theme="solid"
|
||||
className="w-full !rounded-full"
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
theme='solid'
|
||||
className='w-full !rounded-full'
|
||||
type='primary'
|
||||
htmlType='submit'
|
||||
onClick={handleSubmit}
|
||||
loading={loginLoading}
|
||||
>
|
||||
@@ -463,9 +470,9 @@ const LoginForm = () => {
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
theme="borderless"
|
||||
theme='borderless'
|
||||
type='tertiary'
|
||||
className="w-full !rounded-full"
|
||||
className='w-full !rounded-full'
|
||||
onClick={handleResetPasswordClick}
|
||||
loading={resetPasswordLoading}
|
||||
>
|
||||
@@ -474,17 +481,21 @@ const LoginForm = () => {
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
{(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth) && (
|
||||
{(status.github_oauth ||
|
||||
status.oidc_enabled ||
|
||||
status.wechat_login ||
|
||||
status.linuxdo_oauth ||
|
||||
status.telegram_oauth) && (
|
||||
<>
|
||||
<Divider margin='12px' align='center'>
|
||||
{t('或')}
|
||||
</Divider>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<div className='mt-4 text-center'>
|
||||
<Button
|
||||
theme="outline"
|
||||
type="tertiary"
|
||||
className="w-full !rounded-full"
|
||||
theme='outline'
|
||||
type='tertiary'
|
||||
className='w-full !rounded-full'
|
||||
onClick={handleOtherLoginOptionsClick}
|
||||
loading={otherLoginOptionsLoading}
|
||||
>
|
||||
@@ -495,12 +506,12 @@ const LoginForm = () => {
|
||||
)}
|
||||
|
||||
{!status.self_use_mode_enabled && (
|
||||
<div className="mt-6 text-center text-sm">
|
||||
<div className='mt-6 text-center text-sm'>
|
||||
<Text>
|
||||
{t('没有账户?')}{' '}
|
||||
<Link
|
||||
to="/register"
|
||||
className="text-blue-600 hover:text-blue-800 font-medium"
|
||||
to='/register'
|
||||
className='text-blue-600 hover:text-blue-800 font-medium'
|
||||
>
|
||||
{t('注册')}
|
||||
</Link>
|
||||
@@ -529,21 +540,25 @@ const LoginForm = () => {
|
||||
loading: wechatCodeSubmitLoading,
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col items-center">
|
||||
<img src={status.wechat_qrcode} alt="微信二维码" className="mb-4" />
|
||||
<div className='flex flex-col items-center'>
|
||||
<img src={status.wechat_qrcode} alt='微信二维码' className='mb-4' />
|
||||
</div>
|
||||
|
||||
<div className="text-center mb-4">
|
||||
<p>{t('微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)')}</p>
|
||||
<div className='text-center mb-4'>
|
||||
<p>
|
||||
{t('微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Form>
|
||||
<Form.Input
|
||||
field="wechat_verification_code"
|
||||
field='wechat_verification_code'
|
||||
placeholder={t('验证码')}
|
||||
label={t('验证码')}
|
||||
value={inputs.wechat_verification_code}
|
||||
onChange={(value) => handleChange('wechat_verification_code', value)}
|
||||
onChange={(value) =>
|
||||
handleChange('wechat_verification_code', value)
|
||||
}
|
||||
/>
|
||||
</Form>
|
||||
</Modal>
|
||||
@@ -555,10 +570,18 @@ const LoginForm = () => {
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<div className="flex items-center">
|
||||
<div className="w-8 h-8 rounded-full bg-green-100 dark:bg-green-900 flex items-center justify-center mr-3">
|
||||
<svg className="w-4 h-4 text-green-600 dark:text-green-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M6 8a2 2 0 11-4 0 2 2 0 014 0zM8 7a1 1 0 100 2h8a1 1 0 100-2H8zM6 14a2 2 0 11-4 0 2 2 0 014 0zM8 13a1 1 0 100 2h8a1 1 0 100-2H8z" clipRule="evenodd" />
|
||||
<div className='flex items-center'>
|
||||
<div className='w-8 h-8 rounded-full bg-green-100 dark:bg-green-900 flex items-center justify-center mr-3'>
|
||||
<svg
|
||||
className='w-4 h-4 text-green-600 dark:text-green-400'
|
||||
fill='currentColor'
|
||||
viewBox='0 0 20 20'
|
||||
>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
d='M6 8a2 2 0 11-4 0 2 2 0 014 0zM8 7a1 1 0 100 2h8a1 1 0 100-2H8zM6 14a2 2 0 11-4 0 2 2 0 014 0zM8 13a1 1 0 100 2h8a1 1 0 100-2H8z'
|
||||
clipRule='evenodd'
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
两步验证
|
||||
@@ -580,19 +603,32 @@ const LoginForm = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className='relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
|
||||
{/* 背景模糊晕染球 */}
|
||||
<div className="blur-ball blur-ball-indigo" style={{ top: '-80px', right: '-80px', transform: 'none' }} />
|
||||
<div className="blur-ball blur-ball-teal" style={{ top: '50%', left: '-120px' }} />
|
||||
<div className="w-full max-w-sm mt-[60px]">
|
||||
{showEmailLogin || !(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth)
|
||||
<div
|
||||
className='blur-ball blur-ball-indigo'
|
||||
style={{ top: '-80px', right: '-80px', transform: 'none' }}
|
||||
/>
|
||||
<div
|
||||
className='blur-ball blur-ball-teal'
|
||||
style={{ top: '50%', left: '-120px' }}
|
||||
/>
|
||||
<div className='w-full max-w-sm mt-[60px]'>
|
||||
{showEmailLogin ||
|
||||
!(
|
||||
status.github_oauth ||
|
||||
status.oidc_enabled ||
|
||||
status.wechat_login ||
|
||||
status.linuxdo_oauth ||
|
||||
status.telegram_oauth
|
||||
)
|
||||
? renderEmailLoginForm()
|
||||
: renderOAuthOptions()}
|
||||
{renderWeChatLoginModal()}
|
||||
{render2FAModal()}
|
||||
|
||||
{turnstileEnabled && (
|
||||
<div className="flex justify-center mt-6">
|
||||
<div className='flex justify-center mt-6'>
|
||||
<Turnstile
|
||||
sitekey={turnstileSiteKey}
|
||||
onVerify={(token) => {
|
||||
|
||||
@@ -20,7 +20,13 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
import React, { useContext, useEffect } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { API, showError, showSuccess, updateAPI, setUserData } from '../../helpers';
|
||||
import {
|
||||
API,
|
||||
showError,
|
||||
showSuccess,
|
||||
updateAPI,
|
||||
setUserData,
|
||||
} from '../../helpers';
|
||||
import { UserContext } from '../../context/User';
|
||||
import Loading from '../common/ui/Loading';
|
||||
|
||||
|
||||
@@ -18,7 +18,14 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { API, copy, showError, showNotice, getLogo, getSystemName } from '../../helpers';
|
||||
import {
|
||||
API,
|
||||
copy,
|
||||
showError,
|
||||
showNotice,
|
||||
getLogo,
|
||||
getSystemName,
|
||||
} from '../../helpers';
|
||||
import { useSearchParams, Link } from 'react-router-dom';
|
||||
import { Button, Card, Form, Typography, Banner } from '@douyinfe/semi-ui';
|
||||
import { IconMail, IconLock, IconCopy } from '@douyinfe/semi-icons';
|
||||
@@ -55,7 +62,7 @@ const PasswordResetConfirm = () => {
|
||||
if (formApi) {
|
||||
formApi.setValues({
|
||||
email: email || '',
|
||||
newPassword: newPassword || ''
|
||||
newPassword: newPassword || '',
|
||||
});
|
||||
}
|
||||
}, [searchParams, newPassword, formApi]);
|
||||
@@ -97,40 +104,53 @@ const PasswordResetConfirm = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className='relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
|
||||
{/* 背景模糊晕染球 */}
|
||||
<div className="blur-ball blur-ball-indigo" style={{ top: '-80px', right: '-80px', transform: 'none' }} />
|
||||
<div className="blur-ball blur-ball-teal" style={{ top: '50%', left: '-120px' }} />
|
||||
<div className="w-full max-w-sm mt-[60px]">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="flex items-center justify-center mb-6 gap-2">
|
||||
<img src={logo} alt="Logo" className="h-10 rounded-full" />
|
||||
<Title heading={3} className='!text-gray-800'>{systemName}</Title>
|
||||
<div
|
||||
className='blur-ball blur-ball-indigo'
|
||||
style={{ top: '-80px', right: '-80px', transform: 'none' }}
|
||||
/>
|
||||
<div
|
||||
className='blur-ball blur-ball-teal'
|
||||
style={{ top: '50%', left: '-120px' }}
|
||||
/>
|
||||
<div className='w-full max-w-sm mt-[60px]'>
|
||||
<div className='flex flex-col items-center'>
|
||||
<div className='w-full max-w-md'>
|
||||
<div className='flex items-center justify-center mb-6 gap-2'>
|
||||
<img src={logo} alt='Logo' className='h-10 rounded-full' />
|
||||
<Title heading={3} className='!text-gray-800'>
|
||||
{systemName}
|
||||
</Title>
|
||||
</div>
|
||||
|
||||
<Card className="border-0 !rounded-2xl overflow-hidden">
|
||||
<div className="flex justify-center pt-6 pb-2">
|
||||
<Title heading={3} className="text-gray-800 dark:text-gray-200">{t('密码重置确认')}</Title>
|
||||
<Card className='border-0 !rounded-2xl overflow-hidden'>
|
||||
<div className='flex justify-center pt-6 pb-2'>
|
||||
<Title heading={3} className='text-gray-800 dark:text-gray-200'>
|
||||
{t('密码重置确认')}
|
||||
</Title>
|
||||
</div>
|
||||
<div className="px-2 py-8">
|
||||
<div className='px-2 py-8'>
|
||||
{!isValidResetLink && (
|
||||
<Banner
|
||||
type="danger"
|
||||
type='danger'
|
||||
description={t('无效的重置链接,请重新发起密码重置请求')}
|
||||
className="mb-4 !rounded-lg"
|
||||
className='mb-4 !rounded-lg'
|
||||
closeIcon={null}
|
||||
/>
|
||||
)}
|
||||
<Form
|
||||
getFormApi={(api) => setFormApi(api)}
|
||||
initValues={{ email: email || '', newPassword: newPassword || '' }}
|
||||
className="space-y-4"
|
||||
initValues={{
|
||||
email: email || '',
|
||||
newPassword: newPassword || '',
|
||||
}}
|
||||
className='space-y-4'
|
||||
>
|
||||
<Form.Input
|
||||
field="email"
|
||||
field='email'
|
||||
label={t('邮箱')}
|
||||
name="email"
|
||||
name='email'
|
||||
disabled={true}
|
||||
prefix={<IconMail />}
|
||||
placeholder={email ? '' : t('等待获取邮箱信息...')}
|
||||
@@ -138,19 +158,21 @@ const PasswordResetConfirm = () => {
|
||||
|
||||
{newPassword && (
|
||||
<Form.Input
|
||||
field="newPassword"
|
||||
field='newPassword'
|
||||
label={t('新密码')}
|
||||
name="newPassword"
|
||||
name='newPassword'
|
||||
disabled={true}
|
||||
prefix={<IconLock />}
|
||||
suffix={
|
||||
<Button
|
||||
icon={<IconCopy />}
|
||||
type="tertiary"
|
||||
theme="borderless"
|
||||
type='tertiary'
|
||||
theme='borderless'
|
||||
onClick={async () => {
|
||||
await copy(newPassword);
|
||||
showNotice(`${t('密码已复制到剪贴板:')} ${newPassword}`);
|
||||
showNotice(
|
||||
`${t('密码已复制到剪贴板:')} ${newPassword}`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
{t('复制')}
|
||||
@@ -159,23 +181,32 @@ const PasswordResetConfirm = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 pt-2">
|
||||
<div className='space-y-2 pt-2'>
|
||||
<Button
|
||||
theme="solid"
|
||||
className="w-full !rounded-full"
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
theme='solid'
|
||||
className='w-full !rounded-full'
|
||||
type='primary'
|
||||
htmlType='submit'
|
||||
onClick={handleSubmit}
|
||||
loading={loading}
|
||||
disabled={disableButton || newPassword || !isValidResetLink}
|
||||
disabled={
|
||||
disableButton || newPassword || !isValidResetLink
|
||||
}
|
||||
>
|
||||
{newPassword ? t('密码重置完成') : t('确认重置密码')}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
<div className="mt-6 text-center text-sm">
|
||||
<Text><Link to="/login" className="text-blue-600 hover:text-blue-800 font-medium">{t('返回登录')}</Link></Text>
|
||||
<div className='mt-6 text-center text-sm'>
|
||||
<Text>
|
||||
<Link
|
||||
to='/login'
|
||||
className='text-blue-600 hover:text-blue-800 font-medium'
|
||||
>
|
||||
{t('返回登录')}
|
||||
</Link>
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -18,7 +18,14 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { API, getLogo, showError, showInfo, showSuccess, getSystemName } from '../../helpers';
|
||||
import {
|
||||
API,
|
||||
getLogo,
|
||||
showError,
|
||||
showInfo,
|
||||
showSuccess,
|
||||
getSystemName,
|
||||
} from '../../helpers';
|
||||
import Turnstile from 'react-turnstile';
|
||||
import { Button, Card, Form, Typography } from '@douyinfe/semi-ui';
|
||||
import { IconMail } from '@douyinfe/semi-icons';
|
||||
@@ -97,57 +104,77 @@ const PasswordResetForm = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className='relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
|
||||
{/* 背景模糊晕染球 */}
|
||||
<div className="blur-ball blur-ball-indigo" style={{ top: '-80px', right: '-80px', transform: 'none' }} />
|
||||
<div className="blur-ball blur-ball-teal" style={{ top: '50%', left: '-120px' }} />
|
||||
<div className="w-full max-w-sm mt-[60px]">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="flex items-center justify-center mb-6 gap-2">
|
||||
<img src={logo} alt="Logo" className="h-10 rounded-full" />
|
||||
<Title heading={3} className='!text-gray-800'>{systemName}</Title>
|
||||
<div
|
||||
className='blur-ball blur-ball-indigo'
|
||||
style={{ top: '-80px', right: '-80px', transform: 'none' }}
|
||||
/>
|
||||
<div
|
||||
className='blur-ball blur-ball-teal'
|
||||
style={{ top: '50%', left: '-120px' }}
|
||||
/>
|
||||
<div className='w-full max-w-sm mt-[60px]'>
|
||||
<div className='flex flex-col items-center'>
|
||||
<div className='w-full max-w-md'>
|
||||
<div className='flex items-center justify-center mb-6 gap-2'>
|
||||
<img src={logo} alt='Logo' className='h-10 rounded-full' />
|
||||
<Title heading={3} className='!text-gray-800'>
|
||||
{systemName}
|
||||
</Title>
|
||||
</div>
|
||||
|
||||
<Card className="border-0 !rounded-2xl overflow-hidden">
|
||||
<div className="flex justify-center pt-6 pb-2">
|
||||
<Title heading={3} className="text-gray-800 dark:text-gray-200">{t('密码重置')}</Title>
|
||||
<Card className='border-0 !rounded-2xl overflow-hidden'>
|
||||
<div className='flex justify-center pt-6 pb-2'>
|
||||
<Title heading={3} className='text-gray-800 dark:text-gray-200'>
|
||||
{t('密码重置')}
|
||||
</Title>
|
||||
</div>
|
||||
<div className="px-2 py-8">
|
||||
<Form className="space-y-3">
|
||||
<div className='px-2 py-8'>
|
||||
<Form className='space-y-3'>
|
||||
<Form.Input
|
||||
field="email"
|
||||
field='email'
|
||||
label={t('邮箱')}
|
||||
placeholder={t('请输入您的邮箱地址')}
|
||||
name="email"
|
||||
name='email'
|
||||
value={email}
|
||||
onChange={handleChange}
|
||||
prefix={<IconMail />}
|
||||
/>
|
||||
|
||||
<div className="space-y-2 pt-2">
|
||||
<div className='space-y-2 pt-2'>
|
||||
<Button
|
||||
theme="solid"
|
||||
className="w-full !rounded-full"
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
theme='solid'
|
||||
className='w-full !rounded-full'
|
||||
type='primary'
|
||||
htmlType='submit'
|
||||
onClick={handleSubmit}
|
||||
loading={loading}
|
||||
disabled={disableButton}
|
||||
>
|
||||
{disableButton ? `${t('重试')} (${countdown})` : t('提交')}
|
||||
{disableButton
|
||||
? `${t('重试')} (${countdown})`
|
||||
: t('提交')}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
<div className="mt-6 text-center text-sm">
|
||||
<Text>{t('想起来了?')} <Link to="/login" className="text-blue-600 hover:text-blue-800 font-medium">{t('登录')}</Link></Text>
|
||||
<div className='mt-6 text-center text-sm'>
|
||||
<Text>
|
||||
{t('想起来了?')}{' '}
|
||||
<Link
|
||||
to='/login'
|
||||
className='text-blue-600 hover:text-blue-800 font-medium'
|
||||
>
|
||||
{t('登录')}
|
||||
</Link>
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{turnstileEnabled && (
|
||||
<div className="flex justify-center mt-6">
|
||||
<div className='flex justify-center mt-6'>
|
||||
<Turnstile
|
||||
sitekey={turnstileSiteKey}
|
||||
onVerify={(token) => {
|
||||
|
||||
@@ -27,20 +27,19 @@ import {
|
||||
showSuccess,
|
||||
updateAPI,
|
||||
getSystemName,
|
||||
setUserData
|
||||
setUserData,
|
||||
} from '../../helpers';
|
||||
import Turnstile from 'react-turnstile';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Form,
|
||||
Icon,
|
||||
Modal,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { Button, Card, Divider, Form, Icon, Modal } from '@douyinfe/semi-ui';
|
||||
import Title from '@douyinfe/semi-ui/lib/es/typography/title';
|
||||
import Text from '@douyinfe/semi-ui/lib/es/typography/text';
|
||||
import { IconGithubLogo, IconMail, IconUser, IconLock, IconKey } from '@douyinfe/semi-icons';
|
||||
import {
|
||||
IconGithubLogo,
|
||||
IconMail,
|
||||
IconUser,
|
||||
IconLock,
|
||||
IconKey,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import {
|
||||
onGitHubOAuthClicked,
|
||||
onLinuxDOOAuthClicked,
|
||||
@@ -78,7 +77,8 @@ const RegisterForm = () => {
|
||||
const [emailRegisterLoading, setEmailRegisterLoading] = useState(false);
|
||||
const [registerLoading, setRegisterLoading] = useState(false);
|
||||
const [verificationCodeLoading, setVerificationCodeLoading] = useState(false);
|
||||
const [otherRegisterOptionsLoading, setOtherRegisterOptionsLoading] = useState(false);
|
||||
const [otherRegisterOptionsLoading, setOtherRegisterOptionsLoading] =
|
||||
useState(false);
|
||||
const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false);
|
||||
const [disableButton, setDisableButton] = useState(false);
|
||||
const [countdown, setCountdown] = useState(30);
|
||||
@@ -236,10 +236,7 @@ const RegisterForm = () => {
|
||||
const handleOIDCClick = () => {
|
||||
setOidcLoading(true);
|
||||
try {
|
||||
onOIDCClicked(
|
||||
status.oidc_authorization_endpoint,
|
||||
status.oidc_client_id
|
||||
);
|
||||
onOIDCClicked(status.oidc_authorization_endpoint, status.oidc_client_id);
|
||||
} finally {
|
||||
setTimeout(() => setOidcLoading(false), 3000);
|
||||
}
|
||||
@@ -303,73 +300,87 @@ const RegisterForm = () => {
|
||||
|
||||
const renderOAuthOptions = () => {
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="flex items-center justify-center mb-6 gap-2">
|
||||
<img src={logo} alt="Logo" className="h-10 rounded-full" />
|
||||
<Title heading={3} className='!text-gray-800'>{systemName}</Title>
|
||||
<div className='flex flex-col items-center'>
|
||||
<div className='w-full max-w-md'>
|
||||
<div className='flex items-center justify-center mb-6 gap-2'>
|
||||
<img src={logo} alt='Logo' className='h-10 rounded-full' />
|
||||
<Title heading={3} className='!text-gray-800'>
|
||||
{systemName}
|
||||
</Title>
|
||||
</div>
|
||||
|
||||
<Card className="border-0 !rounded-2xl overflow-hidden">
|
||||
<div className="flex justify-center pt-6 pb-2">
|
||||
<Title heading={3} className="text-gray-800 dark:text-gray-200">{t('注 册')}</Title>
|
||||
<Card className='border-0 !rounded-2xl overflow-hidden'>
|
||||
<div className='flex justify-center pt-6 pb-2'>
|
||||
<Title heading={3} className='text-gray-800 dark:text-gray-200'>
|
||||
{t('注 册')}
|
||||
</Title>
|
||||
</div>
|
||||
<div className="px-2 py-8">
|
||||
<div className="space-y-3">
|
||||
<div className='px-2 py-8'>
|
||||
<div className='space-y-3'>
|
||||
{status.wechat_login && (
|
||||
<Button
|
||||
theme='outline'
|
||||
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors"
|
||||
type="tertiary"
|
||||
icon={<Icon svg={<WeChatIcon />} style={{ color: '#07C160' }} />}
|
||||
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
|
||||
type='tertiary'
|
||||
icon={
|
||||
<Icon svg={<WeChatIcon />} style={{ color: '#07C160' }} />
|
||||
}
|
||||
onClick={onWeChatLoginClicked}
|
||||
loading={wechatLoading}
|
||||
>
|
||||
<span className="ml-3">{t('使用 微信 继续')}</span>
|
||||
<span className='ml-3'>{t('使用 微信 继续')}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{status.github_oauth && (
|
||||
<Button
|
||||
theme='outline'
|
||||
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors"
|
||||
type="tertiary"
|
||||
icon={<IconGithubLogo size="large" />}
|
||||
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
|
||||
type='tertiary'
|
||||
icon={<IconGithubLogo size='large' />}
|
||||
onClick={handleGitHubClick}
|
||||
loading={githubLoading}
|
||||
>
|
||||
<span className="ml-3">{t('使用 GitHub 继续')}</span>
|
||||
<span className='ml-3'>{t('使用 GitHub 继续')}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{status.oidc_enabled && (
|
||||
<Button
|
||||
theme='outline'
|
||||
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors"
|
||||
type="tertiary"
|
||||
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
|
||||
type='tertiary'
|
||||
icon={<OIDCIcon style={{ color: '#1877F2' }} />}
|
||||
onClick={handleOIDCClick}
|
||||
loading={oidcLoading}
|
||||
>
|
||||
<span className="ml-3">{t('使用 OIDC 继续')}</span>
|
||||
<span className='ml-3'>{t('使用 OIDC 继续')}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{status.linuxdo_oauth && (
|
||||
<Button
|
||||
theme='outline'
|
||||
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors"
|
||||
type="tertiary"
|
||||
icon={<LinuxDoIcon style={{ color: '#E95420', width: '20px', height: '20px' }} />}
|
||||
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
|
||||
type='tertiary'
|
||||
icon={
|
||||
<LinuxDoIcon
|
||||
style={{
|
||||
color: '#E95420',
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
onClick={handleLinuxDOClick}
|
||||
loading={linuxdoLoading}
|
||||
>
|
||||
<span className="ml-3">{t('使用 LinuxDO 继续')}</span>
|
||||
<span className='ml-3'>{t('使用 LinuxDO 继续')}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{status.telegram_oauth && (
|
||||
<div className="flex justify-center my-2">
|
||||
<div className='flex justify-center my-2'>
|
||||
<TelegramLoginButton
|
||||
dataOnauth={onTelegramLoginClicked}
|
||||
botName={status.telegram_bot_name}
|
||||
@@ -382,19 +393,27 @@ const RegisterForm = () => {
|
||||
</Divider>
|
||||
|
||||
<Button
|
||||
theme="solid"
|
||||
type="primary"
|
||||
className="w-full h-12 flex items-center justify-center bg-black text-white !rounded-full hover:bg-gray-800 transition-colors"
|
||||
icon={<IconMail size="large" />}
|
||||
theme='solid'
|
||||
type='primary'
|
||||
className='w-full h-12 flex items-center justify-center bg-black text-white !rounded-full hover:bg-gray-800 transition-colors'
|
||||
icon={<IconMail size='large' />}
|
||||
onClick={handleEmailRegisterClick}
|
||||
loading={emailRegisterLoading}
|
||||
>
|
||||
<span className="ml-3">{t('使用 用户名 注册')}</span>
|
||||
<span className='ml-3'>{t('使用 用户名 注册')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-center text-sm">
|
||||
<Text>{t('已有账户?')} <Link to="/login" className="text-blue-600 hover:text-blue-800 font-medium">{t('登录')}</Link></Text>
|
||||
<div className='mt-6 text-center text-sm'>
|
||||
<Text>
|
||||
{t('已有账户?')}{' '}
|
||||
<Link
|
||||
to='/login'
|
||||
className='text-blue-600 hover:text-blue-800 font-medium'
|
||||
>
|
||||
{t('登录')}
|
||||
</Link>
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -405,44 +424,48 @@ const RegisterForm = () => {
|
||||
|
||||
const renderEmailRegisterForm = () => {
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="flex items-center justify-center mb-6 gap-2">
|
||||
<img src={logo} alt="Logo" className="h-10 rounded-full" />
|
||||
<Title heading={3} className='!text-gray-800'>{systemName}</Title>
|
||||
<div className='flex flex-col items-center'>
|
||||
<div className='w-full max-w-md'>
|
||||
<div className='flex items-center justify-center mb-6 gap-2'>
|
||||
<img src={logo} alt='Logo' className='h-10 rounded-full' />
|
||||
<Title heading={3} className='!text-gray-800'>
|
||||
{systemName}
|
||||
</Title>
|
||||
</div>
|
||||
|
||||
<Card className="border-0 !rounded-2xl overflow-hidden">
|
||||
<div className="flex justify-center pt-6 pb-2">
|
||||
<Title heading={3} className="text-gray-800 dark:text-gray-200">{t('注 册')}</Title>
|
||||
<Card className='border-0 !rounded-2xl overflow-hidden'>
|
||||
<div className='flex justify-center pt-6 pb-2'>
|
||||
<Title heading={3} className='text-gray-800 dark:text-gray-200'>
|
||||
{t('注 册')}
|
||||
</Title>
|
||||
</div>
|
||||
<div className="px-2 py-8">
|
||||
<Form className="space-y-3">
|
||||
<div className='px-2 py-8'>
|
||||
<Form className='space-y-3'>
|
||||
<Form.Input
|
||||
field="username"
|
||||
field='username'
|
||||
label={t('用户名')}
|
||||
placeholder={t('请输入用户名')}
|
||||
name="username"
|
||||
name='username'
|
||||
onChange={(value) => handleChange('username', value)}
|
||||
prefix={<IconUser />}
|
||||
/>
|
||||
|
||||
<Form.Input
|
||||
field="password"
|
||||
field='password'
|
||||
label={t('密码')}
|
||||
placeholder={t('输入密码,最短 8 位,最长 20 位')}
|
||||
name="password"
|
||||
mode="password"
|
||||
name='password'
|
||||
mode='password'
|
||||
onChange={(value) => handleChange('password', value)}
|
||||
prefix={<IconLock />}
|
||||
/>
|
||||
|
||||
<Form.Input
|
||||
field="password2"
|
||||
field='password2'
|
||||
label={t('确认密码')}
|
||||
placeholder={t('确认密码')}
|
||||
name="password2"
|
||||
mode="password"
|
||||
name='password2'
|
||||
mode='password'
|
||||
onChange={(value) => handleChange('password2', value)}
|
||||
prefix={<IconLock />}
|
||||
/>
|
||||
@@ -450,11 +473,11 @@ const RegisterForm = () => {
|
||||
{showEmailVerification && (
|
||||
<>
|
||||
<Form.Input
|
||||
field="email"
|
||||
field='email'
|
||||
label={t('邮箱')}
|
||||
placeholder={t('输入邮箱地址')}
|
||||
name="email"
|
||||
type="email"
|
||||
name='email'
|
||||
type='email'
|
||||
onChange={(value) => handleChange('email', value)}
|
||||
prefix={<IconMail />}
|
||||
suffix={
|
||||
@@ -463,27 +486,31 @@ const RegisterForm = () => {
|
||||
loading={verificationCodeLoading}
|
||||
disabled={disableButton || verificationCodeLoading}
|
||||
>
|
||||
{disableButton ? `${t('重新发送')} (${countdown})` : t('获取验证码')}
|
||||
{disableButton
|
||||
? `${t('重新发送')} (${countdown})`
|
||||
: t('获取验证码')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Form.Input
|
||||
field="verification_code"
|
||||
field='verification_code'
|
||||
label={t('验证码')}
|
||||
placeholder={t('输入验证码')}
|
||||
name="verification_code"
|
||||
onChange={(value) => handleChange('verification_code', value)}
|
||||
name='verification_code'
|
||||
onChange={(value) =>
|
||||
handleChange('verification_code', value)
|
||||
}
|
||||
prefix={<IconKey />}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 pt-2">
|
||||
<div className='space-y-2 pt-2'>
|
||||
<Button
|
||||
theme="solid"
|
||||
className="w-full !rounded-full"
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
theme='solid'
|
||||
className='w-full !rounded-full'
|
||||
type='primary'
|
||||
htmlType='submit'
|
||||
onClick={handleSubmit}
|
||||
loading={registerLoading}
|
||||
>
|
||||
@@ -492,17 +519,21 @@ const RegisterForm = () => {
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
{(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth) && (
|
||||
{(status.github_oauth ||
|
||||
status.oidc_enabled ||
|
||||
status.wechat_login ||
|
||||
status.linuxdo_oauth ||
|
||||
status.telegram_oauth) && (
|
||||
<>
|
||||
<Divider margin='12px' align='center'>
|
||||
{t('或')}
|
||||
</Divider>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<div className='mt-4 text-center'>
|
||||
<Button
|
||||
theme="outline"
|
||||
type="tertiary"
|
||||
className="w-full !rounded-full"
|
||||
theme='outline'
|
||||
type='tertiary'
|
||||
className='w-full !rounded-full'
|
||||
onClick={handleOtherRegisterOptionsClick}
|
||||
loading={otherRegisterOptionsLoading}
|
||||
>
|
||||
@@ -512,8 +543,16 @@ const RegisterForm = () => {
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-6 text-center text-sm">
|
||||
<Text>{t('已有账户?')} <Link to="/login" className="text-blue-600 hover:text-blue-800 font-medium">{t('登录')}</Link></Text>
|
||||
<div className='mt-6 text-center text-sm'>
|
||||
<Text>
|
||||
{t('已有账户?')}{' '}
|
||||
<Link
|
||||
to='/login'
|
||||
className='text-blue-600 hover:text-blue-800 font-medium'
|
||||
>
|
||||
{t('登录')}
|
||||
</Link>
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -536,21 +575,25 @@ const RegisterForm = () => {
|
||||
loading: wechatCodeSubmitLoading,
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col items-center">
|
||||
<img src={status.wechat_qrcode} alt="微信二维码" className="mb-4" />
|
||||
<div className='flex flex-col items-center'>
|
||||
<img src={status.wechat_qrcode} alt='微信二维码' className='mb-4' />
|
||||
</div>
|
||||
|
||||
<div className="text-center mb-4">
|
||||
<p>{t('微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)')}</p>
|
||||
<div className='text-center mb-4'>
|
||||
<p>
|
||||
{t('微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Form>
|
||||
<Form.Input
|
||||
field="wechat_verification_code"
|
||||
field='wechat_verification_code'
|
||||
placeholder={t('验证码')}
|
||||
label={t('验证码')}
|
||||
value={inputs.wechat_verification_code}
|
||||
onChange={(value) => handleChange('wechat_verification_code', value)}
|
||||
onChange={(value) =>
|
||||
handleChange('wechat_verification_code', value)
|
||||
}
|
||||
/>
|
||||
</Form>
|
||||
</Modal>
|
||||
@@ -558,18 +601,31 @@ const RegisterForm = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className='relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
|
||||
{/* 背景模糊晕染球 */}
|
||||
<div className="blur-ball blur-ball-indigo" style={{ top: '-80px', right: '-80px', transform: 'none' }} />
|
||||
<div className="blur-ball blur-ball-teal" style={{ top: '50%', left: '-120px' }} />
|
||||
<div className="w-full max-w-sm mt-[60px]">
|
||||
{showEmailRegister || !(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth)
|
||||
<div
|
||||
className='blur-ball blur-ball-indigo'
|
||||
style={{ top: '-80px', right: '-80px', transform: 'none' }}
|
||||
/>
|
||||
<div
|
||||
className='blur-ball blur-ball-teal'
|
||||
style={{ top: '50%', left: '-120px' }}
|
||||
/>
|
||||
<div className='w-full max-w-sm mt-[60px]'>
|
||||
{showEmailRegister ||
|
||||
!(
|
||||
status.github_oauth ||
|
||||
status.oidc_enabled ||
|
||||
status.wechat_login ||
|
||||
status.linuxdo_oauth ||
|
||||
status.telegram_oauth
|
||||
)
|
||||
? renderEmailRegisterForm()
|
||||
: renderOAuthOptions()}
|
||||
{renderWeChatLoginModal()}
|
||||
|
||||
{turnstileEnabled && (
|
||||
<div className="flex justify-center mt-6">
|
||||
<div className='flex justify-center mt-6'>
|
||||
<Turnstile
|
||||
sitekey={turnstileSiteKey}
|
||||
onVerify={(token) => {
|
||||
|
||||
@@ -17,7 +17,14 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { API, showError, showSuccess } from '../../helpers';
|
||||
import { Button, Card, Divider, Form, Input, Typography } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Form,
|
||||
Input,
|
||||
Typography,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
@@ -44,7 +51,7 @@ const TwoFAVerification = ({ onSuccess, onBack, isModal = false }) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.post('/api/user/login/2fa', {
|
||||
code: verificationCode
|
||||
code: verificationCode,
|
||||
});
|
||||
|
||||
if (res.data.success) {
|
||||
@@ -72,30 +79,30 @@ const TwoFAVerification = ({ onSuccess, onBack, isModal = false }) => {
|
||||
|
||||
if (isModal) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Paragraph className="text-gray-600 dark:text-gray-300">
|
||||
<div className='space-y-4'>
|
||||
<Paragraph className='text-gray-600 dark:text-gray-300'>
|
||||
请输入认证器应用显示的验证码完成登录
|
||||
</Paragraph>
|
||||
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<Form.Input
|
||||
field="code"
|
||||
label={useBackupCode ? "备用码" : "验证码"}
|
||||
placeholder={useBackupCode ? "请输入8位备用码" : "请输入6位验证码"}
|
||||
field='code'
|
||||
label={useBackupCode ? '备用码' : '验证码'}
|
||||
placeholder={useBackupCode ? '请输入8位备用码' : '请输入6位验证码'}
|
||||
value={verificationCode}
|
||||
onChange={setVerificationCode}
|
||||
onKeyPress={handleKeyPress}
|
||||
size="large"
|
||||
size='large'
|
||||
style={{ marginBottom: 16 }}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<Button
|
||||
htmlType="submit"
|
||||
type="primary"
|
||||
htmlType='submit'
|
||||
type='primary'
|
||||
loading={loading}
|
||||
block
|
||||
size="large"
|
||||
size='large'
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
验证并登录
|
||||
@@ -106,8 +113,8 @@ const TwoFAVerification = ({ onSuccess, onBack, isModal = false }) => {
|
||||
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Button
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
theme='borderless'
|
||||
type='tertiary'
|
||||
onClick={() => {
|
||||
setUseBackupCode(!useBackupCode);
|
||||
setVerificationCode('');
|
||||
@@ -119,8 +126,8 @@ const TwoFAVerification = ({ onSuccess, onBack, isModal = false }) => {
|
||||
|
||||
{onBack && (
|
||||
<Button
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
theme='borderless'
|
||||
type='tertiary'
|
||||
onClick={onBack}
|
||||
style={{ color: '#1890ff', padding: 0 }}
|
||||
>
|
||||
@@ -129,15 +136,14 @@ const TwoFAVerification = ({ onSuccess, onBack, isModal = false }) => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-3">
|
||||
<Text size="small" type="secondary">
|
||||
<div className='bg-gray-50 dark:bg-gray-800 rounded-lg p-3'>
|
||||
<Text size='small' type='secondary'>
|
||||
<strong>提示:</strong>
|
||||
<br />
|
||||
• 验证码每30秒更新一次
|
||||
<br />
|
||||
• 如果无法获取验证码,请使用备用码
|
||||
<br />
|
||||
• 每个备用码只能使用一次
|
||||
<br />• 每个备用码只能使用一次
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
@@ -145,39 +151,41 @@ const TwoFAVerification = ({ onSuccess, onBack, isModal = false }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '60vh'
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '60vh',
|
||||
}}
|
||||
>
|
||||
<Card style={{ width: 400, padding: 24 }}>
|
||||
<div style={{ textAlign: 'center', marginBottom: 24 }}>
|
||||
<Title heading={3}>两步验证</Title>
|
||||
<Paragraph type="secondary">
|
||||
<Paragraph type='secondary'>
|
||||
请输入认证器应用显示的验证码完成登录
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<Form.Input
|
||||
field="code"
|
||||
label={useBackupCode ? "备用码" : "验证码"}
|
||||
placeholder={useBackupCode ? "请输入8位备用码" : "请输入6位验证码"}
|
||||
field='code'
|
||||
label={useBackupCode ? '备用码' : '验证码'}
|
||||
placeholder={useBackupCode ? '请输入8位备用码' : '请输入6位验证码'}
|
||||
value={verificationCode}
|
||||
onChange={setVerificationCode}
|
||||
onKeyPress={handleKeyPress}
|
||||
size="large"
|
||||
size='large'
|
||||
style={{ marginBottom: 16 }}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<Button
|
||||
htmlType="submit"
|
||||
type="primary"
|
||||
htmlType='submit'
|
||||
type='primary'
|
||||
loading={loading}
|
||||
block
|
||||
size="large"
|
||||
size='large'
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
验证并登录
|
||||
@@ -188,8 +196,8 @@ const TwoFAVerification = ({ onSuccess, onBack, isModal = false }) => {
|
||||
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Button
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
theme='borderless'
|
||||
type='tertiary'
|
||||
onClick={() => {
|
||||
setUseBackupCode(!useBackupCode);
|
||||
setVerificationCode('');
|
||||
@@ -201,8 +209,8 @@ const TwoFAVerification = ({ onSuccess, onBack, isModal = false }) => {
|
||||
|
||||
{onBack && (
|
||||
<Button
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
theme='borderless'
|
||||
type='tertiary'
|
||||
onClick={onBack}
|
||||
style={{ color: '#1890ff', padding: 0 }}
|
||||
>
|
||||
@@ -211,15 +219,21 @@ const TwoFAVerification = ({ onSuccess, onBack, isModal = false }) => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 24, padding: 16, background: '#f6f8fa', borderRadius: 6 }}>
|
||||
<Text size="small" type="secondary">
|
||||
<div
|
||||
style={{
|
||||
marginTop: 24,
|
||||
padding: 16,
|
||||
background: '#f6f8fa',
|
||||
borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
<Text size='small' type='secondary'>
|
||||
<strong>提示:</strong>
|
||||
<br />
|
||||
• 验证码每30秒更新一次
|
||||
<br />
|
||||
• 如果无法获取验证码,请使用备用码
|
||||
<br />
|
||||
• 每个备用码只能使用一次
|
||||
<br />• 每个备用码只能使用一次
|
||||
</Text>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -227,4 +241,4 @@ const TwoFAVerification = ({ onSuccess, onBack, isModal = false }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default TwoFAVerification;
|
||||
export default TwoFAVerification;
|
||||
|
||||
@@ -160,7 +160,7 @@ export function PreCode(props) {
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="copy-code-button"
|
||||
className='copy-code-button'
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '8px',
|
||||
@@ -174,14 +174,15 @@ export function PreCode(props) {
|
||||
>
|
||||
<Tooltip content={t('复制代码')}>
|
||||
<Button
|
||||
size="small"
|
||||
theme="borderless"
|
||||
size='small'
|
||||
theme='borderless'
|
||||
icon={<IconCopy />}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (ref.current) {
|
||||
const code = ref.current.querySelector('code')?.innerText ?? '';
|
||||
const code =
|
||||
ref.current.querySelector('code')?.innerText ?? '';
|
||||
copy(code).then((success) => {
|
||||
if (success) {
|
||||
Toast.success(t('代码已复制到剪贴板'));
|
||||
@@ -217,7 +218,13 @@ export function PreCode(props) {
|
||||
backgroundColor: 'var(--semi-color-bg-1)',
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: '8px', fontSize: '12px', color: 'var(--semi-color-text-2)' }}>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: '8px',
|
||||
fontSize: '12px',
|
||||
color: 'var(--semi-color-text-2)',
|
||||
}}
|
||||
>
|
||||
HTML预览:
|
||||
</div>
|
||||
<div dangerouslySetInnerHTML={{ __html: htmlCode }} />
|
||||
@@ -258,7 +265,7 @@ function CustomCode(props) {
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Button size="small" onClick={toggleCollapsed} theme="solid">
|
||||
<Button size='small' onClick={toggleCollapsed} theme='solid'>
|
||||
{t('显示更多')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -367,7 +374,16 @@ function _MarkdownContent(props) {
|
||||
components={{
|
||||
pre: PreCode,
|
||||
code: CustomCode,
|
||||
p: (pProps) => <p {...pProps} dir="auto" style={{ lineHeight: '1.6', color: isUserMessage ? 'white' : 'inherit' }} />,
|
||||
p: (pProps) => (
|
||||
<p
|
||||
{...pProps}
|
||||
dir='auto'
|
||||
style={{
|
||||
lineHeight: '1.6',
|
||||
color: isUserMessage ? 'white' : 'inherit',
|
||||
}}
|
||||
/>
|
||||
),
|
||||
a: (aProps) => {
|
||||
const href = aProps.href || '';
|
||||
if (/\.(aac|mp3|opus|wav)$/.test(href)) {
|
||||
@@ -379,13 +395,16 @@ function _MarkdownContent(props) {
|
||||
}
|
||||
if (/\.(3gp|3g2|webm|ogv|mpeg|mp4|avi)$/.test(href)) {
|
||||
return (
|
||||
<video controls style={{ width: '100%', maxWidth: '100%', margin: '12px 0' }}>
|
||||
<video
|
||||
controls
|
||||
style={{ width: '100%', maxWidth: '100%', margin: '12px 0' }}
|
||||
>
|
||||
<source src={href} />
|
||||
</video>
|
||||
);
|
||||
}
|
||||
const isInternal = /^\/#/i.test(href);
|
||||
const target = isInternal ? '_self' : aProps.target ?? '_blank';
|
||||
const target = isInternal ? '_self' : (aProps.target ?? '_blank');
|
||||
return (
|
||||
<a
|
||||
{...aProps}
|
||||
@@ -403,20 +422,84 @@ function _MarkdownContent(props) {
|
||||
/>
|
||||
);
|
||||
},
|
||||
h1: (props) => <h1 {...props} style={{ fontSize: '24px', fontWeight: 'bold', margin: '20px 0 12px 0', color: isUserMessage ? 'white' : 'var(--semi-color-text-0)' }} />,
|
||||
h2: (props) => <h2 {...props} style={{ fontSize: '20px', fontWeight: 'bold', margin: '18px 0 10px 0', color: isUserMessage ? 'white' : 'var(--semi-color-text-0)' }} />,
|
||||
h3: (props) => <h3 {...props} style={{ fontSize: '18px', fontWeight: 'bold', margin: '16px 0 8px 0', color: isUserMessage ? 'white' : 'var(--semi-color-text-0)' }} />,
|
||||
h4: (props) => <h4 {...props} style={{ fontSize: '16px', fontWeight: 'bold', margin: '14px 0 6px 0', color: isUserMessage ? 'white' : 'var(--semi-color-text-0)' }} />,
|
||||
h5: (props) => <h5 {...props} style={{ fontSize: '14px', fontWeight: 'bold', margin: '12px 0 4px 0', color: isUserMessage ? 'white' : 'var(--semi-color-text-0)' }} />,
|
||||
h6: (props) => <h6 {...props} style={{ fontSize: '13px', fontWeight: 'bold', margin: '10px 0 4px 0', color: isUserMessage ? 'white' : 'var(--semi-color-text-0)' }} />,
|
||||
h1: (props) => (
|
||||
<h1
|
||||
{...props}
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
margin: '20px 0 12px 0',
|
||||
color: isUserMessage ? 'white' : 'var(--semi-color-text-0)',
|
||||
}}
|
||||
/>
|
||||
),
|
||||
h2: (props) => (
|
||||
<h2
|
||||
{...props}
|
||||
style={{
|
||||
fontSize: '20px',
|
||||
fontWeight: 'bold',
|
||||
margin: '18px 0 10px 0',
|
||||
color: isUserMessage ? 'white' : 'var(--semi-color-text-0)',
|
||||
}}
|
||||
/>
|
||||
),
|
||||
h3: (props) => (
|
||||
<h3
|
||||
{...props}
|
||||
style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
margin: '16px 0 8px 0',
|
||||
color: isUserMessage ? 'white' : 'var(--semi-color-text-0)',
|
||||
}}
|
||||
/>
|
||||
),
|
||||
h4: (props) => (
|
||||
<h4
|
||||
{...props}
|
||||
style={{
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
margin: '14px 0 6px 0',
|
||||
color: isUserMessage ? 'white' : 'var(--semi-color-text-0)',
|
||||
}}
|
||||
/>
|
||||
),
|
||||
h5: (props) => (
|
||||
<h5
|
||||
{...props}
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
margin: '12px 0 4px 0',
|
||||
color: isUserMessage ? 'white' : 'var(--semi-color-text-0)',
|
||||
}}
|
||||
/>
|
||||
),
|
||||
h6: (props) => (
|
||||
<h6
|
||||
{...props}
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: 'bold',
|
||||
margin: '10px 0 4px 0',
|
||||
color: isUserMessage ? 'white' : 'var(--semi-color-text-0)',
|
||||
}}
|
||||
/>
|
||||
),
|
||||
blockquote: (props) => (
|
||||
<blockquote
|
||||
{...props}
|
||||
style={{
|
||||
borderLeft: isUserMessage ? '4px solid rgba(255, 255, 255, 0.5)' : '4px solid var(--semi-color-primary)',
|
||||
borderLeft: isUserMessage
|
||||
? '4px solid rgba(255, 255, 255, 0.5)'
|
||||
: '4px solid var(--semi-color-primary)',
|
||||
paddingLeft: '16px',
|
||||
margin: '12px 0',
|
||||
backgroundColor: isUserMessage ? 'rgba(255, 255, 255, 0.1)' : 'var(--semi-color-fill-0)',
|
||||
backgroundColor: isUserMessage
|
||||
? 'rgba(255, 255, 255, 0.1)'
|
||||
: 'var(--semi-color-fill-0)',
|
||||
padding: '8px 16px',
|
||||
borderRadius: '0 4px 4px 0',
|
||||
fontStyle: 'italic',
|
||||
@@ -424,9 +507,36 @@ function _MarkdownContent(props) {
|
||||
}}
|
||||
/>
|
||||
),
|
||||
ul: (props) => <ul {...props} style={{ margin: '8px 0', paddingLeft: '20px', color: isUserMessage ? 'white' : 'inherit' }} />,
|
||||
ol: (props) => <ol {...props} style={{ margin: '8px 0', paddingLeft: '20px', color: isUserMessage ? 'white' : 'inherit' }} />,
|
||||
li: (props) => <li {...props} style={{ margin: '4px 0', lineHeight: '1.6', color: isUserMessage ? 'white' : 'inherit' }} />,
|
||||
ul: (props) => (
|
||||
<ul
|
||||
{...props}
|
||||
style={{
|
||||
margin: '8px 0',
|
||||
paddingLeft: '20px',
|
||||
color: isUserMessage ? 'white' : 'inherit',
|
||||
}}
|
||||
/>
|
||||
),
|
||||
ol: (props) => (
|
||||
<ol
|
||||
{...props}
|
||||
style={{
|
||||
margin: '8px 0',
|
||||
paddingLeft: '20px',
|
||||
color: isUserMessage ? 'white' : 'inherit',
|
||||
}}
|
||||
/>
|
||||
),
|
||||
li: (props) => (
|
||||
<li
|
||||
{...props}
|
||||
style={{
|
||||
margin: '4px 0',
|
||||
lineHeight: '1.6',
|
||||
color: isUserMessage ? 'white' : 'inherit',
|
||||
}}
|
||||
/>
|
||||
),
|
||||
table: (props) => (
|
||||
<div style={{ overflow: 'auto', margin: '12px 0' }}>
|
||||
<table
|
||||
@@ -434,7 +544,9 @@ function _MarkdownContent(props) {
|
||||
style={{
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse',
|
||||
border: isUserMessage ? '1px solid rgba(255, 255, 255, 0.3)' : '1px solid var(--semi-color-border)',
|
||||
border: isUserMessage
|
||||
? '1px solid rgba(255, 255, 255, 0.3)'
|
||||
: '1px solid var(--semi-color-border)',
|
||||
borderRadius: '6px',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
@@ -446,8 +558,12 @@ function _MarkdownContent(props) {
|
||||
{...props}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
backgroundColor: isUserMessage ? 'rgba(255, 255, 255, 0.2)' : 'var(--semi-color-fill-1)',
|
||||
border: isUserMessage ? '1px solid rgba(255, 255, 255, 0.3)' : '1px solid var(--semi-color-border)',
|
||||
backgroundColor: isUserMessage
|
||||
? 'rgba(255, 255, 255, 0.2)'
|
||||
: 'var(--semi-color-fill-1)',
|
||||
border: isUserMessage
|
||||
? '1px solid rgba(255, 255, 255, 0.3)'
|
||||
: '1px solid var(--semi-color-border)',
|
||||
fontWeight: 'bold',
|
||||
textAlign: 'left',
|
||||
color: isUserMessage ? 'white' : 'inherit',
|
||||
@@ -459,7 +575,9 @@ function _MarkdownContent(props) {
|
||||
{...props}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
border: isUserMessage ? '1px solid rgba(255, 255, 255, 0.3)' : '1px solid var(--semi-color-border)',
|
||||
border: isUserMessage
|
||||
? '1px solid rgba(255, 255, 255, 0.3)'
|
||||
: '1px solid var(--semi-color-border)',
|
||||
color: isUserMessage ? 'white' : 'inherit',
|
||||
}}
|
||||
/>
|
||||
@@ -496,25 +614,29 @@ export function MarkdownRenderer(props) {
|
||||
color: 'var(--semi-color-text-0)',
|
||||
...style,
|
||||
}}
|
||||
dir="auto"
|
||||
dir='auto'
|
||||
{...otherProps}
|
||||
>
|
||||
{loading ? (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '16px',
|
||||
color: 'var(--semi-color-text-2)',
|
||||
}}>
|
||||
<div style={{
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
border: '2px solid var(--semi-color-border)',
|
||||
borderTop: '2px solid var(--semi-color-primary)',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite',
|
||||
}} />
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '16px',
|
||||
color: 'var(--semi-color-text-2)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
border: '2px solid var(--semi-color-border)',
|
||||
borderTop: '2px solid var(--semi-color-primary)',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite',
|
||||
}}
|
||||
/>
|
||||
正在渲染...
|
||||
</div>
|
||||
) : (
|
||||
@@ -529,4 +651,4 @@ export function MarkdownRenderer(props) {
|
||||
);
|
||||
}
|
||||
|
||||
export default MarkdownRenderer;
|
||||
export default MarkdownRenderer;
|
||||
|
||||
@@ -59,12 +59,12 @@
|
||||
}
|
||||
|
||||
.user-message a {
|
||||
color: #87CEEB !important;
|
||||
color: #87ceeb !important;
|
||||
/* 浅蓝色链接 */
|
||||
}
|
||||
|
||||
.user-message a:hover {
|
||||
color: #B0E0E6 !important;
|
||||
color: #b0e0e6 !important;
|
||||
/* hover时更浅的蓝色 */
|
||||
}
|
||||
|
||||
@@ -298,7 +298,12 @@ pre:hover .copy-code-button {
|
||||
.markdown-body hr {
|
||||
border: none;
|
||||
height: 1px;
|
||||
background: linear-gradient(to right, transparent, var(--semi-color-border), transparent);
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
var(--semi-color-border),
|
||||
transparent
|
||||
);
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
@@ -332,7 +337,7 @@ pre:hover .copy-code-button {
|
||||
}
|
||||
|
||||
/* 任务列表样式 */
|
||||
.markdown-body input[type="checkbox"] {
|
||||
.markdown-body input[type='checkbox'] {
|
||||
margin-right: 8px;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
@@ -441,4 +446,4 @@ pre:hover .copy-code-button {
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.6s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ const TwoFactorAuthModal = ({
|
||||
onCancel,
|
||||
title,
|
||||
description,
|
||||
placeholder
|
||||
placeholder,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -56,10 +56,18 @@ const TwoFactorAuthModal = ({
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<div className="flex items-center">
|
||||
<div className="w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900 flex items-center justify-center mr-3">
|
||||
<svg className="w-4 h-4 text-blue-600 dark:text-blue-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clipRule="evenodd" />
|
||||
<div className='flex items-center'>
|
||||
<div className='w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900 flex items-center justify-center mr-3'>
|
||||
<svg
|
||||
className='w-4 h-4 text-blue-600 dark:text-blue-400'
|
||||
fill='currentColor'
|
||||
viewBox='0 0 20 20'
|
||||
>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
d='M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z'
|
||||
clipRule='evenodd'
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{title || t('安全验证')}
|
||||
@@ -69,11 +77,9 @@ const TwoFactorAuthModal = ({
|
||||
onCancel={onCancel}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onCancel}>
|
||||
{t('取消')}
|
||||
</Button>
|
||||
<Button onClick={onCancel}>{t('取消')}</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
type='primary'
|
||||
loading={loading}
|
||||
disabled={!code || loading}
|
||||
onClick={onVerify}
|
||||
@@ -85,18 +91,29 @@ const TwoFactorAuthModal = ({
|
||||
width={500}
|
||||
style={{ maxWidth: '90vw' }}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<div className='space-y-6'>
|
||||
{/* 安全提示 */}
|
||||
<div className="bg-blue-50 dark:bg-blue-900 rounded-lg p-4">
|
||||
<div className="flex items-start">
|
||||
<svg className="w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 mr-3 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
|
||||
<div className='bg-blue-50 dark:bg-blue-900 rounded-lg p-4'>
|
||||
<div className='flex items-start'>
|
||||
<svg
|
||||
className='w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 mr-3 flex-shrink-0'
|
||||
fill='currentColor'
|
||||
viewBox='0 0 20 20'
|
||||
>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
d='M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z'
|
||||
clipRule='evenodd'
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<Typography.Text strong className="text-blue-800 dark:text-blue-200">
|
||||
<Typography.Text
|
||||
strong
|
||||
className='text-blue-800 dark:text-blue-200'
|
||||
>
|
||||
{t('安全验证')}
|
||||
</Typography.Text>
|
||||
<Typography.Text className="block text-blue-700 dark:text-blue-300 text-sm mt-1">
|
||||
<Typography.Text className='block text-blue-700 dark:text-blue-300 text-sm mt-1'>
|
||||
{description || t('为了保护账户安全,请验证您的两步验证码。')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
@@ -105,19 +122,19 @@ const TwoFactorAuthModal = ({
|
||||
|
||||
{/* 验证码输入 */}
|
||||
<div>
|
||||
<Typography.Text strong className="block mb-2">
|
||||
<Typography.Text strong className='block mb-2'>
|
||||
{t('验证身份')}
|
||||
</Typography.Text>
|
||||
<Input
|
||||
placeholder={placeholder || t('请输入认证器验证码或备用码')}
|
||||
value={code}
|
||||
onChange={onCodeChange}
|
||||
size="large"
|
||||
size='large'
|
||||
maxLength={8}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoFocus
|
||||
/>
|
||||
<Typography.Text type="tertiary" size="small" className="mt-2 block">
|
||||
<Typography.Text type='tertiary' size='small' className='mt-2 block'>
|
||||
{t('支持6位TOTP验证码或8位备用码')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
@@ -126,4 +143,4 @@ const TwoFactorAuthModal = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default TwoFactorAuthModal;
|
||||
export default TwoFactorAuthModal;
|
||||
|
||||
@@ -27,15 +27,15 @@ const { Text } = Typography;
|
||||
|
||||
/**
|
||||
* CardPro 高级卡片组件
|
||||
*
|
||||
*
|
||||
* 布局分为6个区域:
|
||||
* 1. 统计信息区域 (statsArea)
|
||||
* 2. 描述信息区域 (descriptionArea)
|
||||
* 2. 描述信息区域 (descriptionArea)
|
||||
* 3. 类型切换/标签区域 (tabsArea)
|
||||
* 4. 操作按钮区域 (actionsArea)
|
||||
* 5. 搜索表单区域 (searchArea)
|
||||
* 6. 分页区域 (paginationArea) - 固定在卡片底部
|
||||
*
|
||||
*
|
||||
* 支持三种布局类型:
|
||||
* - type1: 操作型 (如TokensTable) - 描述信息 + 操作按钮 + 搜索表单
|
||||
* - type2: 查询型 (如LogsTable) - 统计信息 + 搜索表单
|
||||
@@ -71,47 +71,38 @@ const CardPro = ({
|
||||
const hasMobileHideableContent = actionsArea || searchArea;
|
||||
|
||||
const renderHeader = () => {
|
||||
const hasContent = statsArea || descriptionArea || tabsArea || actionsArea || searchArea;
|
||||
const hasContent =
|
||||
statsArea || descriptionArea || tabsArea || actionsArea || searchArea;
|
||||
if (!hasContent) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full">
|
||||
<div className='flex flex-col w-full'>
|
||||
{/* 统计信息区域 - 用于type2 */}
|
||||
{type === 'type2' && statsArea && (
|
||||
<>
|
||||
{statsArea}
|
||||
</>
|
||||
)}
|
||||
{type === 'type2' && statsArea && <>{statsArea}</>}
|
||||
|
||||
{/* 描述信息区域 - 用于type1和type3 */}
|
||||
{(type === 'type1' || type === 'type3') && descriptionArea && (
|
||||
<>
|
||||
{descriptionArea}
|
||||
</>
|
||||
<>{descriptionArea}</>
|
||||
)}
|
||||
|
||||
{/* 第一个分隔线 - 在描述信息或统计信息后面 */}
|
||||
{((type === 'type1' || type === 'type3') && descriptionArea) ||
|
||||
(type === 'type2' && statsArea) ? (
|
||||
<Divider margin="12px" />
|
||||
(type === 'type2' && statsArea) ? (
|
||||
<Divider margin='12px' />
|
||||
) : null}
|
||||
|
||||
{/* 类型切换/标签区域 - 主要用于type3 */}
|
||||
{type === 'type3' && tabsArea && (
|
||||
<>
|
||||
{tabsArea}
|
||||
</>
|
||||
)}
|
||||
{type === 'type3' && tabsArea && <>{tabsArea}</>}
|
||||
|
||||
{/* 移动端操作切换按钮 */}
|
||||
{isMobile && hasMobileHideableContent && (
|
||||
<>
|
||||
<div className="w-full mb-2">
|
||||
<div className='w-full mb-2'>
|
||||
<Button
|
||||
onClick={toggleMobileActions}
|
||||
icon={showMobileActions ? <IconEyeClosed /> : <IconEyeOpened />}
|
||||
type="tertiary"
|
||||
size="small"
|
||||
type='tertiary'
|
||||
size='small'
|
||||
theme='outline'
|
||||
block
|
||||
>
|
||||
@@ -126,32 +117,24 @@ const CardPro = ({
|
||||
className={`flex flex-col gap-2 ${isMobile && !showMobileActions ? 'hidden' : ''}`}
|
||||
>
|
||||
{/* 操作按钮区域 - 用于type1和type3 */}
|
||||
{(type === 'type1' || type === 'type3') && actionsArea && (
|
||||
Array.isArray(actionsArea) ? (
|
||||
{(type === 'type1' || type === 'type3') &&
|
||||
actionsArea &&
|
||||
(Array.isArray(actionsArea) ? (
|
||||
actionsArea.map((area, idx) => (
|
||||
<React.Fragment key={idx}>
|
||||
{idx !== 0 && <Divider />}
|
||||
<div className="w-full">
|
||||
{area}
|
||||
</div>
|
||||
<div className='w-full'>{area}</div>
|
||||
</React.Fragment>
|
||||
))
|
||||
) : (
|
||||
<div className="w-full">
|
||||
{actionsArea}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
<div className='w-full'>{actionsArea}</div>
|
||||
))}
|
||||
|
||||
{/* 当同时存在操作区和搜索区时,插入分隔线 */}
|
||||
{(actionsArea && searchArea) && <Divider />}
|
||||
{actionsArea && searchArea && <Divider />}
|
||||
|
||||
{/* 搜索表单区域 - 所有类型都可能有 */}
|
||||
{searchArea && (
|
||||
<div className="w-full">
|
||||
{searchArea}
|
||||
</div>
|
||||
)}
|
||||
{searchArea && <div className='w-full'>{searchArea}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -214,4 +197,4 @@ CardPro.propTypes = {
|
||||
t: PropTypes.func,
|
||||
};
|
||||
|
||||
export default CardPro;
|
||||
export default CardPro;
|
||||
|
||||
@@ -19,7 +19,15 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Table, Card, Skeleton, Pagination, Empty, Button, Collapsible } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
Table,
|
||||
Card,
|
||||
Skeleton,
|
||||
Pagination,
|
||||
Empty,
|
||||
Button,
|
||||
Collapsible,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { IconChevronDown, IconChevronUp } from '@douyinfe/semi-icons';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useIsMobile } from '../../../hooks/common/useIsMobile';
|
||||
@@ -27,7 +35,7 @@ import { useMinimumLoadingTime } from '../../../hooks/common/useMinimumLoadingTi
|
||||
|
||||
/**
|
||||
* CardTable 响应式表格组件
|
||||
*
|
||||
*
|
||||
* 在桌面端渲染 Semi-UI 的 Table 组件,在移动端则将每一行数据渲染成 Card 形式。
|
||||
* 该组件与 Table 组件的大部分 API 保持一致,只需将原 Table 换成 CardTable 即可。
|
||||
*/
|
||||
@@ -75,18 +83,22 @@ const CardTable = ({
|
||||
|
||||
const renderSkeletonCard = (key) => {
|
||||
const placeholder = (
|
||||
<div className="p-2">
|
||||
<div className='p-2'>
|
||||
{visibleCols.map((col, idx) => {
|
||||
if (!col.title) {
|
||||
return (
|
||||
<div key={idx} className="mt-2 flex justify-end">
|
||||
<div key={idx} className='mt-2 flex justify-end'>
|
||||
<Skeleton.Title active style={{ width: 100, height: 24 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={idx} className="flex justify-between items-center py-1 border-b last:border-b-0 border-dashed" style={{ borderColor: 'var(--semi-color-border)' }}>
|
||||
<div
|
||||
key={idx}
|
||||
className='flex justify-between items-center py-1 border-b last:border-b-0 border-dashed'
|
||||
style={{ borderColor: 'var(--semi-color-border)' }}
|
||||
>
|
||||
<Skeleton.Title active style={{ width: 80, height: 14 }} />
|
||||
<Skeleton.Title
|
||||
active
|
||||
@@ -103,14 +115,14 @@ const CardTable = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<Card key={key} className="!rounded-2xl shadow-sm">
|
||||
<Card key={key} className='!rounded-2xl shadow-sm'>
|
||||
<Skeleton loading={true} active placeholder={placeholder}></Skeleton>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className='flex flex-col gap-2'>
|
||||
{[1, 2, 3].map((i) => renderSkeletonCard(i))}
|
||||
</div>
|
||||
);
|
||||
@@ -127,9 +139,12 @@ const CardTable = ({
|
||||
(!tableProps.rowExpandable || tableProps.rowExpandable(record));
|
||||
|
||||
return (
|
||||
<Card key={rowKeyVal} className="!rounded-2xl shadow-sm">
|
||||
<Card key={rowKeyVal} className='!rounded-2xl shadow-sm'>
|
||||
{columns.map((col, colIdx) => {
|
||||
if (tableProps?.visibleColumns && !tableProps.visibleColumns[col.key]) {
|
||||
if (
|
||||
tableProps?.visibleColumns &&
|
||||
!tableProps.visibleColumns[col.key]
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -140,7 +155,7 @@ const CardTable = ({
|
||||
|
||||
if (!title) {
|
||||
return (
|
||||
<div key={col.key || colIdx} className="mt-2 flex justify-end">
|
||||
<div key={col.key || colIdx} className='mt-2 flex justify-end'>
|
||||
{cellContent}
|
||||
</div>
|
||||
);
|
||||
@@ -149,14 +164,16 @@ const CardTable = ({
|
||||
return (
|
||||
<div
|
||||
key={col.key || colIdx}
|
||||
className="flex justify-between items-start py-1 border-b last:border-b-0 border-dashed"
|
||||
className='flex justify-between items-start py-1 border-b last:border-b-0 border-dashed'
|
||||
style={{ borderColor: 'var(--semi-color-border)' }}
|
||||
>
|
||||
<span className="font-medium text-gray-600 mr-2 whitespace-nowrap select-none">
|
||||
<span className='font-medium text-gray-600 mr-2 whitespace-nowrap select-none'>
|
||||
{title}
|
||||
</span>
|
||||
<div className="flex-1 break-all flex justify-end items-center gap-1">
|
||||
{cellContent !== undefined && cellContent !== null ? cellContent : '-'}
|
||||
<div className='flex-1 break-all flex justify-end items-center gap-1'>
|
||||
{cellContent !== undefined && cellContent !== null
|
||||
? cellContent
|
||||
: '-'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -177,7 +194,7 @@ const CardTable = ({
|
||||
{showDetails ? t('收起') : t('详情')}
|
||||
</Button>
|
||||
<Collapsible isOpen={showDetails} keepDOM>
|
||||
<div className="pt-2">
|
||||
<div className='pt-2'>
|
||||
{tableProps.expandedRowRender(record, index)}
|
||||
</div>
|
||||
</Collapsible>
|
||||
@@ -190,19 +207,23 @@ const CardTable = ({
|
||||
if (isEmpty) {
|
||||
if (tableProps.empty) return tableProps.empty;
|
||||
return (
|
||||
<div className="flex justify-center p-4">
|
||||
<Empty description="No Data" />
|
||||
<div className='flex justify-center p-4'>
|
||||
<Empty description='No Data' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className='flex flex-col gap-2'>
|
||||
{dataSource.map((record, index) => (
|
||||
<MobileRowCard key={getRowKey(record, index)} record={record} index={index} />
|
||||
<MobileRowCard
|
||||
key={getRowKey(record, index)}
|
||||
record={record}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
{!hidePagination && tableProps.pagination && dataSource.length > 0 && (
|
||||
<div className="mt-2 flex justify-center">
|
||||
<div className='mt-2 flex justify-center'>
|
||||
<Pagination {...tableProps.pagination} />
|
||||
</div>
|
||||
)}
|
||||
@@ -218,4 +239,4 @@ CardTable.propTypes = {
|
||||
hidePagination: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default CardTable;
|
||||
export default CardTable;
|
||||
|
||||
@@ -30,9 +30,9 @@ import { copy, showSuccess } from '../../../helpers';
|
||||
*/
|
||||
const parseChannelKeys = (keyData, t) => {
|
||||
if (!keyData) return [];
|
||||
|
||||
|
||||
const trimmed = keyData.trim();
|
||||
|
||||
|
||||
// 检查是否是JSON数组格式(如Vertex AI)
|
||||
if (trimmed.startsWith('[')) {
|
||||
try {
|
||||
@@ -40,9 +40,10 @@ const parseChannelKeys = (keyData, t) => {
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.map((item, index) => ({
|
||||
id: index,
|
||||
content: typeof item === 'string' ? item : JSON.stringify(item, null, 2),
|
||||
content:
|
||||
typeof item === 'string' ? item : JSON.stringify(item, null, 2),
|
||||
type: typeof item === 'string' ? 'text' : 'json',
|
||||
label: `${t('密钥')} ${index + 1}`
|
||||
label: `${t('密钥')} ${index + 1}`,
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -50,25 +51,27 @@ const parseChannelKeys = (keyData, t) => {
|
||||
console.warn('Failed to parse JSON keys:', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 检查是否是多行密钥(按换行符分割)
|
||||
const lines = trimmed.split('\n').filter(line => line.trim());
|
||||
const lines = trimmed.split('\n').filter((line) => line.trim());
|
||||
if (lines.length > 1) {
|
||||
return lines.map((line, index) => ({
|
||||
id: index,
|
||||
content: line.trim(),
|
||||
type: 'text',
|
||||
label: `${t('密钥')} ${index + 1}`
|
||||
label: `${t('密钥')} ${index + 1}`,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
// 单个密钥
|
||||
return [{
|
||||
id: 0,
|
||||
content: trimmed,
|
||||
type: trimmed.startsWith('{') ? 'json' : 'text',
|
||||
label: t('密钥')
|
||||
}];
|
||||
return [
|
||||
{
|
||||
id: 0,
|
||||
content: trimmed,
|
||||
type: trimmed.startsWith('{') ? 'json' : 'text',
|
||||
label: t('密钥'),
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -85,7 +88,7 @@ const ChannelKeyDisplay = ({
|
||||
showSuccessIcon = true,
|
||||
successText,
|
||||
showWarning = true,
|
||||
warningText
|
||||
warningText,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -103,34 +106,42 @@ const ChannelKeyDisplay = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className='space-y-4'>
|
||||
{/* 成功状态 */}
|
||||
{showSuccessIcon && (
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-5 h-5 text-green-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
<div className='flex items-center gap-2'>
|
||||
<svg
|
||||
className='w-5 h-5 text-green-600'
|
||||
fill='currentColor'
|
||||
viewBox='0 0 20 20'
|
||||
>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
d='M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z'
|
||||
clipRule='evenodd'
|
||||
/>
|
||||
</svg>
|
||||
<Typography.Text strong className="text-green-700">
|
||||
<Typography.Text strong className='text-green-700'>
|
||||
{successText || t('验证成功')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 密钥内容 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className='space-y-3'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Typography.Text strong>
|
||||
{isMultipleKeys ? t('渠道密钥列表') : t('渠道密钥')}
|
||||
</Typography.Text>
|
||||
{isMultipleKeys && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Typography.Text type="tertiary" size="small">
|
||||
<div className='flex items-center gap-2'>
|
||||
<Typography.Text type='tertiary' size='small'>
|
||||
{t('共 {{count}} 个密钥', { count: parsedKeys.length })}
|
||||
</Typography.Text>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
theme="outline"
|
||||
size='small'
|
||||
type='primary'
|
||||
theme='outline'
|
||||
onClick={handleCopyAll}
|
||||
>
|
||||
{t('复制全部')}
|
||||
@@ -138,27 +149,40 @@ const ChannelKeyDisplay = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 max-h-80 overflow-auto">
|
||||
|
||||
<div className='space-y-3 max-h-80 overflow-auto'>
|
||||
{parsedKeys.map((keyItem) => (
|
||||
<Card key={keyItem.id} className="!rounded-lg !border !border-gray-200 dark:!border-gray-700">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Typography.Text strong size="small" className="text-gray-700 dark:text-gray-300">
|
||||
<Card
|
||||
key={keyItem.id}
|
||||
className='!rounded-lg !border !border-gray-200 dark:!border-gray-700'
|
||||
>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Typography.Text
|
||||
strong
|
||||
size='small'
|
||||
className='text-gray-700 dark:text-gray-300'
|
||||
>
|
||||
{keyItem.label}
|
||||
</Typography.Text>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className='flex items-center gap-2'>
|
||||
{keyItem.type === 'json' && (
|
||||
<Tag size="small" color="blue">{t('JSON')}</Tag>
|
||||
<Tag size='small' color='blue'>
|
||||
{t('JSON')}
|
||||
</Tag>
|
||||
)}
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
theme="outline"
|
||||
size='small'
|
||||
type='primary'
|
||||
theme='outline'
|
||||
icon={
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M8 3a1 1 0 011-1h2a1 1 0 110 2H9a1 1 0 01-1-1z" />
|
||||
<path d="M6 3a2 2 0 00-2 2v11a2 2 0 002 2h8a2 2 0 002-2V5a2 2 0 00-2-2 3 3 0 01-3 3H9a3 3 0 01-3-3z" />
|
||||
<svg
|
||||
className='w-3 h-3'
|
||||
fill='currentColor'
|
||||
viewBox='0 0 20 20'
|
||||
>
|
||||
<path d='M8 3a1 1 0 011-1h2a1 1 0 110 2H9a1 1 0 01-1-1z' />
|
||||
<path d='M6 3a2 2 0 00-2 2v11a2 2 0 002 2h8a2 2 0 002-2V5a2 2 0 00-2-2 3 3 0 01-3 3H9a3 3 0 01-3-3z' />
|
||||
</svg>
|
||||
}
|
||||
onClick={() => handleCopyKey(keyItem.content)}
|
||||
@@ -167,18 +191,22 @@ const ChannelKeyDisplay = ({
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-3 max-h-40 overflow-auto">
|
||||
|
||||
<div className='bg-gray-50 dark:bg-gray-800 rounded-lg p-3 max-h-40 overflow-auto'>
|
||||
<Typography.Text
|
||||
code
|
||||
className="text-xs font-mono break-all whitespace-pre-wrap text-gray-800 dark:text-gray-200"
|
||||
className='text-xs font-mono break-all whitespace-pre-wrap text-gray-800 dark:text-gray-200'
|
||||
>
|
||||
{keyItem.content}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
|
||||
{keyItem.type === 'json' && (
|
||||
<Typography.Text type="tertiary" size="small" className="block">
|
||||
<Typography.Text
|
||||
type='tertiary'
|
||||
size='small'
|
||||
className='block'
|
||||
>
|
||||
{t('JSON格式密钥,请确保格式正确')}
|
||||
</Typography.Text>
|
||||
)}
|
||||
@@ -186,14 +214,28 @@ const ChannelKeyDisplay = ({
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
{isMultipleKeys && (
|
||||
<div className="bg-blue-50 dark:bg-blue-900 rounded-lg p-3">
|
||||
<Typography.Text type="tertiary" size="small" className="text-blue-700 dark:text-blue-300">
|
||||
<svg className="w-4 h-4 inline mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
|
||||
<div className='bg-blue-50 dark:bg-blue-900 rounded-lg p-3'>
|
||||
<Typography.Text
|
||||
type='tertiary'
|
||||
size='small'
|
||||
className='text-blue-700 dark:text-blue-300'
|
||||
>
|
||||
<svg
|
||||
className='w-4 h-4 inline mr-1'
|
||||
fill='currentColor'
|
||||
viewBox='0 0 20 20'
|
||||
>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
d='M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z'
|
||||
clipRule='evenodd'
|
||||
/>
|
||||
</svg>
|
||||
{t('检测到多个密钥,您可以单独复制每个密钥,或点击复制全部获取完整内容。')}
|
||||
{t(
|
||||
'检测到多个密钥,您可以单独复制每个密钥,或点击复制全部获取完整内容。',
|
||||
)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
@@ -201,17 +243,31 @@ const ChannelKeyDisplay = ({
|
||||
|
||||
{/* 安全警告 */}
|
||||
{showWarning && (
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900 rounded-lg p-4">
|
||||
<div className="flex items-start">
|
||||
<svg className="w-5 h-5 text-yellow-600 dark:text-yellow-400 mt-0.5 mr-3 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
<div className='bg-yellow-50 dark:bg-yellow-900 rounded-lg p-4'>
|
||||
<div className='flex items-start'>
|
||||
<svg
|
||||
className='w-5 h-5 text-yellow-600 dark:text-yellow-400 mt-0.5 mr-3 flex-shrink-0'
|
||||
fill='currentColor'
|
||||
viewBox='0 0 20 20'
|
||||
>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
d='M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z'
|
||||
clipRule='evenodd'
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<Typography.Text strong className="text-yellow-800 dark:text-yellow-200">
|
||||
<Typography.Text
|
||||
strong
|
||||
className='text-yellow-800 dark:text-yellow-200'
|
||||
>
|
||||
{t('安全提醒')}
|
||||
</Typography.Text>
|
||||
<Typography.Text className="block text-yellow-700 dark:text-yellow-300 text-sm mt-1">
|
||||
{warningText || t('请妥善保管密钥信息,不要泄露给他人。如有安全疑虑,请及时更换密钥。')}
|
||||
<Typography.Text className='block text-yellow-700 dark:text-yellow-300 text-sm mt-1'>
|
||||
{warningText ||
|
||||
t(
|
||||
'请妥善保管密钥信息,不要泄露给他人。如有安全疑虑,请及时更换密钥。',
|
||||
)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
@@ -221,4 +277,4 @@ const ChannelKeyDisplay = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelKeyDisplay;
|
||||
export default ChannelKeyDisplay;
|
||||
|
||||
@@ -65,4 +65,4 @@ CompactModeToggle.propTypes = {
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
export default CompactModeToggle;
|
||||
export default CompactModeToggle;
|
||||
|
||||
@@ -36,11 +36,7 @@ import {
|
||||
Divider,
|
||||
Tooltip,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconPlus,
|
||||
IconDelete,
|
||||
IconAlertTriangle,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { IconPlus, IconDelete, IconAlertTriangle } from '@douyinfe/semi-icons';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -88,7 +84,7 @@ const JSONEditor = ({
|
||||
// 将键值对数组转换为对象(重复键时后面的会覆盖前面的)
|
||||
const keyValueArrayToObject = useCallback((arr) => {
|
||||
const result = {};
|
||||
arr.forEach(item => {
|
||||
arr.forEach((item) => {
|
||||
if (item.key) {
|
||||
result[item.key] = item.value;
|
||||
}
|
||||
@@ -115,7 +111,8 @@ const JSONEditor = ({
|
||||
// 手动模式下的本地文本缓冲
|
||||
const [manualText, setManualText] = useState(() => {
|
||||
if (typeof value === 'string') return value;
|
||||
if (value && typeof value === 'object') return JSON.stringify(value, null, 2);
|
||||
if (value && typeof value === 'object')
|
||||
return JSON.stringify(value, null, 2);
|
||||
return '';
|
||||
});
|
||||
|
||||
@@ -140,7 +137,7 @@ const JSONEditor = ({
|
||||
const keyCount = {};
|
||||
const duplicates = new Set();
|
||||
|
||||
keyValuePairs.forEach(pair => {
|
||||
keyValuePairs.forEach((pair) => {
|
||||
if (pair.key) {
|
||||
keyCount[pair.key] = (keyCount[pair.key] || 0) + 1;
|
||||
if (keyCount[pair.key] > 1) {
|
||||
@@ -178,51 +175,65 @@ const JSONEditor = ({
|
||||
useEffect(() => {
|
||||
if (editMode !== 'manual') {
|
||||
if (typeof value === 'string') setManualText(value);
|
||||
else if (value && typeof value === 'object') setManualText(JSON.stringify(value, null, 2));
|
||||
else if (value && typeof value === 'object')
|
||||
setManualText(JSON.stringify(value, null, 2));
|
||||
else setManualText('');
|
||||
}
|
||||
}, [value, editMode]);
|
||||
|
||||
// 处理可视化编辑的数据变化
|
||||
const handleVisualChange = useCallback((newPairs) => {
|
||||
setKeyValuePairs(newPairs);
|
||||
const jsonObject = keyValueArrayToObject(newPairs);
|
||||
const jsonString = Object.keys(jsonObject).length === 0 ? '' : JSON.stringify(jsonObject, null, 2);
|
||||
const handleVisualChange = useCallback(
|
||||
(newPairs) => {
|
||||
setKeyValuePairs(newPairs);
|
||||
const jsonObject = keyValueArrayToObject(newPairs);
|
||||
const jsonString =
|
||||
Object.keys(jsonObject).length === 0
|
||||
? ''
|
||||
: JSON.stringify(jsonObject, null, 2);
|
||||
|
||||
setJsonError('');
|
||||
setJsonError('');
|
||||
|
||||
// 通过formApi设置值
|
||||
if (formApi && field) {
|
||||
formApi.setValue(field, jsonString);
|
||||
}
|
||||
// 通过formApi设置值
|
||||
if (formApi && field) {
|
||||
formApi.setValue(field, jsonString);
|
||||
}
|
||||
|
||||
onChange?.(jsonString);
|
||||
}, [onChange, formApi, field, keyValueArrayToObject]);
|
||||
onChange?.(jsonString);
|
||||
},
|
||||
[onChange, formApi, field, keyValueArrayToObject],
|
||||
);
|
||||
|
||||
// 处理手动编辑的数据变化
|
||||
const handleManualChange = useCallback((newValue) => {
|
||||
setManualText(newValue);
|
||||
if (newValue && newValue.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(newValue);
|
||||
setKeyValuePairs(objectToKeyValueArray(parsed, keyValuePairs));
|
||||
const handleManualChange = useCallback(
|
||||
(newValue) => {
|
||||
setManualText(newValue);
|
||||
if (newValue && newValue.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(newValue);
|
||||
setKeyValuePairs(objectToKeyValueArray(parsed, keyValuePairs));
|
||||
setJsonError('');
|
||||
onChange?.(newValue);
|
||||
} catch (error) {
|
||||
setJsonError(error.message);
|
||||
}
|
||||
} else {
|
||||
setKeyValuePairs([]);
|
||||
setJsonError('');
|
||||
onChange?.(newValue);
|
||||
} catch (error) {
|
||||
setJsonError(error.message);
|
||||
onChange?.('');
|
||||
}
|
||||
} else {
|
||||
setKeyValuePairs([]);
|
||||
setJsonError('');
|
||||
onChange?.('');
|
||||
}
|
||||
}, [onChange, objectToKeyValueArray, keyValuePairs]);
|
||||
},
|
||||
[onChange, objectToKeyValueArray, keyValuePairs],
|
||||
);
|
||||
|
||||
// 切换编辑模式
|
||||
const toggleEditMode = useCallback(() => {
|
||||
if (editMode === 'visual') {
|
||||
const jsonObject = keyValueArrayToObject(keyValuePairs);
|
||||
setManualText(Object.keys(jsonObject).length === 0 ? '' : JSON.stringify(jsonObject, null, 2));
|
||||
setManualText(
|
||||
Object.keys(jsonObject).length === 0
|
||||
? ''
|
||||
: JSON.stringify(jsonObject, null, 2),
|
||||
);
|
||||
setEditMode('manual');
|
||||
} else {
|
||||
try {
|
||||
@@ -242,12 +253,19 @@ const JSONEditor = ({
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, [editMode, value, manualText, keyValuePairs, keyValueArrayToObject, objectToKeyValueArray]);
|
||||
}, [
|
||||
editMode,
|
||||
value,
|
||||
manualText,
|
||||
keyValuePairs,
|
||||
keyValueArrayToObject,
|
||||
objectToKeyValueArray,
|
||||
]);
|
||||
|
||||
// 添加键值对
|
||||
const addKeyValue = useCallback(() => {
|
||||
const newPairs = [...keyValuePairs];
|
||||
const existingKeys = newPairs.map(p => p.key);
|
||||
const existingKeys = newPairs.map((p) => p.key);
|
||||
let counter = 1;
|
||||
let newKey = `field_${counter}`;
|
||||
while (existingKeys.includes(newKey)) {
|
||||
@@ -257,32 +275,41 @@ const JSONEditor = ({
|
||||
newPairs.push({
|
||||
id: generateUniqueId(),
|
||||
key: newKey,
|
||||
value: ''
|
||||
value: '',
|
||||
});
|
||||
handleVisualChange(newPairs);
|
||||
}, [keyValuePairs, handleVisualChange]);
|
||||
|
||||
// 删除键值对
|
||||
const removeKeyValue = useCallback((id) => {
|
||||
const newPairs = keyValuePairs.filter(pair => pair.id !== id);
|
||||
handleVisualChange(newPairs);
|
||||
}, [keyValuePairs, handleVisualChange]);
|
||||
const removeKeyValue = useCallback(
|
||||
(id) => {
|
||||
const newPairs = keyValuePairs.filter((pair) => pair.id !== id);
|
||||
handleVisualChange(newPairs);
|
||||
},
|
||||
[keyValuePairs, handleVisualChange],
|
||||
);
|
||||
|
||||
// 更新键名
|
||||
const updateKey = useCallback((id, newKey) => {
|
||||
const newPairs = keyValuePairs.map(pair =>
|
||||
pair.id === id ? { ...pair, key: newKey } : pair
|
||||
);
|
||||
handleVisualChange(newPairs);
|
||||
}, [keyValuePairs, handleVisualChange]);
|
||||
const updateKey = useCallback(
|
||||
(id, newKey) => {
|
||||
const newPairs = keyValuePairs.map((pair) =>
|
||||
pair.id === id ? { ...pair, key: newKey } : pair,
|
||||
);
|
||||
handleVisualChange(newPairs);
|
||||
},
|
||||
[keyValuePairs, handleVisualChange],
|
||||
);
|
||||
|
||||
// 更新值
|
||||
const updateValue = useCallback((id, newValue) => {
|
||||
const newPairs = keyValuePairs.map(pair =>
|
||||
pair.id === id ? { ...pair, value: newValue } : pair
|
||||
);
|
||||
handleVisualChange(newPairs);
|
||||
}, [keyValuePairs, handleVisualChange]);
|
||||
const updateValue = useCallback(
|
||||
(id, newValue) => {
|
||||
const newPairs = keyValuePairs.map((pair) =>
|
||||
pair.id === id ? { ...pair, value: newValue } : pair,
|
||||
);
|
||||
handleVisualChange(newPairs);
|
||||
},
|
||||
[keyValuePairs, handleVisualChange],
|
||||
);
|
||||
|
||||
// 填入模板
|
||||
const fillTemplate = useCallback(() => {
|
||||
@@ -298,7 +325,14 @@ const JSONEditor = ({
|
||||
onChange?.(templateString);
|
||||
setJsonError('');
|
||||
}
|
||||
}, [template, onChange, formApi, field, objectToKeyValueArray, keyValuePairs]);
|
||||
}, [
|
||||
template,
|
||||
onChange,
|
||||
formApi,
|
||||
field,
|
||||
objectToKeyValueArray,
|
||||
keyValuePairs,
|
||||
]);
|
||||
|
||||
// 渲染值输入控件(支持嵌套)
|
||||
const renderValueInput = (pairId, value) => {
|
||||
@@ -306,12 +340,12 @@ const JSONEditor = ({
|
||||
|
||||
if (valueType === 'boolean') {
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<div className='flex items-center'>
|
||||
<Switch
|
||||
checked={value}
|
||||
onChange={(newValue) => updateValue(pairId, newValue)}
|
||||
/>
|
||||
<Text type="tertiary" className="ml-2">
|
||||
<Text type='tertiary' className='ml-2'>
|
||||
{value ? t('true') : t('false')}
|
||||
</Text>
|
||||
</div>
|
||||
@@ -373,29 +407,29 @@ const JSONEditor = ({
|
||||
// 渲染键值对编辑器
|
||||
const renderKeyValueEditor = () => {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className='space-y-1'>
|
||||
{/* 重复键警告 */}
|
||||
{duplicateKeys.size > 0 && (
|
||||
<Banner
|
||||
type="warning"
|
||||
type='warning'
|
||||
icon={<IconAlertTriangle />}
|
||||
description={
|
||||
<div>
|
||||
<Text strong>{t('存在重复的键名:')}</Text>
|
||||
<Text>{Array.from(duplicateKeys).join(', ')}</Text>
|
||||
<br />
|
||||
<Text type="tertiary" size="small">
|
||||
<Text type='tertiary' size='small'>
|
||||
{t('注意:JSON中重复的键只会保留最后一个同名键的值')}
|
||||
</Text>
|
||||
</div>
|
||||
}
|
||||
className="mb-3"
|
||||
className='mb-3'
|
||||
/>
|
||||
)}
|
||||
|
||||
{keyValuePairs.length === 0 && (
|
||||
<div className="text-center py-6 px-4">
|
||||
<Text type="tertiary" className="text-gray-500 text-sm">
|
||||
<div className='text-center py-6 px-4'>
|
||||
<Text type='tertiary' className='text-gray-500 text-sm'>
|
||||
{t('暂无数据,点击下方按钮添加键值对')}
|
||||
</Text>
|
||||
</div>
|
||||
@@ -403,13 +437,14 @@ const JSONEditor = ({
|
||||
|
||||
{keyValuePairs.map((pair, index) => {
|
||||
const isDuplicate = duplicateKeys.has(pair.key);
|
||||
const isLastDuplicate = isDuplicate &&
|
||||
keyValuePairs.slice(index + 1).every(p => p.key !== pair.key);
|
||||
const isLastDuplicate =
|
||||
isDuplicate &&
|
||||
keyValuePairs.slice(index + 1).every((p) => p.key !== pair.key);
|
||||
|
||||
return (
|
||||
<Row key={pair.id} gutter={8} align="middle">
|
||||
<Row key={pair.id} gutter={8} align='middle'>
|
||||
<Col span={6}>
|
||||
<div className="relative">
|
||||
<div className='relative'>
|
||||
<Input
|
||||
placeholder={t('键名')}
|
||||
value={pair.key}
|
||||
@@ -425,24 +460,22 @@ const JSONEditor = ({
|
||||
}
|
||||
>
|
||||
<IconAlertTriangle
|
||||
className="absolute right-2 top-1/2 transform -translate-y-1/2"
|
||||
className='absolute right-2 top-1/2 transform -translate-y-1/2'
|
||||
style={{
|
||||
color: isLastDuplicate ? '#ff7d00' : '#faad14',
|
||||
fontSize: '14px'
|
||||
fontSize: '14px',
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={16}>
|
||||
{renderValueInput(pair.id, pair.value)}
|
||||
</Col>
|
||||
<Col span={16}>{renderValueInput(pair.id, pair.value)}</Col>
|
||||
<Col span={2}>
|
||||
<Button
|
||||
icon={<IconDelete />}
|
||||
type="danger"
|
||||
theme="borderless"
|
||||
type='danger'
|
||||
theme='borderless'
|
||||
onClick={() => removeKeyValue(pair.id)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
@@ -451,11 +484,11 @@ const JSONEditor = ({
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="mt-2 flex justify-center">
|
||||
<div className='mt-2 flex justify-center'>
|
||||
<Button
|
||||
icon={<IconPlus />}
|
||||
type="primary"
|
||||
theme="outline"
|
||||
type='primary'
|
||||
theme='outline'
|
||||
onClick={addKeyValue}
|
||||
>
|
||||
{t('添加键值对')}
|
||||
@@ -467,27 +500,27 @@ const JSONEditor = ({
|
||||
|
||||
// 渲染区域编辑器(特殊格式)- 也需要改造以支持重复键
|
||||
const renderRegionEditor = () => {
|
||||
const defaultPair = keyValuePairs.find(pair => pair.key === 'default');
|
||||
const modelPairs = keyValuePairs.filter(pair => pair.key !== 'default');
|
||||
const defaultPair = keyValuePairs.find((pair) => pair.key === 'default');
|
||||
const modelPairs = keyValuePairs.filter((pair) => pair.key !== 'default');
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className='space-y-2'>
|
||||
{/* 重复键警告 */}
|
||||
{duplicateKeys.size > 0 && (
|
||||
<Banner
|
||||
type="warning"
|
||||
type='warning'
|
||||
icon={<IconAlertTriangle />}
|
||||
description={
|
||||
<div>
|
||||
<Text strong>{t('存在重复的键名:')}</Text>
|
||||
<Text>{Array.from(duplicateKeys).join(', ')}</Text>
|
||||
<br />
|
||||
<Text type="tertiary" size="small">
|
||||
<Text type='tertiary' size='small'>
|
||||
{t('注意:JSON中重复的键只会保留最后一个同名键的值')}
|
||||
</Text>
|
||||
</div>
|
||||
}
|
||||
className="mb-3"
|
||||
className='mb-3'
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -500,11 +533,14 @@ const JSONEditor = ({
|
||||
if (defaultPair) {
|
||||
updateValue(defaultPair.id, value);
|
||||
} else {
|
||||
const newPairs = [...keyValuePairs, {
|
||||
id: generateUniqueId(),
|
||||
key: 'default',
|
||||
value: value
|
||||
}];
|
||||
const newPairs = [
|
||||
...keyValuePairs,
|
||||
{
|
||||
id: generateUniqueId(),
|
||||
key: 'default',
|
||||
value: value,
|
||||
},
|
||||
];
|
||||
handleVisualChange(newPairs);
|
||||
}
|
||||
}}
|
||||
@@ -517,9 +553,9 @@ const JSONEditor = ({
|
||||
{modelPairs.map((pair) => {
|
||||
const isDuplicate = duplicateKeys.has(pair.key);
|
||||
return (
|
||||
<Row key={pair.id} gutter={8} align="middle" className="mb-2">
|
||||
<Row key={pair.id} gutter={8} align='middle' className='mb-2'>
|
||||
<Col span={10}>
|
||||
<div className="relative">
|
||||
<div className='relative'>
|
||||
<Input
|
||||
placeholder={t('模型名称')}
|
||||
value={pair.key}
|
||||
@@ -529,7 +565,7 @@ const JSONEditor = ({
|
||||
{isDuplicate && (
|
||||
<Tooltip content={t('重复的键名')}>
|
||||
<IconAlertTriangle
|
||||
className="absolute right-2 top-1/2 transform -translate-y-1/2"
|
||||
className='absolute right-2 top-1/2 transform -translate-y-1/2'
|
||||
style={{ color: '#faad14', fontSize: '14px' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -546,8 +582,8 @@ const JSONEditor = ({
|
||||
<Col span={2}>
|
||||
<Button
|
||||
icon={<IconDelete />}
|
||||
type="danger"
|
||||
theme="borderless"
|
||||
type='danger'
|
||||
theme='borderless'
|
||||
onClick={() => removeKeyValue(pair.id)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
@@ -556,12 +592,12 @@ const JSONEditor = ({
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="mt-2 flex justify-center">
|
||||
<div className='mt-2 flex justify-center'>
|
||||
<Button
|
||||
icon={<IconPlus />}
|
||||
onClick={addKeyValue}
|
||||
type="primary"
|
||||
theme="outline"
|
||||
type='primary'
|
||||
theme='outline'
|
||||
>
|
||||
{t('添加模型区域')}
|
||||
</Button>
|
||||
@@ -590,9 +626,9 @@ const JSONEditor = ({
|
||||
<Form.Slot label={label}>
|
||||
<Card
|
||||
header={
|
||||
<div className="flex justify-between items-center">
|
||||
<div className='flex justify-between items-center'>
|
||||
<Tabs
|
||||
type="slash"
|
||||
type='slash'
|
||||
activeKey={editMode}
|
||||
onChange={(key) => {
|
||||
if (key === 'manual' && editMode === 'visual') {
|
||||
@@ -602,16 +638,12 @@ const JSONEditor = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TabPane tab={t('可视化')} itemKey="visual" />
|
||||
<TabPane tab={t('手动编辑')} itemKey="manual" />
|
||||
<TabPane tab={t('可视化')} itemKey='visual' />
|
||||
<TabPane tab={t('手动编辑')} itemKey='manual' />
|
||||
</Tabs>
|
||||
|
||||
{template && templateLabel && (
|
||||
<Button
|
||||
type="tertiary"
|
||||
onClick={fillTemplate}
|
||||
size="small"
|
||||
>
|
||||
<Button type='tertiary' onClick={fillTemplate} size='small'>
|
||||
{templateLabel}
|
||||
</Button>
|
||||
)}
|
||||
@@ -619,14 +651,14 @@ const JSONEditor = ({
|
||||
}
|
||||
headerStyle={{ padding: '12px 16px' }}
|
||||
bodyStyle={{ padding: '16px' }}
|
||||
className="!rounded-2xl"
|
||||
className='!rounded-2xl'
|
||||
>
|
||||
{/* JSON错误提示 */}
|
||||
{hasJsonError && (
|
||||
<Banner
|
||||
type="danger"
|
||||
type='danger'
|
||||
description={`JSON 格式错误: ${jsonError}`}
|
||||
className="mb-3"
|
||||
className='mb-3'
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -668,17 +700,15 @@ const JSONEditor = ({
|
||||
{/* 额外文本显示在卡片底部 */}
|
||||
{extraText && (
|
||||
<Divider margin='12px' align='center'>
|
||||
<Text type="tertiary" size="small">{extraText}</Text>
|
||||
<Text type='tertiary' size='small'>
|
||||
{extraText}
|
||||
</Text>
|
||||
</Divider>
|
||||
)}
|
||||
{extraFooter && (
|
||||
<div className="mt-1">
|
||||
{extraFooter}
|
||||
</div>
|
||||
)}
|
||||
{extraFooter && <div className='mt-1'>{extraFooter}</div>}
|
||||
</Card>
|
||||
</Form.Slot>
|
||||
);
|
||||
};
|
||||
|
||||
export default JSONEditor;
|
||||
export default JSONEditor;
|
||||
|
||||
@@ -21,13 +21,9 @@ import React from 'react';
|
||||
import { Spin } from '@douyinfe/semi-ui';
|
||||
|
||||
const Loading = ({ size = 'small' }) => {
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 w-screen h-screen flex items-center justify-center">
|
||||
<Spin
|
||||
size={size}
|
||||
spinning={true}
|
||||
/>
|
||||
<div className='fixed inset-0 w-screen h-screen flex items-center justify-center'>
|
||||
<Spin size={size} spinning={true} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -57,4 +57,4 @@ export const renderDescription = (text, maxWidth = 200) => {
|
||||
{text || '-'}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -24,197 +24,219 @@ import React, {
|
||||
useCallback,
|
||||
useMemo,
|
||||
useImperativeHandle,
|
||||
forwardRef
|
||||
forwardRef,
|
||||
} from 'react';
|
||||
|
||||
/**
|
||||
* ScrollableContainer 可滚动容器组件
|
||||
*
|
||||
*
|
||||
* 提供自动检测滚动状态和显示渐变指示器的功能
|
||||
* 当内容超出容器高度且未滚动到底部时,会显示底部渐变指示器
|
||||
*
|
||||
*
|
||||
*/
|
||||
const ScrollableContainer = forwardRef(({
|
||||
children,
|
||||
maxHeight = '24rem',
|
||||
className = '',
|
||||
contentClassName = '',
|
||||
fadeIndicatorClassName = '',
|
||||
checkInterval = 100,
|
||||
scrollThreshold = 5,
|
||||
debounceDelay = 16, // ~60fps
|
||||
onScroll,
|
||||
onScrollStateChange,
|
||||
...props
|
||||
}, ref) => {
|
||||
const scrollRef = useRef(null);
|
||||
const containerRef = useRef(null);
|
||||
const debounceTimerRef = useRef(null);
|
||||
const resizeObserverRef = useRef(null);
|
||||
const onScrollStateChangeRef = useRef(onScrollStateChange);
|
||||
const onScrollRef = useRef(onScroll);
|
||||
|
||||
const [showScrollHint, setShowScrollHint] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
onScrollStateChangeRef.current = onScrollStateChange;
|
||||
}, [onScrollStateChange]);
|
||||
|
||||
useEffect(() => {
|
||||
onScrollRef.current = onScroll;
|
||||
}, [onScroll]);
|
||||
|
||||
const debounce = useCallback((func, delay) => {
|
||||
return (...args) => {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
debounceTimerRef.current = setTimeout(() => func(...args), delay);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const checkScrollable = useCallback(() => {
|
||||
if (!scrollRef.current) return;
|
||||
|
||||
const element = scrollRef.current;
|
||||
const isScrollable = element.scrollHeight > element.clientHeight;
|
||||
const isAtBottom = element.scrollTop + element.clientHeight >= element.scrollHeight - scrollThreshold;
|
||||
const shouldShowHint = isScrollable && !isAtBottom;
|
||||
|
||||
setShowScrollHint(shouldShowHint);
|
||||
|
||||
if (onScrollStateChangeRef.current) {
|
||||
onScrollStateChangeRef.current({
|
||||
isScrollable,
|
||||
isAtBottom,
|
||||
showScrollHint: shouldShowHint,
|
||||
scrollTop: element.scrollTop,
|
||||
scrollHeight: element.scrollHeight,
|
||||
clientHeight: element.clientHeight
|
||||
});
|
||||
}
|
||||
}, [scrollThreshold]);
|
||||
|
||||
const debouncedCheckScrollable = useMemo(() =>
|
||||
debounce(checkScrollable, debounceDelay),
|
||||
[debounce, checkScrollable, debounceDelay]
|
||||
);
|
||||
|
||||
const handleScroll = useCallback((e) => {
|
||||
debouncedCheckScrollable();
|
||||
if (onScrollRef.current) {
|
||||
onScrollRef.current(e);
|
||||
}
|
||||
}, [debouncedCheckScrollable]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
checkScrollable: () => {
|
||||
checkScrollable();
|
||||
const ScrollableContainer = forwardRef(
|
||||
(
|
||||
{
|
||||
children,
|
||||
maxHeight = '24rem',
|
||||
className = '',
|
||||
contentClassName = '',
|
||||
fadeIndicatorClassName = '',
|
||||
checkInterval = 100,
|
||||
scrollThreshold = 5,
|
||||
debounceDelay = 16, // ~60fps
|
||||
onScroll,
|
||||
onScrollStateChange,
|
||||
...props
|
||||
},
|
||||
scrollToTop: () => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = 0;
|
||||
}
|
||||
},
|
||||
scrollToBottom: () => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
},
|
||||
getScrollInfo: () => {
|
||||
if (!scrollRef.current) return null;
|
||||
const element = scrollRef.current;
|
||||
return {
|
||||
scrollTop: element.scrollTop,
|
||||
scrollHeight: element.scrollHeight,
|
||||
clientHeight: element.clientHeight,
|
||||
isScrollable: element.scrollHeight > element.clientHeight,
|
||||
isAtBottom: element.scrollTop + element.clientHeight >= element.scrollHeight - scrollThreshold
|
||||
ref,
|
||||
) => {
|
||||
const scrollRef = useRef(null);
|
||||
const containerRef = useRef(null);
|
||||
const debounceTimerRef = useRef(null);
|
||||
const resizeObserverRef = useRef(null);
|
||||
const onScrollStateChangeRef = useRef(onScrollStateChange);
|
||||
const onScrollRef = useRef(onScroll);
|
||||
|
||||
const [showScrollHint, setShowScrollHint] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
onScrollStateChangeRef.current = onScrollStateChange;
|
||||
}, [onScrollStateChange]);
|
||||
|
||||
useEffect(() => {
|
||||
onScrollRef.current = onScroll;
|
||||
}, [onScroll]);
|
||||
|
||||
const debounce = useCallback((func, delay) => {
|
||||
return (...args) => {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
debounceTimerRef.current = setTimeout(() => func(...args), delay);
|
||||
};
|
||||
}
|
||||
}), [checkScrollable, scrollThreshold]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
checkScrollable();
|
||||
}, checkInterval);
|
||||
return () => clearTimeout(timer);
|
||||
}, [checkScrollable, checkInterval]);
|
||||
const checkScrollable = useCallback(() => {
|
||||
if (!scrollRef.current) return;
|
||||
|
||||
useEffect(() => {
|
||||
if (!scrollRef.current) return;
|
||||
const element = scrollRef.current;
|
||||
const isScrollable = element.scrollHeight > element.clientHeight;
|
||||
const isAtBottom =
|
||||
element.scrollTop + element.clientHeight >=
|
||||
element.scrollHeight - scrollThreshold;
|
||||
const shouldShowHint = isScrollable && !isAtBottom;
|
||||
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
if (typeof MutationObserver !== 'undefined') {
|
||||
const observer = new MutationObserver(() => {
|
||||
debouncedCheckScrollable();
|
||||
setShowScrollHint(shouldShowHint);
|
||||
|
||||
if (onScrollStateChangeRef.current) {
|
||||
onScrollStateChangeRef.current({
|
||||
isScrollable,
|
||||
isAtBottom,
|
||||
showScrollHint: shouldShowHint,
|
||||
scrollTop: element.scrollTop,
|
||||
scrollHeight: element.scrollHeight,
|
||||
clientHeight: element.clientHeight,
|
||||
});
|
||||
|
||||
observer.observe(scrollRef.current, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
characterData: true
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}, [scrollThreshold]);
|
||||
|
||||
resizeObserverRef.current = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const debouncedCheckScrollable = useMemo(
|
||||
() => debounce(checkScrollable, debounceDelay),
|
||||
[debounce, checkScrollable, debounceDelay],
|
||||
);
|
||||
|
||||
const handleScroll = useCallback(
|
||||
(e) => {
|
||||
debouncedCheckScrollable();
|
||||
if (onScrollRef.current) {
|
||||
onScrollRef.current(e);
|
||||
}
|
||||
},
|
||||
[debouncedCheckScrollable],
|
||||
);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
checkScrollable: () => {
|
||||
checkScrollable();
|
||||
},
|
||||
scrollToTop: () => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = 0;
|
||||
}
|
||||
},
|
||||
scrollToBottom: () => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
},
|
||||
getScrollInfo: () => {
|
||||
if (!scrollRef.current) return null;
|
||||
const element = scrollRef.current;
|
||||
return {
|
||||
scrollTop: element.scrollTop,
|
||||
scrollHeight: element.scrollHeight,
|
||||
clientHeight: element.clientHeight,
|
||||
isScrollable: element.scrollHeight > element.clientHeight,
|
||||
isAtBottom:
|
||||
element.scrollTop + element.clientHeight >=
|
||||
element.scrollHeight - scrollThreshold,
|
||||
};
|
||||
},
|
||||
}),
|
||||
[checkScrollable, scrollThreshold],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
checkScrollable();
|
||||
}, checkInterval);
|
||||
return () => clearTimeout(timer);
|
||||
}, [checkScrollable, checkInterval]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!scrollRef.current) return;
|
||||
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
if (typeof MutationObserver !== 'undefined') {
|
||||
const observer = new MutationObserver(() => {
|
||||
debouncedCheckScrollable();
|
||||
});
|
||||
|
||||
observer.observe(scrollRef.current, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
characterData: true,
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
resizeObserverRef.current.observe(scrollRef.current);
|
||||
resizeObserverRef.current = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
debouncedCheckScrollable();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (resizeObserverRef.current) {
|
||||
resizeObserverRef.current.disconnect();
|
||||
}
|
||||
};
|
||||
}, [debouncedCheckScrollable]);
|
||||
resizeObserverRef.current.observe(scrollRef.current);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
return () => {
|
||||
if (resizeObserverRef.current) {
|
||||
resizeObserverRef.current.disconnect();
|
||||
}
|
||||
};
|
||||
}, [debouncedCheckScrollable]);
|
||||
|
||||
const containerStyle = useMemo(() => ({
|
||||
maxHeight
|
||||
}), [maxHeight]);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fadeIndicatorStyle = useMemo(() => ({
|
||||
opacity: showScrollHint ? 1 : 0
|
||||
}), [showScrollHint]);
|
||||
const containerStyle = useMemo(
|
||||
() => ({
|
||||
maxHeight,
|
||||
}),
|
||||
[maxHeight],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`card-content-container ${className}`}
|
||||
{...props}
|
||||
>
|
||||
const fadeIndicatorStyle = useMemo(
|
||||
() => ({
|
||||
opacity: showScrollHint ? 1 : 0,
|
||||
}),
|
||||
[showScrollHint],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={`overflow-y-auto card-content-scroll ${contentClassName}`}
|
||||
style={containerStyle}
|
||||
onScroll={handleScroll}
|
||||
ref={containerRef}
|
||||
className={`card-content-container ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={`overflow-y-auto card-content-scroll ${contentClassName}`}
|
||||
style={containerStyle}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<div
|
||||
className={`card-content-fade-indicator ${fadeIndicatorClassName}`}
|
||||
style={fadeIndicatorStyle}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`card-content-fade-indicator ${fadeIndicatorClassName}`}
|
||||
style={fadeIndicatorStyle}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ScrollableContainer.displayName = 'ScrollableContainer';
|
||||
|
||||
export default ScrollableContainer;
|
||||
export default ScrollableContainer;
|
||||
|
||||
@@ -20,7 +20,17 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { useMinimumLoadingTime } from '../../../hooks/common/useMinimumLoadingTime';
|
||||
import { useContainerWidth } from '../../../hooks/common/useContainerWidth';
|
||||
import { Divider, Button, Tag, Row, Col, Collapsible, Checkbox, Skeleton, Tooltip } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
Divider,
|
||||
Button,
|
||||
Tag,
|
||||
Row,
|
||||
Col,
|
||||
Collapsible,
|
||||
Checkbox,
|
||||
Skeleton,
|
||||
Tooltip,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { IconChevronDown, IconChevronUp } from '@douyinfe/semi-icons';
|
||||
|
||||
/**
|
||||
@@ -47,7 +57,7 @@ const SelectableButtonGroup = ({
|
||||
collapsible = true,
|
||||
collapseHeight = 200,
|
||||
withCheckbox = false,
|
||||
loading = false
|
||||
loading = false,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [skeletonCount] = useState(12);
|
||||
@@ -64,15 +74,13 @@ const SelectableButtonGroup = ({
|
||||
}, [text, containerWidth]);
|
||||
|
||||
const textElement = (
|
||||
<span ref={textRef} className="sbg-ellipsis">
|
||||
<span ref={textRef} className='sbg-ellipsis'>
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
|
||||
return isOverflowing ? (
|
||||
<Tooltip content={text}>
|
||||
{textElement}
|
||||
</Tooltip>
|
||||
<Tooltip content={text}>{textElement}</Tooltip>
|
||||
) : (
|
||||
textElement
|
||||
);
|
||||
@@ -80,10 +88,10 @@ const SelectableButtonGroup = ({
|
||||
|
||||
// 基于容器宽度计算响应式列数和标签显示策略
|
||||
const getResponsiveConfig = () => {
|
||||
if (containerWidth <= 280) return { columns: 1, showTags: true }; // 极窄:1列+标签
|
||||
if (containerWidth <= 380) return { columns: 2, showTags: true }; // 窄屏:2列+标签
|
||||
if (containerWidth <= 460) return { columns: 3, showTags: false }; // 中等:3列不加标签
|
||||
return { columns: 3, showTags: true }; // 最宽:3列+标签
|
||||
if (containerWidth <= 280) return { columns: 1, showTags: true }; // 极窄:1列+标签
|
||||
if (containerWidth <= 380) return { columns: 2, showTags: true }; // 窄屏:2列+标签
|
||||
if (containerWidth <= 460) return { columns: 3, showTags: false }; // 中等:3列不加标签
|
||||
return { columns: 3, showTags: true }; // 最宽:3列+标签
|
||||
};
|
||||
|
||||
const { columns: perRow, showTags: shouldShowTags } = getResponsiveConfig();
|
||||
@@ -102,9 +110,9 @@ const SelectableButtonGroup = ({
|
||||
const maskStyle = isOpen
|
||||
? {}
|
||||
: {
|
||||
WebkitMaskImage:
|
||||
'linear-gradient(to bottom, black 0%, rgba(0, 0, 0, 1) 60%, rgba(0, 0, 0, 0.2) 80%, transparent 100%)',
|
||||
};
|
||||
WebkitMaskImage:
|
||||
'linear-gradient(to bottom, black 0%, rgba(0, 0, 0, 1) 60%, rgba(0, 0, 0, 0.2) 80%, transparent 100%)',
|
||||
};
|
||||
|
||||
const toggle = () => {
|
||||
setIsOpen(!isOpen);
|
||||
@@ -127,25 +135,23 @@ const SelectableButtonGroup = ({
|
||||
};
|
||||
|
||||
const renderSkeletonButtons = () => {
|
||||
|
||||
const placeholder = (
|
||||
<Row gutter={gutterSize} style={{ lineHeight: '32px', ...style }}>
|
||||
{Array.from({ length: skeletonCount }).map((_, index) => (
|
||||
<Col
|
||||
span={getColSpan()}
|
||||
key={index}
|
||||
>
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '32px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
border: '1px solid var(--semi-color-border)',
|
||||
borderRadius: 'var(--semi-border-radius-medium)',
|
||||
padding: '0 12px',
|
||||
gap: '6px'
|
||||
}}>
|
||||
<Col span={getColSpan()} key={index}>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '32px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
border: '1px solid var(--semi-color-border)',
|
||||
borderRadius: 'var(--semi-border-radius-medium)',
|
||||
padding: '0 12px',
|
||||
gap: '6px',
|
||||
}}
|
||||
>
|
||||
{withCheckbox && (
|
||||
<Skeleton.Title active style={{ width: 14, height: 14 }} />
|
||||
)}
|
||||
@@ -153,7 +159,7 @@ const SelectableButtonGroup = ({
|
||||
active
|
||||
style={{
|
||||
width: `${60 + (index % 3) * 20}px`,
|
||||
height: 14
|
||||
height: 14,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -167,26 +173,29 @@ const SelectableButtonGroup = ({
|
||||
);
|
||||
};
|
||||
|
||||
const contentElement = showSkeleton ? renderSkeletonButtons() : (
|
||||
const contentElement = showSkeleton ? (
|
||||
renderSkeletonButtons()
|
||||
) : (
|
||||
<Row gutter={gutterSize} style={{ lineHeight: '32px', ...style }}>
|
||||
{items.map((item) => {
|
||||
const isDisabled = item.disabled || (typeof item.tagCount === 'number' && item.tagCount === 0);
|
||||
const isDisabled =
|
||||
item.disabled ||
|
||||
(typeof item.tagCount === 'number' && item.tagCount === 0);
|
||||
const isActive = Array.isArray(activeValue)
|
||||
? activeValue.includes(item.value)
|
||||
: activeValue === item.value;
|
||||
|
||||
if (withCheckbox) {
|
||||
return (
|
||||
<Col
|
||||
span={getColSpan()}
|
||||
key={item.value}
|
||||
>
|
||||
<Col span={getColSpan()} key={item.value}>
|
||||
<Button
|
||||
onClick={() => { /* disabled */ }}
|
||||
onClick={() => {
|
||||
/* disabled */
|
||||
}}
|
||||
theme={isActive ? 'light' : 'outline'}
|
||||
type={isActive ? 'primary' : 'tertiary'}
|
||||
disabled={isDisabled}
|
||||
className="sbg-button"
|
||||
className='sbg-button'
|
||||
icon={
|
||||
<Checkbox
|
||||
checked={isActive}
|
||||
@@ -197,11 +206,18 @@ const SelectableButtonGroup = ({
|
||||
}
|
||||
style={{ width: '100%', cursor: 'default' }}
|
||||
>
|
||||
<div className="sbg-content">
|
||||
{item.icon && (<span className="sbg-icon">{item.icon}</span>)}
|
||||
<div className='sbg-content'>
|
||||
{item.icon && <span className='sbg-icon'>{item.icon}</span>}
|
||||
<ConditionalTooltipText text={item.label} />
|
||||
{item.tagCount !== undefined && shouldShowTags && (
|
||||
<Tag className="sbg-tag" color='white' shape="circle" size="small">{item.tagCount}</Tag>
|
||||
<Tag
|
||||
className='sbg-tag'
|
||||
color='white'
|
||||
shape='circle'
|
||||
size='small'
|
||||
>
|
||||
{item.tagCount}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
@@ -210,23 +226,27 @@ const SelectableButtonGroup = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Col
|
||||
span={getColSpan()}
|
||||
key={item.value}
|
||||
>
|
||||
<Col span={getColSpan()} key={item.value}>
|
||||
<Button
|
||||
onClick={() => onChange(item.value)}
|
||||
theme={isActive ? 'light' : 'outline'}
|
||||
type={isActive ? 'primary' : 'tertiary'}
|
||||
disabled={isDisabled}
|
||||
className="sbg-button"
|
||||
className='sbg-button'
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<div className="sbg-content">
|
||||
{item.icon && (<span className="sbg-icon">{item.icon}</span>)}
|
||||
<div className='sbg-content'>
|
||||
{item.icon && <span className='sbg-icon'>{item.icon}</span>}
|
||||
<ConditionalTooltipText text={item.label} />
|
||||
{item.tagCount !== undefined && shouldShowTags && (
|
||||
<Tag className="sbg-tag" color='white' shape="circle" size="small">{item.tagCount}</Tag>
|
||||
<Tag
|
||||
className='sbg-tag'
|
||||
color='white'
|
||||
shape='circle'
|
||||
size='small'
|
||||
>
|
||||
{item.tagCount}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
@@ -237,9 +257,12 @@ const SelectableButtonGroup = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`mb-8 ${containerWidth <= 400 ? 'sbg-compact' : ''}`} ref={containerRef}>
|
||||
<div
|
||||
className={`mb-8 ${containerWidth <= 400 ? 'sbg-compact' : ''}`}
|
||||
ref={containerRef}
|
||||
>
|
||||
{title && (
|
||||
<Divider margin="12px" align="left">
|
||||
<Divider margin='12px' align='left'>
|
||||
{showSkeleton ? (
|
||||
<Skeleton.Title active style={{ width: 80, height: 14 }} />
|
||||
) : (
|
||||
@@ -249,23 +272,30 @@ const SelectableButtonGroup = ({
|
||||
)}
|
||||
{needCollapse && !showSkeleton ? (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Collapsible isOpen={isOpen} collapseHeight={collapseHeight} style={{ ...maskStyle }}>
|
||||
<Collapsible
|
||||
isOpen={isOpen}
|
||||
collapseHeight={collapseHeight}
|
||||
style={{ ...maskStyle }}
|
||||
>
|
||||
{contentElement}
|
||||
</Collapsible>
|
||||
{isOpen ? null : (
|
||||
<div onClick={toggle} style={{ ...linkStyle }}>
|
||||
<IconChevronDown size="small" />
|
||||
<IconChevronDown size='small' />
|
||||
<span>{t('展开更多')}</span>
|
||||
</div>
|
||||
)}
|
||||
{isOpen && (
|
||||
<div onClick={toggle} style={{
|
||||
...linkStyle,
|
||||
position: 'static',
|
||||
marginTop: 8,
|
||||
bottom: 'auto'
|
||||
}}>
|
||||
<IconChevronUp size="small" />
|
||||
<div
|
||||
onClick={toggle}
|
||||
style={{
|
||||
...linkStyle,
|
||||
position: 'static',
|
||||
marginTop: 8,
|
||||
bottom: 'auto',
|
||||
}}
|
||||
>
|
||||
<IconChevronUp size='small' />
|
||||
<span>{t('收起')}</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -277,4 +307,4 @@ const SelectableButtonGroup = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectableButtonGroup;
|
||||
export default SelectableButtonGroup;
|
||||
|
||||
@@ -21,7 +21,10 @@ import React from 'react';
|
||||
import { Card, Tag, Timeline, Empty } from '@douyinfe/semi-ui';
|
||||
import { Bell } from 'lucide-react';
|
||||
import { marked } from 'marked';
|
||||
import { IllustrationConstruction, IllustrationConstructionDark } from '@douyinfe/semi-illustrations';
|
||||
import {
|
||||
IllustrationConstruction,
|
||||
IllustrationConstructionDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import ScrollableContainer from '../common/ui/ScrollableContainer';
|
||||
|
||||
const AnnouncementsPanel = ({
|
||||
@@ -29,36 +32,43 @@ const AnnouncementsPanel = ({
|
||||
announcementLegendData,
|
||||
CARD_PROPS,
|
||||
ILLUSTRATION_SIZE,
|
||||
t
|
||||
t,
|
||||
}) => {
|
||||
return (
|
||||
<Card
|
||||
{...CARD_PROPS}
|
||||
className="shadow-sm !rounded-2xl lg:col-span-2"
|
||||
className='shadow-sm !rounded-2xl lg:col-span-2'
|
||||
title={
|
||||
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-2 w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className='flex flex-col lg:flex-row lg:items-center lg:justify-between gap-2 w-full'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Bell size={16} />
|
||||
{t('系统公告')}
|
||||
<Tag color="white" shape="circle">
|
||||
<Tag color='white' shape='circle'>
|
||||
{t('显示最新20条')}
|
||||
</Tag>
|
||||
</div>
|
||||
{/* 图例 */}
|
||||
<div className="flex flex-wrap gap-3 text-xs">
|
||||
<div className='flex flex-wrap gap-3 text-xs'>
|
||||
{announcementLegendData.map((legend, index) => (
|
||||
<div key={index} className="flex items-center gap-1">
|
||||
<div key={index} className='flex items-center gap-1'>
|
||||
<div
|
||||
className="w-2 h-2 rounded-full"
|
||||
className='w-2 h-2 rounded-full'
|
||||
style={{
|
||||
backgroundColor: legend.color === 'grey' ? '#8b9aa7' :
|
||||
legend.color === 'blue' ? '#3b82f6' :
|
||||
legend.color === 'green' ? '#10b981' :
|
||||
legend.color === 'orange' ? '#f59e0b' :
|
||||
legend.color === 'red' ? '#ef4444' : '#8b9aa7'
|
||||
backgroundColor:
|
||||
legend.color === 'grey'
|
||||
? '#8b9aa7'
|
||||
: legend.color === 'blue'
|
||||
? '#3b82f6'
|
||||
: legend.color === 'green'
|
||||
? '#10b981'
|
||||
: legend.color === 'orange'
|
||||
? '#f59e0b'
|
||||
: legend.color === 'red'
|
||||
? '#ef4444'
|
||||
: '#8b9aa7',
|
||||
}}
|
||||
/>
|
||||
<span className="text-gray-600">{legend.label}</span>
|
||||
<span className='text-gray-600'>{legend.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -66,9 +76,9 @@ const AnnouncementsPanel = ({
|
||||
}
|
||||
bodyStyle={{ padding: 0 }}
|
||||
>
|
||||
<ScrollableContainer maxHeight="24rem">
|
||||
<ScrollableContainer maxHeight='24rem'>
|
||||
{announcementData.length > 0 ? (
|
||||
<Timeline mode="left">
|
||||
<Timeline mode='left'>
|
||||
{announcementData.map((item, idx) => {
|
||||
const htmlExtra = item.extra ? marked.parse(item.extra) : '';
|
||||
return (
|
||||
@@ -76,16 +86,20 @@ const AnnouncementsPanel = ({
|
||||
key={idx}
|
||||
type={item.type || 'default'}
|
||||
time={`${item.relative ? item.relative + ' ' : ''}${item.time}`}
|
||||
extra={item.extra ? (
|
||||
<div
|
||||
className="text-xs text-gray-500"
|
||||
dangerouslySetInnerHTML={{ __html: htmlExtra }}
|
||||
/>
|
||||
) : null}
|
||||
extra={
|
||||
item.extra ? (
|
||||
<div
|
||||
className='text-xs text-gray-500'
|
||||
dangerouslySetInnerHTML={{ __html: htmlExtra }}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: marked.parse(item.content || '') }}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: marked.parse(item.content || ''),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Timeline.Item>
|
||||
@@ -93,10 +107,12 @@ const AnnouncementsPanel = ({
|
||||
})}
|
||||
</Timeline>
|
||||
) : (
|
||||
<div className="flex justify-center items-center py-8">
|
||||
<div className='flex justify-center items-center py-8'>
|
||||
<Empty
|
||||
image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
|
||||
darkModeImage={<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />}
|
||||
darkModeImage={
|
||||
<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />
|
||||
}
|
||||
title={t('暂无系统公告')}
|
||||
description={t('请联系管理员在系统设置中配置公告信息')}
|
||||
/>
|
||||
@@ -107,4 +123,4 @@ const AnnouncementsPanel = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default AnnouncementsPanel;
|
||||
export default AnnouncementsPanel;
|
||||
|
||||
@@ -20,7 +20,10 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
import React from 'react';
|
||||
import { Card, Avatar, Tag, Divider, Empty } from '@douyinfe/semi-ui';
|
||||
import { Server, Gauge, ExternalLink } from 'lucide-react';
|
||||
import { IllustrationConstruction, IllustrationConstructionDark } from '@douyinfe/semi-illustrations';
|
||||
import {
|
||||
IllustrationConstruction,
|
||||
IllustrationConstructionDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import ScrollableContainer from '../common/ui/ScrollableContainer';
|
||||
|
||||
const ApiInfoPanel = ({
|
||||
@@ -30,12 +33,12 @@ const ApiInfoPanel = ({
|
||||
CARD_PROPS,
|
||||
FLEX_CENTER_GAP2,
|
||||
ILLUSTRATION_SIZE,
|
||||
t
|
||||
t,
|
||||
}) => {
|
||||
return (
|
||||
<Card
|
||||
{...CARD_PROPS}
|
||||
className="bg-gray-50 border-0 !rounded-2xl"
|
||||
className='bg-gray-50 border-0 !rounded-2xl'
|
||||
title={
|
||||
<div className={FLEX_CENTER_GAP2}>
|
||||
<Server size={16} />
|
||||
@@ -44,66 +47,65 @@ const ApiInfoPanel = ({
|
||||
}
|
||||
bodyStyle={{ padding: 0 }}
|
||||
>
|
||||
<ScrollableContainer maxHeight="24rem">
|
||||
<ScrollableContainer maxHeight='24rem'>
|
||||
{apiInfoData.length > 0 ? (
|
||||
apiInfoData.map((api) => (
|
||||
<React.Fragment key={api.id}>
|
||||
<div className="flex p-2 hover:bg-white rounded-lg transition-colors cursor-pointer">
|
||||
<div className="flex-shrink-0 mr-3">
|
||||
<Avatar
|
||||
size="extra-small"
|
||||
color={api.color}
|
||||
>
|
||||
<div className='flex p-2 hover:bg-white rounded-lg transition-colors cursor-pointer'>
|
||||
<div className='flex-shrink-0 mr-3'>
|
||||
<Avatar size='extra-small' color={api.color}>
|
||||
{api.route.substring(0, 2)}
|
||||
</Avatar>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex flex-wrap items-center justify-between mb-1 w-full gap-2">
|
||||
<span className="text-sm font-medium text-gray-900 !font-bold break-all">
|
||||
<div className='flex-1'>
|
||||
<div className='flex flex-wrap items-center justify-between mb-1 w-full gap-2'>
|
||||
<span className='text-sm font-medium text-gray-900 !font-bold break-all'>
|
||||
{api.route}
|
||||
</span>
|
||||
<div className="flex items-center gap-1 mt-1 lg:mt-0">
|
||||
<div className='flex items-center gap-1 mt-1 lg:mt-0'>
|
||||
<Tag
|
||||
prefixIcon={<Gauge size={12} />}
|
||||
size="small"
|
||||
color="white"
|
||||
size='small'
|
||||
color='white'
|
||||
shape='circle'
|
||||
onClick={() => handleSpeedTest(api.url)}
|
||||
className="cursor-pointer hover:opacity-80 text-xs"
|
||||
className='cursor-pointer hover:opacity-80 text-xs'
|
||||
>
|
||||
{t('测速')}
|
||||
</Tag>
|
||||
<Tag
|
||||
prefixIcon={<ExternalLink size={12} />}
|
||||
size="small"
|
||||
color="white"
|
||||
size='small'
|
||||
color='white'
|
||||
shape='circle'
|
||||
onClick={() => window.open(api.url, '_blank', 'noopener,noreferrer')}
|
||||
className="cursor-pointer hover:opacity-80 text-xs"
|
||||
onClick={() =>
|
||||
window.open(api.url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
className='cursor-pointer hover:opacity-80 text-xs'
|
||||
>
|
||||
{t('跳转')}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="!text-semi-color-primary break-all cursor-pointer hover:underline mb-1"
|
||||
className='!text-semi-color-primary break-all cursor-pointer hover:underline mb-1'
|
||||
onClick={() => handleCopyUrl(api.url)}
|
||||
>
|
||||
{api.url}
|
||||
</div>
|
||||
<div className="text-gray-500">
|
||||
{api.description}
|
||||
</div>
|
||||
<div className='text-gray-500'>{api.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
<Divider />
|
||||
</React.Fragment>
|
||||
))
|
||||
) : (
|
||||
<div className="flex justify-center items-center py-8">
|
||||
<div className='flex justify-center items-center py-8'>
|
||||
<Empty
|
||||
image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
|
||||
darkModeImage={<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />}
|
||||
darkModeImage={
|
||||
<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />
|
||||
}
|
||||
title={t('暂无API信息')}
|
||||
description={t('请联系管理员在系统设置中配置API信息')}
|
||||
/>
|
||||
@@ -114,4 +116,4 @@ const ApiInfoPanel = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default ApiInfoPanel;
|
||||
export default ApiInfoPanel;
|
||||
|
||||
@@ -23,7 +23,7 @@ import { PieChart } from 'lucide-react';
|
||||
import {
|
||||
IconHistogram,
|
||||
IconPulse,
|
||||
IconPieChart2Stroked
|
||||
IconPieChart2Stroked,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { VChart } from '@visactor/react-vchart';
|
||||
|
||||
@@ -38,80 +38,80 @@ const ChartsPanel = ({
|
||||
CHART_CONFIG,
|
||||
FLEX_CENTER_GAP2,
|
||||
hasApiInfoPanel,
|
||||
t
|
||||
t,
|
||||
}) => {
|
||||
return (
|
||||
<Card
|
||||
{...CARD_PROPS}
|
||||
className={`!rounded-2xl ${hasApiInfoPanel ? 'lg:col-span-3' : ''}`}
|
||||
title={
|
||||
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between w-full gap-3">
|
||||
<div className='flex flex-col lg:flex-row lg:items-center lg:justify-between w-full gap-3'>
|
||||
<div className={FLEX_CENTER_GAP2}>
|
||||
<PieChart size={16} />
|
||||
{t('模型数据分析')}
|
||||
</div>
|
||||
<Tabs
|
||||
type="button"
|
||||
type='button'
|
||||
activeKey={activeChartTab}
|
||||
onChange={setActiveChartTab}
|
||||
>
|
||||
<TabPane tab={
|
||||
<span>
|
||||
<IconHistogram />
|
||||
{t('消耗分布')}
|
||||
</span>
|
||||
} itemKey="1" />
|
||||
<TabPane tab={
|
||||
<span>
|
||||
<IconPulse />
|
||||
{t('消耗趋势')}
|
||||
</span>
|
||||
} itemKey="2" />
|
||||
<TabPane tab={
|
||||
<span>
|
||||
<IconPieChart2Stroked />
|
||||
{t('调用次数分布')}
|
||||
</span>
|
||||
} itemKey="3" />
|
||||
<TabPane tab={
|
||||
<span>
|
||||
<IconHistogram />
|
||||
{t('调用次数排行')}
|
||||
</span>
|
||||
} itemKey="4" />
|
||||
<TabPane
|
||||
tab={
|
||||
<span>
|
||||
<IconHistogram />
|
||||
{t('消耗分布')}
|
||||
</span>
|
||||
}
|
||||
itemKey='1'
|
||||
/>
|
||||
<TabPane
|
||||
tab={
|
||||
<span>
|
||||
<IconPulse />
|
||||
{t('消耗趋势')}
|
||||
</span>
|
||||
}
|
||||
itemKey='2'
|
||||
/>
|
||||
<TabPane
|
||||
tab={
|
||||
<span>
|
||||
<IconPieChart2Stroked />
|
||||
{t('调用次数分布')}
|
||||
</span>
|
||||
}
|
||||
itemKey='3'
|
||||
/>
|
||||
<TabPane
|
||||
tab={
|
||||
<span>
|
||||
<IconHistogram />
|
||||
{t('调用次数排行')}
|
||||
</span>
|
||||
}
|
||||
itemKey='4'
|
||||
/>
|
||||
</Tabs>
|
||||
</div>
|
||||
}
|
||||
bodyStyle={{ padding: 0 }}
|
||||
>
|
||||
<div className="h-96 p-2">
|
||||
<div className='h-96 p-2'>
|
||||
{activeChartTab === '1' && (
|
||||
<VChart
|
||||
spec={spec_line}
|
||||
option={CHART_CONFIG}
|
||||
/>
|
||||
<VChart spec={spec_line} option={CHART_CONFIG} />
|
||||
)}
|
||||
{activeChartTab === '2' && (
|
||||
<VChart
|
||||
spec={spec_model_line}
|
||||
option={CHART_CONFIG}
|
||||
/>
|
||||
<VChart spec={spec_model_line} option={CHART_CONFIG} />
|
||||
)}
|
||||
{activeChartTab === '3' && (
|
||||
<VChart
|
||||
spec={spec_pie}
|
||||
option={CHART_CONFIG}
|
||||
/>
|
||||
<VChart spec={spec_pie} option={CHART_CONFIG} />
|
||||
)}
|
||||
{activeChartTab === '4' && (
|
||||
<VChart
|
||||
spec={spec_rank_bar}
|
||||
option={CHART_CONFIG}
|
||||
/>
|
||||
<VChart spec={spec_rank_bar} option={CHART_CONFIG} />
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChartsPanel;
|
||||
export default ChartsPanel;
|
||||
|
||||
@@ -27,19 +27,19 @@ const DashboardHeader = ({
|
||||
showSearchModal,
|
||||
refresh,
|
||||
loading,
|
||||
t
|
||||
t,
|
||||
}) => {
|
||||
const ICON_BUTTON_CLASS = "text-white hover:bg-opacity-80 !rounded-full";
|
||||
const ICON_BUTTON_CLASS = 'text-white hover:bg-opacity-80 !rounded-full';
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className='flex items-center justify-between mb-4'>
|
||||
<h2
|
||||
className="text-2xl font-semibold text-gray-800 transition-opacity duration-1000 ease-in-out"
|
||||
className='text-2xl font-semibold text-gray-800 transition-opacity duration-1000 ease-in-out'
|
||||
style={{ opacity: greetingVisible ? 1 : 0 }}
|
||||
>
|
||||
{getGreeting}
|
||||
</h2>
|
||||
<div className="flex gap-3">
|
||||
<div className='flex gap-3'>
|
||||
<Button
|
||||
type='tertiary'
|
||||
icon={<Search size={16} />}
|
||||
@@ -58,4 +58,4 @@ const DashboardHeader = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardHeader;
|
||||
export default DashboardHeader;
|
||||
|
||||
@@ -22,7 +22,10 @@ import { Card, Collapse, Empty } from '@douyinfe/semi-ui';
|
||||
import { HelpCircle } from 'lucide-react';
|
||||
import { IconPlus, IconMinus } from '@douyinfe/semi-icons';
|
||||
import { marked } from 'marked';
|
||||
import { IllustrationConstruction, IllustrationConstructionDark } from '@douyinfe/semi-illustrations';
|
||||
import {
|
||||
IllustrationConstruction,
|
||||
IllustrationConstructionDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import ScrollableContainer from '../common/ui/ScrollableContainer';
|
||||
|
||||
const FaqPanel = ({
|
||||
@@ -30,12 +33,12 @@ const FaqPanel = ({
|
||||
CARD_PROPS,
|
||||
FLEX_CENTER_GAP2,
|
||||
ILLUSTRATION_SIZE,
|
||||
t
|
||||
t,
|
||||
}) => {
|
||||
return (
|
||||
<Card
|
||||
{...CARD_PROPS}
|
||||
className="shadow-sm !rounded-2xl lg:col-span-1"
|
||||
className='shadow-sm !rounded-2xl lg:col-span-1'
|
||||
title={
|
||||
<div className={FLEX_CENTER_GAP2}>
|
||||
<HelpCircle size={16} />
|
||||
@@ -44,7 +47,7 @@ const FaqPanel = ({
|
||||
}
|
||||
bodyStyle={{ padding: 0 }}
|
||||
>
|
||||
<ScrollableContainer maxHeight="24rem">
|
||||
<ScrollableContainer maxHeight='24rem'>
|
||||
{faqData.length > 0 ? (
|
||||
<Collapse
|
||||
accordion
|
||||
@@ -58,16 +61,20 @@ const FaqPanel = ({
|
||||
itemKey={index.toString()}
|
||||
>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: marked.parse(item.answer || '') }}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: marked.parse(item.answer || ''),
|
||||
}}
|
||||
/>
|
||||
</Collapse.Panel>
|
||||
))}
|
||||
</Collapse>
|
||||
) : (
|
||||
<div className="flex justify-center items-center py-8">
|
||||
<div className='flex justify-center items-center py-8'>
|
||||
<Empty
|
||||
image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
|
||||
darkModeImage={<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />}
|
||||
darkModeImage={
|
||||
<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />
|
||||
}
|
||||
title={t('暂无常见问答')}
|
||||
description={t('请联系管理员在系统设置中配置常见问答')}
|
||||
/>
|
||||
@@ -78,4 +85,4 @@ const FaqPanel = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default FaqPanel;
|
||||
export default FaqPanel;
|
||||
|
||||
@@ -28,13 +28,13 @@ const StatsCards = ({
|
||||
loading,
|
||||
getTrendSpec,
|
||||
CARD_PROPS,
|
||||
CHART_CONFIG
|
||||
CHART_CONFIG,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className='mb-4'>
|
||||
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4'>
|
||||
{groupedStatsData.map((group, idx) => (
|
||||
<Card
|
||||
key={idx}
|
||||
@@ -42,24 +42,24 @@ const StatsCards = ({
|
||||
className={`${group.color} border-0 !rounded-2xl w-full`}
|
||||
title={group.title}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className='space-y-4'>
|
||||
{group.items.map((item, itemIdx) => (
|
||||
<div
|
||||
key={itemIdx}
|
||||
className="flex items-center justify-between cursor-pointer"
|
||||
className='flex items-center justify-between cursor-pointer'
|
||||
onClick={item.onClick}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className='flex items-center'>
|
||||
<Avatar
|
||||
className="mr-3"
|
||||
size="small"
|
||||
className='mr-3'
|
||||
size='small'
|
||||
color={item.avatarColor}
|
||||
>
|
||||
{item.icon}
|
||||
</Avatar>
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">{item.title}</div>
|
||||
<div className="text-lg font-semibold">
|
||||
<div className='text-xs text-gray-500'>{item.title}</div>
|
||||
<div className='text-lg font-semibold'>
|
||||
<Skeleton
|
||||
loading={loading}
|
||||
active
|
||||
@@ -67,7 +67,11 @@ const StatsCards = ({
|
||||
<Skeleton.Paragraph
|
||||
active
|
||||
rows={1}
|
||||
style={{ width: '65px', height: '24px', marginTop: '4px' }}
|
||||
style={{
|
||||
width: '65px',
|
||||
height: '24px',
|
||||
marginTop: '4px',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
@@ -78,9 +82,9 @@ const StatsCards = ({
|
||||
</div>
|
||||
{item.title === t('当前余额') ? (
|
||||
<Tag
|
||||
color="white"
|
||||
color='white'
|
||||
shape='circle'
|
||||
size="large"
|
||||
size='large'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/console/topup');
|
||||
@@ -88,13 +92,16 @@ const StatsCards = ({
|
||||
>
|
||||
{t('充值')}
|
||||
</Tag>
|
||||
) : (loading || (item.trendData && item.trendData.length > 0)) && (
|
||||
<div className="w-24 h-10">
|
||||
<VChart
|
||||
spec={getTrendSpec(item.trendData, item.trendColor)}
|
||||
option={CHART_CONFIG}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
(loading ||
|
||||
(item.trendData && item.trendData.length > 0)) && (
|
||||
<div className='w-24 h-10'>
|
||||
<VChart
|
||||
spec={getTrendSpec(item.trendData, item.trendColor)}
|
||||
option={CHART_CONFIG}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
@@ -106,4 +113,4 @@ const StatsCards = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default StatsCards;
|
||||
export default StatsCards;
|
||||
|
||||
@@ -18,9 +18,20 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Card, Button, Spin, Tabs, TabPane, Tag, Empty } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
Spin,
|
||||
Tabs,
|
||||
TabPane,
|
||||
Tag,
|
||||
Empty,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { Gauge, RefreshCw } from 'lucide-react';
|
||||
import { IllustrationConstruction, IllustrationConstructionDark } from '@douyinfe/semi-illustrations';
|
||||
import {
|
||||
IllustrationConstruction,
|
||||
IllustrationConstructionDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import ScrollableContainer from '../common/ui/ScrollableContainer';
|
||||
|
||||
const UptimePanel = ({
|
||||
@@ -33,15 +44,15 @@ const UptimePanel = ({
|
||||
renderMonitorList,
|
||||
CARD_PROPS,
|
||||
ILLUSTRATION_SIZE,
|
||||
t
|
||||
t,
|
||||
}) => {
|
||||
return (
|
||||
<Card
|
||||
{...CARD_PROPS}
|
||||
className="shadow-sm !rounded-2xl lg:col-span-1"
|
||||
className='shadow-sm !rounded-2xl lg:col-span-1'
|
||||
title={
|
||||
<div className="flex items-center justify-between w-full gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className='flex items-center justify-between w-full gap-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Gauge size={16} />
|
||||
{t('服务可用性')}
|
||||
</div>
|
||||
@@ -49,39 +60,43 @@ const UptimePanel = ({
|
||||
icon={<RefreshCw size={14} />}
|
||||
onClick={loadUptimeData}
|
||||
loading={uptimeLoading}
|
||||
size="small"
|
||||
theme="borderless"
|
||||
size='small'
|
||||
theme='borderless'
|
||||
type='tertiary'
|
||||
className="text-gray-500 hover:text-blue-500 hover:bg-blue-50 !rounded-full"
|
||||
className='text-gray-500 hover:text-blue-500 hover:bg-blue-50 !rounded-full'
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
bodyStyle={{ padding: 0 }}
|
||||
>
|
||||
{/* 内容区域 */}
|
||||
<div className="relative">
|
||||
<div className='relative'>
|
||||
<Spin spinning={uptimeLoading}>
|
||||
{uptimeData.length > 0 ? (
|
||||
uptimeData.length === 1 ? (
|
||||
<ScrollableContainer maxHeight="24rem">
|
||||
<ScrollableContainer maxHeight='24rem'>
|
||||
{renderMonitorList(uptimeData[0].monitors)}
|
||||
</ScrollableContainer>
|
||||
) : (
|
||||
<Tabs
|
||||
type="card"
|
||||
type='card'
|
||||
collapsible
|
||||
activeKey={activeUptimeTab}
|
||||
onChange={setActiveUptimeTab}
|
||||
size="small"
|
||||
size='small'
|
||||
>
|
||||
{uptimeData.map((group, groupIdx) => (
|
||||
<TabPane
|
||||
tab={
|
||||
<span className="flex items-center gap-2">
|
||||
<span className='flex items-center gap-2'>
|
||||
<Gauge size={14} />
|
||||
{group.categoryName}
|
||||
<Tag
|
||||
color={activeUptimeTab === group.categoryName ? 'red' : 'grey'}
|
||||
color={
|
||||
activeUptimeTab === group.categoryName
|
||||
? 'red'
|
||||
: 'grey'
|
||||
}
|
||||
size='small'
|
||||
shape='circle'
|
||||
>
|
||||
@@ -92,7 +107,7 @@ const UptimePanel = ({
|
||||
itemKey={group.categoryName}
|
||||
key={groupIdx}
|
||||
>
|
||||
<ScrollableContainer maxHeight="21.5rem">
|
||||
<ScrollableContainer maxHeight='21.5rem'>
|
||||
{renderMonitorList(group.monitors)}
|
||||
</ScrollableContainer>
|
||||
</TabPane>
|
||||
@@ -100,10 +115,12 @@ const UptimePanel = ({
|
||||
</Tabs>
|
||||
)
|
||||
) : (
|
||||
<div className="flex justify-center items-center py-8">
|
||||
<div className='flex justify-center items-center py-8'>
|
||||
<Empty
|
||||
image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
|
||||
darkModeImage={<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />}
|
||||
darkModeImage={
|
||||
<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />
|
||||
}
|
||||
title={t('暂无监控数据')}
|
||||
description={t('请联系管理员在系统设置中配置Uptime')}
|
||||
/>
|
||||
@@ -114,15 +131,15 @@ const UptimePanel = ({
|
||||
|
||||
{/* 图例 */}
|
||||
{uptimeData.length > 0 && (
|
||||
<div className="p-3 bg-gray-50 rounded-b-2xl">
|
||||
<div className="flex flex-wrap gap-3 text-xs justify-center">
|
||||
<div className='p-3 bg-gray-50 rounded-b-2xl'>
|
||||
<div className='flex flex-wrap gap-3 text-xs justify-center'>
|
||||
{uptimeLegendData.map((legend, index) => (
|
||||
<div key={index} className="flex items-center gap-1">
|
||||
<div key={index} className='flex items-center gap-1'>
|
||||
<div
|
||||
className="w-2 h-2 rounded-full"
|
||||
className='w-2 h-2 rounded-full'
|
||||
style={{ backgroundColor: legend.color }}
|
||||
/>
|
||||
<span className="text-gray-600">{legend.label}</span>
|
||||
<span className='text-gray-600'>{legend.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -132,4 +149,4 @@ const UptimePanel = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default UptimePanel;
|
||||
export default UptimePanel;
|
||||
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
FLEX_CENTER_GAP2,
|
||||
ILLUSTRATION_SIZE,
|
||||
ANNOUNCEMENT_LEGEND_DATA,
|
||||
UPTIME_STATUS_MAP
|
||||
UPTIME_STATUS_MAP,
|
||||
} from '../../constants/dashboard.constants';
|
||||
import {
|
||||
getTrendSpec,
|
||||
@@ -49,7 +49,7 @@ import {
|
||||
handleSpeedTest,
|
||||
getUptimeStatusColor,
|
||||
getUptimeStatusText,
|
||||
renderMonitorList
|
||||
renderMonitorList,
|
||||
} from '../../helpers/dashboard';
|
||||
|
||||
const Dashboard = () => {
|
||||
@@ -70,7 +70,7 @@ const Dashboard = () => {
|
||||
dashboardData.setPieData,
|
||||
dashboardData.setLineData,
|
||||
dashboardData.setModelColors,
|
||||
dashboardData.t
|
||||
dashboardData.t,
|
||||
);
|
||||
|
||||
// ========== 统计数据 ==========
|
||||
@@ -82,12 +82,12 @@ const Dashboard = () => {
|
||||
dashboardData.trendData,
|
||||
dashboardData.performanceMetrics,
|
||||
dashboardData.navigate,
|
||||
dashboardData.t
|
||||
dashboardData.t,
|
||||
);
|
||||
|
||||
// ========== 数据处理 ==========
|
||||
const initChart = async () => {
|
||||
await dashboardData.loadQuotaData().then(data => {
|
||||
await dashboardData.loadQuotaData().then((data) => {
|
||||
if (data && data.length > 0) {
|
||||
dashboardCharts.updateChartData(data);
|
||||
}
|
||||
@@ -108,25 +108,30 @@ const Dashboard = () => {
|
||||
|
||||
// ========== 数据准备 ==========
|
||||
const apiInfoData = statusState?.status?.api_info || [];
|
||||
const announcementData = (statusState?.status?.announcements || []).map(item => {
|
||||
const pubDate = item?.publishDate ? new Date(item.publishDate) : null;
|
||||
const absoluteTime = pubDate && !isNaN(pubDate.getTime())
|
||||
? `${pubDate.getFullYear()}-${String(pubDate.getMonth() + 1).padStart(2, '0')}-${String(pubDate.getDate()).padStart(2, '0')} ${String(pubDate.getHours()).padStart(2, '0')}:${String(pubDate.getMinutes()).padStart(2, '0')}`
|
||||
: (item?.publishDate || '');
|
||||
const relativeTime = getRelativeTime(item.publishDate);
|
||||
return ({
|
||||
...item,
|
||||
time: absoluteTime,
|
||||
relative: relativeTime
|
||||
});
|
||||
});
|
||||
const announcementData = (statusState?.status?.announcements || []).map(
|
||||
(item) => {
|
||||
const pubDate = item?.publishDate ? new Date(item.publishDate) : null;
|
||||
const absoluteTime =
|
||||
pubDate && !isNaN(pubDate.getTime())
|
||||
? `${pubDate.getFullYear()}-${String(pubDate.getMonth() + 1).padStart(2, '0')}-${String(pubDate.getDate()).padStart(2, '0')} ${String(pubDate.getHours()).padStart(2, '0')}:${String(pubDate.getMinutes()).padStart(2, '0')}`
|
||||
: item?.publishDate || '';
|
||||
const relativeTime = getRelativeTime(item.publishDate);
|
||||
return {
|
||||
...item,
|
||||
time: absoluteTime,
|
||||
relative: relativeTime,
|
||||
};
|
||||
},
|
||||
);
|
||||
const faqData = statusState?.status?.faq || [];
|
||||
|
||||
const uptimeLegendData = Object.entries(UPTIME_STATUS_MAP).map(([status, info]) => ({
|
||||
status: Number(status),
|
||||
color: info.color,
|
||||
label: dashboardData.t(info.label)
|
||||
}));
|
||||
const uptimeLegendData = Object.entries(UPTIME_STATUS_MAP).map(
|
||||
([status, info]) => ({
|
||||
status: Number(status),
|
||||
color: info.color,
|
||||
label: dashboardData.t(info.label),
|
||||
}),
|
||||
);
|
||||
|
||||
// ========== Effects ==========
|
||||
useEffect(() => {
|
||||
@@ -134,7 +139,7 @@ const Dashboard = () => {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="h-full">
|
||||
<div className='h-full'>
|
||||
<DashboardHeader
|
||||
getGreeting={dashboardData.getGreeting}
|
||||
greetingVisible={dashboardData.greetingVisible}
|
||||
@@ -166,8 +171,10 @@ const Dashboard = () => {
|
||||
/>
|
||||
|
||||
{/* API信息和图表面板 */}
|
||||
<div className="mb-4">
|
||||
<div className={`grid grid-cols-1 gap-4 ${dashboardData.hasApiInfoPanel ? 'lg:grid-cols-4' : ''}`}>
|
||||
<div className='mb-4'>
|
||||
<div
|
||||
className={`grid grid-cols-1 gap-4 ${dashboardData.hasApiInfoPanel ? 'lg:grid-cols-4' : ''}`}
|
||||
>
|
||||
<ChartsPanel
|
||||
activeChartTab={dashboardData.activeChartTab}
|
||||
setActiveChartTab={dashboardData.setActiveChartTab}
|
||||
@@ -198,16 +205,18 @@ const Dashboard = () => {
|
||||
|
||||
{/* 系统公告和常见问答卡片 */}
|
||||
{dashboardData.hasInfoPanels && (
|
||||
<div className="mb-4">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4">
|
||||
<div className='mb-4'>
|
||||
<div className='grid grid-cols-1 lg:grid-cols-4 gap-4'>
|
||||
{/* 公告卡片 */}
|
||||
{dashboardData.announcementsEnabled && (
|
||||
<AnnouncementsPanel
|
||||
announcementData={announcementData}
|
||||
announcementLegendData={ANNOUNCEMENT_LEGEND_DATA.map(item => ({
|
||||
...item,
|
||||
label: dashboardData.t(item.label)
|
||||
}))}
|
||||
announcementLegendData={ANNOUNCEMENT_LEGEND_DATA.map(
|
||||
(item) => ({
|
||||
...item,
|
||||
label: dashboardData.t(item.label),
|
||||
}),
|
||||
)}
|
||||
CARD_PROPS={CARD_PROPS}
|
||||
ILLUSTRATION_SIZE={ILLUSTRATION_SIZE}
|
||||
t={dashboardData.t}
|
||||
@@ -234,12 +243,19 @@ const Dashboard = () => {
|
||||
setActiveUptimeTab={dashboardData.setActiveUptimeTab}
|
||||
loadUptimeData={dashboardData.loadUptimeData}
|
||||
uptimeLegendData={uptimeLegendData}
|
||||
renderMonitorList={(monitors) => renderMonitorList(
|
||||
monitors,
|
||||
(status) => getUptimeStatusColor(status, UPTIME_STATUS_MAP),
|
||||
(status) => getUptimeStatusText(status, UPTIME_STATUS_MAP, dashboardData.t),
|
||||
dashboardData.t
|
||||
)}
|
||||
renderMonitorList={(monitors) =>
|
||||
renderMonitorList(
|
||||
monitors,
|
||||
(status) => getUptimeStatusColor(status, UPTIME_STATUS_MAP),
|
||||
(status) =>
|
||||
getUptimeStatusText(
|
||||
status,
|
||||
UPTIME_STATUS_MAP,
|
||||
dashboardData.t,
|
||||
),
|
||||
dashboardData.t,
|
||||
)
|
||||
}
|
||||
CARD_PROPS={CARD_PROPS}
|
||||
ILLUSTRATION_SIZE={ILLUSTRATION_SIZE}
|
||||
t={dashboardData.t}
|
||||
@@ -252,4 +268,4 @@ const Dashboard = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
export default Dashboard;
|
||||
|
||||
@@ -30,12 +30,12 @@ const SearchModal = ({
|
||||
dataExportDefaultTime,
|
||||
timeOptions,
|
||||
handleInputChange,
|
||||
t
|
||||
t,
|
||||
}) => {
|
||||
const formRef = useRef();
|
||||
|
||||
const FORM_FIELD_PROPS = {
|
||||
className: "w-full mb-2 !rounded-lg",
|
||||
className: 'w-full mb-2 !rounded-lg',
|
||||
};
|
||||
|
||||
const createFormField = (Component, props) => (
|
||||
@@ -54,7 +54,7 @@ const SearchModal = ({
|
||||
size={isMobile ? 'full-width' : 'small'}
|
||||
centered
|
||||
>
|
||||
<Form ref={formRef} layout='vertical' className="w-full">
|
||||
<Form ref={formRef} layout='vertical' className='w-full'>
|
||||
{createFormField(Form.DatePicker, {
|
||||
field: 'start_timestamp',
|
||||
label: t('起始时间'),
|
||||
@@ -62,7 +62,7 @@ const SearchModal = ({
|
||||
value: start_timestamp,
|
||||
type: 'dateTime',
|
||||
name: 'start_timestamp',
|
||||
onChange: (value) => handleInputChange(value, 'start_timestamp')
|
||||
onChange: (value) => handleInputChange(value, 'start_timestamp'),
|
||||
})}
|
||||
|
||||
{createFormField(Form.DatePicker, {
|
||||
@@ -72,7 +72,7 @@ const SearchModal = ({
|
||||
value: end_timestamp,
|
||||
type: 'dateTime',
|
||||
name: 'end_timestamp',
|
||||
onChange: (value) => handleInputChange(value, 'end_timestamp')
|
||||
onChange: (value) => handleInputChange(value, 'end_timestamp'),
|
||||
})}
|
||||
|
||||
{createFormField(Form.Select, {
|
||||
@@ -82,20 +82,22 @@ const SearchModal = ({
|
||||
placeholder: t('时间粒度'),
|
||||
name: 'data_export_default_time',
|
||||
optionList: timeOptions,
|
||||
onChange: (value) => handleInputChange(value, 'data_export_default_time')
|
||||
onChange: (value) =>
|
||||
handleInputChange(value, 'data_export_default_time'),
|
||||
})}
|
||||
|
||||
{isAdminUser && createFormField(Form.Input, {
|
||||
field: 'username',
|
||||
label: t('用户名称'),
|
||||
value: username,
|
||||
placeholder: t('可选值'),
|
||||
name: 'username',
|
||||
onChange: (value) => handleInputChange(value, 'username')
|
||||
})}
|
||||
{isAdminUser &&
|
||||
createFormField(Form.Input, {
|
||||
field: 'username',
|
||||
label: t('用户名称'),
|
||||
value: username,
|
||||
placeholder: t('可选值'),
|
||||
name: 'username',
|
||||
onChange: (value) => handleInputChange(value, 'username'),
|
||||
})}
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchModal;
|
||||
export default SearchModal;
|
||||
|
||||
@@ -40,85 +40,191 @@ const FooterBar = () => {
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
const customFooter = useMemo(() => (
|
||||
<footer className="relative h-auto py-16 px-6 md:px-24 w-full flex flex-col items-center justify-between overflow-hidden">
|
||||
<div className="absolute hidden md:block top-[204px] left-[-100px] w-[151px] h-[151px] rounded-full bg-[#FFD166]"></div>
|
||||
<div className="absolute md:hidden bottom-[20px] left-[-50px] w-[80px] h-[80px] rounded-full bg-[#FFD166] opacity-60"></div>
|
||||
const customFooter = useMemo(
|
||||
() => (
|
||||
<footer className='relative h-auto py-16 px-6 md:px-24 w-full flex flex-col items-center justify-between overflow-hidden'>
|
||||
<div className='absolute hidden md:block top-[204px] left-[-100px] w-[151px] h-[151px] rounded-full bg-[#FFD166]'></div>
|
||||
<div className='absolute md:hidden bottom-[20px] left-[-50px] w-[80px] h-[80px] rounded-full bg-[#FFD166] opacity-60'></div>
|
||||
|
||||
{isDemoSiteMode && (
|
||||
<div className="flex flex-col md:flex-row justify-between w-full max-w-[1110px] mb-10 gap-8">
|
||||
<div className="flex-shrink-0">
|
||||
<img
|
||||
src={logo}
|
||||
alt={systemName}
|
||||
className="w-16 h-16 rounded-full bg-gray-800 p-1.5 object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-8 w-full">
|
||||
<div className="text-left">
|
||||
<p className="!text-semi-color-text-0 font-semibold mb-5">{t('关于我们')}</p>
|
||||
<div className="flex flex-col gap-4">
|
||||
<a href="https://docs.newapi.pro/wiki/project-introduction/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">{t('关于项目')}</a>
|
||||
<a href="https://docs.newapi.pro/support/community-interaction/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">{t('联系我们')}</a>
|
||||
<a href="https://docs.newapi.pro/wiki/features-introduction/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">{t('功能特性')}</a>
|
||||
</div>
|
||||
{isDemoSiteMode && (
|
||||
<div className='flex flex-col md:flex-row justify-between w-full max-w-[1110px] mb-10 gap-8'>
|
||||
<div className='flex-shrink-0'>
|
||||
<img
|
||||
src={logo}
|
||||
alt={systemName}
|
||||
className='w-16 h-16 rounded-full bg-gray-800 p-1.5 object-contain'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-left">
|
||||
<p className="!text-semi-color-text-0 font-semibold mb-5">{t('文档')}</p>
|
||||
<div className="flex flex-col gap-4">
|
||||
<a href="https://docs.newapi.pro/getting-started/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">{t('快速开始')}</a>
|
||||
<a href="https://docs.newapi.pro/installation/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">{t('安装指南')}</a>
|
||||
<a href="https://docs.newapi.pro/api/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">{t('API 文档')}</a>
|
||||
<div className='grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-8 w-full'>
|
||||
<div className='text-left'>
|
||||
<p className='!text-semi-color-text-0 font-semibold mb-5'>
|
||||
{t('关于我们')}
|
||||
</p>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<a
|
||||
href='https://docs.newapi.pro/wiki/project-introduction/'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='!text-semi-color-text-1'
|
||||
>
|
||||
{t('关于项目')}
|
||||
</a>
|
||||
<a
|
||||
href='https://docs.newapi.pro/support/community-interaction/'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='!text-semi-color-text-1'
|
||||
>
|
||||
{t('联系我们')}
|
||||
</a>
|
||||
<a
|
||||
href='https://docs.newapi.pro/wiki/features-introduction/'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='!text-semi-color-text-1'
|
||||
>
|
||||
{t('功能特性')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-left">
|
||||
<p className="!text-semi-color-text-0 font-semibold mb-5">{t('相关项目')}</p>
|
||||
<div className="flex flex-col gap-4">
|
||||
<a href="https://github.com/songquanpeng/one-api" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">One API</a>
|
||||
<a href="https://github.com/novicezk/midjourney-proxy" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">Midjourney-Proxy</a>
|
||||
<a href="https://github.com/Deeptrain-Community/chatnio" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">chatnio</a>
|
||||
<a href="https://github.com/Calcium-Ion/neko-api-key-tool" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">neko-api-key-tool</a>
|
||||
<div className='text-left'>
|
||||
<p className='!text-semi-color-text-0 font-semibold mb-5'>
|
||||
{t('文档')}
|
||||
</p>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<a
|
||||
href='https://docs.newapi.pro/getting-started/'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='!text-semi-color-text-1'
|
||||
>
|
||||
{t('快速开始')}
|
||||
</a>
|
||||
<a
|
||||
href='https://docs.newapi.pro/installation/'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='!text-semi-color-text-1'
|
||||
>
|
||||
{t('安装指南')}
|
||||
</a>
|
||||
<a
|
||||
href='https://docs.newapi.pro/api/'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='!text-semi-color-text-1'
|
||||
>
|
||||
{t('API 文档')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-left">
|
||||
<p className="!text-semi-color-text-0 font-semibold mb-5">{t('基于New API的项目')}</p>
|
||||
<div className="flex flex-col gap-4">
|
||||
<a href="https://github.com/Calcium-Ion/new-api-horizon" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">new-api-horizon</a>
|
||||
{/* <a href="https://github.com/VoAPI/VoAPI" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">VoAPI</a> */}
|
||||
<div className='text-left'>
|
||||
<p className='!text-semi-color-text-0 font-semibold mb-5'>
|
||||
{t('相关项目')}
|
||||
</p>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<a
|
||||
href='https://github.com/songquanpeng/one-api'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='!text-semi-color-text-1'
|
||||
>
|
||||
One API
|
||||
</a>
|
||||
<a
|
||||
href='https://github.com/novicezk/midjourney-proxy'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='!text-semi-color-text-1'
|
||||
>
|
||||
Midjourney-Proxy
|
||||
</a>
|
||||
<a
|
||||
href='https://github.com/Deeptrain-Community/chatnio'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='!text-semi-color-text-1'
|
||||
>
|
||||
chatnio
|
||||
</a>
|
||||
<a
|
||||
href='https://github.com/Calcium-Ion/neko-api-key-tool'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='!text-semi-color-text-1'
|
||||
>
|
||||
neko-api-key-tool
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='text-left'>
|
||||
<p className='!text-semi-color-text-0 font-semibold mb-5'>
|
||||
{t('基于New API的项目')}
|
||||
</p>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<a
|
||||
href='https://github.com/Calcium-Ion/new-api-horizon'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='!text-semi-color-text-1'
|
||||
>
|
||||
new-api-horizon
|
||||
</a>
|
||||
{/* <a href="https://github.com/VoAPI/VoAPI" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">VoAPI</a> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
<div className="flex flex-col md:flex-row items-center justify-between w-full max-w-[1110px] gap-6">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Typography.Text className="text-sm !text-semi-color-text-1">© {currentYear} {systemName}. {t('版权所有')}</Typography.Text>
|
||||
</div>
|
||||
<div className='flex flex-col md:flex-row items-center justify-between w-full max-w-[1110px] gap-6'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<Typography.Text className='text-sm !text-semi-color-text-1'>
|
||||
© {currentYear} {systemName}. {t('版权所有')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
<div className="text-sm">
|
||||
<span className="!text-semi-color-text-1">{t('设计与开发由')} </span>
|
||||
<a href="https://github.com/QuantumNous/new-api" target="_blank" rel="noopener noreferrer" className="!text-semi-color-primary font-medium">New API</a>
|
||||
<span className="!text-semi-color-text-1"> & </span>
|
||||
<a href="https://github.com/songquanpeng/one-api" target="_blank" rel="noopener noreferrer" className="!text-semi-color-primary font-medium">One API</a>
|
||||
<div className='text-sm'>
|
||||
<span className='!text-semi-color-text-1'>
|
||||
{t('设计与开发由')}{' '}
|
||||
</span>
|
||||
<a
|
||||
href='https://github.com/QuantumNous/new-api'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='!text-semi-color-primary font-medium'
|
||||
>
|
||||
New API
|
||||
</a>
|
||||
<span className='!text-semi-color-text-1'> & </span>
|
||||
<a
|
||||
href='https://github.com/songquanpeng/one-api'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='!text-semi-color-primary font-medium'
|
||||
>
|
||||
One API
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
), [logo, systemName, t, currentYear, isDemoSiteMode]);
|
||||
</footer>
|
||||
),
|
||||
[logo, systemName, t, currentYear, isDemoSiteMode],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadFooter();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className='w-full'>
|
||||
{footer ? (
|
||||
<div
|
||||
className="custom-footer"
|
||||
className='custom-footer'
|
||||
dangerouslySetInnerHTML={{ __html: footer }}
|
||||
></div>
|
||||
) : (
|
||||
|
||||
@@ -41,7 +41,7 @@ const ActionButtons = ({
|
||||
t,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2 md:gap-3">
|
||||
<div className='flex items-center gap-2 md:gap-3'>
|
||||
<NewYearButton isNewYear={isNewYear} />
|
||||
|
||||
<NotificationButton
|
||||
@@ -50,11 +50,7 @@ const ActionButtons = ({
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<ThemeToggle
|
||||
theme={theme}
|
||||
onThemeToggle={onThemeToggle}
|
||||
t={t}
|
||||
/>
|
||||
<ThemeToggle theme={theme} onThemeToggle={onThemeToggle} t={t} />
|
||||
|
||||
<LanguageSelector
|
||||
currentLang={currentLang}
|
||||
|
||||
@@ -38,35 +38,35 @@ const HeaderLogo = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Link to="/" className="group flex items-center gap-2">
|
||||
<div className="relative w-8 h-8 md:w-8 md:h-8">
|
||||
<SkeletonWrapper
|
||||
loading={isLoading || !logoLoaded}
|
||||
type="image"
|
||||
/>
|
||||
<Link to='/' className='group flex items-center gap-2'>
|
||||
<div className='relative w-8 h-8 md:w-8 md:h-8'>
|
||||
<SkeletonWrapper loading={isLoading || !logoLoaded} type='image' />
|
||||
<img
|
||||
src={logo}
|
||||
alt="logo"
|
||||
className={`absolute inset-0 w-full h-full transition-all duration-200 group-hover:scale-110 rounded-full ${(!isLoading && logoLoaded) ? 'opacity-100' : 'opacity-0'}`}
|
||||
alt='logo'
|
||||
className={`absolute inset-0 w-full h-full transition-all duration-200 group-hover:scale-110 rounded-full ${!isLoading && logoLoaded ? 'opacity-100' : 'opacity-0'}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="hidden md:flex items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className='hidden md:flex items-center gap-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<SkeletonWrapper
|
||||
loading={isLoading}
|
||||
type="title"
|
||||
type='title'
|
||||
width={120}
|
||||
height={24}
|
||||
>
|
||||
<Typography.Title heading={4} className="!text-lg !font-semibold !mb-0">
|
||||
<Typography.Title
|
||||
heading={4}
|
||||
className='!text-lg !font-semibold !mb-0'
|
||||
>
|
||||
{systemName}
|
||||
</Typography.Title>
|
||||
</SkeletonWrapper>
|
||||
{(isSelfUseMode || isDemoSiteMode) && !isLoading && (
|
||||
<Tag
|
||||
color={isSelfUseMode ? 'purple' : 'blue'}
|
||||
className="text-xs px-1.5 py-0.5 rounded whitespace-nowrap shadow-sm"
|
||||
size="small"
|
||||
className='text-xs px-1.5 py-0.5 rounded whitespace-nowrap shadow-sm'
|
||||
size='small'
|
||||
shape='circle'
|
||||
>
|
||||
{isSelfUseMode ? t('自用模式') : t('演示站点')}
|
||||
|
||||
@@ -25,21 +25,21 @@ import { CN, GB } from 'country-flag-icons/react/3x2';
|
||||
const LanguageSelector = ({ currentLang, onLanguageChange, t }) => {
|
||||
return (
|
||||
<Dropdown
|
||||
position="bottomRight"
|
||||
position='bottomRight'
|
||||
render={
|
||||
<Dropdown.Menu className="!bg-semi-color-bg-overlay !border-semi-color-border !shadow-lg !rounded-lg dark:!bg-gray-700 dark:!border-gray-600">
|
||||
<Dropdown.Menu className='!bg-semi-color-bg-overlay !border-semi-color-border !shadow-lg !rounded-lg dark:!bg-gray-700 dark:!border-gray-600'>
|
||||
<Dropdown.Item
|
||||
onClick={() => onLanguageChange('zh')}
|
||||
className={`!flex !items-center !gap-2 !px-3 !py-1.5 !text-sm !text-semi-color-text-0 dark:!text-gray-200 ${currentLang === 'zh' ? '!bg-semi-color-primary-light-default dark:!bg-blue-600 !font-semibold' : 'hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-600'}`}
|
||||
>
|
||||
<CN title="中文" className="!w-5 !h-auto" />
|
||||
<CN title='中文' className='!w-5 !h-auto' />
|
||||
<span>中文</span>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
onClick={() => onLanguageChange('en')}
|
||||
className={`!flex !items-center !gap-2 !px-3 !py-1.5 !text-sm !text-semi-color-text-0 dark:!text-gray-200 ${currentLang === 'en' ? '!bg-semi-color-primary-light-default dark:!bg-blue-600 !font-semibold' : 'hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-600'}`}
|
||||
>
|
||||
<GB title="English" className="!w-5 !h-auto" />
|
||||
<GB title='English' className='!w-5 !h-auto' />
|
||||
<span>English</span>
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
@@ -48,9 +48,9 @@ const LanguageSelector = ({ currentLang, onLanguageChange, t }) => {
|
||||
<Button
|
||||
icon={<Languages size={18} />}
|
||||
aria-label={t('切换语言')}
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
className="!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 !rounded-full !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2"
|
||||
theme='borderless'
|
||||
type='tertiary'
|
||||
className='!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 !rounded-full !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2'
|
||||
/>
|
||||
</Dropdown>
|
||||
);
|
||||
|
||||
@@ -36,13 +36,19 @@ const MobileMenuButton = ({
|
||||
return (
|
||||
<Button
|
||||
icon={
|
||||
(isMobile ? drawerOpen : collapsed) ? <IconClose className="text-lg" /> : <IconMenu className="text-lg" />
|
||||
(isMobile ? drawerOpen : collapsed) ? (
|
||||
<IconClose className='text-lg' />
|
||||
) : (
|
||||
<IconMenu className='text-lg' />
|
||||
)
|
||||
}
|
||||
aria-label={
|
||||
(isMobile ? drawerOpen : collapsed) ? t('关闭侧边栏') : t('打开侧边栏')
|
||||
}
|
||||
aria-label={(isMobile ? drawerOpen : collapsed) ? t('关闭侧边栏') : t('打开侧边栏')}
|
||||
onClick={onToggle}
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
className="!p-2 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700"
|
||||
theme='borderless'
|
||||
type='tertiary'
|
||||
className='!p-2 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700'
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -21,21 +21,16 @@ import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import SkeletonWrapper from './SkeletonWrapper';
|
||||
|
||||
const Navigation = ({
|
||||
mainNavLinks,
|
||||
isMobile,
|
||||
isLoading,
|
||||
userState
|
||||
}) => {
|
||||
const Navigation = ({ mainNavLinks, isMobile, isLoading, userState }) => {
|
||||
const renderNavLinks = () => {
|
||||
const baseClasses = 'flex-shrink-0 flex items-center gap-1 font-semibold rounded-md transition-all duration-200 ease-in-out';
|
||||
const baseClasses =
|
||||
'flex-shrink-0 flex items-center gap-1 font-semibold rounded-md transition-all duration-200 ease-in-out';
|
||||
const hoverClasses = 'hover:text-semi-color-primary';
|
||||
const spacingClasses = isMobile ? 'p-1' : 'p-2';
|
||||
|
||||
const commonLinkClasses = `${baseClasses} ${spacingClasses} ${hoverClasses}`;
|
||||
|
||||
return mainNavLinks.map((link) => {
|
||||
|
||||
const linkContent = <span>{link.text}</span>;
|
||||
|
||||
if (link.isExternal) {
|
||||
@@ -58,11 +53,7 @@ const Navigation = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={link.itemKey}
|
||||
to={targetPath}
|
||||
className={commonLinkClasses}
|
||||
>
|
||||
<Link key={link.itemKey} to={targetPath} className={commonLinkClasses}>
|
||||
{linkContent}
|
||||
</Link>
|
||||
);
|
||||
@@ -70,10 +61,10 @@ const Navigation = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="flex flex-1 items-center gap-1 lg:gap-2 mx-2 md:mx-4 overflow-x-auto whitespace-nowrap scrollbar-hide">
|
||||
<nav className='flex flex-1 items-center gap-1 lg:gap-2 mx-2 md:mx-4 overflow-x-auto whitespace-nowrap scrollbar-hide'>
|
||||
<SkeletonWrapper
|
||||
loading={isLoading}
|
||||
type="navigation"
|
||||
type='navigation'
|
||||
count={4}
|
||||
width={60}
|
||||
height={16}
|
||||
|
||||
@@ -36,21 +36,24 @@ const NewYearButton = ({ isNewYear }) => {
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
position="bottomRight"
|
||||
position='bottomRight'
|
||||
render={
|
||||
<Dropdown.Menu className="!bg-semi-color-bg-overlay !border-semi-color-border !shadow-lg !rounded-lg dark:!bg-gray-700 dark:!border-gray-600">
|
||||
<Dropdown.Item onClick={handleNewYearClick} className="!text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-gray-600">
|
||||
<Dropdown.Menu className='!bg-semi-color-bg-overlay !border-semi-color-border !shadow-lg !rounded-lg dark:!bg-gray-700 dark:!border-gray-600'>
|
||||
<Dropdown.Item
|
||||
onClick={handleNewYearClick}
|
||||
className='!text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-gray-600'
|
||||
>
|
||||
Happy New Year!!! 🎉
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
icon={<span className="text-xl">🎉</span>}
|
||||
aria-label="New Year"
|
||||
className="!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 rounded-full"
|
||||
theme='borderless'
|
||||
type='tertiary'
|
||||
icon={<span className='text-xl'>🎉</span>}
|
||||
aria-label='New Year'
|
||||
className='!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 rounded-full'
|
||||
/>
|
||||
</Dropdown>
|
||||
);
|
||||
|
||||
@@ -26,14 +26,15 @@ const NotificationButton = ({ unreadCount, onNoticeOpen, t }) => {
|
||||
icon: <Bell size={18} />,
|
||||
'aria-label': t('系统公告'),
|
||||
onClick: onNoticeOpen,
|
||||
theme: "borderless",
|
||||
type: "tertiary",
|
||||
className: "!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 !rounded-full !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2",
|
||||
theme: 'borderless',
|
||||
type: 'tertiary',
|
||||
className:
|
||||
'!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 !rounded-full !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2',
|
||||
};
|
||||
|
||||
if (unreadCount > 0) {
|
||||
return (
|
||||
<Badge count={unreadCount} type="danger" overflowCount={99}>
|
||||
<Badge count={unreadCount} type='danger' overflowCount={99}>
|
||||
<Button {...buttonProps} />
|
||||
</Badge>
|
||||
);
|
||||
|
||||
@@ -62,13 +62,17 @@ const SkeletonWrapper = ({
|
||||
// 用户区域骨架屏 (头像 + 文本)
|
||||
const renderUserAreaSkeleton = () => {
|
||||
return (
|
||||
<div className={`flex items-center p-1 rounded-full bg-semi-color-fill-0 dark:bg-semi-color-fill-1 ${className}`}>
|
||||
<div
|
||||
className={`flex items-center p-1 rounded-full bg-semi-color-fill-0 dark:bg-semi-color-fill-1 ${className}`}
|
||||
>
|
||||
<Skeleton
|
||||
loading={true}
|
||||
active
|
||||
placeholder={<Skeleton.Avatar active size="extra-small" className="shadow-sm" />}
|
||||
placeholder={
|
||||
<Skeleton.Avatar active size='extra-small' className='shadow-sm' />
|
||||
}
|
||||
/>
|
||||
<div className="ml-1.5 mr-1">
|
||||
<div className='ml-1.5 mr-1'>
|
||||
<Skeleton
|
||||
loading={true}
|
||||
active
|
||||
@@ -107,12 +111,7 @@ const SkeletonWrapper = ({
|
||||
<Skeleton
|
||||
loading={true}
|
||||
active
|
||||
placeholder={
|
||||
<Skeleton.Title
|
||||
active
|
||||
style={{ width, height: 24 }}
|
||||
/>
|
||||
}
|
||||
placeholder={<Skeleton.Title active style={{ width, height: 24 }} />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -124,12 +123,7 @@ const SkeletonWrapper = ({
|
||||
<Skeleton
|
||||
loading={true}
|
||||
active
|
||||
placeholder={
|
||||
<Skeleton.Title
|
||||
active
|
||||
style={{ width, height }}
|
||||
/>
|
||||
}
|
||||
placeholder={<Skeleton.Title active style={{ width, height }} />}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -25,29 +25,32 @@ import { useActualTheme } from '../../../context/Theme';
|
||||
const ThemeToggle = ({ theme, onThemeToggle, t }) => {
|
||||
const actualTheme = useActualTheme();
|
||||
|
||||
const themeOptions = useMemo(() => ([
|
||||
{
|
||||
key: 'light',
|
||||
icon: <Sun size={18} />,
|
||||
buttonIcon: <Sun size={18} />,
|
||||
label: t('浅色模式'),
|
||||
description: t('始终使用浅色主题')
|
||||
},
|
||||
{
|
||||
key: 'dark',
|
||||
icon: <Moon size={18} />,
|
||||
buttonIcon: <Moon size={18} />,
|
||||
label: t('深色模式'),
|
||||
description: t('始终使用深色主题')
|
||||
},
|
||||
{
|
||||
key: 'auto',
|
||||
icon: <Monitor size={18} />,
|
||||
buttonIcon: <Monitor size={18} />,
|
||||
label: t('自动模式'),
|
||||
description: t('跟随系统主题设置')
|
||||
}
|
||||
]), [t]);
|
||||
const themeOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'light',
|
||||
icon: <Sun size={18} />,
|
||||
buttonIcon: <Sun size={18} />,
|
||||
label: t('浅色模式'),
|
||||
description: t('始终使用浅色主题'),
|
||||
},
|
||||
{
|
||||
key: 'dark',
|
||||
icon: <Moon size={18} />,
|
||||
buttonIcon: <Moon size={18} />,
|
||||
label: t('深色模式'),
|
||||
description: t('始终使用深色主题'),
|
||||
},
|
||||
{
|
||||
key: 'auto',
|
||||
icon: <Monitor size={18} />,
|
||||
buttonIcon: <Monitor size={18} />,
|
||||
label: t('自动模式'),
|
||||
description: t('跟随系统主题设置'),
|
||||
},
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
const getItemClassName = (isSelected) =>
|
||||
isSelected
|
||||
@@ -55,13 +58,13 @@ const ThemeToggle = ({ theme, onThemeToggle, t }) => {
|
||||
: 'hover:!bg-semi-color-fill-1';
|
||||
|
||||
const currentButtonIcon = useMemo(() => {
|
||||
const currentOption = themeOptions.find(option => option.key === theme);
|
||||
const currentOption = themeOptions.find((option) => option.key === theme);
|
||||
return currentOption?.buttonIcon || themeOptions[2].buttonIcon;
|
||||
}, [theme, themeOptions]);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
position="bottomRight"
|
||||
position='bottomRight'
|
||||
render={
|
||||
<Dropdown.Menu>
|
||||
{themeOptions.map((option) => (
|
||||
@@ -71,9 +74,9 @@ const ThemeToggle = ({ theme, onThemeToggle, t }) => {
|
||||
onClick={() => onThemeToggle(option.key)}
|
||||
className={getItemClassName(theme === option.key)}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<div className='flex flex-col'>
|
||||
<span>{option.label}</span>
|
||||
<span className="text-xs text-semi-color-text-2">
|
||||
<span className='text-xs text-semi-color-text-2'>
|
||||
{option.description}
|
||||
</span>
|
||||
</div>
|
||||
@@ -83,8 +86,9 @@ const ThemeToggle = ({ theme, onThemeToggle, t }) => {
|
||||
{theme === 'auto' && (
|
||||
<>
|
||||
<Dropdown.Divider />
|
||||
<div className="px-3 py-2 text-xs text-semi-color-text-2">
|
||||
{t('当前跟随系统')}:{actualTheme === 'dark' ? t('深色') : t('浅色')}
|
||||
<div className='px-3 py-2 text-xs text-semi-color-text-2'>
|
||||
{t('当前跟随系统')}:
|
||||
{actualTheme === 'dark' ? t('深色') : t('浅色')}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -94,9 +98,9 @@ const ThemeToggle = ({ theme, onThemeToggle, t }) => {
|
||||
<Button
|
||||
icon={currentButtonIcon}
|
||||
aria-label={t('切换主题')}
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
className="!p-1.5 !text-current focus:!bg-semi-color-fill-1 !rounded-full !bg-semi-color-fill-0 hover:!bg-semi-color-fill-1"
|
||||
theme='borderless'
|
||||
type='tertiary'
|
||||
className='!p-1.5 !text-current focus:!bg-semi-color-fill-1 !rounded-full !bg-semi-color-fill-0 hover:!bg-semi-color-fill-1'
|
||||
/>
|
||||
</Dropdown>
|
||||
);
|
||||
|
||||
@@ -19,12 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
Dropdown,
|
||||
Typography,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { Avatar, Button, Dropdown, Typography } from '@douyinfe/semi-ui';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import {
|
||||
IconExit,
|
||||
@@ -48,7 +43,7 @@ const UserArea = ({
|
||||
return (
|
||||
<SkeletonWrapper
|
||||
loading={true}
|
||||
type="userArea"
|
||||
type='userArea'
|
||||
width={50}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
@@ -58,17 +53,20 @@ const UserArea = ({
|
||||
if (userState.user) {
|
||||
return (
|
||||
<Dropdown
|
||||
position="bottomRight"
|
||||
position='bottomRight'
|
||||
render={
|
||||
<Dropdown.Menu className="!bg-semi-color-bg-overlay !border-semi-color-border !shadow-lg !rounded-lg dark:!bg-gray-700 dark:!border-gray-600">
|
||||
<Dropdown.Menu className='!bg-semi-color-bg-overlay !border-semi-color-border !shadow-lg !rounded-lg dark:!bg-gray-700 dark:!border-gray-600'>
|
||||
<Dropdown.Item
|
||||
onClick={() => {
|
||||
navigate('/console/personal');
|
||||
}}
|
||||
className="!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white"
|
||||
className='!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white'
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<IconUserSetting size="small" className="text-gray-500 dark:text-gray-400" />
|
||||
<div className='flex items-center gap-2'>
|
||||
<IconUserSetting
|
||||
size='small'
|
||||
className='text-gray-500 dark:text-gray-400'
|
||||
/>
|
||||
<span>{t('个人设置')}</span>
|
||||
</div>
|
||||
</Dropdown.Item>
|
||||
@@ -76,10 +74,13 @@ const UserArea = ({
|
||||
onClick={() => {
|
||||
navigate('/console/token');
|
||||
}}
|
||||
className="!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white"
|
||||
className='!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white'
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<IconKey size="small" className="text-gray-500 dark:text-gray-400" />
|
||||
<div className='flex items-center gap-2'>
|
||||
<IconKey
|
||||
size='small'
|
||||
className='text-gray-500 dark:text-gray-400'
|
||||
/>
|
||||
<span>{t('令牌管理')}</span>
|
||||
</div>
|
||||
</Dropdown.Item>
|
||||
@@ -87,16 +88,25 @@ const UserArea = ({
|
||||
onClick={() => {
|
||||
navigate('/console/topup');
|
||||
}}
|
||||
className="!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white"
|
||||
className='!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white'
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<IconCreditCard size="small" className="text-gray-500 dark:text-gray-400" />
|
||||
<div className='flex items-center gap-2'>
|
||||
<IconCreditCard
|
||||
size='small'
|
||||
className='text-gray-500 dark:text-gray-400'
|
||||
/>
|
||||
<span>{t('钱包管理')}</span>
|
||||
</div>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item onClick={logout} className="!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-red-500 dark:hover:!text-white">
|
||||
<div className="flex items-center gap-2">
|
||||
<IconExit size="small" className="text-gray-500 dark:text-gray-400" />
|
||||
<Dropdown.Item
|
||||
onClick={logout}
|
||||
className='!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-red-500 dark:hover:!text-white'
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<IconExit
|
||||
size='small'
|
||||
className='text-gray-500 dark:text-gray-400'
|
||||
/>
|
||||
<span>{t('退出')}</span>
|
||||
</div>
|
||||
</Dropdown.Item>
|
||||
@@ -104,74 +114,76 @@ const UserArea = ({
|
||||
}
|
||||
>
|
||||
<Button
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
className="flex items-center gap-1.5 !p-1 !rounded-full hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-700 !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2"
|
||||
theme='borderless'
|
||||
type='tertiary'
|
||||
className='flex items-center gap-1.5 !p-1 !rounded-full hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-700 !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2'
|
||||
>
|
||||
<Avatar
|
||||
size="extra-small"
|
||||
size='extra-small'
|
||||
color={stringToColor(userState.user.username)}
|
||||
className="mr-1"
|
||||
className='mr-1'
|
||||
>
|
||||
{userState.user.username[0].toUpperCase()}
|
||||
</Avatar>
|
||||
<span className="hidden md:inline">
|
||||
<Typography.Text className="!text-xs !font-medium !text-semi-color-text-1 dark:!text-gray-300 mr-1">
|
||||
<span className='hidden md:inline'>
|
||||
<Typography.Text className='!text-xs !font-medium !text-semi-color-text-1 dark:!text-gray-300 mr-1'>
|
||||
{userState.user.username}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
<ChevronDown size={14} className="text-xs text-semi-color-text-2 dark:text-gray-400" />
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className='text-xs text-semi-color-text-2 dark:text-gray-400'
|
||||
/>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
} else {
|
||||
const showRegisterButton = !isSelfUseMode;
|
||||
|
||||
const commonSizingAndLayoutClass = "flex items-center justify-center !py-[10px] !px-1.5";
|
||||
const commonSizingAndLayoutClass =
|
||||
'flex items-center justify-center !py-[10px] !px-1.5';
|
||||
|
||||
const loginButtonSpecificStyling = "!bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-700 transition-colors";
|
||||
const loginButtonSpecificStyling =
|
||||
'!bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-700 transition-colors';
|
||||
let loginButtonClasses = `${commonSizingAndLayoutClass} ${loginButtonSpecificStyling}`;
|
||||
|
||||
let registerButtonClasses = `${commonSizingAndLayoutClass}`;
|
||||
|
||||
const loginButtonTextSpanClass = "!text-xs !text-semi-color-text-1 dark:!text-gray-300 !p-1.5";
|
||||
const registerButtonTextSpanClass = "!text-xs !text-white !p-1.5";
|
||||
const loginButtonTextSpanClass =
|
||||
'!text-xs !text-semi-color-text-1 dark:!text-gray-300 !p-1.5';
|
||||
const registerButtonTextSpanClass = '!text-xs !text-white !p-1.5';
|
||||
|
||||
if (showRegisterButton) {
|
||||
if (isMobile) {
|
||||
loginButtonClasses += " !rounded-full";
|
||||
loginButtonClasses += ' !rounded-full';
|
||||
} else {
|
||||
loginButtonClasses += " !rounded-l-full !rounded-r-none";
|
||||
loginButtonClasses += ' !rounded-l-full !rounded-r-none';
|
||||
}
|
||||
registerButtonClasses += " !rounded-r-full !rounded-l-none";
|
||||
registerButtonClasses += ' !rounded-r-full !rounded-l-none';
|
||||
} else {
|
||||
loginButtonClasses += " !rounded-full";
|
||||
loginButtonClasses += ' !rounded-full';
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<Link to="/login" className="flex">
|
||||
<div className='flex items-center'>
|
||||
<Link to='/login' className='flex'>
|
||||
<Button
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
theme='borderless'
|
||||
type='tertiary'
|
||||
className={loginButtonClasses}
|
||||
>
|
||||
<span className={loginButtonTextSpanClass}>
|
||||
{t('登录')}
|
||||
</span>
|
||||
<span className={loginButtonTextSpanClass}>{t('登录')}</span>
|
||||
</Button>
|
||||
</Link>
|
||||
{showRegisterButton && (
|
||||
<div className="hidden md:block">
|
||||
<Link to="/register" className="flex -ml-px">
|
||||
<div className='hidden md:block'>
|
||||
<Link to='/register' className='flex -ml-px'>
|
||||
<Button
|
||||
theme="solid"
|
||||
type="primary"
|
||||
theme='solid'
|
||||
type='primary'
|
||||
className={registerButtonClasses}
|
||||
>
|
||||
<span className={registerButtonTextSpanClass}>
|
||||
{t('注册')}
|
||||
</span>
|
||||
<span className={registerButtonTextSpanClass}>{t('注册')}</span>
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -63,7 +63,7 @@ const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
|
||||
const { mainNavLinks } = useNavigation(t, docsLink);
|
||||
|
||||
return (
|
||||
<header className="text-semi-color-text-0 sticky top-0 z-50 transition-colors duration-300 bg-white/75 dark:bg-zinc-900/75 backdrop-blur-lg">
|
||||
<header className='text-semi-color-text-0 sticky top-0 z-50 transition-colors duration-300 bg-white/75 dark:bg-zinc-900/75 backdrop-blur-lg'>
|
||||
<NoticeModal
|
||||
visible={noticeVisible}
|
||||
onClose={handleNoticeClose}
|
||||
@@ -72,9 +72,9 @@ const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
|
||||
unreadKeys={getUnreadKeys()}
|
||||
/>
|
||||
|
||||
<div className="w-full px-2">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<div className="flex items-center">
|
||||
<div className='w-full px-2'>
|
||||
<div className='flex items-center justify-between h-16'>
|
||||
<div className='flex items-center'>
|
||||
<MobileMenuButton
|
||||
isConsoleRoute={isConsoleRoute}
|
||||
isMobile={isMobile}
|
||||
|
||||
@@ -18,15 +18,31 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useContext, useMemo } from 'react';
|
||||
import { Button, Modal, Empty, Tabs, TabPane, Timeline } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
Button,
|
||||
Modal,
|
||||
Empty,
|
||||
Tabs,
|
||||
TabPane,
|
||||
Timeline,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { API, showError, getRelativeTime } from '../../helpers';
|
||||
import { marked } from 'marked';
|
||||
import { IllustrationNoContent, IllustrationNoContentDark } from '@douyinfe/semi-illustrations';
|
||||
import {
|
||||
IllustrationNoContent,
|
||||
IllustrationNoContentDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import { StatusContext } from '../../context/Status';
|
||||
import { Bell, Megaphone } from 'lucide-react';
|
||||
|
||||
const NoticeModal = ({ visible, onClose, isMobile, defaultTab = 'inApp', unreadKeys = [] }) => {
|
||||
const NoticeModal = ({
|
||||
visible,
|
||||
onClose,
|
||||
isMobile,
|
||||
defaultTab = 'inApp',
|
||||
unreadKeys = [],
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [noticeContent, setNoticeContent] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -38,23 +54,25 @@ const NoticeModal = ({ visible, onClose, isMobile, defaultTab = 'inApp', unreadK
|
||||
|
||||
const unreadSet = useMemo(() => new Set(unreadKeys), [unreadKeys]);
|
||||
|
||||
const getKeyForItem = (item) => `${item?.publishDate || ''}-${(item?.content || '').slice(0, 30)}`;
|
||||
const getKeyForItem = (item) =>
|
||||
`${item?.publishDate || ''}-${(item?.content || '').slice(0, 30)}`;
|
||||
|
||||
const processedAnnouncements = useMemo(() => {
|
||||
return (announcements || []).slice(0, 20).map(item => {
|
||||
return (announcements || []).slice(0, 20).map((item) => {
|
||||
const pubDate = item?.publishDate ? new Date(item.publishDate) : null;
|
||||
const absoluteTime = pubDate && !isNaN(pubDate.getTime())
|
||||
? `${pubDate.getFullYear()}-${String(pubDate.getMonth() + 1).padStart(2, '0')}-${String(pubDate.getDate()).padStart(2, '0')} ${String(pubDate.getHours()).padStart(2, '0')}:${String(pubDate.getMinutes()).padStart(2, '0')}`
|
||||
: (item?.publishDate || '');
|
||||
return ({
|
||||
const absoluteTime =
|
||||
pubDate && !isNaN(pubDate.getTime())
|
||||
? `${pubDate.getFullYear()}-${String(pubDate.getMonth() + 1).padStart(2, '0')}-${String(pubDate.getDate()).padStart(2, '0')} ${String(pubDate.getHours()).padStart(2, '0')}:${String(pubDate.getMinutes()).padStart(2, '0')}`
|
||||
: item?.publishDate || '';
|
||||
return {
|
||||
key: getKeyForItem(item),
|
||||
type: item.type || 'default',
|
||||
time: absoluteTime,
|
||||
content: item.content,
|
||||
extra: item.extra,
|
||||
relative: getRelativeTime(item.publishDate),
|
||||
isUnread: unreadSet.has(getKeyForItem(item))
|
||||
});
|
||||
isUnread: unreadSet.has(getKeyForItem(item)),
|
||||
};
|
||||
});
|
||||
}, [announcements, unreadSet]);
|
||||
|
||||
@@ -100,15 +118,23 @@ const NoticeModal = ({ visible, onClose, isMobile, defaultTab = 'inApp', unreadK
|
||||
|
||||
const renderMarkdownNotice = () => {
|
||||
if (loading) {
|
||||
return <div className="py-12"><Empty description={t('加载中...')} /></div>;
|
||||
return (
|
||||
<div className='py-12'>
|
||||
<Empty description={t('加载中...')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!noticeContent) {
|
||||
return (
|
||||
<div className="py-12">
|
||||
<div className='py-12'>
|
||||
<Empty
|
||||
image={<IllustrationNoContent style={{ width: 150, height: 150 }} />}
|
||||
darkModeImage={<IllustrationNoContentDark style={{ width: 150, height: 150 }} />}
|
||||
image={
|
||||
<IllustrationNoContent style={{ width: 150, height: 150 }} />
|
||||
}
|
||||
darkModeImage={
|
||||
<IllustrationNoContentDark style={{ width: 150, height: 150 }} />
|
||||
}
|
||||
description={t('暂无公告')}
|
||||
/>
|
||||
</div>
|
||||
@@ -118,7 +144,7 @@ const NoticeModal = ({ visible, onClose, isMobile, defaultTab = 'inApp', unreadK
|
||||
return (
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: noticeContent }}
|
||||
className="notice-content-scroll max-h-[55vh] overflow-y-auto pr-2"
|
||||
className='notice-content-scroll max-h-[55vh] overflow-y-auto pr-2'
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -126,10 +152,14 @@ const NoticeModal = ({ visible, onClose, isMobile, defaultTab = 'inApp', unreadK
|
||||
const renderAnnouncementTimeline = () => {
|
||||
if (processedAnnouncements.length === 0) {
|
||||
return (
|
||||
<div className="py-12">
|
||||
<div className='py-12'>
|
||||
<Empty
|
||||
image={<IllustrationNoContent style={{ width: 150, height: 150 }} />}
|
||||
darkModeImage={<IllustrationNoContentDark style={{ width: 150, height: 150 }} />}
|
||||
image={
|
||||
<IllustrationNoContent style={{ width: 150, height: 150 }} />
|
||||
}
|
||||
darkModeImage={
|
||||
<IllustrationNoContentDark style={{ width: 150, height: 150 }} />
|
||||
}
|
||||
description={t('暂无系统公告')}
|
||||
/>
|
||||
</div>
|
||||
@@ -137,8 +167,8 @@ const NoticeModal = ({ visible, onClose, isMobile, defaultTab = 'inApp', unreadK
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-h-[55vh] overflow-y-auto pr-2 card-content-scroll">
|
||||
<Timeline mode="left">
|
||||
<div className='max-h-[55vh] overflow-y-auto pr-2 card-content-scroll'>
|
||||
<Timeline mode='left'>
|
||||
{processedAnnouncements.map((item, idx) => {
|
||||
const htmlContent = marked.parse(item.content || '');
|
||||
const htmlExtra = item.extra ? marked.parse(item.extra) : '';
|
||||
@@ -147,12 +177,14 @@ const NoticeModal = ({ visible, onClose, isMobile, defaultTab = 'inApp', unreadK
|
||||
key={idx}
|
||||
type={item.type}
|
||||
time={`${item.relative ? item.relative + ' ' : ''}${item.time}`}
|
||||
extra={item.extra ? (
|
||||
<div
|
||||
className="text-xs text-gray-500"
|
||||
dangerouslySetInnerHTML={{ __html: htmlExtra }}
|
||||
/>
|
||||
) : null}
|
||||
extra={
|
||||
item.extra ? (
|
||||
<div
|
||||
className='text-xs text-gray-500'
|
||||
dangerouslySetInnerHTML={{ __html: htmlExtra }}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
className={item.isUnread ? '' : ''}
|
||||
>
|
||||
<div>
|
||||
@@ -179,26 +211,40 @@ const NoticeModal = ({ visible, onClose, isMobile, defaultTab = 'inApp', unreadK
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className='flex items-center justify-between w-full'>
|
||||
<span>{t('系统公告')}</span>
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
type='button'
|
||||
>
|
||||
<TabPane tab={<span className="flex items-center gap-1"><Bell size={14} /> {t('通知')}</span>} itemKey='inApp' />
|
||||
<TabPane tab={<span className="flex items-center gap-1"><Megaphone size={14} /> {t('系统公告')}</span>} itemKey='system' />
|
||||
<Tabs activeKey={activeTab} onChange={setActiveTab} type='button'>
|
||||
<TabPane
|
||||
tab={
|
||||
<span className='flex items-center gap-1'>
|
||||
<Bell size={14} /> {t('通知')}
|
||||
</span>
|
||||
}
|
||||
itemKey='inApp'
|
||||
/>
|
||||
<TabPane
|
||||
tab={
|
||||
<span className='flex items-center gap-1'>
|
||||
<Megaphone size={14} /> {t('系统公告')}
|
||||
</span>
|
||||
}
|
||||
itemKey='system'
|
||||
/>
|
||||
</Tabs>
|
||||
</div>
|
||||
}
|
||||
visible={visible}
|
||||
onCancel={onClose}
|
||||
footer={(
|
||||
<div className="flex justify-end">
|
||||
<Button type='secondary' onClick={handleCloseTodayNotice}>{t('今日关闭')}</Button>
|
||||
<Button type="primary" onClick={onClose}>{t('关闭公告')}</Button>
|
||||
footer={
|
||||
<div className='flex justify-end'>
|
||||
<Button type='secondary' onClick={handleCloseTodayNotice}>
|
||||
{t('今日关闭')}
|
||||
</Button>
|
||||
<Button type='primary' onClick={onClose}>
|
||||
{t('关闭公告')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
size={isMobile ? 'full-width' : 'large'}
|
||||
>
|
||||
{renderBody()}
|
||||
@@ -206,4 +252,4 @@ const NoticeModal = ({ visible, onClose, isMobile, defaultTab = 'inApp', unreadK
|
||||
);
|
||||
};
|
||||
|
||||
export default NoticeModal;
|
||||
export default NoticeModal;
|
||||
|
||||
@@ -27,7 +27,13 @@ import React, { useContext, useEffect, useState } from 'react';
|
||||
import { useIsMobile } from '../../hooks/common/useIsMobile';
|
||||
import { useSidebarCollapsed } from '../../hooks/common/useSidebarCollapsed';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { API, getLogo, getSystemName, showError, setStatusData } from '../../helpers';
|
||||
import {
|
||||
API,
|
||||
getLogo,
|
||||
getSystemName,
|
||||
showError,
|
||||
setStatusData,
|
||||
} from '../../helpers';
|
||||
import { UserContext } from '../../context/User';
|
||||
import { StatusContext } from '../../context/Status';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
@@ -42,9 +48,12 @@ const PageLayout = () => {
|
||||
const { i18n } = useTranslation();
|
||||
const location = useLocation();
|
||||
|
||||
const shouldHideFooter = location.pathname.startsWith('/console') || location.pathname === '/pricing';
|
||||
const shouldHideFooter =
|
||||
location.pathname.startsWith('/console') ||
|
||||
location.pathname === '/pricing';
|
||||
|
||||
const shouldInnerPadding = location.pathname.includes('/console') &&
|
||||
const shouldInnerPadding =
|
||||
location.pathname.includes('/console') &&
|
||||
!location.pathname.startsWith('/console/chat') &&
|
||||
location.pathname !== '/console/playground';
|
||||
|
||||
@@ -120,7 +129,10 @@ const PageLayout = () => {
|
||||
zIndex: 100,
|
||||
}}
|
||||
>
|
||||
<HeaderBar onMobileMenuToggle={() => setDrawerOpen(prev => !prev)} drawerOpen={drawerOpen} />
|
||||
<HeaderBar
|
||||
onMobileMenuToggle={() => setDrawerOpen((prev) => !prev)}
|
||||
drawerOpen={drawerOpen}
|
||||
/>
|
||||
</Header>
|
||||
<Layout
|
||||
style={{
|
||||
@@ -142,12 +154,20 @@ const PageLayout = () => {
|
||||
width: 'var(--sidebar-current-width)',
|
||||
}}
|
||||
>
|
||||
<SiderBar onNavigate={() => { if (isMobile) setDrawerOpen(false); }} />
|
||||
<SiderBar
|
||||
onNavigate={() => {
|
||||
if (isMobile) setDrawerOpen(false);
|
||||
}}
|
||||
/>
|
||||
</Sider>
|
||||
)}
|
||||
<Layout
|
||||
style={{
|
||||
marginLeft: isMobile ? '0' : showSider ? 'var(--sidebar-current-width)' : '0',
|
||||
marginLeft: isMobile
|
||||
? '0'
|
||||
: showSider
|
||||
? 'var(--sidebar-current-width)'
|
||||
: '0',
|
||||
flex: '1 1 auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
|
||||
@@ -26,7 +26,10 @@ const SetupCheck = ({ children }) => {
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
if (statusState?.status?.setup === false && location.pathname !== '/setup') {
|
||||
if (
|
||||
statusState?.status?.setup === false &&
|
||||
location.pathname !== '/setup'
|
||||
) {
|
||||
window.location.href = '/setup';
|
||||
}
|
||||
}, [statusState?.status?.setup, location.pathname]);
|
||||
@@ -34,4 +37,4 @@ const SetupCheck = ({ children }) => {
|
||||
return children;
|
||||
};
|
||||
|
||||
export default SetupCheck;
|
||||
export default SetupCheck;
|
||||
|
||||
@@ -23,17 +23,9 @@ import { useTranslation } from 'react-i18next';
|
||||
import { getLucideIcon } from '../../helpers/render';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { useSidebarCollapsed } from '../../hooks/common/useSidebarCollapsed';
|
||||
import {
|
||||
isAdmin,
|
||||
isRoot,
|
||||
showError
|
||||
} from '../../helpers';
|
||||
import { isAdmin, isRoot, showError } from '../../helpers';
|
||||
|
||||
import {
|
||||
Nav,
|
||||
Divider,
|
||||
Button,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { Nav, Divider, Button } from '@douyinfe/semi-ui';
|
||||
|
||||
const routerMap = {
|
||||
home: '/',
|
||||
@@ -54,7 +46,7 @@ const routerMap = {
|
||||
personal: '/console/personal',
|
||||
};
|
||||
|
||||
const SiderBar = ({ onNavigate = () => { } }) => {
|
||||
const SiderBar = ({ onNavigate = () => {} }) => {
|
||||
const { t } = useTranslation();
|
||||
const [collapsed, toggleCollapsed] = useSidebarCollapsed();
|
||||
|
||||
@@ -275,14 +267,17 @@ const SiderBar = ({ onNavigate = () => { } }) => {
|
||||
key={item.itemKey}
|
||||
itemKey={item.itemKey}
|
||||
text={
|
||||
<div className="flex items-center">
|
||||
<span className="truncate font-medium text-sm" style={{ color: textColor }}>
|
||||
<div className='flex items-center'>
|
||||
<span
|
||||
className='truncate font-medium text-sm'
|
||||
style={{ color: textColor }}
|
||||
>
|
||||
{item.text}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
icon={
|
||||
<div className="sidebar-icon-container flex-shrink-0">
|
||||
<div className='sidebar-icon-container flex-shrink-0'>
|
||||
{getLucideIcon(item.itemKey, isSelected)}
|
||||
</div>
|
||||
}
|
||||
@@ -302,14 +297,17 @@ const SiderBar = ({ onNavigate = () => { } }) => {
|
||||
key={item.itemKey}
|
||||
itemKey={item.itemKey}
|
||||
text={
|
||||
<div className="flex items-center">
|
||||
<span className="truncate font-medium text-sm" style={{ color: textColor }}>
|
||||
<div className='flex items-center'>
|
||||
<span
|
||||
className='truncate font-medium text-sm'
|
||||
style={{ color: textColor }}
|
||||
>
|
||||
{item.text}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
icon={
|
||||
<div className="sidebar-icon-container flex-shrink-0">
|
||||
<div className='sidebar-icon-container flex-shrink-0'>
|
||||
{getLucideIcon(item.itemKey, isSelected)}
|
||||
</div>
|
||||
}
|
||||
@@ -323,7 +321,10 @@ const SiderBar = ({ onNavigate = () => { } }) => {
|
||||
key={subItem.itemKey}
|
||||
itemKey={subItem.itemKey}
|
||||
text={
|
||||
<span className="truncate font-medium text-sm" style={{ color: subTextColor }}>
|
||||
<span
|
||||
className='truncate font-medium text-sm'
|
||||
style={{ color: subTextColor }}
|
||||
>
|
||||
{subItem.text}
|
||||
</span>
|
||||
}
|
||||
@@ -339,18 +340,18 @@ const SiderBar = ({ onNavigate = () => { } }) => {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="sidebar-container"
|
||||
className='sidebar-container'
|
||||
style={{ width: 'var(--sidebar-current-width)' }}
|
||||
>
|
||||
<Nav
|
||||
className="sidebar-nav"
|
||||
className='sidebar-nav'
|
||||
defaultIsCollapsed={collapsed}
|
||||
isCollapsed={collapsed}
|
||||
onCollapseChange={toggleCollapsed}
|
||||
selectedKeys={selectedKeys}
|
||||
itemStyle="sidebar-nav-item"
|
||||
hoverStyle="sidebar-nav-item:hover"
|
||||
selectedStyle="sidebar-nav-item-selected"
|
||||
itemStyle='sidebar-nav-item'
|
||||
hoverStyle='sidebar-nav-item:hover'
|
||||
selectedStyle='sidebar-nav-item-selected'
|
||||
renderWrapper={({ itemElement, props }) => {
|
||||
const to = routerMapState[props.itemKey] || routerMap[props.itemKey];
|
||||
|
||||
@@ -381,27 +382,25 @@ const SiderBar = ({ onNavigate = () => { } }) => {
|
||||
}}
|
||||
>
|
||||
{/* 聊天区域 */}
|
||||
<div className="sidebar-section">
|
||||
{!collapsed && (
|
||||
<div className="sidebar-group-label">{t('聊天')}</div>
|
||||
)}
|
||||
<div className='sidebar-section'>
|
||||
{!collapsed && <div className='sidebar-group-label'>{t('聊天')}</div>}
|
||||
{chatMenuItems.map((item) => renderSubItem(item))}
|
||||
</div>
|
||||
|
||||
{/* 控制台区域 */}
|
||||
<Divider className="sidebar-divider" />
|
||||
<Divider className='sidebar-divider' />
|
||||
<div>
|
||||
{!collapsed && (
|
||||
<div className="sidebar-group-label">{t('控制台')}</div>
|
||||
<div className='sidebar-group-label'>{t('控制台')}</div>
|
||||
)}
|
||||
{workspaceItems.map((item) => renderNavItem(item))}
|
||||
</div>
|
||||
|
||||
{/* 个人中心区域 */}
|
||||
<Divider className="sidebar-divider" />
|
||||
<Divider className='sidebar-divider' />
|
||||
<div>
|
||||
{!collapsed && (
|
||||
<div className="sidebar-group-label">{t('个人中心')}</div>
|
||||
<div className='sidebar-group-label'>{t('个人中心')}</div>
|
||||
)}
|
||||
{financeItems.map((item) => renderNavItem(item))}
|
||||
</div>
|
||||
@@ -409,10 +408,10 @@ const SiderBar = ({ onNavigate = () => { } }) => {
|
||||
{/* 管理员区域 - 只在管理员时显示 */}
|
||||
{isAdmin() && (
|
||||
<>
|
||||
<Divider className="sidebar-divider" />
|
||||
<Divider className='sidebar-divider' />
|
||||
<div>
|
||||
{!collapsed && (
|
||||
<div className="sidebar-group-label">{t('管理员')}</div>
|
||||
<div className='sidebar-group-label'>{t('管理员')}</div>
|
||||
)}
|
||||
{adminItems.map((item) => renderNavItem(item))}
|
||||
</div>
|
||||
@@ -421,22 +420,28 @@ const SiderBar = ({ onNavigate = () => { } }) => {
|
||||
</Nav>
|
||||
|
||||
{/* 底部折叠按钮 */}
|
||||
<div className="sidebar-collapse-button">
|
||||
<div className='sidebar-collapse-button'>
|
||||
<Button
|
||||
theme="outline"
|
||||
type="tertiary"
|
||||
size="small"
|
||||
theme='outline'
|
||||
type='tertiary'
|
||||
size='small'
|
||||
icon={
|
||||
<ChevronLeft
|
||||
size={16}
|
||||
strokeWidth={2.5}
|
||||
color="var(--semi-color-text-2)"
|
||||
style={{ transform: collapsed ? 'rotate(180deg)' : 'rotate(0deg)' }}
|
||||
color='var(--semi-color-text-2)'
|
||||
style={{
|
||||
transform: collapsed ? 'rotate(180deg)' : 'rotate(0deg)',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
onClick={toggleCollapsed}
|
||||
icononly={collapsed}
|
||||
style={collapsed ? { padding: '4px', width: '100%' } : { padding: '4px 12px', width: '100%' }}
|
||||
style={
|
||||
collapsed
|
||||
? { padding: '4px', width: '100%' }
|
||||
: { padding: '4px 12px', width: '100%' }
|
||||
}
|
||||
>
|
||||
{!collapsed ? t('收起侧边栏') : null}
|
||||
</Button>
|
||||
|
||||
@@ -18,17 +18,8 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Card,
|
||||
Chat,
|
||||
Typography,
|
||||
Button,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
MessageSquare,
|
||||
Eye,
|
||||
EyeOff,
|
||||
} from 'lucide-react';
|
||||
import { Card, Chat, Typography, Button } from '@douyinfe/semi-ui';
|
||||
import { MessageSquare, Eye, EyeOff } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CustomInputRender from './CustomInputRender';
|
||||
|
||||
@@ -57,37 +48,43 @@ const ChatArea = ({
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="h-full"
|
||||
className='h-full'
|
||||
bordered={false}
|
||||
bodyStyle={{ padding: 0, height: 'calc(100vh - 66px)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}
|
||||
bodyStyle={{
|
||||
padding: 0,
|
||||
height: 'calc(100vh - 66px)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* 聊天头部 */}
|
||||
{styleState.isMobile ? (
|
||||
<div className="pt-4"></div>
|
||||
<div className='pt-4'></div>
|
||||
) : (
|
||||
<div className="px-6 py-4 bg-gradient-to-r from-purple-500 to-blue-500 rounded-t-2xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-white/20 backdrop-blur flex items-center justify-center">
|
||||
<MessageSquare size={20} className="text-white" />
|
||||
<div className='px-6 py-4 bg-gradient-to-r from-purple-500 to-blue-500 rounded-t-2xl'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className='w-10 h-10 rounded-full bg-white/20 backdrop-blur flex items-center justify-center'>
|
||||
<MessageSquare size={20} className='text-white' />
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Title heading={5} className="!text-white mb-0">
|
||||
<Typography.Title heading={5} className='!text-white mb-0'>
|
||||
{t('AI 对话')}
|
||||
</Typography.Title>
|
||||
<Typography.Text className="!text-white/80 text-sm hidden sm:inline">
|
||||
<Typography.Text className='!text-white/80 text-sm hidden sm:inline'>
|
||||
{inputs.model || t('选择模型开始对话')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button
|
||||
icon={showDebugPanel ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
onClick={onToggleDebugPanel}
|
||||
theme="borderless"
|
||||
type="primary"
|
||||
size="small"
|
||||
className="!rounded-lg !text-white/80 hover:!text-white hover:!bg-white/10"
|
||||
theme='borderless'
|
||||
type='primary'
|
||||
size='small'
|
||||
className='!rounded-lg !text-white/80 hover:!text-white hover:!bg-white/10'
|
||||
>
|
||||
{showDebugPanel ? t('隐藏调试') : t('显示调试')}
|
||||
</Button>
|
||||
@@ -97,7 +94,7 @@ const ChatArea = ({
|
||||
)}
|
||||
|
||||
{/* 聊天内容区域 */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className='flex-1 overflow-hidden'>
|
||||
<Chat
|
||||
ref={chatRef}
|
||||
chatBoxRenderConfig={{
|
||||
@@ -110,7 +107,7 @@ const ChatArea = ({
|
||||
style={{
|
||||
height: '100%',
|
||||
maxWidth: '100%',
|
||||
overflow: 'hidden'
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
chats={message}
|
||||
onMessageSend={onMessageSend}
|
||||
@@ -121,7 +118,7 @@ const ChatArea = ({
|
||||
showStopGenerate
|
||||
onStopGenerator={onStopGenerator}
|
||||
onClear={onClearMessages}
|
||||
className="h-full"
|
||||
className='h-full'
|
||||
placeholder={t('请输入您的问题...')}
|
||||
/>
|
||||
</div>
|
||||
@@ -129,4 +126,4 @@ const ChatArea = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatArea;
|
||||
export default ChatArea;
|
||||
|
||||
@@ -102,15 +102,17 @@ const highlightJson = (str) => {
|
||||
color = '#569cd6';
|
||||
}
|
||||
return `<span style="color: ${color}">${match}</span>`;
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const isJsonLike = (content, language) => {
|
||||
if (language === 'json') return true;
|
||||
const trimmed = content.trim();
|
||||
return (trimmed.startsWith('{') && trimmed.endsWith('}')) ||
|
||||
(trimmed.startsWith('[') && trimmed.endsWith(']'));
|
||||
return (
|
||||
(trimmed.startsWith('{') && trimmed.endsWith('}')) ||
|
||||
(trimmed.startsWith('[') && trimmed.endsWith(']'))
|
||||
);
|
||||
};
|
||||
|
||||
const formatContent = (content) => {
|
||||
@@ -148,7 +150,10 @@ const CodeViewer = ({ content, title, language = 'json' }) => {
|
||||
const contentMetrics = useMemo(() => {
|
||||
const length = formattedContent.length;
|
||||
const isLarge = length > PERFORMANCE_CONFIG.MAX_DISPLAY_LENGTH;
|
||||
const isVeryLarge = length > PERFORMANCE_CONFIG.MAX_DISPLAY_LENGTH * PERFORMANCE_CONFIG.VERY_LARGE_MULTIPLIER;
|
||||
const isVeryLarge =
|
||||
length >
|
||||
PERFORMANCE_CONFIG.MAX_DISPLAY_LENGTH *
|
||||
PERFORMANCE_CONFIG.VERY_LARGE_MULTIPLIER;
|
||||
return { length, isLarge, isVeryLarge };
|
||||
}, [formattedContent.length]);
|
||||
|
||||
@@ -156,8 +161,10 @@ const CodeViewer = ({ content, title, language = 'json' }) => {
|
||||
if (!contentMetrics.isLarge || isExpanded) {
|
||||
return formattedContent;
|
||||
}
|
||||
return formattedContent.substring(0, PERFORMANCE_CONFIG.PREVIEW_LENGTH) +
|
||||
'\n\n// ... 内容被截断以提升性能 ...';
|
||||
return (
|
||||
formattedContent.substring(0, PERFORMANCE_CONFIG.PREVIEW_LENGTH) +
|
||||
'\n\n// ... 内容被截断以提升性能 ...'
|
||||
);
|
||||
}, [formattedContent, contentMetrics.isLarge, isExpanded]);
|
||||
|
||||
const highlightedContent = useMemo(() => {
|
||||
@@ -174,9 +181,10 @@ const CodeViewer = ({ content, title, language = 'json' }) => {
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
try {
|
||||
const textToCopy = typeof content === 'object' && content !== null
|
||||
? JSON.stringify(content, null, 2)
|
||||
: content;
|
||||
const textToCopy =
|
||||
typeof content === 'object' && content !== null
|
||||
? JSON.stringify(content, null, 2)
|
||||
: content;
|
||||
|
||||
const success = await copy(textToCopy);
|
||||
setCopied(true);
|
||||
@@ -205,11 +213,12 @@ const CodeViewer = ({ content, title, language = 'json' }) => {
|
||||
}, [isExpanded, contentMetrics.isVeryLarge]);
|
||||
|
||||
if (!content) {
|
||||
const placeholderText = {
|
||||
preview: t('正在构造请求体预览...'),
|
||||
request: t('暂无请求数据'),
|
||||
response: t('暂无响应数据')
|
||||
}[title] || t('暂无数据');
|
||||
const placeholderText =
|
||||
{
|
||||
preview: t('正在构造请求体预览...'),
|
||||
request: t('暂无请求数据'),
|
||||
response: t('暂无响应数据'),
|
||||
}[title] || t('暂无数据');
|
||||
|
||||
return (
|
||||
<div style={codeThemeStyles.noContent}>
|
||||
@@ -222,7 +231,7 @@ const CodeViewer = ({ content, title, language = 'json' }) => {
|
||||
const contentPadding = contentMetrics.isLarge ? '52px' : '16px';
|
||||
|
||||
return (
|
||||
<div style={codeThemeStyles.container} className="h-full">
|
||||
<div style={codeThemeStyles.container} className='h-full'>
|
||||
{/* 性能警告 */}
|
||||
{contentMetrics.isLarge && (
|
||||
<div style={codeThemeStyles.performanceWarning}>
|
||||
@@ -250,8 +259,8 @@ const CodeViewer = ({ content, title, language = 'json' }) => {
|
||||
<Button
|
||||
icon={<Copy size={14} />}
|
||||
onClick={handleCopy}
|
||||
size="small"
|
||||
theme="borderless"
|
||||
size='small'
|
||||
theme='borderless'
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
@@ -268,25 +277,29 @@ const CodeViewer = ({ content, title, language = 'json' }) => {
|
||||
...codeThemeStyles.content,
|
||||
paddingTop: contentPadding,
|
||||
}}
|
||||
className="model-settings-scroll"
|
||||
className='model-settings-scroll'
|
||||
>
|
||||
{isProcessing ? (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '200px',
|
||||
color: '#888'
|
||||
}}>
|
||||
<div style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
border: '2px solid #444',
|
||||
borderTop: '2px solid #888',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite',
|
||||
marginRight: '8px'
|
||||
}} />
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '200px',
|
||||
color: '#888',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
border: '2px solid #444',
|
||||
borderTop: '2px solid #888',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite',
|
||||
marginRight: '8px',
|
||||
}}
|
||||
/>
|
||||
{t('正在处理大内容...')}
|
||||
</div>
|
||||
) : (
|
||||
@@ -296,18 +309,22 @@ const CodeViewer = ({ content, title, language = 'json' }) => {
|
||||
|
||||
{/* 展开/收起按钮 */}
|
||||
{contentMetrics.isLarge && !isProcessing && (
|
||||
<div style={{
|
||||
...codeThemeStyles.actionButton,
|
||||
bottom: '12px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
...codeThemeStyles.actionButton,
|
||||
bottom: '12px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
}}
|
||||
>
|
||||
<Tooltip content={isExpanded ? t('收起内容') : t('显示完整内容')}>
|
||||
<Button
|
||||
icon={isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
icon={
|
||||
isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />
|
||||
}
|
||||
onClick={handleToggleExpand}
|
||||
size="small"
|
||||
theme="borderless"
|
||||
size='small'
|
||||
theme='borderless'
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
@@ -317,8 +334,16 @@ const CodeViewer = ({ content, title, language = 'json' }) => {
|
||||
>
|
||||
{isExpanded ? t('收起') : t('展开')}
|
||||
{!isExpanded && (
|
||||
<span style={{ fontSize: '11px', opacity: 0.7, marginLeft: '4px' }}>
|
||||
(+{Math.round((contentMetrics.length - PERFORMANCE_CONFIG.PREVIEW_LENGTH) / 1000)}K)
|
||||
<span
|
||||
style={{ fontSize: '11px', opacity: 0.7, marginLeft: '4px' }}
|
||||
>
|
||||
(+
|
||||
{Math.round(
|
||||
(contentMetrics.length -
|
||||
PERFORMANCE_CONFIG.PREVIEW_LENGTH) /
|
||||
1000,
|
||||
)}
|
||||
K)
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
@@ -329,4 +354,4 @@ const CodeViewer = ({ content, title, language = 'json' }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default CodeViewer;
|
||||
export default CodeViewer;
|
||||
|
||||
@@ -18,21 +18,16 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useRef } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Typography,
|
||||
Toast,
|
||||
Modal,
|
||||
Dropdown,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
Download,
|
||||
Upload,
|
||||
RotateCcw,
|
||||
Settings2,
|
||||
} from 'lucide-react';
|
||||
import { Button, Typography, Toast, Modal, Dropdown } from '@douyinfe/semi-ui';
|
||||
import { Download, Upload, RotateCcw, Settings2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { exportConfig, importConfig, clearConfig, hasStoredConfig, getConfigTimestamp } from './configStorage';
|
||||
import {
|
||||
exportConfig,
|
||||
importConfig,
|
||||
clearConfig,
|
||||
hasStoredConfig,
|
||||
getConfigTimestamp,
|
||||
} from './configStorage';
|
||||
|
||||
const ConfigManager = ({
|
||||
currentConfig,
|
||||
@@ -51,7 +46,10 @@ const ConfigManager = ({
|
||||
...currentConfig,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
localStorage.setItem('playground_config', JSON.stringify(configWithTimestamp));
|
||||
localStorage.setItem(
|
||||
'playground_config',
|
||||
JSON.stringify(configWithTimestamp),
|
||||
);
|
||||
|
||||
exportConfig(currentConfig, messages);
|
||||
Toast.success({
|
||||
@@ -104,7 +102,9 @@ const ConfigManager = ({
|
||||
const handleReset = () => {
|
||||
Modal.confirm({
|
||||
title: t('重置配置'),
|
||||
content: t('将清除所有保存的配置并恢复默认设置,此操作不可撤销。是否继续?'),
|
||||
content: t(
|
||||
'将清除所有保存的配置并恢复默认设置,此操作不可撤销。是否继续?',
|
||||
),
|
||||
okText: t('确定重置'),
|
||||
cancelText: t('取消'),
|
||||
okButtonProps: {
|
||||
@@ -114,7 +114,9 @@ const ConfigManager = ({
|
||||
// 询问是否同时重置消息
|
||||
Modal.confirm({
|
||||
title: t('重置选项'),
|
||||
content: t('是否同时重置对话消息?选择"是"将清空所有对话记录并恢复默认示例;选择"否"将保留当前对话记录。'),
|
||||
content: t(
|
||||
'是否同时重置对话消息?选择"是"将清空所有对话记录并恢复默认示例;选择"否"将保留当前对话记录。',
|
||||
),
|
||||
okText: t('同时重置消息'),
|
||||
cancelText: t('仅重置配置'),
|
||||
okButtonProps: {
|
||||
@@ -159,7 +161,7 @@ const ConfigManager = ({
|
||||
name: 'export',
|
||||
onClick: handleExport,
|
||||
children: (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className='flex items-center gap-2'>
|
||||
<Download size={14} />
|
||||
{t('导出配置')}
|
||||
</div>
|
||||
@@ -170,7 +172,7 @@ const ConfigManager = ({
|
||||
name: 'import',
|
||||
onClick: handleImportClick,
|
||||
children: (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className='flex items-center gap-2'>
|
||||
<Upload size={14} />
|
||||
{t('导入配置')}
|
||||
</div>
|
||||
@@ -184,7 +186,7 @@ const ConfigManager = ({
|
||||
name: 'reset',
|
||||
onClick: handleReset,
|
||||
children: (
|
||||
<div className="flex items-center gap-2 text-red-600">
|
||||
<div className='flex items-center gap-2 text-red-600'>
|
||||
<RotateCcw size={14} />
|
||||
{t('重置配置')}
|
||||
</div>
|
||||
@@ -197,24 +199,24 @@ const ConfigManager = ({
|
||||
return (
|
||||
<>
|
||||
<Dropdown
|
||||
trigger="click"
|
||||
position="bottomLeft"
|
||||
trigger='click'
|
||||
position='bottomLeft'
|
||||
showTick
|
||||
menu={dropdownItems}
|
||||
>
|
||||
<Button
|
||||
icon={<Settings2 size={14} />}
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
size="small"
|
||||
className="!rounded-lg !text-gray-600 hover:!text-blue-600 hover:!bg-blue-50"
|
||||
theme='borderless'
|
||||
type='tertiary'
|
||||
size='small'
|
||||
className='!rounded-lg !text-gray-600 hover:!text-blue-600 hover:!bg-blue-50'
|
||||
/>
|
||||
</Dropdown>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json"
|
||||
type='file'
|
||||
accept='.json'
|
||||
onChange={handleFileChange}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
@@ -224,42 +226,42 @@ const ConfigManager = ({
|
||||
|
||||
// 桌面端显示紧凑的按钮组
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className='space-y-3'>
|
||||
{/* 配置状态信息和重置按钮 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Typography.Text className="text-xs text-gray-500">
|
||||
<div className='flex items-center justify-between'>
|
||||
<Typography.Text className='text-xs text-gray-500'>
|
||||
{getConfigStatus()}
|
||||
</Typography.Text>
|
||||
<Button
|
||||
icon={<RotateCcw size={12} />}
|
||||
size="small"
|
||||
theme="borderless"
|
||||
type="danger"
|
||||
size='small'
|
||||
theme='borderless'
|
||||
type='danger'
|
||||
onClick={handleReset}
|
||||
className="!rounded-full !text-xs !px-2"
|
||||
className='!rounded-full !text-xs !px-2'
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 导出和导入按钮 */}
|
||||
<div className="flex gap-2">
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
icon={<Download size={12} />}
|
||||
size="small"
|
||||
theme="solid"
|
||||
type="primary"
|
||||
size='small'
|
||||
theme='solid'
|
||||
type='primary'
|
||||
onClick={handleExport}
|
||||
className="!rounded-lg flex-1 !text-xs !h-7"
|
||||
className='!rounded-lg flex-1 !text-xs !h-7'
|
||||
>
|
||||
{t('导出')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
icon={<Upload size={12} />}
|
||||
size="small"
|
||||
theme="outline"
|
||||
type="primary"
|
||||
size='small'
|
||||
theme='outline'
|
||||
type='primary'
|
||||
onClick={handleImportClick}
|
||||
className="!rounded-lg flex-1 !text-xs !h-7"
|
||||
className='!rounded-lg flex-1 !text-xs !h-7'
|
||||
>
|
||||
{t('导入')}
|
||||
</Button>
|
||||
@@ -267,8 +269,8 @@ const ConfigManager = ({
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json"
|
||||
type='file'
|
||||
accept='.json'
|
||||
onChange={handleFileChange}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
@@ -276,4 +278,4 @@ const ConfigManager = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfigManager;
|
||||
export default ConfigManager;
|
||||
|
||||
@@ -21,23 +21,24 @@ import React from 'react';
|
||||
|
||||
const CustomInputRender = (props) => {
|
||||
const { detailProps } = props;
|
||||
const { clearContextNode, uploadNode, inputNode, sendNode, onClick } = detailProps;
|
||||
const { clearContextNode, uploadNode, inputNode, sendNode, onClick } =
|
||||
detailProps;
|
||||
|
||||
// 清空按钮
|
||||
const styledClearNode = clearContextNode
|
||||
? React.cloneElement(clearContextNode, {
|
||||
className: `!rounded-full !bg-gray-100 hover:!bg-red-500 hover:!text-white flex-shrink-0 transition-all ${clearContextNode.props.className || ''}`,
|
||||
style: {
|
||||
...clearContextNode.props.style,
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
minWidth: '32px',
|
||||
padding: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}
|
||||
})
|
||||
className: `!rounded-full !bg-gray-100 hover:!bg-red-500 hover:!text-white flex-shrink-0 transition-all ${clearContextNode.props.className || ''}`,
|
||||
style: {
|
||||
...clearContextNode.props.style,
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
minWidth: '32px',
|
||||
padding: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
// 发送按钮
|
||||
@@ -52,21 +53,19 @@ const CustomInputRender = (props) => {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="p-2 sm:p-4">
|
||||
<div className='p-2 sm:p-4'>
|
||||
<div
|
||||
className="flex items-center gap-2 sm:gap-3 p-2 bg-gray-50 rounded-xl sm:rounded-2xl shadow-sm hover:shadow-md transition-shadow"
|
||||
className='flex items-center gap-2 sm:gap-3 p-2 bg-gray-50 rounded-xl sm:rounded-2xl shadow-sm hover:shadow-md transition-shadow'
|
||||
style={{ border: '1px solid var(--semi-color-border)' }}
|
||||
onClick={onClick}
|
||||
>
|
||||
{/* 清空对话按钮 - 左边 */}
|
||||
{styledClearNode}
|
||||
<div className="flex-1">
|
||||
{inputNode}
|
||||
</div>
|
||||
<div className='flex-1'>{inputNode}</div>
|
||||
{/* 发送按钮 - 右边 */}
|
||||
{styledSendNode}
|
||||
</div>
|
||||
@@ -74,4 +73,4 @@ const CustomInputRender = (props) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomInputRender;
|
||||
export default CustomInputRender;
|
||||
|
||||
@@ -25,13 +25,7 @@ import {
|
||||
Switch,
|
||||
Banner,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
Code,
|
||||
Edit,
|
||||
Check,
|
||||
X,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import { Code, Edit, Check, X, AlertTriangle } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const CustomRequestEditor = ({
|
||||
@@ -48,12 +42,22 @@ const CustomRequestEditor = ({
|
||||
|
||||
// 当切换到自定义模式时,用默认payload初始化
|
||||
useEffect(() => {
|
||||
if (customRequestMode && (!customRequestBody || customRequestBody.trim() === '')) {
|
||||
const defaultJson = defaultPayload ? JSON.stringify(defaultPayload, null, 2) : '';
|
||||
if (
|
||||
customRequestMode &&
|
||||
(!customRequestBody || customRequestBody.trim() === '')
|
||||
) {
|
||||
const defaultJson = defaultPayload
|
||||
? JSON.stringify(defaultPayload, null, 2)
|
||||
: '';
|
||||
setLocalValue(defaultJson);
|
||||
onCustomRequestBodyChange(defaultJson);
|
||||
}
|
||||
}, [customRequestMode, defaultPayload, customRequestBody, onCustomRequestBodyChange]);
|
||||
}, [
|
||||
customRequestMode,
|
||||
defaultPayload,
|
||||
customRequestBody,
|
||||
onCustomRequestBodyChange,
|
||||
]);
|
||||
|
||||
// 同步外部传入的customRequestBody到本地状态
|
||||
useEffect(() => {
|
||||
@@ -113,21 +117,21 @@ const CustomRequestEditor = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className='space-y-4'>
|
||||
{/* 自定义模式开关 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Code size={16} className="text-gray-500" />
|
||||
<Typography.Text strong className="text-sm">
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Code size={16} className='text-gray-500' />
|
||||
<Typography.Text strong className='text-sm'>
|
||||
自定义请求体模式
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={customRequestMode}
|
||||
onChange={handleModeToggle}
|
||||
checkedText="开"
|
||||
uncheckedText="关"
|
||||
size="small"
|
||||
checkedText='开'
|
||||
uncheckedText='关'
|
||||
size='small'
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -135,43 +139,43 @@ const CustomRequestEditor = ({
|
||||
<>
|
||||
{/* 提示信息 */}
|
||||
<Banner
|
||||
type="warning"
|
||||
description="启用此模式后,将使用您自定义的请求体发送API请求,模型配置面板的参数设置将被忽略。"
|
||||
type='warning'
|
||||
description='启用此模式后,将使用您自定义的请求体发送API请求,模型配置面板的参数设置将被忽略。'
|
||||
icon={<AlertTriangle size={16} />}
|
||||
className="!rounded-lg"
|
||||
className='!rounded-lg'
|
||||
closeIcon={null}
|
||||
/>
|
||||
|
||||
{/* JSON编辑器 */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Typography.Text strong className="text-sm">
|
||||
<div className='flex items-center justify-between mb-2'>
|
||||
<Typography.Text strong className='text-sm'>
|
||||
请求体 JSON
|
||||
</Typography.Text>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className='flex items-center gap-2'>
|
||||
{isValid ? (
|
||||
<div className="flex items-center gap-1 text-green-600">
|
||||
<div className='flex items-center gap-1 text-green-600'>
|
||||
<Check size={14} />
|
||||
<Typography.Text className="text-xs">
|
||||
<Typography.Text className='text-xs'>
|
||||
格式正确
|
||||
</Typography.Text>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1 text-red-600">
|
||||
<div className='flex items-center gap-1 text-red-600'>
|
||||
<X size={14} />
|
||||
<Typography.Text className="text-xs">
|
||||
<Typography.Text className='text-xs'>
|
||||
格式错误
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
size="small"
|
||||
theme='borderless'
|
||||
type='tertiary'
|
||||
size='small'
|
||||
icon={<Edit size={14} />}
|
||||
onClick={formatJson}
|
||||
disabled={!isValid}
|
||||
className="!rounded-lg"
|
||||
className='!rounded-lg'
|
||||
>
|
||||
格式化
|
||||
</Button>
|
||||
@@ -191,12 +195,12 @@ const CustomRequestEditor = ({
|
||||
/>
|
||||
|
||||
{!isValid && errorMessage && (
|
||||
<Typography.Text type="danger" className="text-xs mt-1 block">
|
||||
<Typography.Text type='danger' className='text-xs mt-1 block'>
|
||||
{errorMessage}
|
||||
</Typography.Text>
|
||||
)}
|
||||
|
||||
<Typography.Text className="text-xs text-gray-500 mt-2 block">
|
||||
<Typography.Text className='text-xs text-gray-500 mt-2 block'>
|
||||
请输入有效的JSON格式的请求体。您可以参考预览面板中的默认请求体格式。
|
||||
</Typography.Text>
|
||||
</div>
|
||||
@@ -206,4 +210,4 @@ const CustomRequestEditor = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomRequestEditor;
|
||||
export default CustomRequestEditor;
|
||||
|
||||
@@ -26,14 +26,7 @@ import {
|
||||
Button,
|
||||
Dropdown,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
Code,
|
||||
Zap,
|
||||
Clock,
|
||||
X,
|
||||
Eye,
|
||||
Send,
|
||||
} from 'lucide-react';
|
||||
import { Code, Zap, Clock, X, Eye, Send } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CodeViewer from './CodeViewer';
|
||||
|
||||
@@ -76,7 +69,7 @@ const DebugPanel = ({
|
||||
<Dropdown
|
||||
render={
|
||||
<Dropdown.Menu>
|
||||
{items.map(item => {
|
||||
{items.map((item) => {
|
||||
return (
|
||||
<Dropdown.Item
|
||||
key={item.itemKey}
|
||||
@@ -104,21 +97,21 @@ const DebugPanel = ({
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="h-full flex flex-col"
|
||||
className='h-full flex flex-col'
|
||||
bordered={false}
|
||||
bodyStyle={{
|
||||
padding: styleState.isMobile ? '16px' : '24px',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-6 flex-shrink-0">
|
||||
<div className="flex items-center">
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-r from-green-500 to-blue-500 flex items-center justify-center mr-3">
|
||||
<Code size={20} className="text-white" />
|
||||
<div className='flex items-center justify-between mb-6 flex-shrink-0'>
|
||||
<div className='flex items-center'>
|
||||
<div className='w-10 h-10 rounded-full bg-gradient-to-r from-green-500 to-blue-500 flex items-center justify-center mr-3'>
|
||||
<Code size={20} className='text-white' />
|
||||
</div>
|
||||
<Typography.Title heading={5} className="mb-0">
|
||||
<Typography.Title heading={5} className='mb-0'>
|
||||
{t('调试信息')}
|
||||
</Typography.Title>
|
||||
</div>
|
||||
@@ -127,75 +120,84 @@ const DebugPanel = ({
|
||||
<Button
|
||||
icon={<X size={16} />}
|
||||
onClick={onCloseDebugPanel}
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
size="small"
|
||||
className="!rounded-lg"
|
||||
theme='borderless'
|
||||
type='tertiary'
|
||||
size='small'
|
||||
className='!rounded-lg'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden debug-panel">
|
||||
<div className='flex-1 overflow-hidden debug-panel'>
|
||||
<Tabs
|
||||
renderArrow={renderArrow}
|
||||
type="card"
|
||||
type='card'
|
||||
collapsible
|
||||
className="h-full"
|
||||
className='h-full'
|
||||
style={{ height: '100%', display: 'flex', flexDirection: 'column' }}
|
||||
activeKey={activeKey}
|
||||
onChange={handleTabChange}
|
||||
>
|
||||
<TabPane tab={
|
||||
<div className="flex items-center gap-2">
|
||||
<Eye size={16} />
|
||||
{t('预览请求体')}
|
||||
{customRequestMode && (
|
||||
<span className="px-1.5 py-0.5 text-xs bg-orange-100 text-orange-600 rounded-full">
|
||||
自定义
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
} itemKey="preview">
|
||||
<TabPane
|
||||
tab={
|
||||
<div className='flex items-center gap-2'>
|
||||
<Eye size={16} />
|
||||
{t('预览请求体')}
|
||||
{customRequestMode && (
|
||||
<span className='px-1.5 py-0.5 text-xs bg-orange-100 text-orange-600 rounded-full'>
|
||||
自定义
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
itemKey='preview'
|
||||
>
|
||||
<CodeViewer
|
||||
content={debugData.previewRequest}
|
||||
title="preview"
|
||||
language="json"
|
||||
title='preview'
|
||||
language='json'
|
||||
/>
|
||||
</TabPane>
|
||||
|
||||
<TabPane tab={
|
||||
<div className="flex items-center gap-2">
|
||||
<Send size={16} />
|
||||
{t('实际请求体')}
|
||||
</div>
|
||||
} itemKey="request">
|
||||
<TabPane
|
||||
tab={
|
||||
<div className='flex items-center gap-2'>
|
||||
<Send size={16} />
|
||||
{t('实际请求体')}
|
||||
</div>
|
||||
}
|
||||
itemKey='request'
|
||||
>
|
||||
<CodeViewer
|
||||
content={debugData.request}
|
||||
title="request"
|
||||
language="json"
|
||||
title='request'
|
||||
language='json'
|
||||
/>
|
||||
</TabPane>
|
||||
|
||||
<TabPane tab={
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap size={16} />
|
||||
{t('响应')}
|
||||
</div>
|
||||
} itemKey="response">
|
||||
<TabPane
|
||||
tab={
|
||||
<div className='flex items-center gap-2'>
|
||||
<Zap size={16} />
|
||||
{t('响应')}
|
||||
</div>
|
||||
}
|
||||
itemKey='response'
|
||||
>
|
||||
<CodeViewer
|
||||
content={debugData.response}
|
||||
title="response"
|
||||
language="json"
|
||||
title='response'
|
||||
language='json'
|
||||
/>
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mt-4 pt-4 flex-shrink-0">
|
||||
<div className='flex items-center justify-between mt-4 pt-4 flex-shrink-0'>
|
||||
{(debugData.timestamp || debugData.previewTimestamp) && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock size={14} className="text-gray-500" />
|
||||
<Typography.Text className="text-xs text-gray-500">
|
||||
<div className='flex items-center gap-2'>
|
||||
<Clock size={14} className='text-gray-500' />
|
||||
<Typography.Text className='text-xs text-gray-500'>
|
||||
{activeKey === 'preview' && debugData.previewTimestamp
|
||||
? `${t('预览更新')}: ${new Date(debugData.previewTimestamp).toLocaleString()}`
|
||||
: debugData.timestamp
|
||||
@@ -209,4 +211,4 @@ const DebugPanel = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default DebugPanel;
|
||||
export default DebugPanel;
|
||||
|
||||
@@ -19,11 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
|
||||
import React from 'react';
|
||||
import { Button } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
Settings,
|
||||
Eye,
|
||||
EyeOff,
|
||||
} from 'lucide-react';
|
||||
import { Settings, Eye, EyeOff } from 'lucide-react';
|
||||
|
||||
const FloatingButtons = ({
|
||||
styleState,
|
||||
@@ -55,7 +51,7 @@ const FloatingButtons = ({
|
||||
onClick={onToggleSettings}
|
||||
theme='solid'
|
||||
type='primary'
|
||||
className="lg:hidden"
|
||||
className='lg:hidden'
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -64,8 +60,8 @@ const FloatingButtons = ({
|
||||
<Button
|
||||
icon={showDebugPanel ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
onClick={onToggleDebugPanel}
|
||||
theme="solid"
|
||||
type={showDebugPanel ? "danger" : "primary"}
|
||||
theme='solid'
|
||||
type={showDebugPanel ? 'danger' : 'primary'}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
right: 16,
|
||||
@@ -80,11 +76,11 @@ const FloatingButtons = ({
|
||||
? 'linear-gradient(to right, #e11d48, #be123c)'
|
||||
: 'linear-gradient(to right, #4f46e5, #6366f1)',
|
||||
}}
|
||||
className="lg:hidden"
|
||||
className='lg:hidden'
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default FloatingButtons;
|
||||
export default FloatingButtons;
|
||||
|
||||
@@ -18,21 +18,17 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Input,
|
||||
Typography,
|
||||
Button,
|
||||
Switch,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { Input, Typography, Button, Switch } from '@douyinfe/semi-ui';
|
||||
import { IconFile } from '@douyinfe/semi-icons';
|
||||
import {
|
||||
FileText,
|
||||
Plus,
|
||||
X,
|
||||
Image,
|
||||
} from 'lucide-react';
|
||||
import { FileText, Plus, X, Image } from 'lucide-react';
|
||||
|
||||
const ImageUrlInput = ({ imageUrls, imageEnabled, onImageUrlsChange, onImageEnabledChange, disabled = false }) => {
|
||||
const ImageUrlInput = ({
|
||||
imageUrls,
|
||||
imageEnabled,
|
||||
onImageUrlsChange,
|
||||
onImageEnabledChange,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const handleAddImageUrl = () => {
|
||||
const newUrls = [...imageUrls, ''];
|
||||
onImageUrlsChange(newUrls);
|
||||
@@ -51,75 +47,87 @@ const ImageUrlInput = ({ imageUrls, imageEnabled, onImageUrlsChange, onImageEnab
|
||||
|
||||
return (
|
||||
<div className={disabled ? 'opacity-50' : ''}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Image size={16} className={imageEnabled && !disabled ? "text-blue-500" : "text-gray-400"} />
|
||||
<Typography.Text strong className="text-sm">
|
||||
<div className='flex items-center justify-between mb-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Image
|
||||
size={16}
|
||||
className={
|
||||
imageEnabled && !disabled ? 'text-blue-500' : 'text-gray-400'
|
||||
}
|
||||
/>
|
||||
<Typography.Text strong className='text-sm'>
|
||||
图片地址
|
||||
</Typography.Text>
|
||||
{disabled && (
|
||||
<Typography.Text className="text-xs text-orange-600">
|
||||
<Typography.Text className='text-xs text-orange-600'>
|
||||
(已在自定义模式中忽略)
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className='flex items-center gap-2'>
|
||||
<Switch
|
||||
checked={imageEnabled}
|
||||
onChange={onImageEnabledChange}
|
||||
checkedText="启用"
|
||||
uncheckedText="停用"
|
||||
size="small"
|
||||
className="flex-shrink-0"
|
||||
checkedText='启用'
|
||||
uncheckedText='停用'
|
||||
size='small'
|
||||
className='flex-shrink-0'
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Button
|
||||
icon={<Plus size={14} />}
|
||||
size="small"
|
||||
theme="solid"
|
||||
type="primary"
|
||||
size='small'
|
||||
theme='solid'
|
||||
type='primary'
|
||||
onClick={handleAddImageUrl}
|
||||
className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
|
||||
className='!rounded-full !w-4 !h-4 !p-0 !min-w-0'
|
||||
disabled={!imageEnabled || disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!imageEnabled ? (
|
||||
<Typography.Text className="text-xs text-gray-500 mb-2 block">
|
||||
{disabled ? '图片功能在自定义请求体模式下不可用' : '启用后可添加图片URL进行多模态对话'}
|
||||
<Typography.Text className='text-xs text-gray-500 mb-2 block'>
|
||||
{disabled
|
||||
? '图片功能在自定义请求体模式下不可用'
|
||||
: '启用后可添加图片URL进行多模态对话'}
|
||||
</Typography.Text>
|
||||
) : imageUrls.length === 0 ? (
|
||||
<Typography.Text className="text-xs text-gray-500 mb-2 block">
|
||||
{disabled ? '图片功能在自定义请求体模式下不可用' : '点击 + 按钮添加图片URL进行多模态对话'}
|
||||
<Typography.Text className='text-xs text-gray-500 mb-2 block'>
|
||||
{disabled
|
||||
? '图片功能在自定义请求体模式下不可用'
|
||||
: '点击 + 按钮添加图片URL进行多模态对话'}
|
||||
</Typography.Text>
|
||||
) : (
|
||||
<Typography.Text className="text-xs text-gray-500 mb-2 block">
|
||||
已添加 {imageUrls.length} 张图片{disabled ? ' (自定义模式下不可用)' : ''}
|
||||
<Typography.Text className='text-xs text-gray-500 mb-2 block'>
|
||||
已添加 {imageUrls.length} 张图片
|
||||
{disabled ? ' (自定义模式下不可用)' : ''}
|
||||
</Typography.Text>
|
||||
)}
|
||||
|
||||
<div className={`space-y-2 max-h-32 overflow-y-auto image-list-scroll ${!imageEnabled || disabled ? 'opacity-50' : ''}`}>
|
||||
<div
|
||||
className={`space-y-2 max-h-32 overflow-y-auto image-list-scroll ${!imageEnabled || disabled ? 'opacity-50' : ''}`}
|
||||
>
|
||||
{imageUrls.map((url, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<div key={index} className='flex items-center gap-2'>
|
||||
<div className='flex-1'>
|
||||
<Input
|
||||
placeholder={`https://example.com/image${index + 1}.jpg`}
|
||||
value={url}
|
||||
onChange={(value) => handleUpdateImageUrl(index, value)}
|
||||
className="!rounded-lg"
|
||||
size="small"
|
||||
className='!rounded-lg'
|
||||
size='small'
|
||||
prefix={<IconFile size='small' />}
|
||||
disabled={!imageEnabled || disabled}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
icon={<X size={12} />}
|
||||
size="small"
|
||||
theme="borderless"
|
||||
type="danger"
|
||||
size='small'
|
||||
theme='borderless'
|
||||
type='danger'
|
||||
onClick={() => handleRemoveImageUrl(index)}
|
||||
className="!rounded-full !w-6 !h-6 !p-0 !min-w-0 !text-red-500 hover:!bg-red-50 flex-shrink-0"
|
||||
className='!rounded-full !w-6 !h-6 !p-0 !min-w-0 !text-red-500 hover:!bg-red-50 flex-shrink-0'
|
||||
disabled={!imageEnabled || disabled}
|
||||
/>
|
||||
</div>
|
||||
@@ -129,4 +137,4 @@ const ImageUrlInput = ({ imageUrls, imageEnabled, onImageUrlsChange, onImageEnab
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageUrlInput;
|
||||
export default ImageUrlInput;
|
||||
|
||||
@@ -18,17 +18,8 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Button,
|
||||
Tooltip,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
RefreshCw,
|
||||
Copy,
|
||||
Trash2,
|
||||
UserCheck,
|
||||
Edit,
|
||||
} from 'lucide-react';
|
||||
import { Button, Tooltip } from '@douyinfe/semi-ui';
|
||||
import { RefreshCw, Copy, Trash2, UserCheck, Edit } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const MessageActions = ({
|
||||
@@ -40,23 +31,32 @@ const MessageActions = ({
|
||||
onRoleToggle,
|
||||
onMessageEdit,
|
||||
isAnyMessageGenerating = false,
|
||||
isEditing = false
|
||||
isEditing = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isLoading = message.status === 'loading' || message.status === 'incomplete';
|
||||
const isLoading =
|
||||
message.status === 'loading' || message.status === 'incomplete';
|
||||
const shouldDisableActions = isAnyMessageGenerating || isEditing;
|
||||
const canToggleRole = message.role === 'assistant' || message.role === 'system';
|
||||
const canEdit = !isLoading && message.content && typeof onMessageEdit === 'function' && !isEditing;
|
||||
const canToggleRole =
|
||||
message.role === 'assistant' || message.role === 'system';
|
||||
const canEdit =
|
||||
!isLoading &&
|
||||
message.content &&
|
||||
typeof onMessageEdit === 'function' &&
|
||||
!isEditing;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<div className='flex items-center gap-0.5'>
|
||||
{!isLoading && (
|
||||
<Tooltip content={shouldDisableActions ? t('操作暂时被禁用') : t('重试')} position="top">
|
||||
<Tooltip
|
||||
content={shouldDisableActions ? t('操作暂时被禁用') : t('重试')}
|
||||
position='top'
|
||||
>
|
||||
<Button
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
size="small"
|
||||
theme='borderless'
|
||||
type='tertiary'
|
||||
size='small'
|
||||
icon={<RefreshCw size={styleState.isMobile ? 12 : 14} />}
|
||||
onClick={() => !shouldDisableActions && onMessageReset(message)}
|
||||
disabled={shouldDisableActions}
|
||||
@@ -67,11 +67,11 @@ const MessageActions = ({
|
||||
)}
|
||||
|
||||
{message.content && (
|
||||
<Tooltip content={t('复制')} position="top">
|
||||
<Tooltip content={t('复制')} position='top'>
|
||||
<Button
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
size="small"
|
||||
theme='borderless'
|
||||
type='tertiary'
|
||||
size='small'
|
||||
icon={<Copy size={styleState.isMobile ? 12 : 14} />}
|
||||
onClick={() => onMessageCopy(message)}
|
||||
className={`!rounded-full !text-gray-400 hover:!text-green-600 hover:!bg-green-50 ${styleState.isMobile ? '!w-6 !h-6' : '!w-7 !h-7'} !p-0 transition-all`}
|
||||
@@ -81,11 +81,14 @@ const MessageActions = ({
|
||||
)}
|
||||
|
||||
{canEdit && (
|
||||
<Tooltip content={shouldDisableActions ? t('操作暂时被禁用') : t('编辑')} position="top">
|
||||
<Tooltip
|
||||
content={shouldDisableActions ? t('操作暂时被禁用') : t('编辑')}
|
||||
position='top'
|
||||
>
|
||||
<Button
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
size="small"
|
||||
theme='borderless'
|
||||
type='tertiary'
|
||||
size='small'
|
||||
icon={<Edit size={styleState.isMobile ? 12 : 14} />}
|
||||
onClick={() => !shouldDisableActions && onMessageEdit(message)}
|
||||
disabled={shouldDisableActions}
|
||||
@@ -104,27 +107,36 @@ const MessageActions = ({
|
||||
? t('切换为System角色')
|
||||
: t('切换为Assistant角色')
|
||||
}
|
||||
position="top"
|
||||
position='top'
|
||||
>
|
||||
<Button
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
size="small"
|
||||
theme='borderless'
|
||||
type='tertiary'
|
||||
size='small'
|
||||
icon={<UserCheck size={styleState.isMobile ? 12 : 14} />}
|
||||
onClick={() => !shouldDisableActions && onRoleToggle && onRoleToggle(message)}
|
||||
onClick={() =>
|
||||
!shouldDisableActions && onRoleToggle && onRoleToggle(message)
|
||||
}
|
||||
disabled={shouldDisableActions}
|
||||
className={`!rounded-full ${shouldDisableActions ? '!text-gray-300 !cursor-not-allowed' : message.role === 'system' ? '!text-purple-500 hover:!text-purple-700 hover:!bg-purple-50' : '!text-gray-400 hover:!text-purple-600 hover:!bg-purple-50'} ${styleState.isMobile ? '!w-6 !h-6' : '!w-7 !h-7'} !p-0 transition-all`}
|
||||
aria-label={message.role === 'assistant' ? t('切换为System角色') : t('切换为Assistant角色')}
|
||||
aria-label={
|
||||
message.role === 'assistant'
|
||||
? t('切换为System角色')
|
||||
: t('切换为Assistant角色')
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{!isLoading && (
|
||||
<Tooltip content={shouldDisableActions ? t('操作暂时被禁用') : t('删除')} position="top">
|
||||
<Tooltip
|
||||
content={shouldDisableActions ? t('操作暂时被禁用') : t('删除')}
|
||||
position='top'
|
||||
>
|
||||
<Button
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
size="small"
|
||||
theme='borderless'
|
||||
type='tertiary'
|
||||
size='small'
|
||||
icon={<Trash2 size={styleState.isMobile ? 12 : 14} />}
|
||||
onClick={() => !shouldDisableActions && onMessageDelete(message)}
|
||||
disabled={shouldDisableActions}
|
||||
@@ -137,4 +149,4 @@ const MessageActions = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default MessageActions;
|
||||
export default MessageActions;
|
||||
|
||||
@@ -18,18 +18,10 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import {
|
||||
Typography,
|
||||
TextArea,
|
||||
Button,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { Typography, TextArea, Button } from '@douyinfe/semi-ui';
|
||||
import MarkdownRenderer from '../common/markdown/MarkdownRenderer';
|
||||
import ThinkingContent from './ThinkingContent';
|
||||
import {
|
||||
Loader2,
|
||||
Check,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { Loader2, Check, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const MessageContent = ({
|
||||
@@ -41,13 +33,14 @@ const MessageContent = ({
|
||||
onEditSave,
|
||||
onEditCancel,
|
||||
editValue,
|
||||
onEditValueChange
|
||||
onEditValueChange,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const previousContentLengthRef = useRef(0);
|
||||
const lastContentRef = useRef('');
|
||||
|
||||
const isThinkingStatus = message.status === 'loading' || message.status === 'incomplete';
|
||||
const isThinkingStatus =
|
||||
message.status === 'loading' || message.status === 'incomplete';
|
||||
|
||||
useEffect(() => {
|
||||
if (!isThinkingStatus) {
|
||||
@@ -60,10 +53,11 @@ const MessageContent = ({
|
||||
let errorText;
|
||||
|
||||
if (Array.isArray(message.content)) {
|
||||
const textContent = message.content.find(item => item.type === 'text');
|
||||
errorText = textContent && textContent.text && typeof textContent.text === 'string'
|
||||
? textContent.text
|
||||
: t('请求发生错误');
|
||||
const textContent = message.content.find((item) => item.type === 'text');
|
||||
errorText =
|
||||
textContent && textContent.text && typeof textContent.text === 'string'
|
||||
? textContent.text
|
||||
: t('请求发生错误');
|
||||
} else if (typeof message.content === 'string') {
|
||||
errorText = message.content;
|
||||
} else {
|
||||
@@ -72,21 +66,21 @@ const MessageContent = ({
|
||||
|
||||
return (
|
||||
<div className={`${className}`}>
|
||||
<Typography.Text className="text-white">
|
||||
{errorText}
|
||||
</Typography.Text>
|
||||
<Typography.Text className='text-white'>{errorText}</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let currentExtractedThinkingContent = null;
|
||||
let currentDisplayableFinalContent = "";
|
||||
let currentDisplayableFinalContent = '';
|
||||
let thinkingSource = null;
|
||||
|
||||
const getTextContent = (content) => {
|
||||
if (Array.isArray(content)) {
|
||||
const textItem = content.find(item => item.type === 'text');
|
||||
return textItem && textItem.text && typeof textItem.text === 'string' ? textItem.text : '';
|
||||
const textItem = content.find((item) => item.type === 'text');
|
||||
return textItem && textItem.text && typeof textItem.text === 'string'
|
||||
? textItem.text
|
||||
: '';
|
||||
} else if (typeof content === 'string') {
|
||||
return content;
|
||||
}
|
||||
@@ -97,7 +91,7 @@ const MessageContent = ({
|
||||
|
||||
if (message.role === 'assistant') {
|
||||
let baseContentForDisplay = getTextContent(message.content);
|
||||
let combinedThinkingContent = "";
|
||||
let combinedThinkingContent = '';
|
||||
|
||||
if (message.reasoningContent) {
|
||||
combinedThinkingContent = message.reasoningContent;
|
||||
@@ -112,7 +106,9 @@ const MessageContent = ({
|
||||
let lastIndex = 0;
|
||||
|
||||
while ((match = thinkTagRegex.exec(baseContentForDisplay)) !== null) {
|
||||
replyParts.push(baseContentForDisplay.substring(lastIndex, match.index));
|
||||
replyParts.push(
|
||||
baseContentForDisplay.substring(lastIndex, match.index),
|
||||
);
|
||||
thoughtsFromPairedTags.push(match[1]);
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
@@ -125,7 +121,9 @@ const MessageContent = ({
|
||||
} else {
|
||||
combinedThinkingContent = pairedThoughtsStr;
|
||||
}
|
||||
thinkingSource = thinkingSource ? thinkingSource + ' & <think> tags' : '<think> tags';
|
||||
thinkingSource = thinkingSource
|
||||
? thinkingSource + ' & <think> tags'
|
||||
: '<think> tags';
|
||||
}
|
||||
|
||||
baseContentForDisplay = replyParts.join('');
|
||||
@@ -134,37 +132,55 @@ const MessageContent = ({
|
||||
if (isThinkingStatus) {
|
||||
const lastOpenThinkIndex = baseContentForDisplay.lastIndexOf('<think>');
|
||||
if (lastOpenThinkIndex !== -1) {
|
||||
const fragmentAfterLastOpen = baseContentForDisplay.substring(lastOpenThinkIndex);
|
||||
const fragmentAfterLastOpen =
|
||||
baseContentForDisplay.substring(lastOpenThinkIndex);
|
||||
if (!fragmentAfterLastOpen.includes('</think>')) {
|
||||
const unclosedThought = fragmentAfterLastOpen.substring('<think>'.length).trim();
|
||||
const unclosedThought = fragmentAfterLastOpen
|
||||
.substring('<think>'.length)
|
||||
.trim();
|
||||
if (unclosedThought) {
|
||||
if (combinedThinkingContent) {
|
||||
combinedThinkingContent += '\n\n---\n\n' + unclosedThought;
|
||||
} else {
|
||||
combinedThinkingContent = unclosedThought;
|
||||
}
|
||||
thinkingSource = thinkingSource ? thinkingSource + ' + streaming <think>' : 'streaming <think>';
|
||||
thinkingSource = thinkingSource
|
||||
? thinkingSource + ' + streaming <think>'
|
||||
: 'streaming <think>';
|
||||
}
|
||||
baseContentForDisplay = baseContentForDisplay.substring(0, lastOpenThinkIndex);
|
||||
baseContentForDisplay = baseContentForDisplay.substring(
|
||||
0,
|
||||
lastOpenThinkIndex,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
currentExtractedThinkingContent = combinedThinkingContent || null;
|
||||
currentDisplayableFinalContent = baseContentForDisplay.replace(/<\/?think>/g, '').trim();
|
||||
currentDisplayableFinalContent = baseContentForDisplay
|
||||
.replace(/<\/?think>/g, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
const finalExtractedThinkingContent = currentExtractedThinkingContent;
|
||||
const finalDisplayableFinalContent = currentDisplayableFinalContent;
|
||||
|
||||
if (message.role === 'assistant' &&
|
||||
if (
|
||||
message.role === 'assistant' &&
|
||||
isThinkingStatus &&
|
||||
!finalExtractedThinkingContent &&
|
||||
(!finalDisplayableFinalContent || finalDisplayableFinalContent.trim() === '')) {
|
||||
(!finalDisplayableFinalContent ||
|
||||
finalDisplayableFinalContent.trim() === '')
|
||||
) {
|
||||
return (
|
||||
<div className={`${className} flex items-center gap-2 sm:gap-4 bg-gradient-to-r from-purple-50 to-indigo-50`}>
|
||||
<div className="w-5 h-5 rounded-full bg-gradient-to-br from-purple-500 to-indigo-600 flex items-center justify-center shadow-lg">
|
||||
<Loader2 className="animate-spin text-white" size={styleState.isMobile ? 16 : 20} />
|
||||
<div
|
||||
className={`${className} flex items-center gap-2 sm:gap-4 bg-gradient-to-r from-purple-50 to-indigo-50`}
|
||||
>
|
||||
<div className='w-5 h-5 rounded-full bg-gradient-to-br from-purple-500 to-indigo-600 flex items-center justify-center shadow-lg'>
|
||||
<Loader2
|
||||
className='animate-spin text-white'
|
||||
size={styleState.isMobile ? 16 : 20}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -173,12 +189,17 @@ const MessageContent = ({
|
||||
return (
|
||||
<div className={className}>
|
||||
{message.role === 'system' && (
|
||||
<div className="mb-2 sm:mb-4">
|
||||
<div className="flex items-center gap-2 p-2 sm:p-3 bg-gradient-to-r from-amber-50 to-orange-50 rounded-lg" style={{ border: '1px solid var(--semi-color-border)' }}>
|
||||
<div className="w-4 h-4 sm:w-5 sm:h-5 rounded-full bg-gradient-to-br from-amber-500 to-orange-600 flex items-center justify-center shadow-sm">
|
||||
<Typography.Text className="text-white text-xs font-bold">S</Typography.Text>
|
||||
<div className='mb-2 sm:mb-4'>
|
||||
<div
|
||||
className='flex items-center gap-2 p-2 sm:p-3 bg-gradient-to-r from-amber-50 to-orange-50 rounded-lg'
|
||||
style={{ border: '1px solid var(--semi-color-border)' }}
|
||||
>
|
||||
<div className='w-4 h-4 sm:w-5 sm:h-5 rounded-full bg-gradient-to-br from-amber-500 to-orange-600 flex items-center justify-center shadow-sm'>
|
||||
<Typography.Text className='text-white text-xs font-bold'>
|
||||
S
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Typography.Text className="text-amber-700 text-xs sm:text-sm font-medium">
|
||||
<Typography.Text className='text-amber-700 text-xs sm:text-sm font-medium'>
|
||||
{t('系统消息')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
@@ -196,7 +217,7 @@ const MessageContent = ({
|
||||
)}
|
||||
|
||||
{isEditing ? (
|
||||
<div className="space-y-3">
|
||||
<div className='space-y-3'>
|
||||
<TextArea
|
||||
value={editValue}
|
||||
onChange={(value) => onEditValueChange(value)}
|
||||
@@ -207,27 +228,27 @@ const MessageContent = ({
|
||||
fontSize: styleState.isMobile ? '14px' : '15px',
|
||||
lineHeight: '1.6',
|
||||
}}
|
||||
className="!border-blue-200 focus:!border-blue-400 !bg-blue-50/50"
|
||||
className='!border-blue-200 focus:!border-blue-400 !bg-blue-50/50'
|
||||
/>
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<div className='flex items-center gap-2 w-full'>
|
||||
<Button
|
||||
size="small"
|
||||
type="danger"
|
||||
theme="light"
|
||||
size='small'
|
||||
type='danger'
|
||||
theme='light'
|
||||
icon={<X size={14} />}
|
||||
onClick={onEditCancel}
|
||||
className="flex-1"
|
||||
className='flex-1'
|
||||
>
|
||||
{t('取消')}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
type="warning"
|
||||
theme="solid"
|
||||
size='small'
|
||||
type='warning'
|
||||
theme='solid'
|
||||
icon={<Check size={14} />}
|
||||
onClick={onEditSave}
|
||||
disabled={!editValue || editValue.trim() === ''}
|
||||
className="flex-1"
|
||||
className='flex-1'
|
||||
>
|
||||
{t('保存')}
|
||||
</Button>
|
||||
@@ -236,19 +257,23 @@ const MessageContent = ({
|
||||
) : (
|
||||
(() => {
|
||||
if (Array.isArray(message.content)) {
|
||||
const textContent = message.content.find(item => item.type === 'text');
|
||||
const imageContents = message.content.filter(item => item.type === 'image_url');
|
||||
const textContent = message.content.find(
|
||||
(item) => item.type === 'text',
|
||||
);
|
||||
const imageContents = message.content.filter(
|
||||
(item) => item.type === 'image_url',
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{imageContents.length > 0 && (
|
||||
<div className="mb-3 space-y-2">
|
||||
<div className='mb-3 space-y-2'>
|
||||
{imageContents.map((imgItem, index) => (
|
||||
<div key={index} className="max-w-sm">
|
||||
<div key={index} className='max-w-sm'>
|
||||
<img
|
||||
src={imgItem.image_url.url}
|
||||
alt={`用户上传的图片 ${index + 1}`}
|
||||
className="rounded-lg max-w-full h-auto shadow-sm border"
|
||||
className='rounded-lg max-w-full h-auto shadow-sm border'
|
||||
style={{ maxHeight: '300px' }}
|
||||
onError={(e) => {
|
||||
e.target.style.display = 'none';
|
||||
@@ -256,7 +281,7 @@ const MessageContent = ({
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="text-red-500 text-sm p-2 bg-red-50 rounded-lg border border-red-200"
|
||||
className='text-red-500 text-sm p-2 bg-red-50 rounded-lg border border-red-200'
|
||||
style={{ display: 'none' }}
|
||||
>
|
||||
图片加载失败: {imgItem.image_url.url}
|
||||
@@ -266,28 +291,42 @@ const MessageContent = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{textContent && textContent.text && typeof textContent.text === 'string' && textContent.text.trim() !== '' && (
|
||||
<div className={`prose prose-xs sm:prose-sm prose-gray max-w-none overflow-x-auto text-xs sm:text-sm ${message.role === 'user' ? 'user-message' : ''}`}>
|
||||
<MarkdownRenderer
|
||||
content={textContent.text}
|
||||
className={message.role === 'user' ? 'user-message' : ''}
|
||||
animated={false}
|
||||
previousContentLength={0}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{textContent &&
|
||||
textContent.text &&
|
||||
typeof textContent.text === 'string' &&
|
||||
textContent.text.trim() !== '' && (
|
||||
<div
|
||||
className={`prose prose-xs sm:prose-sm prose-gray max-w-none overflow-x-auto text-xs sm:text-sm ${message.role === 'user' ? 'user-message' : ''}`}
|
||||
>
|
||||
<MarkdownRenderer
|
||||
content={textContent.text}
|
||||
className={
|
||||
message.role === 'user' ? 'user-message' : ''
|
||||
}
|
||||
animated={false}
|
||||
previousContentLength={0}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof message.content === 'string') {
|
||||
if (message.role === 'assistant') {
|
||||
if (finalDisplayableFinalContent && finalDisplayableFinalContent.trim() !== '') {
|
||||
if (
|
||||
finalDisplayableFinalContent &&
|
||||
finalDisplayableFinalContent.trim() !== ''
|
||||
) {
|
||||
// 获取上一次的内容长度
|
||||
let prevLength = 0;
|
||||
if (isThinkingStatus && lastContentRef.current) {
|
||||
// 只有当前内容包含上一次内容时,才使用上一次的长度
|
||||
if (finalDisplayableFinalContent.startsWith(lastContentRef.current)) {
|
||||
if (
|
||||
finalDisplayableFinalContent.startsWith(
|
||||
lastContentRef.current,
|
||||
)
|
||||
) {
|
||||
prevLength = lastContentRef.current.length;
|
||||
}
|
||||
}
|
||||
@@ -298,10 +337,10 @@ const MessageContent = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="prose prose-xs sm:prose-sm prose-gray max-w-none overflow-x-auto text-xs sm:text-sm">
|
||||
<div className='prose prose-xs sm:prose-sm prose-gray max-w-none overflow-x-auto text-xs sm:text-sm'>
|
||||
<MarkdownRenderer
|
||||
content={finalDisplayableFinalContent}
|
||||
className=""
|
||||
className=''
|
||||
animated={isThinkingStatus}
|
||||
previousContentLength={prevLength}
|
||||
/>
|
||||
@@ -310,7 +349,9 @@ const MessageContent = ({
|
||||
}
|
||||
} else {
|
||||
return (
|
||||
<div className={`prose prose-xs sm:prose-sm prose-gray max-w-none overflow-x-auto text-xs sm:text-sm ${message.role === 'user' ? 'user-message' : ''}`}>
|
||||
<div
|
||||
className={`prose prose-xs sm:prose-sm prose-gray max-w-none overflow-x-auto text-xs sm:text-sm ${message.role === 'user' ? 'user-message' : ''}`}
|
||||
>
|
||||
<MarkdownRenderer
|
||||
content={message.content}
|
||||
className={message.role === 'user' ? 'user-message' : ''}
|
||||
@@ -329,4 +370,4 @@ const MessageContent = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default MessageContent;
|
||||
export default MessageContent;
|
||||
|
||||
@@ -24,56 +24,74 @@ import SettingsPanel from './SettingsPanel';
|
||||
import DebugPanel from './DebugPanel';
|
||||
|
||||
// 优化的消息内容组件
|
||||
export const OptimizedMessageContent = React.memo(MessageContent, (prevProps, nextProps) => {
|
||||
// 只有这些属性变化时才重新渲染
|
||||
return (
|
||||
prevProps.message.id === nextProps.message.id &&
|
||||
prevProps.message.content === nextProps.message.content &&
|
||||
prevProps.message.status === nextProps.message.status &&
|
||||
prevProps.message.role === nextProps.message.role &&
|
||||
prevProps.message.reasoningContent === nextProps.message.reasoningContent &&
|
||||
prevProps.message.isReasoningExpanded === nextProps.message.isReasoningExpanded &&
|
||||
prevProps.isEditing === nextProps.isEditing &&
|
||||
prevProps.editValue === nextProps.editValue &&
|
||||
prevProps.styleState.isMobile === nextProps.styleState.isMobile
|
||||
);
|
||||
});
|
||||
export const OptimizedMessageContent = React.memo(
|
||||
MessageContent,
|
||||
(prevProps, nextProps) => {
|
||||
// 只有这些属性变化时才重新渲染
|
||||
return (
|
||||
prevProps.message.id === nextProps.message.id &&
|
||||
prevProps.message.content === nextProps.message.content &&
|
||||
prevProps.message.status === nextProps.message.status &&
|
||||
prevProps.message.role === nextProps.message.role &&
|
||||
prevProps.message.reasoningContent ===
|
||||
nextProps.message.reasoningContent &&
|
||||
prevProps.message.isReasoningExpanded ===
|
||||
nextProps.message.isReasoningExpanded &&
|
||||
prevProps.isEditing === nextProps.isEditing &&
|
||||
prevProps.editValue === nextProps.editValue &&
|
||||
prevProps.styleState.isMobile === nextProps.styleState.isMobile
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// 优化的消息操作组件
|
||||
export const OptimizedMessageActions = React.memo(MessageActions, (prevProps, nextProps) => {
|
||||
return (
|
||||
prevProps.message.id === nextProps.message.id &&
|
||||
prevProps.message.role === nextProps.message.role &&
|
||||
prevProps.isAnyMessageGenerating === nextProps.isAnyMessageGenerating &&
|
||||
prevProps.isEditing === nextProps.isEditing &&
|
||||
prevProps.onMessageReset === nextProps.onMessageReset
|
||||
);
|
||||
});
|
||||
export const OptimizedMessageActions = React.memo(
|
||||
MessageActions,
|
||||
(prevProps, nextProps) => {
|
||||
return (
|
||||
prevProps.message.id === nextProps.message.id &&
|
||||
prevProps.message.role === nextProps.message.role &&
|
||||
prevProps.isAnyMessageGenerating === nextProps.isAnyMessageGenerating &&
|
||||
prevProps.isEditing === nextProps.isEditing &&
|
||||
prevProps.onMessageReset === nextProps.onMessageReset
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// 优化的设置面板组件
|
||||
export const OptimizedSettingsPanel = React.memo(SettingsPanel, (prevProps, nextProps) => {
|
||||
return (
|
||||
JSON.stringify(prevProps.inputs) === JSON.stringify(nextProps.inputs) &&
|
||||
JSON.stringify(prevProps.parameterEnabled) === JSON.stringify(nextProps.parameterEnabled) &&
|
||||
JSON.stringify(prevProps.models) === JSON.stringify(nextProps.models) &&
|
||||
JSON.stringify(prevProps.groups) === JSON.stringify(nextProps.groups) &&
|
||||
prevProps.customRequestMode === nextProps.customRequestMode &&
|
||||
prevProps.customRequestBody === nextProps.customRequestBody &&
|
||||
prevProps.showDebugPanel === nextProps.showDebugPanel &&
|
||||
prevProps.showSettings === nextProps.showSettings &&
|
||||
JSON.stringify(prevProps.previewPayload) === JSON.stringify(nextProps.previewPayload) &&
|
||||
JSON.stringify(prevProps.messages) === JSON.stringify(nextProps.messages)
|
||||
);
|
||||
});
|
||||
export const OptimizedSettingsPanel = React.memo(
|
||||
SettingsPanel,
|
||||
(prevProps, nextProps) => {
|
||||
return (
|
||||
JSON.stringify(prevProps.inputs) === JSON.stringify(nextProps.inputs) &&
|
||||
JSON.stringify(prevProps.parameterEnabled) ===
|
||||
JSON.stringify(nextProps.parameterEnabled) &&
|
||||
JSON.stringify(prevProps.models) === JSON.stringify(nextProps.models) &&
|
||||
JSON.stringify(prevProps.groups) === JSON.stringify(nextProps.groups) &&
|
||||
prevProps.customRequestMode === nextProps.customRequestMode &&
|
||||
prevProps.customRequestBody === nextProps.customRequestBody &&
|
||||
prevProps.showDebugPanel === nextProps.showDebugPanel &&
|
||||
prevProps.showSettings === nextProps.showSettings &&
|
||||
JSON.stringify(prevProps.previewPayload) ===
|
||||
JSON.stringify(nextProps.previewPayload) &&
|
||||
JSON.stringify(prevProps.messages) === JSON.stringify(nextProps.messages)
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// 优化的调试面板组件
|
||||
export const OptimizedDebugPanel = React.memo(DebugPanel, (prevProps, nextProps) => {
|
||||
return (
|
||||
prevProps.show === nextProps.show &&
|
||||
prevProps.activeTab === nextProps.activeTab &&
|
||||
JSON.stringify(prevProps.debugData) === JSON.stringify(nextProps.debugData) &&
|
||||
JSON.stringify(prevProps.previewPayload) === JSON.stringify(nextProps.previewPayload) &&
|
||||
prevProps.customRequestMode === nextProps.customRequestMode &&
|
||||
prevProps.showDebugPanel === nextProps.showDebugPanel
|
||||
);
|
||||
});
|
||||
export const OptimizedDebugPanel = React.memo(
|
||||
DebugPanel,
|
||||
(prevProps, nextProps) => {
|
||||
return (
|
||||
prevProps.show === nextProps.show &&
|
||||
prevProps.activeTab === nextProps.activeTab &&
|
||||
JSON.stringify(prevProps.debugData) ===
|
||||
JSON.stringify(nextProps.debugData) &&
|
||||
JSON.stringify(prevProps.previewPayload) ===
|
||||
JSON.stringify(nextProps.previewPayload) &&
|
||||
prevProps.customRequestMode === nextProps.customRequestMode &&
|
||||
prevProps.showDebugPanel === nextProps.showDebugPanel
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -18,13 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Input,
|
||||
Slider,
|
||||
Typography,
|
||||
Button,
|
||||
Tag,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { Input, Slider, Typography, Button, Tag } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
Hash,
|
||||
Thermometer,
|
||||
@@ -46,28 +40,36 @@ const ParameterControl = ({
|
||||
return (
|
||||
<>
|
||||
{/* Temperature */}
|
||||
<div className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.temperature || disabled ? 'opacity-50' : ''}`}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Thermometer size={16} className="text-gray-500" />
|
||||
<Typography.Text strong className="text-sm">
|
||||
<div
|
||||
className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.temperature || disabled ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<div className='flex items-center justify-between mb-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Thermometer size={16} className='text-gray-500' />
|
||||
<Typography.Text strong className='text-sm'>
|
||||
Temperature
|
||||
</Typography.Text>
|
||||
<Tag size="small" shape='circle'>
|
||||
<Tag size='small' shape='circle'>
|
||||
{inputs.temperature}
|
||||
</Tag>
|
||||
</div>
|
||||
<Button
|
||||
theme={parameterEnabled.temperature ? 'solid' : 'borderless'}
|
||||
type={parameterEnabled.temperature ? 'primary' : 'tertiary'}
|
||||
size="small"
|
||||
icon={parameterEnabled.temperature ? <Check size={10} /> : <X size={10} />}
|
||||
size='small'
|
||||
icon={
|
||||
parameterEnabled.temperature ? (
|
||||
<Check size={10} />
|
||||
) : (
|
||||
<X size={10} />
|
||||
)
|
||||
}
|
||||
onClick={() => onParameterToggle('temperature')}
|
||||
className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
|
||||
className='!rounded-full !w-4 !h-4 !p-0 !min-w-0'
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<Typography.Text className="text-xs text-gray-500 mb-2">
|
||||
<Typography.Text className='text-xs text-gray-500 mb-2'>
|
||||
控制输出的随机性和创造性
|
||||
</Typography.Text>
|
||||
<Slider
|
||||
@@ -76,34 +78,38 @@ const ParameterControl = ({
|
||||
max={1}
|
||||
value={inputs.temperature}
|
||||
onChange={(value) => onInputChange('temperature', value)}
|
||||
className="mt-2"
|
||||
className='mt-2'
|
||||
disabled={!parameterEnabled.temperature || disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Top P */}
|
||||
<div className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.top_p || disabled ? 'opacity-50' : ''}`}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Target size={16} className="text-gray-500" />
|
||||
<Typography.Text strong className="text-sm">
|
||||
<div
|
||||
className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.top_p || disabled ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<div className='flex items-center justify-between mb-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Target size={16} className='text-gray-500' />
|
||||
<Typography.Text strong className='text-sm'>
|
||||
Top P
|
||||
</Typography.Text>
|
||||
<Tag size="small" shape='circle'>
|
||||
<Tag size='small' shape='circle'>
|
||||
{inputs.top_p}
|
||||
</Tag>
|
||||
</div>
|
||||
<Button
|
||||
theme={parameterEnabled.top_p ? 'solid' : 'borderless'}
|
||||
type={parameterEnabled.top_p ? 'primary' : 'tertiary'}
|
||||
size="small"
|
||||
icon={parameterEnabled.top_p ? <Check size={10} /> : <X size={10} />}
|
||||
size='small'
|
||||
icon={
|
||||
parameterEnabled.top_p ? <Check size={10} /> : <X size={10} />
|
||||
}
|
||||
onClick={() => onParameterToggle('top_p')}
|
||||
className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
|
||||
className='!rounded-full !w-4 !h-4 !p-0 !min-w-0'
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<Typography.Text className="text-xs text-gray-500 mb-2">
|
||||
<Typography.Text className='text-xs text-gray-500 mb-2'>
|
||||
核采样,控制词汇选择的多样性
|
||||
</Typography.Text>
|
||||
<Slider
|
||||
@@ -112,34 +118,42 @@ const ParameterControl = ({
|
||||
max={1}
|
||||
value={inputs.top_p}
|
||||
onChange={(value) => onInputChange('top_p', value)}
|
||||
className="mt-2"
|
||||
className='mt-2'
|
||||
disabled={!parameterEnabled.top_p || disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Frequency Penalty */}
|
||||
<div className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.frequency_penalty || disabled ? 'opacity-50' : ''}`}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Repeat size={16} className="text-gray-500" />
|
||||
<Typography.Text strong className="text-sm">
|
||||
<div
|
||||
className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.frequency_penalty || disabled ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<div className='flex items-center justify-between mb-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Repeat size={16} className='text-gray-500' />
|
||||
<Typography.Text strong className='text-sm'>
|
||||
Frequency Penalty
|
||||
</Typography.Text>
|
||||
<Tag size="small" shape='circle'>
|
||||
<Tag size='small' shape='circle'>
|
||||
{inputs.frequency_penalty}
|
||||
</Tag>
|
||||
</div>
|
||||
<Button
|
||||
theme={parameterEnabled.frequency_penalty ? 'solid' : 'borderless'}
|
||||
type={parameterEnabled.frequency_penalty ? 'primary' : 'tertiary'}
|
||||
size="small"
|
||||
icon={parameterEnabled.frequency_penalty ? <Check size={10} /> : <X size={10} />}
|
||||
size='small'
|
||||
icon={
|
||||
parameterEnabled.frequency_penalty ? (
|
||||
<Check size={10} />
|
||||
) : (
|
||||
<X size={10} />
|
||||
)
|
||||
}
|
||||
onClick={() => onParameterToggle('frequency_penalty')}
|
||||
className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
|
||||
className='!rounded-full !w-4 !h-4 !p-0 !min-w-0'
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<Typography.Text className="text-xs text-gray-500 mb-2">
|
||||
<Typography.Text className='text-xs text-gray-500 mb-2'>
|
||||
频率惩罚,减少重复词汇的出现
|
||||
</Typography.Text>
|
||||
<Slider
|
||||
@@ -148,34 +162,42 @@ const ParameterControl = ({
|
||||
max={2}
|
||||
value={inputs.frequency_penalty}
|
||||
onChange={(value) => onInputChange('frequency_penalty', value)}
|
||||
className="mt-2"
|
||||
className='mt-2'
|
||||
disabled={!parameterEnabled.frequency_penalty || disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Presence Penalty */}
|
||||
<div className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.presence_penalty || disabled ? 'opacity-50' : ''}`}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Ban size={16} className="text-gray-500" />
|
||||
<Typography.Text strong className="text-sm">
|
||||
<div
|
||||
className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.presence_penalty || disabled ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<div className='flex items-center justify-between mb-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Ban size={16} className='text-gray-500' />
|
||||
<Typography.Text strong className='text-sm'>
|
||||
Presence Penalty
|
||||
</Typography.Text>
|
||||
<Tag size="small" shape='circle'>
|
||||
<Tag size='small' shape='circle'>
|
||||
{inputs.presence_penalty}
|
||||
</Tag>
|
||||
</div>
|
||||
<Button
|
||||
theme={parameterEnabled.presence_penalty ? 'solid' : 'borderless'}
|
||||
type={parameterEnabled.presence_penalty ? 'primary' : 'tertiary'}
|
||||
size="small"
|
||||
icon={parameterEnabled.presence_penalty ? <Check size={10} /> : <X size={10} />}
|
||||
size='small'
|
||||
icon={
|
||||
parameterEnabled.presence_penalty ? (
|
||||
<Check size={10} />
|
||||
) : (
|
||||
<X size={10} />
|
||||
)
|
||||
}
|
||||
onClick={() => onParameterToggle('presence_penalty')}
|
||||
className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
|
||||
className='!rounded-full !w-4 !h-4 !p-0 !min-w-0'
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<Typography.Text className="text-xs text-gray-500 mb-2">
|
||||
<Typography.Text className='text-xs text-gray-500 mb-2'>
|
||||
存在惩罚,鼓励讨论新话题
|
||||
</Typography.Text>
|
||||
<Slider
|
||||
@@ -184,27 +206,35 @@ const ParameterControl = ({
|
||||
max={2}
|
||||
value={inputs.presence_penalty}
|
||||
onChange={(value) => onInputChange('presence_penalty', value)}
|
||||
className="mt-2"
|
||||
className='mt-2'
|
||||
disabled={!parameterEnabled.presence_penalty || disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* MaxTokens */}
|
||||
<div className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.max_tokens || disabled ? 'opacity-50' : ''}`}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Hash size={16} className="text-gray-500" />
|
||||
<Typography.Text strong className="text-sm">
|
||||
<div
|
||||
className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.max_tokens || disabled ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<div className='flex items-center justify-between mb-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Hash size={16} className='text-gray-500' />
|
||||
<Typography.Text strong className='text-sm'>
|
||||
Max Tokens
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Button
|
||||
theme={parameterEnabled.max_tokens ? 'solid' : 'borderless'}
|
||||
type={parameterEnabled.max_tokens ? 'primary' : 'tertiary'}
|
||||
size="small"
|
||||
icon={parameterEnabled.max_tokens ? <Check size={10} /> : <X size={10} />}
|
||||
size='small'
|
||||
icon={
|
||||
parameterEnabled.max_tokens ? (
|
||||
<Check size={10} />
|
||||
) : (
|
||||
<X size={10} />
|
||||
)
|
||||
}
|
||||
onClick={() => onParameterToggle('max_tokens')}
|
||||
className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
|
||||
className='!rounded-full !w-4 !h-4 !p-0 !min-w-0'
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
@@ -216,30 +246,32 @@ const ParameterControl = ({
|
||||
defaultValue={0}
|
||||
value={inputs.max_tokens}
|
||||
onChange={(value) => onInputChange('max_tokens', value)}
|
||||
className="!rounded-lg"
|
||||
className='!rounded-lg'
|
||||
disabled={!parameterEnabled.max_tokens || disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Seed */}
|
||||
<div className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.seed || disabled ? 'opacity-50' : ''}`}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shuffle size={16} className="text-gray-500" />
|
||||
<Typography.Text strong className="text-sm">
|
||||
<div
|
||||
className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.seed || disabled ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<div className='flex items-center justify-between mb-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Shuffle size={16} className='text-gray-500' />
|
||||
<Typography.Text strong className='text-sm'>
|
||||
Seed
|
||||
</Typography.Text>
|
||||
<Typography.Text className="text-xs text-gray-400">
|
||||
<Typography.Text className='text-xs text-gray-400'>
|
||||
(可选,用于复现结果)
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Button
|
||||
theme={parameterEnabled.seed ? 'solid' : 'borderless'}
|
||||
type={parameterEnabled.seed ? 'primary' : 'tertiary'}
|
||||
size="small"
|
||||
size='small'
|
||||
icon={parameterEnabled.seed ? <Check size={10} /> : <X size={10} />}
|
||||
onClick={() => onParameterToggle('seed')}
|
||||
className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
|
||||
className='!rounded-full !w-4 !h-4 !p-0 !min-w-0'
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
@@ -248,8 +280,10 @@ const ParameterControl = ({
|
||||
name='seed'
|
||||
autoComplete='new-password'
|
||||
value={inputs.seed || ''}
|
||||
onChange={(value) => onInputChange('seed', value === '' ? null : value)}
|
||||
className="!rounded-lg"
|
||||
onChange={(value) =>
|
||||
onInputChange('seed', value === '' ? null : value)
|
||||
}
|
||||
className='!rounded-lg'
|
||||
disabled={!parameterEnabled.seed || disabled}
|
||||
/>
|
||||
</div>
|
||||
@@ -257,4 +291,4 @@ const ParameterControl = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default ParameterControl;
|
||||
export default ParameterControl;
|
||||
|
||||
@@ -18,20 +18,8 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Card,
|
||||
Select,
|
||||
Typography,
|
||||
Button,
|
||||
Switch,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
Sparkles,
|
||||
Users,
|
||||
ToggleLeft,
|
||||
X,
|
||||
Settings,
|
||||
} from 'lucide-react';
|
||||
import { Card, Select, Typography, Button, Switch } from '@douyinfe/semi-ui';
|
||||
import { Sparkles, Users, ToggleLeft, X, Settings } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { renderGroupOption, selectFilter } from '../../helpers';
|
||||
import ParameterControl from './ParameterControl';
|
||||
@@ -70,22 +58,22 @@ const SettingsPanel = ({
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="h-full flex flex-col"
|
||||
className='h-full flex flex-col'
|
||||
bordered={false}
|
||||
bodyStyle={{
|
||||
padding: styleState.isMobile ? '16px' : '24px',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{/* 标题区域 - 与调试面板保持一致 */}
|
||||
<div className="flex items-center justify-between mb-6 flex-shrink-0">
|
||||
<div className="flex items-center">
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-r from-purple-500 to-pink-500 flex items-center justify-center mr-3">
|
||||
<Settings size={20} className="text-white" />
|
||||
<div className='flex items-center justify-between mb-6 flex-shrink-0'>
|
||||
<div className='flex items-center'>
|
||||
<div className='w-10 h-10 rounded-full bg-gradient-to-r from-purple-500 to-pink-500 flex items-center justify-center mr-3'>
|
||||
<Settings size={20} className='text-white' />
|
||||
</div>
|
||||
<Typography.Title heading={5} className="mb-0">
|
||||
<Typography.Title heading={5} className='mb-0'>
|
||||
{t('模型配置')}
|
||||
</Typography.Title>
|
||||
</div>
|
||||
@@ -94,17 +82,17 @@ const SettingsPanel = ({
|
||||
<Button
|
||||
icon={<X size={16} />}
|
||||
onClick={onCloseSettings}
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
size="small"
|
||||
className="!rounded-lg"
|
||||
theme='borderless'
|
||||
type='tertiary'
|
||||
size='small'
|
||||
className='!rounded-lg'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 移动端配置管理 */}
|
||||
{styleState.isMobile && (
|
||||
<div className="mb-4 flex-shrink-0">
|
||||
<div className='mb-4 flex-shrink-0'>
|
||||
<ConfigManager
|
||||
currentConfig={currentConfig}
|
||||
onConfigImport={onConfigImport}
|
||||
@@ -115,7 +103,7 @@ const SettingsPanel = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-6 overflow-y-auto flex-1 pr-2 model-settings-scroll">
|
||||
<div className='space-y-6 overflow-y-auto flex-1 pr-2 model-settings-scroll'>
|
||||
{/* 自定义请求体编辑器 */}
|
||||
<CustomRequestEditor
|
||||
customRequestMode={customRequestMode}
|
||||
@@ -127,13 +115,13 @@ const SettingsPanel = ({
|
||||
|
||||
{/* 分组选择 */}
|
||||
<div className={customRequestMode ? 'opacity-50' : ''}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Users size={16} className="text-gray-500" />
|
||||
<Typography.Text strong className="text-sm">
|
||||
<div className='flex items-center gap-2 mb-2'>
|
||||
<Users size={16} className='text-gray-500' />
|
||||
<Typography.Text strong className='text-sm'>
|
||||
{t('分组')}
|
||||
</Typography.Text>
|
||||
{customRequestMode && (
|
||||
<Typography.Text className="text-xs text-orange-600">
|
||||
<Typography.Text className='text-xs text-orange-600'>
|
||||
(已在自定义模式中忽略)
|
||||
</Typography.Text>
|
||||
)}
|
||||
@@ -152,20 +140,20 @@ const SettingsPanel = ({
|
||||
renderOptionItem={renderGroupOption}
|
||||
style={{ width: '100%' }}
|
||||
dropdownStyle={{ width: '100%', maxWidth: '100%' }}
|
||||
className="!rounded-lg"
|
||||
className='!rounded-lg'
|
||||
disabled={customRequestMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 模型选择 */}
|
||||
<div className={customRequestMode ? 'opacity-50' : ''}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Sparkles size={16} className="text-gray-500" />
|
||||
<Typography.Text strong className="text-sm">
|
||||
<div className='flex items-center gap-2 mb-2'>
|
||||
<Sparkles size={16} className='text-gray-500' />
|
||||
<Typography.Text strong className='text-sm'>
|
||||
{t('模型')}
|
||||
</Typography.Text>
|
||||
{customRequestMode && (
|
||||
<Typography.Text className="text-xs text-orange-600">
|
||||
<Typography.Text className='text-xs text-orange-600'>
|
||||
(已在自定义模式中忽略)
|
||||
</Typography.Text>
|
||||
)}
|
||||
@@ -183,7 +171,7 @@ const SettingsPanel = ({
|
||||
optionList={models}
|
||||
style={{ width: '100%' }}
|
||||
dropdownStyle={{ width: '100%', maxWidth: '100%' }}
|
||||
className="!rounded-lg"
|
||||
className='!rounded-lg'
|
||||
disabled={customRequestMode}
|
||||
/>
|
||||
</div>
|
||||
@@ -194,7 +182,9 @@ const SettingsPanel = ({
|
||||
imageUrls={inputs.imageUrls}
|
||||
imageEnabled={inputs.imageEnabled}
|
||||
onImageUrlsChange={(urls) => onInputChange('imageUrls', urls)}
|
||||
onImageEnabledChange={(enabled) => onInputChange('imageEnabled', enabled)}
|
||||
onImageEnabledChange={(enabled) =>
|
||||
onInputChange('imageEnabled', enabled)
|
||||
}
|
||||
disabled={customRequestMode}
|
||||
/>
|
||||
</div>
|
||||
@@ -212,14 +202,14 @@ const SettingsPanel = ({
|
||||
|
||||
{/* 流式输出开关 */}
|
||||
<div className={customRequestMode ? 'opacity-50' : ''}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ToggleLeft size={16} className="text-gray-500" />
|
||||
<Typography.Text strong className="text-sm">
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<ToggleLeft size={16} className='text-gray-500' />
|
||||
<Typography.Text strong className='text-sm'>
|
||||
流式输出
|
||||
</Typography.Text>
|
||||
{customRequestMode && (
|
||||
<Typography.Text className="text-xs text-orange-600">
|
||||
<Typography.Text className='text-xs text-orange-600'>
|
||||
(已在自定义模式中忽略)
|
||||
</Typography.Text>
|
||||
)}
|
||||
@@ -227,9 +217,9 @@ const SettingsPanel = ({
|
||||
<Switch
|
||||
checked={inputs.stream}
|
||||
onChange={(checked) => onInputChange('stream', checked)}
|
||||
checkedText="开"
|
||||
uncheckedText="关"
|
||||
size="small"
|
||||
checkedText='开'
|
||||
uncheckedText='关'
|
||||
size='small'
|
||||
disabled={customRequestMode}
|
||||
/>
|
||||
</div>
|
||||
@@ -238,7 +228,7 @@ const SettingsPanel = ({
|
||||
|
||||
{/* 桌面端的配置管理放在底部 */}
|
||||
{!styleState.isMobile && (
|
||||
<div className="flex-shrink-0 pt-3">
|
||||
<div className='flex-shrink-0 pt-3'>
|
||||
<ConfigManager
|
||||
currentConfig={currentConfig}
|
||||
onConfigImport={onConfigImport}
|
||||
@@ -252,4 +242,4 @@ const SettingsPanel = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsPanel;
|
||||
export default SettingsPanel;
|
||||
|
||||
@@ -28,17 +28,25 @@ const ThinkingContent = ({
|
||||
finalExtractedThinkingContent,
|
||||
thinkingSource,
|
||||
styleState,
|
||||
onToggleReasoningExpansion
|
||||
onToggleReasoningExpansion,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const scrollRef = useRef(null);
|
||||
const lastContentRef = useRef('');
|
||||
|
||||
const isThinkingStatus = message.status === 'loading' || message.status === 'incomplete';
|
||||
const headerText = (isThinkingStatus && !message.isThinkingComplete) ? t('思考中...') : t('思考过程');
|
||||
const isThinkingStatus =
|
||||
message.status === 'loading' || message.status === 'incomplete';
|
||||
const headerText =
|
||||
isThinkingStatus && !message.isThinkingComplete
|
||||
? t('思考中...')
|
||||
: t('思考过程');
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current && finalExtractedThinkingContent && message.isReasoningExpanded) {
|
||||
if (
|
||||
scrollRef.current &&
|
||||
finalExtractedThinkingContent &&
|
||||
message.isReasoningExpanded
|
||||
) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [finalExtractedThinkingContent, message.isReasoningExpanded]);
|
||||
@@ -63,72 +71,100 @@ const ThinkingContent = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl sm:rounded-2xl mb-2 sm:mb-4 overflow-hidden shadow-sm backdrop-blur-sm">
|
||||
<div className='rounded-xl sm:rounded-2xl mb-2 sm:mb-4 overflow-hidden shadow-sm backdrop-blur-sm'>
|
||||
<div
|
||||
className="flex items-center justify-between p-3 cursor-pointer hover:bg-gradient-to-r hover:from-white/20 hover:to-purple-50/30 transition-all"
|
||||
className='flex items-center justify-between p-3 cursor-pointer hover:bg-gradient-to-r hover:from-white/20 hover:to-purple-50/30 transition-all'
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #4c1d95 0%, #6d28d9 50%, #7c3aed 100%)',
|
||||
position: 'relative'
|
||||
background:
|
||||
'linear-gradient(135deg, #4c1d95 0%, #6d28d9 50%, #7c3aed 100%)',
|
||||
position: 'relative',
|
||||
}}
|
||||
onClick={() => onToggleReasoningExpansion(message.id)}
|
||||
>
|
||||
<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-8 -left-8 w-24 h-24 bg-white opacity-10 rounded-full"></div>
|
||||
<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-8 -left-8 w-24 h-24 bg-white opacity-10 rounded-full'></div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 sm:gap-4 relative">
|
||||
<div className="w-6 h-6 sm:w-8 sm:h-8 rounded-full bg-white/20 flex items-center justify-center shadow-lg">
|
||||
<Brain style={{ color: 'white' }} size={styleState.isMobile ? 12 : 16} />
|
||||
<div className='flex items-center gap-2 sm:gap-4 relative'>
|
||||
<div className='w-6 h-6 sm:w-8 sm:h-8 rounded-full bg-white/20 flex items-center justify-center shadow-lg'>
|
||||
<Brain
|
||||
style={{ color: 'white' }}
|
||||
size={styleState.isMobile ? 12 : 16}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<Typography.Text strong style={{ color: 'white' }} className="text-sm sm:text-base">
|
||||
<div className='flex flex-col'>
|
||||
<Typography.Text
|
||||
strong
|
||||
style={{ color: 'white' }}
|
||||
className='text-sm sm:text-base'
|
||||
>
|
||||
{headerText}
|
||||
</Typography.Text>
|
||||
{thinkingSource && (
|
||||
<Typography.Text style={{ color: 'white' }} className="text-xs mt-0.5 opacity-80 hidden sm:block">
|
||||
<Typography.Text
|
||||
style={{ color: 'white' }}
|
||||
className='text-xs mt-0.5 opacity-80 hidden sm:block'
|
||||
>
|
||||
来源: {thinkingSource}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 sm:gap-3 relative">
|
||||
<div className='flex items-center gap-2 sm:gap-3 relative'>
|
||||
{isThinkingStatus && !message.isThinkingComplete && (
|
||||
<div className="flex items-center gap-1 sm:gap-2">
|
||||
<Loader2 style={{ color: 'white' }} className="animate-spin" size={styleState.isMobile ? 14 : 18} />
|
||||
<Typography.Text style={{ color: 'white' }} className="text-xs sm:text-sm font-medium opacity-90">
|
||||
<div className='flex items-center gap-1 sm:gap-2'>
|
||||
<Loader2
|
||||
style={{ color: 'white' }}
|
||||
className='animate-spin'
|
||||
size={styleState.isMobile ? 14 : 18}
|
||||
/>
|
||||
<Typography.Text
|
||||
style={{ color: 'white' }}
|
||||
className='text-xs sm:text-sm font-medium opacity-90'
|
||||
>
|
||||
思考中
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
{(!isThinkingStatus || message.isThinkingComplete) && (
|
||||
<div className="w-5 h-5 sm:w-6 sm:h-6 rounded-full bg-white/20 flex items-center justify-center">
|
||||
{message.isReasoningExpanded ?
|
||||
<ChevronUp size={styleState.isMobile ? 12 : 16} style={{ color: 'white' }} /> :
|
||||
<ChevronRight size={styleState.isMobile ? 12 : 16} style={{ color: 'white' }} />
|
||||
}
|
||||
<div className='w-5 h-5 sm:w-6 sm:h-6 rounded-full bg-white/20 flex items-center justify-center'>
|
||||
{message.isReasoningExpanded ? (
|
||||
<ChevronUp
|
||||
size={styleState.isMobile ? 12 : 16}
|
||||
style={{ color: 'white' }}
|
||||
/>
|
||||
) : (
|
||||
<ChevronRight
|
||||
size={styleState.isMobile ? 12 : 16}
|
||||
style={{ color: 'white' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`transition-all duration-500 ease-out ${message.isReasoningExpanded ? 'max-h-96 opacity-100' : 'max-h-0 opacity-0'
|
||||
} overflow-hidden bg-gradient-to-br from-purple-50 via-indigo-50 to-violet-50`}
|
||||
className={`transition-all duration-500 ease-out ${
|
||||
message.isReasoningExpanded
|
||||
? 'max-h-96 opacity-100'
|
||||
: 'max-h-0 opacity-0'
|
||||
} overflow-hidden bg-gradient-to-br from-purple-50 via-indigo-50 to-violet-50`}
|
||||
>
|
||||
{message.isReasoningExpanded && (
|
||||
<div className="p-3 sm:p-5 pt-2 sm:pt-4">
|
||||
<div className='p-3 sm:p-5 pt-2 sm:pt-4'>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="bg-white/70 backdrop-blur-sm rounded-lg sm:rounded-xl p-2 shadow-inner overflow-x-auto overflow-y-auto thinking-content-scroll"
|
||||
className='bg-white/70 backdrop-blur-sm rounded-lg sm:rounded-xl p-2 shadow-inner overflow-x-auto overflow-y-auto thinking-content-scroll'
|
||||
style={{
|
||||
maxHeight: '200px',
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: 'rgba(0, 0, 0, 0.3) transparent'
|
||||
scrollbarColor: 'rgba(0, 0, 0, 0.3) transparent',
|
||||
}}
|
||||
>
|
||||
<div className="prose prose-xs sm:prose-sm prose-purple max-w-none text-xs sm:text-sm">
|
||||
<div className='prose prose-xs sm:prose-sm prose-purple max-w-none text-xs sm:text-sm'>
|
||||
<MarkdownRenderer
|
||||
content={finalExtractedThinkingContent}
|
||||
className=""
|
||||
className=''
|
||||
animated={isThinkingStatus}
|
||||
previousContentLength={prevLength}
|
||||
/>
|
||||
@@ -141,4 +177,4 @@ const ThinkingContent = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default ThinkingContent;
|
||||
export default ThinkingContent;
|
||||
|
||||
@@ -17,7 +17,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import { STORAGE_KEYS, DEFAULT_CONFIG } from '../../constants/playground.constants';
|
||||
import {
|
||||
STORAGE_KEYS,
|
||||
DEFAULT_CONFIG,
|
||||
} from '../../constants/playground.constants';
|
||||
|
||||
const MESSAGES_STORAGE_KEY = 'playground_messages';
|
||||
|
||||
@@ -72,9 +75,12 @@ export const loadConfig = () => {
|
||||
...DEFAULT_CONFIG.parameterEnabled,
|
||||
...parsedConfig.parameterEnabled,
|
||||
},
|
||||
showDebugPanel: parsedConfig.showDebugPanel || DEFAULT_CONFIG.showDebugPanel,
|
||||
customRequestMode: parsedConfig.customRequestMode || DEFAULT_CONFIG.customRequestMode,
|
||||
customRequestBody: parsedConfig.customRequestBody || DEFAULT_CONFIG.customRequestBody,
|
||||
showDebugPanel:
|
||||
parsedConfig.showDebugPanel || DEFAULT_CONFIG.showDebugPanel,
|
||||
customRequestMode:
|
||||
parsedConfig.customRequestMode || DEFAULT_CONFIG.customRequestMode,
|
||||
customRequestBody:
|
||||
parsedConfig.customRequestBody || DEFAULT_CONFIG.customRequestBody,
|
||||
};
|
||||
|
||||
return mergedConfig;
|
||||
@@ -180,7 +186,6 @@ export const exportConfig = (config, messages = null) => {
|
||||
link.click();
|
||||
|
||||
URL.revokeObjectURL(link.href);
|
||||
|
||||
} catch (error) {
|
||||
console.error('导出配置失败:', error);
|
||||
}
|
||||
@@ -201,7 +206,10 @@ export const importConfig = (file) => {
|
||||
|
||||
if (importedConfig.inputs && importedConfig.parameterEnabled) {
|
||||
// 如果导入的配置包含消息,也一起导入
|
||||
if (importedConfig.messages && Array.isArray(importedConfig.messages)) {
|
||||
if (
|
||||
importedConfig.messages &&
|
||||
Array.isArray(importedConfig.messages)
|
||||
) {
|
||||
saveMessages(importedConfig.messages);
|
||||
}
|
||||
|
||||
@@ -219,4 +227,4 @@ export const importConfig = (file) => {
|
||||
reject(new Error('导入配置失败: ' + error.message));
|
||||
}
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
@@ -36,4 +36,4 @@ export {
|
||||
getConfigTimestamp,
|
||||
exportConfig,
|
||||
importConfig,
|
||||
} from './configStorage';
|
||||
} from './configStorage';
|
||||
|
||||
@@ -17,7 +17,12 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
} from 'react';
|
||||
import { useIsMobile } from '../../hooks/common/useIsMobile';
|
||||
import {
|
||||
Modal,
|
||||
@@ -31,220 +36,241 @@ import {
|
||||
import { IconSearch } from '@douyinfe/semi-icons';
|
||||
import { CheckCircle, XCircle, AlertCircle, HelpCircle } from 'lucide-react';
|
||||
|
||||
const ChannelSelectorModal = forwardRef(({
|
||||
visible,
|
||||
onCancel,
|
||||
onOk,
|
||||
allChannels,
|
||||
selectedChannelIds,
|
||||
setSelectedChannelIds,
|
||||
channelEndpoints,
|
||||
updateChannelEndpoint,
|
||||
t,
|
||||
}, ref) => {
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const [filteredData, setFilteredData] = useState([]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
resetPagination: () => {
|
||||
setCurrentPage(1);
|
||||
setSearchText('');
|
||||
const ChannelSelectorModal = forwardRef(
|
||||
(
|
||||
{
|
||||
visible,
|
||||
onCancel,
|
||||
onOk,
|
||||
allChannels,
|
||||
selectedChannelIds,
|
||||
setSelectedChannelIds,
|
||||
channelEndpoints,
|
||||
updateChannelEndpoint,
|
||||
t,
|
||||
},
|
||||
}));
|
||||
ref,
|
||||
) => {
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
useEffect(() => {
|
||||
if (!allChannels) return;
|
||||
const [filteredData, setFilteredData] = useState([]);
|
||||
|
||||
const searchLower = searchText.trim().toLowerCase();
|
||||
const matched = searchLower
|
||||
? allChannels.filter((item) => {
|
||||
const name = (item.label || '').toLowerCase();
|
||||
const baseUrl = (item._originalData?.base_url || '').toLowerCase();
|
||||
return name.includes(searchLower) || baseUrl.includes(searchLower);
|
||||
})
|
||||
: allChannels;
|
||||
useImperativeHandle(ref, () => ({
|
||||
resetPagination: () => {
|
||||
setCurrentPage(1);
|
||||
setSearchText('');
|
||||
},
|
||||
}));
|
||||
|
||||
setFilteredData(matched);
|
||||
}, [allChannels, searchText]);
|
||||
useEffect(() => {
|
||||
if (!allChannels) return;
|
||||
|
||||
const total = filteredData.length;
|
||||
const searchLower = searchText.trim().toLowerCase();
|
||||
const matched = searchLower
|
||||
? allChannels.filter((item) => {
|
||||
const name = (item.label || '').toLowerCase();
|
||||
const baseUrl = (item._originalData?.base_url || '').toLowerCase();
|
||||
return name.includes(searchLower) || baseUrl.includes(searchLower);
|
||||
})
|
||||
: allChannels;
|
||||
|
||||
const paginatedData = filteredData.slice(
|
||||
(currentPage - 1) * pageSize,
|
||||
currentPage * pageSize,
|
||||
);
|
||||
setFilteredData(matched);
|
||||
}, [allChannels, searchText]);
|
||||
|
||||
const updateEndpoint = (channelId, endpoint) => {
|
||||
if (typeof updateChannelEndpoint === 'function') {
|
||||
updateChannelEndpoint(channelId, endpoint);
|
||||
}
|
||||
};
|
||||
const total = filteredData.length;
|
||||
|
||||
const renderEndpointCell = (text, record) => {
|
||||
const channelId = record.key || record.value;
|
||||
const currentEndpoint = channelEndpoints[channelId] || '';
|
||||
const paginatedData = filteredData.slice(
|
||||
(currentPage - 1) * pageSize,
|
||||
currentPage * pageSize,
|
||||
);
|
||||
|
||||
const getEndpointType = (ep) => {
|
||||
if (ep === '/api/ratio_config') return 'ratio_config';
|
||||
if (ep === '/api/pricing') return 'pricing';
|
||||
return 'custom';
|
||||
};
|
||||
|
||||
const currentType = getEndpointType(currentEndpoint);
|
||||
|
||||
const handleTypeChange = (val) => {
|
||||
if (val === 'ratio_config') {
|
||||
updateEndpoint(channelId, '/api/ratio_config');
|
||||
} else if (val === 'pricing') {
|
||||
updateEndpoint(channelId, '/api/pricing');
|
||||
} else {
|
||||
if (currentType !== 'custom') {
|
||||
updateEndpoint(channelId, '');
|
||||
}
|
||||
const updateEndpoint = (channelId, endpoint) => {
|
||||
if (typeof updateChannelEndpoint === 'function') {
|
||||
updateChannelEndpoint(channelId, endpoint);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Select
|
||||
size="small"
|
||||
value={currentType}
|
||||
onChange={handleTypeChange}
|
||||
style={{ width: 120 }}
|
||||
optionList={[
|
||||
{ label: 'ratio_config', value: 'ratio_config' },
|
||||
{ label: 'pricing', value: 'pricing' },
|
||||
{ label: 'custom', value: 'custom' },
|
||||
]}
|
||||
/>
|
||||
{currentType === 'custom' && (
|
||||
<Input
|
||||
size="small"
|
||||
value={currentEndpoint}
|
||||
onChange={(val) => updateEndpoint(channelId, val)}
|
||||
placeholder="/your/endpoint"
|
||||
style={{ width: 160, fontSize: 12 }}
|
||||
const renderEndpointCell = (text, record) => {
|
||||
const channelId = record.key || record.value;
|
||||
const currentEndpoint = channelEndpoints[channelId] || '';
|
||||
|
||||
const getEndpointType = (ep) => {
|
||||
if (ep === '/api/ratio_config') return 'ratio_config';
|
||||
if (ep === '/api/pricing') return 'pricing';
|
||||
return 'custom';
|
||||
};
|
||||
|
||||
const currentType = getEndpointType(currentEndpoint);
|
||||
|
||||
const handleTypeChange = (val) => {
|
||||
if (val === 'ratio_config') {
|
||||
updateEndpoint(channelId, '/api/ratio_config');
|
||||
} else if (val === 'pricing') {
|
||||
updateEndpoint(channelId, '/api/pricing');
|
||||
} else {
|
||||
if (currentType !== 'custom') {
|
||||
updateEndpoint(channelId, '');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Select
|
||||
size='small'
|
||||
value={currentType}
|
||||
onChange={handleTypeChange}
|
||||
style={{ width: 120 }}
|
||||
optionList={[
|
||||
{ label: 'ratio_config', value: 'ratio_config' },
|
||||
{ label: 'pricing', value: 'pricing' },
|
||||
{ label: 'custom', value: 'custom' },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{currentType === 'custom' && (
|
||||
<Input
|
||||
size='small'
|
||||
value={currentEndpoint}
|
||||
onChange={(val) => updateEndpoint(channelId, val)}
|
||||
placeholder='/your/endpoint'
|
||||
style={{ width: 160, fontSize: 12 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderStatusCell = (status) => {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return (
|
||||
<Tag
|
||||
color='green'
|
||||
shape='circle'
|
||||
prefixIcon={<CheckCircle size={14} />}
|
||||
>
|
||||
{t('已启用')}
|
||||
</Tag>
|
||||
);
|
||||
case 2:
|
||||
return (
|
||||
<Tag color='red' shape='circle' prefixIcon={<XCircle size={14} />}>
|
||||
{t('已禁用')}
|
||||
</Tag>
|
||||
);
|
||||
case 3:
|
||||
return (
|
||||
<Tag
|
||||
color='yellow'
|
||||
shape='circle'
|
||||
prefixIcon={<AlertCircle size={14} />}
|
||||
>
|
||||
{t('自动禁用')}
|
||||
</Tag>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Tag
|
||||
color='grey'
|
||||
shape='circle'
|
||||
prefixIcon={<HelpCircle size={14} />}
|
||||
>
|
||||
{t('未知状态')}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const renderNameCell = (text) => (
|
||||
<Highlight sourceString={text} searchWords={[searchText]} />
|
||||
);
|
||||
};
|
||||
|
||||
const renderStatusCell = (status) => {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return (
|
||||
<Tag color='green' shape='circle' prefixIcon={<CheckCircle size={14} />}>
|
||||
{t('已启用')}
|
||||
</Tag>
|
||||
);
|
||||
case 2:
|
||||
return (
|
||||
<Tag color='red' shape='circle' prefixIcon={<XCircle size={14} />}>
|
||||
{t('已禁用')}
|
||||
</Tag>
|
||||
);
|
||||
case 3:
|
||||
return (
|
||||
<Tag color='yellow' shape='circle' prefixIcon={<AlertCircle size={14} />}>
|
||||
{t('自动禁用')}
|
||||
</Tag>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Tag color='grey' shape='circle' prefixIcon={<HelpCircle size={14} />}>
|
||||
{t('未知状态')}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
};
|
||||
const renderBaseUrlCell = (text) => (
|
||||
<Highlight sourceString={text} searchWords={[searchText]} />
|
||||
);
|
||||
|
||||
const renderNameCell = (text) => (
|
||||
<Highlight sourceString={text} searchWords={[searchText]} />
|
||||
);
|
||||
const columns = [
|
||||
{
|
||||
title: t('名称'),
|
||||
dataIndex: 'label',
|
||||
render: renderNameCell,
|
||||
},
|
||||
{
|
||||
title: t('源地址'),
|
||||
dataIndex: '_originalData.base_url',
|
||||
render: (_, record) =>
|
||||
renderBaseUrlCell(record._originalData?.base_url || ''),
|
||||
},
|
||||
{
|
||||
title: t('状态'),
|
||||
dataIndex: '_originalData.status',
|
||||
render: (_, record) =>
|
||||
renderStatusCell(record._originalData?.status || 0),
|
||||
},
|
||||
{
|
||||
title: t('同步接口'),
|
||||
dataIndex: 'endpoint',
|
||||
fixed: 'right',
|
||||
render: renderEndpointCell,
|
||||
},
|
||||
];
|
||||
|
||||
const renderBaseUrlCell = (text) => (
|
||||
<Highlight sourceString={text} searchWords={[searchText]} />
|
||||
);
|
||||
const rowSelection = {
|
||||
selectedRowKeys: selectedChannelIds,
|
||||
onChange: (keys) => setSelectedChannelIds(keys),
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('名称'),
|
||||
dataIndex: 'label',
|
||||
render: renderNameCell,
|
||||
},
|
||||
{
|
||||
title: t('源地址'),
|
||||
dataIndex: '_originalData.base_url',
|
||||
render: (_, record) => renderBaseUrlCell(record._originalData?.base_url || ''),
|
||||
},
|
||||
{
|
||||
title: t('状态'),
|
||||
dataIndex: '_originalData.status',
|
||||
render: (_, record) => renderStatusCell(record._originalData?.status || 0),
|
||||
},
|
||||
{
|
||||
title: t('同步接口'),
|
||||
dataIndex: 'endpoint',
|
||||
fixed: 'right',
|
||||
render: renderEndpointCell,
|
||||
},
|
||||
];
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
onCancel={onCancel}
|
||||
onOk={onOk}
|
||||
title={
|
||||
<span className='text-lg font-semibold'>{t('选择同步渠道')}</span>
|
||||
}
|
||||
size={isMobile ? 'full-width' : 'large'}
|
||||
keepDOM
|
||||
lazyRender={false}
|
||||
>
|
||||
<Space vertical style={{ width: '100%' }}>
|
||||
<Input
|
||||
prefix={<IconSearch size={14} />}
|
||||
placeholder={t('搜索渠道名称或地址')}
|
||||
value={searchText}
|
||||
onChange={setSearchText}
|
||||
showClear
|
||||
/>
|
||||
|
||||
const rowSelection = {
|
||||
selectedRowKeys: selectedChannelIds,
|
||||
onChange: (keys) => setSelectedChannelIds(keys),
|
||||
};
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={paginatedData}
|
||||
rowKey='key'
|
||||
rowSelection={rowSelection}
|
||||
pagination={{
|
||||
currentPage: currentPage,
|
||||
pageSize: pageSize,
|
||||
total: total,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
pageSizeOptions: ['10', '20', '50', '100'],
|
||||
onChange: (page, size) => {
|
||||
setCurrentPage(page);
|
||||
setPageSize(size);
|
||||
},
|
||||
onShowSizeChange: (curr, size) => {
|
||||
setCurrentPage(1);
|
||||
setPageSize(size);
|
||||
},
|
||||
}}
|
||||
size='small'
|
||||
/>
|
||||
</Space>
|
||||
</Modal>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
onCancel={onCancel}
|
||||
onOk={onOk}
|
||||
title={<span className="text-lg font-semibold">{t('选择同步渠道')}</span>}
|
||||
size={isMobile ? 'full-width' : 'large'}
|
||||
keepDOM
|
||||
lazyRender={false}
|
||||
>
|
||||
<Space vertical style={{ width: '100%' }}>
|
||||
<Input
|
||||
prefix={<IconSearch size={14} />}
|
||||
placeholder={t('搜索渠道名称或地址')}
|
||||
value={searchText}
|
||||
onChange={setSearchText}
|
||||
showClear
|
||||
/>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={paginatedData}
|
||||
rowKey="key"
|
||||
rowSelection={rowSelection}
|
||||
pagination={{
|
||||
currentPage: currentPage,
|
||||
pageSize: pageSize,
|
||||
total: total,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
pageSizeOptions: ['10', '20', '50', '100'],
|
||||
onChange: (page, size) => {
|
||||
setCurrentPage(page);
|
||||
setPageSize(size);
|
||||
},
|
||||
onShowSizeChange: (curr, size) => {
|
||||
setCurrentPage(1);
|
||||
setPageSize(size);
|
||||
},
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
</Space>
|
||||
</Modal>
|
||||
);
|
||||
});
|
||||
|
||||
export default ChannelSelectorModal;
|
||||
export default ChannelSelectorModal;
|
||||
|
||||
@@ -79,4 +79,4 @@ const ChatsSetting = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatsSetting;
|
||||
export default ChatsSetting;
|
||||
|
||||
@@ -62,8 +62,7 @@ const DashboardSetting = () => {
|
||||
if (item.key in inputs) {
|
||||
newInputs[item.key] = item.value;
|
||||
}
|
||||
if (item.key.endsWith('Enabled') &&
|
||||
(item.key === 'DataExportEnabled')) {
|
||||
if (item.key.endsWith('Enabled') && item.key === 'DataExportEnabled') {
|
||||
newInputs[item.key] = toBoolean(item.value);
|
||||
}
|
||||
});
|
||||
@@ -91,8 +90,14 @@ const DashboardSetting = () => {
|
||||
|
||||
// 用于迁移检测的旧键,下个版本会删除
|
||||
const hasLegacyData = useMemo(() => {
|
||||
const legacyKeys = ['ApiInfo', 'Announcements', 'FAQ', 'UptimeKumaUrl', 'UptimeKumaSlug'];
|
||||
return legacyKeys.some(k => inputs[k]);
|
||||
const legacyKeys = [
|
||||
'ApiInfo',
|
||||
'Announcements',
|
||||
'FAQ',
|
||||
'UptimeKumaUrl',
|
||||
'UptimeKumaSlug',
|
||||
];
|
||||
return legacyKeys.some((k) => inputs[k]);
|
||||
}, [inputs]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -121,17 +126,18 @@ const DashboardSetting = () => {
|
||||
<Spin spinning={loading} size='large'>
|
||||
{/* 用于迁移检测的旧键模态框,下个版本会删除 */}
|
||||
<Modal
|
||||
title="配置迁移确认"
|
||||
title='配置迁移确认'
|
||||
visible={showMigrateModal}
|
||||
onOk={handleMigrate}
|
||||
onCancel={() => setShowMigrateModal(false)}
|
||||
confirmLoading={loading}
|
||||
okText="确认迁移"
|
||||
cancelText="取消"
|
||||
okText='确认迁移'
|
||||
cancelText='取消'
|
||||
>
|
||||
<p>检测到旧版本的配置数据,是否要迁移到新的配置格式?</p>
|
||||
<p style={{ color: '#f57c00', marginTop: '10px' }}>
|
||||
<strong>注意:</strong>迁移过程中会自动处理数据格式转换,迁移完成后旧配置将被清除,请在迁移前在数据库中备份好旧配置。
|
||||
<strong>注意:</strong>
|
||||
迁移过程中会自动处理数据格式转换,迁移完成后旧配置将被清除,请在迁移前在数据库中备份好旧配置。
|
||||
</p>
|
||||
</Modal>
|
||||
|
||||
@@ -164,4 +170,4 @@ const DashboardSetting = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardSetting;
|
||||
export default DashboardSetting;
|
||||
|
||||
@@ -81,4 +81,4 @@ const DrawingSetting = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default DrawingSetting;
|
||||
export default DrawingSetting;
|
||||
|
||||
@@ -56,7 +56,11 @@ const PaymentSetting = () => {
|
||||
switch (item.key) {
|
||||
case 'TopupGroupRatio':
|
||||
try {
|
||||
newInputs[item.key] = JSON.stringify(JSON.parse(item.value), null, 2);
|
||||
newInputs[item.key] = JSON.stringify(
|
||||
JSON.parse(item.value),
|
||||
null,
|
||||
2,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('解析TopupGroupRatio出错:', error);
|
||||
newInputs[item.key] = item.value;
|
||||
@@ -116,4 +120,4 @@ const PaymentSetting = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default PaymentSetting;
|
||||
export default PaymentSetting;
|
||||
|
||||
@@ -19,13 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
API,
|
||||
copy,
|
||||
showError,
|
||||
showInfo,
|
||||
showSuccess
|
||||
} from '../../helpers';
|
||||
import { API, copy, showError, showInfo, showSuccess } from '../../helpers';
|
||||
import { UserContext } from '../../context/User';
|
||||
import { Modal } from '@douyinfe/semi-ui';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -271,7 +265,11 @@ const PersonalSetting = () => {
|
||||
const handleNotificationSettingChange = (type, value) => {
|
||||
setNotificationSettings((prev) => ({
|
||||
...prev,
|
||||
[type]: value.target ? value.target.value !== undefined ? value.target.value : value.target.checked : value, // handle checkbox properly
|
||||
[type]: value.target
|
||||
? value.target.value !== undefined
|
||||
? value.target.value
|
||||
: value.target.checked
|
||||
: value, // handle checkbox properly
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -302,14 +300,14 @@ const PersonalSetting = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-[60px]">
|
||||
<div className="flex justify-center">
|
||||
<div className="w-full max-w-7xl mx-auto px-2">
|
||||
<div className='mt-[60px]'>
|
||||
<div className='flex justify-center'>
|
||||
<div className='w-full max-w-7xl mx-auto px-2'>
|
||||
{/* 顶部用户信息区域 */}
|
||||
<UserInfoHeader t={t} userState={userState} />
|
||||
|
||||
{/* 账户管理和其他设置 */}
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 items-start gap-4 md:gap-6 mt-4 md:mt-6">
|
||||
<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}
|
||||
|
||||
@@ -103,34 +103,19 @@ const RatioSetting = () => {
|
||||
<Card style={{ marginTop: '10px' }}>
|
||||
<Tabs type='card'>
|
||||
<Tabs.TabPane tab={t('模型倍率设置')} itemKey='model'>
|
||||
<ModelRatioSettings
|
||||
options={inputs}
|
||||
refresh={onRefresh}
|
||||
/>
|
||||
<ModelRatioSettings options={inputs} refresh={onRefresh} />
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={t('分组倍率设置')} itemKey='group'>
|
||||
<GroupRatioSettings
|
||||
options={inputs}
|
||||
refresh={onRefresh}
|
||||
/>
|
||||
<GroupRatioSettings options={inputs} refresh={onRefresh} />
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={t('可视化倍率设置')} itemKey='visual'>
|
||||
<ModelSettingsVisualEditor
|
||||
options={inputs}
|
||||
refresh={onRefresh}
|
||||
/>
|
||||
<ModelSettingsVisualEditor options={inputs} refresh={onRefresh} />
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={t('未设置倍率模型')} itemKey='unset_models'>
|
||||
<ModelRatioNotSetEditor
|
||||
options={inputs}
|
||||
refresh={onRefresh}
|
||||
/>
|
||||
<ModelRatioNotSetEditor options={inputs} refresh={onRefresh} />
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={t('上游倍率同步')} itemKey='upstream_sync'>
|
||||
<UpstreamRatioSync
|
||||
options={inputs}
|
||||
refresh={onRefresh}
|
||||
/>
|
||||
<UpstreamRatioSync options={inputs} refresh={onRefresh} />
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
</Card>
|
||||
@@ -138,4 +123,4 @@ const RatioSetting = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default RatioSetting;
|
||||
export default RatioSetting;
|
||||
|
||||
@@ -473,7 +473,10 @@ const SystemSetting = () => {
|
||||
value: inputs.LinuxDOClientSecret,
|
||||
});
|
||||
}
|
||||
if (originInputs['LinuxDOMinimumTrustLevel'] !== inputs.LinuxDOMinimumTrustLevel) {
|
||||
if (
|
||||
originInputs['LinuxDOMinimumTrustLevel'] !==
|
||||
inputs.LinuxDOMinimumTrustLevel
|
||||
) {
|
||||
options.push({
|
||||
key: 'LinuxDOMinimumTrustLevel',
|
||||
value: inputs.LinuxDOMinimumTrustLevel,
|
||||
@@ -530,11 +533,15 @@ const SystemSetting = () => {
|
||||
field='ServerAddress'
|
||||
label={t('服务器地址')}
|
||||
placeholder='https://yourdomain.com'
|
||||
extraText={t('该服务器地址将影响支付回调地址以及默认首页展示的地址,请确保正确配置')}
|
||||
extraText={t(
|
||||
'该服务器地址将影响支付回调地址以及默认首页展示的地址,请确保正确配置',
|
||||
)}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Button onClick={submitServerAddress}>{t('更新服务器地址')}</Button>
|
||||
<Button onClick={submitServerAddress}>
|
||||
{t('更新服务器地址')}
|
||||
</Button>
|
||||
</Form.Section>
|
||||
</Card>
|
||||
|
||||
@@ -755,7 +762,10 @@ const SystemSetting = () => {
|
||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||
>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.Input field='SMTPServer' label={t('SMTP 服务器地址')} />
|
||||
<Form.Input
|
||||
field='SMTPServer'
|
||||
label={t('SMTP 服务器地址')}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.Input field='SMTPPort' label={t('SMTP 端口')} />
|
||||
@@ -769,7 +779,10 @@ const SystemSetting = () => {
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.Input field='SMTPFrom' label={t('SMTP 发送者邮箱')} />
|
||||
<Form.Input
|
||||
field='SMTPFrom'
|
||||
label={t('SMTP 发送者邮箱')}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
@@ -797,7 +810,9 @@ const SystemSetting = () => {
|
||||
<Card>
|
||||
<Form.Section text={t('配置 OIDC')}>
|
||||
<Text>
|
||||
{t('用以支持通过 OIDC 登录,例如 Okta、Auth0 等兼容 OIDC 协议的 IdP')}
|
||||
{t(
|
||||
'用以支持通过 OIDC 登录,例如 Okta、Auth0 等兼容 OIDC 协议的 IdP',
|
||||
)}
|
||||
</Text>
|
||||
<Banner
|
||||
type='info'
|
||||
@@ -805,7 +820,9 @@ const SystemSetting = () => {
|
||||
style={{ marginBottom: 20, marginTop: 16 }}
|
||||
/>
|
||||
<Text>
|
||||
{t('若你的 OIDC Provider 支持 Discovery Endpoint,你可以仅填写 OIDC Well-Known URL,系统会自动获取 OIDC 配置')}
|
||||
{t(
|
||||
'若你的 OIDC Provider 支持 Discovery Endpoint,你可以仅填写 OIDC Well-Known URL,系统会自动获取 OIDC 配置',
|
||||
)}
|
||||
</Text>
|
||||
<Row
|
||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||
@@ -862,7 +879,9 @@ const SystemSetting = () => {
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Button onClick={submitOIDCSettings}>{t('保存 OIDC 设置')}</Button>
|
||||
<Button onClick={submitOIDCSettings}>
|
||||
{t('保存 OIDC 设置')}
|
||||
</Button>
|
||||
</Form.Section>
|
||||
</Card>
|
||||
|
||||
@@ -1033,7 +1052,9 @@ const SystemSetting = () => {
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Button onClick={submitTurnstile}>{t('保存 Turnstile 设置')}</Button>
|
||||
<Button onClick={submitTurnstile}>
|
||||
{t('保存 Turnstile 设置')}
|
||||
</Button>
|
||||
</Form.Section>
|
||||
</Card>
|
||||
|
||||
@@ -1048,7 +1069,11 @@ const SystemSetting = () => {
|
||||
okText={t('确认')}
|
||||
cancelText={t('取消')}
|
||||
>
|
||||
<p>{t('您确定要取消密码登录功能吗?这可能会影响用户的登录方式。')}</p>
|
||||
<p>
|
||||
{t(
|
||||
'您确定要取消密码登录功能吗?这可能会影响用户的登录方式。',
|
||||
)}
|
||||
</p>
|
||||
</Modal>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
Avatar,
|
||||
Tabs,
|
||||
TabPane,
|
||||
Popover
|
||||
Popover,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconMail,
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
IconGithubLogo,
|
||||
IconKey,
|
||||
IconLock,
|
||||
IconDelete
|
||||
IconDelete,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { SiTelegram, SiWechat, SiLinux } from 'react-icons/si';
|
||||
import { UserPlus, ShieldCheck } from 'lucide-react';
|
||||
@@ -43,7 +43,7 @@ import TelegramLoginButton from 'react-telegram-login';
|
||||
import {
|
||||
onGitHubOAuthClicked,
|
||||
onOIDCClicked,
|
||||
onLinuxDOOAuthClicked
|
||||
onLinuxDOOAuthClicked,
|
||||
} from '../../../../helpers';
|
||||
import TwoFASetting from '../components/TwoFASetting';
|
||||
|
||||
@@ -57,77 +57,89 @@ const AccountManagement = ({
|
||||
generateAccessToken,
|
||||
handleSystemTokenClick,
|
||||
setShowChangePasswordModal,
|
||||
setShowAccountDeleteModal
|
||||
setShowAccountDeleteModal,
|
||||
}) => {
|
||||
const renderAccountInfo = (accountId, label) => {
|
||||
if (!accountId || accountId === '') {
|
||||
return <span className="text-gray-500">{t('未绑定')}</span>;
|
||||
return <span className='text-gray-500'>{t('未绑定')}</span>;
|
||||
}
|
||||
|
||||
const popContent = (
|
||||
<div className="text-xs p-2">
|
||||
<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>
|
||||
<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">
|
||||
<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>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<Card className="!rounded-2xl">
|
||||
<Card className='!rounded-2xl'>
|
||||
{/* 卡片头部 */}
|
||||
<div className="flex items-center mb-4">
|
||||
<Avatar size="small" color="teal" className="mr-3 shadow-md">
|
||||
<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>
|
||||
<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">
|
||||
<Tabs type='card' defaultActiveKey='binding'>
|
||||
{/* 账户绑定 Tab */}
|
||||
<TabPane
|
||||
tab={
|
||||
<div className="flex items-center">
|
||||
<UserPlus size={16} className="mr-2" />
|
||||
<div className='flex items-center'>
|
||||
<UserPlus size={16} className='mr-2' />
|
||||
{t('账户绑定')}
|
||||
</div>
|
||||
}
|
||||
itemKey="binding"
|
||||
itemKey='binding'
|
||||
>
|
||||
<div className="py-4">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<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" />
|
||||
<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 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">
|
||||
<div className='flex-shrink-0'>
|
||||
<Button
|
||||
type="primary"
|
||||
theme="outline"
|
||||
size="small"
|
||||
type='primary'
|
||||
theme='outline'
|
||||
size='small'
|
||||
onClick={() => setShowEmailBindModal(true)}
|
||||
>
|
||||
{userState.user && userState.user.email !== ''
|
||||
@@ -139,26 +151,31 @@ const AccountManagement = ({
|
||||
</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" />
|
||||
<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">
|
||||
<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>
|
||||
<div className="flex-shrink-0">
|
||||
<div className='flex-shrink-0'>
|
||||
<Button
|
||||
type="primary"
|
||||
theme="outline"
|
||||
size="small"
|
||||
type='primary'
|
||||
theme='outline'
|
||||
size='small'
|
||||
disabled={!status.wechat_login}
|
||||
onClick={() => setShowWeChatBindModal(true)}
|
||||
>
|
||||
@@ -173,25 +190,35 @@ const AccountManagement = ({
|
||||
</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" />
|
||||
<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 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">
|
||||
<div className='flex-shrink-0'>
|
||||
<Button
|
||||
type="primary"
|
||||
theme="outline"
|
||||
size="small"
|
||||
onClick={() => onGitHubOAuthClicked(status.github_client_id)}
|
||||
type='primary'
|
||||
theme='outline'
|
||||
size='small'
|
||||
onClick={() =>
|
||||
onGitHubOAuthClicked(status.github_client_id)
|
||||
}
|
||||
disabled={
|
||||
(userState.user && userState.user.github_id !== '') ||
|
||||
!status.github_oauth
|
||||
@@ -204,28 +231,38 @@ const AccountManagement = ({
|
||||
</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" />
|
||||
<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 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">
|
||||
<div className='flex-shrink-0'>
|
||||
<Button
|
||||
type="primary"
|
||||
theme="outline"
|
||||
size="small"
|
||||
onClick={() => onOIDCClicked(
|
||||
status.oidc_authorization_endpoint,
|
||||
status.oidc_client_id,
|
||||
)}
|
||||
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
|
||||
@@ -238,27 +275,35 @@ const AccountManagement = ({
|
||||
</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" />
|
||||
<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 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">
|
||||
<div className='flex-shrink-0'>
|
||||
{status.telegram_oauth ? (
|
||||
userState.user.telegram_id !== '' ? (
|
||||
<Button disabled={true} size="small">
|
||||
<Button disabled={true} size='small'>
|
||||
{t('已绑定')}
|
||||
</Button>
|
||||
) : (
|
||||
<div className="scale-75">
|
||||
<div className='scale-75'>
|
||||
<TelegramLoginButton
|
||||
dataAuthUrl='/api/oauth/telegram/bind'
|
||||
botName={status.telegram_bot_name}
|
||||
@@ -266,7 +311,7 @@ const AccountManagement = ({
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<Button disabled={true} size="small">
|
||||
<Button disabled={true} size='small'>
|
||||
{t('未启用')}
|
||||
</Button>
|
||||
)}
|
||||
@@ -275,25 +320,35 @@ const AccountManagement = ({
|
||||
</Card>
|
||||
|
||||
{/* 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" />
|
||||
<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 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">
|
||||
<div className='flex-shrink-0'>
|
||||
<Button
|
||||
type="primary"
|
||||
theme="outline"
|
||||
size="small"
|
||||
onClick={() => onLinuxDOOAuthClicked(status.linuxdo_client_id)}
|
||||
type='primary'
|
||||
theme='outline'
|
||||
size='small'
|
||||
onClick={() =>
|
||||
onLinuxDOOAuthClicked(status.linuxdo_client_id)
|
||||
}
|
||||
disabled={
|
||||
(userState.user && userState.user.linux_do_id !== '') ||
|
||||
!status.linuxdo_oauth
|
||||
@@ -311,37 +366,37 @@ const AccountManagement = ({
|
||||
{/* 安全设置 Tab */}
|
||||
<TabPane
|
||||
tab={
|
||||
<div className="flex items-center">
|
||||
<ShieldCheck size={16} className="mr-2" />
|
||||
<div className='flex items-center'>
|
||||
<ShieldCheck size={16} className='mr-2' />
|
||||
{t('安全设置')}
|
||||
</div>
|
||||
}
|
||||
itemKey="security"
|
||||
itemKey='security'
|
||||
>
|
||||
<div className="py-4">
|
||||
<div className="space-y-6">
|
||||
<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" />
|
||||
<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">
|
||||
<div className='flex-1'>
|
||||
<Typography.Title heading={6} className='mb-1'>
|
||||
{t('系统访问令牌')}
|
||||
</Typography.Title>
|
||||
<Typography.Text type="tertiary" className="text-sm">
|
||||
<Typography.Text type='tertiary' className='text-sm'>
|
||||
{t('用于API调用的身份验证令牌,请妥善保管')}
|
||||
</Typography.Text>
|
||||
{systemToken && (
|
||||
<div className="mt-3">
|
||||
<div className='mt-3'>
|
||||
<Input
|
||||
readonly
|
||||
value={systemToken}
|
||||
onClick={handleSystemTokenClick}
|
||||
size="large"
|
||||
size='large'
|
||||
prefix={<IconKey />}
|
||||
/>
|
||||
</div>
|
||||
@@ -349,10 +404,10 @@ const AccountManagement = ({
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
theme="solid"
|
||||
type='primary'
|
||||
theme='solid'
|
||||
onClick={generateAccessToken}
|
||||
className="!bg-slate-600 hover:!bg-slate-700 w-full sm:w-auto"
|
||||
className='!bg-slate-600 hover:!bg-slate-700 w-full sm:w-auto'
|
||||
icon={<IconKey />}
|
||||
>
|
||||
{systemToken ? t('重新生成') : t('生成令牌')}
|
||||
@@ -361,26 +416,26 @@ const AccountManagement = ({
|
||||
</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" />
|
||||
<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">
|
||||
<Typography.Title heading={6} className='mb-1'>
|
||||
{t('密码管理')}
|
||||
</Typography.Title>
|
||||
<Typography.Text type="tertiary" className="text-sm">
|
||||
<Typography.Text type='tertiary' className='text-sm'>
|
||||
{t('定期更改密码可以提高账户安全性')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
theme="solid"
|
||||
type='primary'
|
||||
theme='solid'
|
||||
onClick={() => setShowChangePasswordModal(true)}
|
||||
className="!bg-slate-600 hover:!bg-slate-700 w-full sm:w-auto"
|
||||
className='!bg-slate-600 hover:!bg-slate-700 w-full sm:w-auto'
|
||||
icon={<IconLock />}
|
||||
>
|
||||
{t('修改密码')}
|
||||
@@ -392,26 +447,29 @@ const AccountManagement = ({
|
||||
<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" />
|
||||
<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">
|
||||
<Typography.Title
|
||||
heading={6}
|
||||
className='mb-1 text-slate-700'
|
||||
>
|
||||
{t('删除账户')}
|
||||
</Typography.Title>
|
||||
<Typography.Text type="tertiary" className="text-sm">
|
||||
<Typography.Text type='tertiary' className='text-sm'>
|
||||
{t('此操作不可逆,所有数据将被永久删除')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="danger"
|
||||
theme="solid"
|
||||
type='danger'
|
||||
theme='solid'
|
||||
onClick={() => setShowAccountDeleteModal(true)}
|
||||
className="w-full sm:w-auto !bg-slate-500 hover:!bg-slate-600"
|
||||
className='w-full sm:w-auto !bg-slate-500 hover:!bg-slate-600'
|
||||
icon={<IconDelete />}
|
||||
>
|
||||
{t('删除账户')}
|
||||
|
||||
@@ -27,13 +27,13 @@ import {
|
||||
Tabs,
|
||||
TabPane,
|
||||
Typography,
|
||||
Avatar
|
||||
Avatar,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { IllustrationNoContent, IllustrationNoContentDark } from '@douyinfe/semi-illustrations';
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconChevronUp
|
||||
} from '@douyinfe/semi-icons';
|
||||
IllustrationNoContent,
|
||||
IllustrationNoContentDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import { IconChevronDown, IconChevronUp } from '@douyinfe/semi-icons';
|
||||
import { Settings } from 'lucide-react';
|
||||
import { renderModelTag, getModelCategories } from '../../../../helpers';
|
||||
|
||||
@@ -52,38 +52,48 @@ const ModelsList = ({ t, models, modelsLoading, copyText }) => {
|
||||
}, [isModelsExpanded]);
|
||||
|
||||
return (
|
||||
<div className="py-4">
|
||||
<div className='py-4'>
|
||||
{/* 卡片头部 */}
|
||||
<div className="flex items-center mb-4">
|
||||
<Avatar size="small" color="green" className="mr-3 shadow-md">
|
||||
<div className='flex items-center mb-4'>
|
||||
<Avatar size='small' color='green' className='mr-3 shadow-md'>
|
||||
<Settings size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Typography.Text className="text-lg font-medium">{t('可用模型')}</Typography.Text>
|
||||
<div className="text-xs text-gray-600">{t('查看当前可用的所有模型')}</div>
|
||||
<Typography.Text className='text-lg font-medium'>
|
||||
{t('可用模型')}
|
||||
</Typography.Text>
|
||||
<div className='text-xs text-gray-600'>
|
||||
{t('查看当前可用的所有模型')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 可用模型部分 */}
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-xl">
|
||||
<div className='bg-gray-50 dark:bg-gray-800 rounded-xl'>
|
||||
{modelsLoading ? (
|
||||
// 骨架屏加载状态 - 模拟实际加载后的布局
|
||||
<div className="space-y-4">
|
||||
<div className='space-y-4'>
|
||||
{/* 模拟分类标签 */}
|
||||
<div className="mb-4" style={{ borderBottom: '1px solid var(--semi-color-border)' }}>
|
||||
<div className="flex overflow-x-auto py-2 gap-2">
|
||||
<div
|
||||
className='mb-4'
|
||||
style={{ borderBottom: '1px solid var(--semi-color-border)' }}
|
||||
>
|
||||
<div className='flex overflow-x-auto py-2 gap-2'>
|
||||
{Array.from({ length: 8 }).map((_, index) => (
|
||||
<Skeleton.Button key={`cat-${index}`} style={{
|
||||
width: index === 0 ? 130 : 100 + Math.random() * 50,
|
||||
height: 36,
|
||||
borderRadius: 8
|
||||
}} />
|
||||
<Skeleton.Button
|
||||
key={`cat-${index}`}
|
||||
style={{
|
||||
width: index === 0 ? 130 : 100 + Math.random() * 50,
|
||||
height: 36,
|
||||
borderRadius: 8,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 模拟模型标签列表 */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{Array.from({ length: 20 }).map((_, index) => (
|
||||
<Skeleton.Button
|
||||
key={`model-${index}`}
|
||||
@@ -91,17 +101,23 @@ const ModelsList = ({ t, models, modelsLoading, copyText }) => {
|
||||
width: 100 + Math.random() * 100,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
margin: '4px'
|
||||
margin: '4px',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : models.length === 0 ? (
|
||||
<div className="py-8">
|
||||
<div className='py-8'>
|
||||
<Empty
|
||||
image={<IllustrationNoContent style={{ width: 150, height: 150 }} />}
|
||||
darkModeImage={<IllustrationNoContentDark style={{ width: 150, height: 150 }} />}
|
||||
image={
|
||||
<IllustrationNoContent style={{ width: 150, height: 150 }} />
|
||||
}
|
||||
darkModeImage={
|
||||
<IllustrationNoContentDark
|
||||
style={{ width: 150, height: 150 }}
|
||||
/>
|
||||
}
|
||||
description={t('没有可用模型')}
|
||||
style={{ padding: '24px 0' }}
|
||||
/>
|
||||
@@ -109,59 +125,81 @@ const ModelsList = ({ t, models, modelsLoading, copyText }) => {
|
||||
) : (
|
||||
<>
|
||||
{/* 模型分类标签页 */}
|
||||
<div className="mb-4">
|
||||
<div className='mb-4'>
|
||||
<Tabs
|
||||
type="card"
|
||||
type='card'
|
||||
activeKey={activeModelCategory}
|
||||
onChange={key => setActiveModelCategory(key)}
|
||||
className="mt-2"
|
||||
onChange={(key) => setActiveModelCategory(key)}
|
||||
className='mt-2'
|
||||
collapsible
|
||||
>
|
||||
{Object.entries(getModelCategories(t)).map(([key, category]) => {
|
||||
// 计算该分类下的模型数量
|
||||
const modelCount = key === 'all'
|
||||
? models.length
|
||||
: models.filter(model => category.filter({ model_name: model })).length;
|
||||
{Object.entries(getModelCategories(t)).map(
|
||||
([key, category]) => {
|
||||
// 计算该分类下的模型数量
|
||||
const modelCount =
|
||||
key === 'all'
|
||||
? models.length
|
||||
: models.filter((model) =>
|
||||
category.filter({ model_name: model }),
|
||||
).length;
|
||||
|
||||
if (modelCount === 0 && key !== 'all') return null;
|
||||
if (modelCount === 0 && key !== 'all') return null;
|
||||
|
||||
return (
|
||||
<TabPane
|
||||
tab={
|
||||
<span className="flex items-center gap-2">
|
||||
{category.icon && <span className="w-4 h-4">{category.icon}</span>}
|
||||
{category.label}
|
||||
<Tag
|
||||
color={activeModelCategory === key ? 'red' : 'grey'}
|
||||
size='small'
|
||||
shape='circle'
|
||||
>
|
||||
{modelCount}
|
||||
</Tag>
|
||||
</span>
|
||||
}
|
||||
itemKey={key}
|
||||
key={key}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
return (
|
||||
<TabPane
|
||||
tab={
|
||||
<span className='flex items-center gap-2'>
|
||||
{category.icon && (
|
||||
<span className='w-4 h-4'>{category.icon}</span>
|
||||
)}
|
||||
{category.label}
|
||||
<Tag
|
||||
color={
|
||||
activeModelCategory === key ? 'red' : 'grey'
|
||||
}
|
||||
size='small'
|
||||
shape='circle'
|
||||
>
|
||||
{modelCount}
|
||||
</Tag>
|
||||
</span>
|
||||
}
|
||||
itemKey={key}
|
||||
key={key}
|
||||
/>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-700 rounded-lg p-3">
|
||||
<div className='bg-white dark:bg-gray-700 rounded-lg p-3'>
|
||||
{(() => {
|
||||
// 根据当前选中的分类过滤模型
|
||||
const categories = getModelCategories(t);
|
||||
const filteredModels = activeModelCategory === 'all'
|
||||
? models
|
||||
: models.filter(model => categories[activeModelCategory].filter({ model_name: model }));
|
||||
const filteredModels =
|
||||
activeModelCategory === 'all'
|
||||
? models
|
||||
: models.filter((model) =>
|
||||
categories[activeModelCategory].filter({
|
||||
model_name: model,
|
||||
}),
|
||||
);
|
||||
|
||||
// 如果过滤后没有模型,显示空状态
|
||||
if (filteredModels.length === 0) {
|
||||
return (
|
||||
<Empty
|
||||
image={<IllustrationNoContent style={{ width: 120, height: 120 }} />}
|
||||
darkModeImage={<IllustrationNoContentDark style={{ width: 120, height: 120 }} />}
|
||||
image={
|
||||
<IllustrationNoContent
|
||||
style={{ width: 120, height: 120 }}
|
||||
/>
|
||||
}
|
||||
darkModeImage={
|
||||
<IllustrationNoContentDark
|
||||
style={{ width: 120, height: 120 }}
|
||||
/>
|
||||
}
|
||||
description={t('该分类下没有可用模型')}
|
||||
style={{ padding: '16px 0' }}
|
||||
/>
|
||||
@@ -171,13 +209,13 @@ const ModelsList = ({ t, models, modelsLoading, copyText }) => {
|
||||
if (filteredModels.length <= MODELS_DISPLAY_COUNT) {
|
||||
return (
|
||||
<Space wrap>
|
||||
{filteredModels.map((model) => (
|
||||
{filteredModels.map((model) =>
|
||||
renderModelTag(model, {
|
||||
size: 'small',
|
||||
shape: 'circle',
|
||||
onClick: () => copyText(model),
|
||||
})
|
||||
))}
|
||||
}),
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
} else {
|
||||
@@ -185,17 +223,17 @@ const ModelsList = ({ t, models, modelsLoading, copyText }) => {
|
||||
<>
|
||||
<Collapsible isOpen={isModelsExpanded}>
|
||||
<Space wrap>
|
||||
{filteredModels.map((model) => (
|
||||
{filteredModels.map((model) =>
|
||||
renderModelTag(model, {
|
||||
size: 'small',
|
||||
shape: 'circle',
|
||||
onClick: () => copyText(model),
|
||||
})
|
||||
))}
|
||||
}),
|
||||
)}
|
||||
<Tag
|
||||
color='grey'
|
||||
type='light'
|
||||
className="cursor-pointer !rounded-lg"
|
||||
className='cursor-pointer !rounded-lg'
|
||||
onClick={() => setIsModelsExpanded(false)}
|
||||
icon={<IconChevronUp />}
|
||||
>
|
||||
@@ -207,21 +245,23 @@ const ModelsList = ({ t, models, modelsLoading, copyText }) => {
|
||||
<Space wrap>
|
||||
{filteredModels
|
||||
.slice(0, MODELS_DISPLAY_COUNT)
|
||||
.map((model) => (
|
||||
.map((model) =>
|
||||
renderModelTag(model, {
|
||||
size: 'small',
|
||||
shape: 'circle',
|
||||
onClick: () => copyText(model),
|
||||
})
|
||||
))}
|
||||
}),
|
||||
)}
|
||||
<Tag
|
||||
color='grey'
|
||||
type='light'
|
||||
className="cursor-pointer !rounded-lg"
|
||||
className='cursor-pointer !rounded-lg'
|
||||
onClick={() => setIsModelsExpanded(true)}
|
||||
icon={<IconChevronDown />}
|
||||
>
|
||||
{t('更多')} {filteredModels.length - MODELS_DISPLAY_COUNT} {t('个模型')}
|
||||
{t('更多')}{' '}
|
||||
{filteredModels.length - MODELS_DISPLAY_COUNT}{' '}
|
||||
{t('个模型')}
|
||||
</Tag>
|
||||
</Space>
|
||||
)}
|
||||
|
||||
@@ -27,14 +27,9 @@ import {
|
||||
Radio,
|
||||
Toast,
|
||||
Tabs,
|
||||
TabPane
|
||||
TabPane,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconMail,
|
||||
IconKey,
|
||||
IconBell,
|
||||
IconLink
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { IconMail, IconKey, IconBell, IconLink } from '@douyinfe/semi-icons';
|
||||
import { ShieldCheck, Bell, DollarSign } from 'lucide-react';
|
||||
import { renderQuotaWithPrompt } from '../../../../helpers';
|
||||
import CodeViewer from '../../../playground/CodeViewer';
|
||||
@@ -43,7 +38,7 @@ const NotificationSettings = ({
|
||||
t,
|
||||
notificationSettings,
|
||||
handleNotificationSettingChange,
|
||||
saveNotificationSettings
|
||||
saveNotificationSettings,
|
||||
}) => {
|
||||
const formApiRef = useRef(null);
|
||||
|
||||
@@ -62,7 +57,8 @@ const NotificationSettings = ({
|
||||
// 表单提交
|
||||
const handleSubmit = () => {
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.validate()
|
||||
formApiRef.current
|
||||
.validate()
|
||||
.then(() => {
|
||||
saveNotificationSettings();
|
||||
})
|
||||
@@ -77,26 +73,27 @@ const NotificationSettings = ({
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="!rounded-2xl shadow-sm border-0"
|
||||
className='!rounded-2xl shadow-sm border-0'
|
||||
footer={
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type='primary'
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
<div className='flex justify-end'>
|
||||
<Button type='primary' onClick={handleSubmit}>
|
||||
{t('保存设置')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* 卡片头部 */}
|
||||
<div className="flex items-center mb-4">
|
||||
<Avatar size="small" color="blue" className="mr-3 shadow-md">
|
||||
<div className='flex items-center mb-4'>
|
||||
<Avatar size='small' color='blue' className='mr-3 shadow-md'>
|
||||
<Bell size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Typography.Text className="text-lg font-medium">{t('其他设置')}</Typography.Text>
|
||||
<div className="text-xs text-gray-600">{t('通知、价格和隐私相关设置')}</div>
|
||||
<Typography.Text className='text-lg font-medium'>
|
||||
{t('其他设置')}
|
||||
</Typography.Text>
|
||||
<div className='text-xs text-gray-600'>
|
||||
{t('通知、价格和隐私相关设置')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -106,18 +103,18 @@ const NotificationSettings = ({
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
{() => (
|
||||
<Tabs type="card" defaultActiveKey="notification">
|
||||
<Tabs type='card' defaultActiveKey='notification'>
|
||||
{/* 通知配置 Tab */}
|
||||
<TabPane
|
||||
tab={
|
||||
<div className="flex items-center">
|
||||
<Bell size={16} className="mr-2" />
|
||||
<div className='flex items-center'>
|
||||
<Bell size={16} className='mr-2' />
|
||||
{t('通知配置')}
|
||||
</div>
|
||||
}
|
||||
itemKey="notification"
|
||||
itemKey='notification'
|
||||
>
|
||||
<div className="py-4">
|
||||
<div className='py-4'>
|
||||
<Form.RadioGroup
|
||||
field='warningType'
|
||||
label={t('通知方式')}
|
||||
@@ -125,15 +122,18 @@ const NotificationSettings = ({
|
||||
onChange={(value) => handleFormChange('warningType', value)}
|
||||
rules={[{ required: true, message: t('请选择通知方式') }]}
|
||||
>
|
||||
<Radio value="email">{t('邮件通知')}</Radio>
|
||||
<Radio value="webhook">{t('Webhook通知')}</Radio>
|
||||
<Radio value='email'>{t('邮件通知')}</Radio>
|
||||
<Radio value='webhook'>{t('Webhook通知')}</Radio>
|
||||
</Form.RadioGroup>
|
||||
|
||||
<Form.AutoComplete
|
||||
field='warningThreshold'
|
||||
label={
|
||||
<span>
|
||||
{t('额度预警阈值')} {renderQuotaWithPrompt(notificationSettings.warningThreshold)}
|
||||
{t('额度预警阈值')}{' '}
|
||||
{renderQuotaWithPrompt(
|
||||
notificationSettings.warningThreshold,
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
placeholder={t('请输入预警额度')}
|
||||
@@ -145,7 +145,9 @@ const NotificationSettings = ({
|
||||
]}
|
||||
onChange={(val) => handleFormChange('warningThreshold', val)}
|
||||
prefix={<IconBell />}
|
||||
extraText={t('当剩余额度低于此数值时,系统将通过选择的方式发送通知')}
|
||||
extraText={t(
|
||||
'当剩余额度低于此数值时,系统将通过选择的方式发送通知',
|
||||
)}
|
||||
style={{ width: '100%', maxWidth: '300px' }}
|
||||
rules={[
|
||||
{ required: true, message: t('请输入预警阈值') },
|
||||
@@ -156,8 +158,8 @@ const NotificationSettings = ({
|
||||
return Promise.reject(t('预警阈值必须为正数'));
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -167,9 +169,13 @@ const NotificationSettings = ({
|
||||
field='notificationEmail'
|
||||
label={t('通知邮箱')}
|
||||
placeholder={t('留空则使用账号绑定的邮箱')}
|
||||
onChange={(val) => handleFormChange('notificationEmail', val)}
|
||||
onChange={(val) =>
|
||||
handleFormChange('notificationEmail', val)
|
||||
}
|
||||
prefix={<IconMail />}
|
||||
extraText={t('设置用于接收额度预警的邮箱地址,不填则使用账号绑定的邮箱')}
|
||||
extraText={t(
|
||||
'设置用于接收额度预警的邮箱地址,不填则使用账号绑定的邮箱',
|
||||
)}
|
||||
showClear
|
||||
/>
|
||||
)}
|
||||
@@ -180,20 +186,25 @@ const NotificationSettings = ({
|
||||
<Form.Input
|
||||
field='webhookUrl'
|
||||
label={t('Webhook地址')}
|
||||
placeholder={t('请输入Webhook地址,例如: https://example.com/webhook')}
|
||||
placeholder={t(
|
||||
'请输入Webhook地址,例如: https://example.com/webhook',
|
||||
)}
|
||||
onChange={(val) => handleFormChange('webhookUrl', val)}
|
||||
prefix={<IconLink />}
|
||||
extraText={t('只支持HTTPS,系统将以POST方式发送通知,请确保地址可以接收POST请求')}
|
||||
extraText={t(
|
||||
'只支持HTTPS,系统将以POST方式发送通知,请确保地址可以接收POST请求',
|
||||
)}
|
||||
showClear
|
||||
rules={[
|
||||
{
|
||||
required: notificationSettings.warningType === 'webhook',
|
||||
message: t('请输入Webhook地址')
|
||||
required:
|
||||
notificationSettings.warningType === 'webhook',
|
||||
message: t('请输入Webhook地址'),
|
||||
},
|
||||
{
|
||||
pattern: /^https:\/\/.+/,
|
||||
message: t('Webhook地址必须以https://开头')
|
||||
}
|
||||
message: t('Webhook地址必须以https://开头'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -203,7 +214,9 @@ const NotificationSettings = ({
|
||||
placeholder={t('请输入密钥')}
|
||||
onChange={(val) => handleFormChange('webhookSecret', val)}
|
||||
prefix={<IconKey />}
|
||||
extraText={t('密钥将以Bearer方式添加到请求头中,用于验证webhook请求的合法性')}
|
||||
extraText={t(
|
||||
'密钥将以Bearer方式添加到请求头中,用于验证webhook请求的合法性',
|
||||
)}
|
||||
showClear
|
||||
/>
|
||||
|
||||
@@ -212,22 +225,36 @@ const NotificationSettings = ({
|
||||
<div style={{ height: '200px', marginBottom: '12px' }}>
|
||||
<CodeViewer
|
||||
content={{
|
||||
"type": "quota_exceed",
|
||||
"title": "额度预警通知",
|
||||
"content": "您的额度即将用尽,当前剩余额度为 {{value}}",
|
||||
"values": ["$0.99"],
|
||||
"timestamp": 1739950503
|
||||
type: 'quota_exceed',
|
||||
title: '额度预警通知',
|
||||
content:
|
||||
'您的额度即将用尽,当前剩余额度为 {{value}}',
|
||||
values: ['$0.99'],
|
||||
timestamp: 1739950503,
|
||||
}}
|
||||
title="webhook"
|
||||
language="json"
|
||||
title='webhook'
|
||||
language='json'
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 leading-relaxed">
|
||||
<div><strong>type:</strong> {t('通知类型 (quota_exceed: 额度预警)')} </div>
|
||||
<div><strong>title:</strong> {t('通知标题')}</div>
|
||||
<div><strong>content:</strong> {t('通知内容,支持 {{value}} 变量占位符')}</div>
|
||||
<div><strong>values:</strong> {t('按顺序替换content中的变量占位符')}</div>
|
||||
<div><strong>timestamp:</strong> {t('Unix时间戳')}</div>
|
||||
<div className='text-xs text-gray-500 leading-relaxed'>
|
||||
<div>
|
||||
<strong>type:</strong>{' '}
|
||||
{t('通知类型 (quota_exceed: 额度预警)')}{' '}
|
||||
</div>
|
||||
<div>
|
||||
<strong>title:</strong> {t('通知标题')}
|
||||
</div>
|
||||
<div>
|
||||
<strong>content:</strong>{' '}
|
||||
{t('通知内容,支持 {{value}} 变量占位符')}
|
||||
</div>
|
||||
<div>
|
||||
<strong>values:</strong>{' '}
|
||||
{t('按顺序替换content中的变量占位符')}
|
||||
</div>
|
||||
<div>
|
||||
<strong>timestamp:</strong> {t('Unix时间戳')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Form.Slot>
|
||||
@@ -239,21 +266,25 @@ const NotificationSettings = ({
|
||||
{/* 价格设置 Tab */}
|
||||
<TabPane
|
||||
tab={
|
||||
<div className="flex items-center">
|
||||
<DollarSign size={16} className="mr-2" />
|
||||
<div className='flex items-center'>
|
||||
<DollarSign size={16} className='mr-2' />
|
||||
{t('价格设置')}
|
||||
</div>
|
||||
}
|
||||
itemKey="pricing"
|
||||
itemKey='pricing'
|
||||
>
|
||||
<div className="py-4">
|
||||
<div className='py-4'>
|
||||
<Form.Switch
|
||||
field='acceptUnsetModelRatioModel'
|
||||
label={t('接受未设置价格模型')}
|
||||
checkedText={t('开')}
|
||||
uncheckedText={t('关')}
|
||||
onChange={(value) => handleFormChange('acceptUnsetModelRatioModel', value)}
|
||||
extraText={t('当模型没有设置价格时仍接受调用,仅当您信任该网站时使用,可能会产生高额费用')}
|
||||
onChange={(value) =>
|
||||
handleFormChange('acceptUnsetModelRatioModel', value)
|
||||
}
|
||||
extraText={t(
|
||||
'当模型没有设置价格时仍接受调用,仅当您信任该网站时使用,可能会产生高额费用',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</TabPane>
|
||||
@@ -261,21 +292,23 @@ const NotificationSettings = ({
|
||||
{/* 隐私设置 Tab */}
|
||||
<TabPane
|
||||
tab={
|
||||
<div className="flex items-center">
|
||||
<ShieldCheck size={16} className="mr-2" />
|
||||
<div className='flex items-center'>
|
||||
<ShieldCheck size={16} className='mr-2' />
|
||||
{t('隐私设置')}
|
||||
</div>
|
||||
}
|
||||
itemKey="privacy"
|
||||
itemKey='privacy'
|
||||
>
|
||||
<div className="py-4">
|
||||
<div className='py-4'>
|
||||
<Form.Switch
|
||||
field='recordIpLog'
|
||||
label={t('记录请求与错误日志IP')}
|
||||
checkedText={t('开')}
|
||||
uncheckedText={t('关')}
|
||||
onChange={(value) => handleFormChange('recordIpLog', value)}
|
||||
extraText={t('开启后,仅"消费"和"错误"日志将记录您的客户端IP地址')}
|
||||
extraText={t(
|
||||
'开启后,仅"消费"和"错误"日志将记录您的客户端IP地址',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</TabPane>
|
||||
|
||||
@@ -17,12 +17,25 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { API, showError, showSuccess, showWarning } from '../../../../helpers';
|
||||
import { Banner, Button, Card, Checkbox, Divider, Input, Modal, Tag, Typography, Steps, Space, Badge } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Input,
|
||||
Modal,
|
||||
Tag,
|
||||
Typography,
|
||||
Steps,
|
||||
Space,
|
||||
Badge,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconShield,
|
||||
IconAlertTriangle,
|
||||
IconRefresh,
|
||||
IconCopy
|
||||
IconCopy,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
@@ -35,7 +48,7 @@ const TwoFASetting = ({ t }) => {
|
||||
const [status, setStatus] = useState({
|
||||
enabled: false,
|
||||
locked: false,
|
||||
backup_codes_remaining: 0
|
||||
backup_codes_remaining: 0,
|
||||
});
|
||||
|
||||
// 模态框状态
|
||||
@@ -96,7 +109,7 @@ const TwoFASetting = ({ t }) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.post('/api/user/2fa/enable', {
|
||||
code: verificationCode
|
||||
code: verificationCode,
|
||||
});
|
||||
if (res.data.success) {
|
||||
showSuccess(t('两步验证启用成功!'));
|
||||
@@ -130,7 +143,7 @@ const TwoFASetting = ({ t }) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.post('/api/user/2fa/disable', {
|
||||
code: verificationCode
|
||||
code: verificationCode,
|
||||
});
|
||||
if (res.data.success) {
|
||||
showSuccess(t('两步验证已禁用'));
|
||||
@@ -158,7 +171,7 @@ const TwoFASetting = ({ t }) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.post('/api/user/2fa/backup_codes', {
|
||||
code: verificationCode
|
||||
code: verificationCode,
|
||||
});
|
||||
if (res.data.success) {
|
||||
setBackupCodes(res.data.data.backup_codes);
|
||||
@@ -177,11 +190,14 @@ const TwoFASetting = ({ t }) => {
|
||||
|
||||
// 通用复制函数
|
||||
const copyTextToClipboard = (text, successMessage = t('已复制到剪贴板')) => {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
showSuccess(successMessage);
|
||||
}).catch(() => {
|
||||
showError(t('复制失败,请手动复制'));
|
||||
});
|
||||
navigator.clipboard
|
||||
.writeText(text)
|
||||
.then(() => {
|
||||
showSuccess(successMessage);
|
||||
})
|
||||
.catch(() => {
|
||||
showError(t('复制失败,请手动复制'));
|
||||
});
|
||||
};
|
||||
|
||||
const copyBackupCodes = () => {
|
||||
@@ -192,28 +208,25 @@ const TwoFASetting = ({ t }) => {
|
||||
// 备用码展示组件
|
||||
const BackupCodesDisplay = ({ codes, title, onCopy }) => {
|
||||
return (
|
||||
<Card
|
||||
className="!rounded-xl"
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Text strong className="text-slate-700 dark:text-slate-200">
|
||||
<Card className='!rounded-xl' style={{ width: '100%' }}>
|
||||
<div className='space-y-3'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Text strong className='text-slate-700 dark:text-slate-200'>
|
||||
{title}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<div className='grid grid-cols-1 sm:grid-cols-2 gap-2'>
|
||||
{codes.map((code, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="rounded-lg p-3"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<Text code className="text-sm font-mono text-slate-700 dark:text-slate-200">
|
||||
<div key={index} className='rounded-lg p-3'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Text
|
||||
code
|
||||
className='text-sm font-mono text-slate-700 dark:text-slate-200'
|
||||
>
|
||||
{code}
|
||||
</Text>
|
||||
<Text type="quaternary" className="text-xs">
|
||||
<Text type='quaternary' className='text-xs'>
|
||||
#{(index + 1).toString().padStart(2, '0')}
|
||||
</Text>
|
||||
</div>
|
||||
@@ -223,11 +236,11 @@ const TwoFASetting = ({ t }) => {
|
||||
|
||||
<Divider margin={12} />
|
||||
<Button
|
||||
type="primary"
|
||||
theme="solid"
|
||||
type='primary'
|
||||
theme='solid'
|
||||
icon={<IconCopy />}
|
||||
onClick={onCopy}
|
||||
className="!rounded-lg !bg-slate-600 hover:!bg-slate-700 w-full"
|
||||
className='!rounded-lg !bg-slate-600 hover:!bg-slate-700 w-full'
|
||||
>
|
||||
{t('复制所有代码')}
|
||||
</Button>
|
||||
@@ -243,24 +256,24 @@ const TwoFASetting = ({ t }) => {
|
||||
{currentStep > 0 && (
|
||||
<Button
|
||||
onClick={() => setCurrentStep(currentStep - 1)}
|
||||
className="!rounded-lg"
|
||||
className='!rounded-lg'
|
||||
>
|
||||
{t('上一步')}
|
||||
</Button>
|
||||
)}
|
||||
{currentStep < 2 ? (
|
||||
<Button
|
||||
type="primary"
|
||||
theme="solid"
|
||||
type='primary'
|
||||
theme='solid'
|
||||
onClick={() => setCurrentStep(currentStep + 1)}
|
||||
className="!rounded-lg !bg-slate-600 hover:!bg-slate-700"
|
||||
className='!rounded-lg !bg-slate-600 hover:!bg-slate-700'
|
||||
>
|
||||
{t('下一步')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="primary"
|
||||
theme="solid"
|
||||
type='primary'
|
||||
theme='solid'
|
||||
loading={loading}
|
||||
onClick={() => {
|
||||
if (!verificationCode) {
|
||||
@@ -269,7 +282,7 @@ const TwoFASetting = ({ t }) => {
|
||||
}
|
||||
handleEnable2FA();
|
||||
}}
|
||||
className="!rounded-lg !bg-slate-600 hover:!bg-slate-700"
|
||||
className='!rounded-lg !bg-slate-600 hover:!bg-slate-700'
|
||||
>
|
||||
{t('完成设置并启用两步验证')}
|
||||
</Button>
|
||||
@@ -288,17 +301,17 @@ const TwoFASetting = ({ t }) => {
|
||||
setVerificationCode('');
|
||||
setConfirmDisable(false);
|
||||
}}
|
||||
className="!rounded-lg"
|
||||
className='!rounded-lg'
|
||||
>
|
||||
{t('取消')}
|
||||
</Button>
|
||||
<Button
|
||||
type="danger"
|
||||
theme="solid"
|
||||
type='danger'
|
||||
theme='solid'
|
||||
loading={loading}
|
||||
disabled={!confirmDisable || !verificationCode}
|
||||
onClick={handleDisable2FA}
|
||||
className="!rounded-lg !bg-slate-500 hover:!bg-slate-600"
|
||||
className='!rounded-lg !bg-slate-500 hover:!bg-slate-600'
|
||||
>
|
||||
{t('确认禁用')}
|
||||
</Button>
|
||||
@@ -311,14 +324,14 @@ const TwoFASetting = ({ t }) => {
|
||||
if (backupCodes.length > 0) {
|
||||
return (
|
||||
<Button
|
||||
type="primary"
|
||||
theme="solid"
|
||||
type='primary'
|
||||
theme='solid'
|
||||
onClick={() => {
|
||||
setBackupModalVisible(false);
|
||||
setVerificationCode('');
|
||||
setBackupCodes([]);
|
||||
}}
|
||||
className="!rounded-lg !bg-slate-600 hover:!bg-slate-700"
|
||||
className='!rounded-lg !bg-slate-600 hover:!bg-slate-700'
|
||||
>
|
||||
{t('完成')}
|
||||
</Button>
|
||||
@@ -333,17 +346,17 @@ const TwoFASetting = ({ t }) => {
|
||||
setVerificationCode('');
|
||||
setBackupCodes([]);
|
||||
}}
|
||||
className="!rounded-lg"
|
||||
className='!rounded-lg'
|
||||
>
|
||||
{t('取消')}
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
theme="solid"
|
||||
type='primary'
|
||||
theme='solid'
|
||||
loading={loading}
|
||||
disabled={!verificationCode}
|
||||
onClick={handleRegenerateBackupCodes}
|
||||
className="!rounded-lg !bg-slate-600 hover:!bg-slate-700"
|
||||
className='!rounded-lg !bg-slate-600 hover:!bg-slate-700'
|
||||
>
|
||||
{t('生成新的备用码')}
|
||||
</Button>
|
||||
@@ -353,67 +366,82 @@ const TwoFASetting = ({ t }) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<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 dark:bg-slate-700 flex items-center justify-center mr-4 flex-shrink-0">
|
||||
<IconShield size="large" className="text-slate-600 dark:text-slate-300" />
|
||||
<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 dark:bg-slate-700 flex items-center justify-center mr-4 flex-shrink-0'>
|
||||
<IconShield
|
||||
size='large'
|
||||
className='text-slate-600 dark:text-slate-300'
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Typography.Title heading={6} className="mb-0">
|
||||
<div className='flex-1'>
|
||||
<div className='flex items-center gap-2 mb-1'>
|
||||
<Typography.Title heading={6} className='mb-0'>
|
||||
{t('两步验证设置')}
|
||||
</Typography.Title>
|
||||
{status.enabled ? (
|
||||
<Tag color="green" shape="circle" size="small">{t('已启用')}</Tag>
|
||||
<Tag color='green' shape='circle' size='small'>
|
||||
{t('已启用')}
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag color="red" shape="circle" size="small">{t('未启用')}</Tag>
|
||||
<Tag color='red' shape='circle' size='small'>
|
||||
{t('未启用')}
|
||||
</Tag>
|
||||
)}
|
||||
{status.locked && (
|
||||
<Tag color="orange" shape="circle" size="small">{t('账户已锁定')}</Tag>
|
||||
<Tag color='orange' shape='circle' size='small'>
|
||||
{t('账户已锁定')}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
<Typography.Text type="tertiary" className="text-sm">
|
||||
{t('两步验证(2FA)为您的账户提供额外的安全保护。启用后,登录时需要输入密码和验证器应用生成的验证码。')}
|
||||
<Typography.Text type='tertiary' className='text-sm'>
|
||||
{t(
|
||||
'两步验证(2FA)为您的账户提供额外的安全保护。启用后,登录时需要输入密码和验证器应用生成的验证码。',
|
||||
)}
|
||||
</Typography.Text>
|
||||
{status.enabled && (
|
||||
<div className="mt-2">
|
||||
<Text size="small" type="secondary">{t('剩余备用码:')}{status.backup_codes_remaining || 0}{t('个')}</Text>
|
||||
<div className='mt-2'>
|
||||
<Text size='small' type='secondary'>
|
||||
{t('剩余备用码:')}
|
||||
{status.backup_codes_remaining || 0}
|
||||
{t('个')}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col space-y-2 w-full sm:w-auto">
|
||||
<div className='flex flex-col space-y-2 w-full sm:w-auto'>
|
||||
{!status.enabled ? (
|
||||
<Button
|
||||
type="primary"
|
||||
theme="solid"
|
||||
size="default"
|
||||
type='primary'
|
||||
theme='solid'
|
||||
size='default'
|
||||
onClick={handleSetup2FA}
|
||||
loading={loading}
|
||||
className="!rounded-lg !bg-slate-600 hover:!bg-slate-700"
|
||||
className='!rounded-lg !bg-slate-600 hover:!bg-slate-700'
|
||||
icon={<IconShield />}
|
||||
>
|
||||
{t('启用验证')}
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex flex-col space-y-2">
|
||||
<div className='flex flex-col space-y-2'>
|
||||
<Button
|
||||
type="danger"
|
||||
theme="solid"
|
||||
size="default"
|
||||
type='danger'
|
||||
theme='solid'
|
||||
size='default'
|
||||
onClick={() => setDisableModalVisible(true)}
|
||||
className="!rounded-lg !bg-slate-500 hover:!bg-slate-600"
|
||||
className='!rounded-lg !bg-slate-500 hover:!bg-slate-600'
|
||||
icon={<IconAlertTriangle />}
|
||||
>
|
||||
{t('禁用两步验证')}
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
theme="solid"
|
||||
size="default"
|
||||
type='primary'
|
||||
theme='solid'
|
||||
size='default'
|
||||
onClick={() => setBackupModalVisible(true)}
|
||||
className="!rounded-lg"
|
||||
className='!rounded-lg'
|
||||
icon={<IconRefresh />}
|
||||
>
|
||||
{t('重新生成备用码')}
|
||||
@@ -427,8 +455,8 @@ const TwoFASetting = ({ t }) => {
|
||||
{/* 2FA设置模态框 */}
|
||||
<Modal
|
||||
title={
|
||||
<div className="flex items-center">
|
||||
<IconShield className="mr-2 text-slate-600" />
|
||||
<div className='flex items-center'>
|
||||
<IconShield className='mr-2 text-slate-600' />
|
||||
{t('设置两步验证')}
|
||||
</div>
|
||||
}
|
||||
@@ -444,36 +472,50 @@ const TwoFASetting = ({ t }) => {
|
||||
style={{ maxWidth: '90vw' }}
|
||||
>
|
||||
{setupData && (
|
||||
<div className="space-y-6">
|
||||
<div className='space-y-6'>
|
||||
{/* 步骤进度 */}
|
||||
<Steps type="basic" size="small" current={currentStep}>
|
||||
<Steps.Step title={t('扫描二维码')} description={t('使用认证器应用扫描二维码')} />
|
||||
<Steps.Step title={t('保存备用码')} description={t('保存备用码以备不时之需')} />
|
||||
<Steps.Step title={t('验证设置')} description={t('输入验证码完成设置')} />
|
||||
<Steps type='basic' size='small' current={currentStep}>
|
||||
<Steps.Step
|
||||
title={t('扫描二维码')}
|
||||
description={t('使用认证器应用扫描二维码')}
|
||||
/>
|
||||
<Steps.Step
|
||||
title={t('保存备用码')}
|
||||
description={t('保存备用码以备不时之需')}
|
||||
/>
|
||||
<Steps.Step
|
||||
title={t('验证设置')}
|
||||
description={t('输入验证码完成设置')}
|
||||
/>
|
||||
</Steps>
|
||||
|
||||
{/* 步骤内容 */}
|
||||
<div className="rounded-xl">
|
||||
<div className='rounded-xl'>
|
||||
{currentStep === 0 && (
|
||||
<div>
|
||||
<Paragraph className="text-gray-600 dark:text-gray-300 mb-4">
|
||||
{t('使用认证器应用(如 Google Authenticator、Microsoft Authenticator)扫描下方二维码:')}
|
||||
<Paragraph className='text-gray-600 dark:text-gray-300 mb-4'>
|
||||
{t(
|
||||
'使用认证器应用(如 Google Authenticator、Microsoft Authenticator)扫描下方二维码:',
|
||||
)}
|
||||
</Paragraph>
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="bg-white p-4 rounded-lg shadow-sm">
|
||||
<div className='flex justify-center mb-4'>
|
||||
<div className='bg-white p-4 rounded-lg shadow-sm'>
|
||||
<QRCodeSVG value={setupData.qr_code_data} size={180} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-blue-50 dark:bg-blue-900 rounded-lg p-3">
|
||||
<Text className="text-blue-800 dark:text-blue-200 text-sm">
|
||||
{t('或手动输入密钥:')}<Text code copyable className="ml-2">{setupData.secret}</Text>
|
||||
<div className='bg-blue-50 dark:bg-blue-900 rounded-lg p-3'>
|
||||
<Text className='text-blue-800 dark:text-blue-200 text-sm'>
|
||||
{t('或手动输入密钥:')}
|
||||
<Text code copyable className='ml-2'>
|
||||
{setupData.secret}
|
||||
</Text>
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === 1 && (
|
||||
<div className="space-y-4">
|
||||
<div className='space-y-4'>
|
||||
{/* 备用码展示 */}
|
||||
<BackupCodesDisplay
|
||||
codes={setupData.backup_codes}
|
||||
@@ -491,9 +533,9 @@ const TwoFASetting = ({ t }) => {
|
||||
placeholder={t('输入认证器应用显示的6位数字验证码')}
|
||||
value={verificationCode}
|
||||
onChange={setVerificationCode}
|
||||
size="large"
|
||||
size='large'
|
||||
maxLength={6}
|
||||
className="!rounded-lg"
|
||||
className='!rounded-lg'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -504,8 +546,8 @@ const TwoFASetting = ({ t }) => {
|
||||
{/* 禁用2FA模态框 */}
|
||||
<Modal
|
||||
title={
|
||||
<div className="flex items-center">
|
||||
<IconAlertTriangle className="mr-2 text-red-500" />
|
||||
<div className='flex items-center'>
|
||||
<IconAlertTriangle className='mr-2 text-red-500' />
|
||||
{t('禁用两步验证')}
|
||||
</div>
|
||||
}
|
||||
@@ -519,36 +561,41 @@ const TwoFASetting = ({ t }) => {
|
||||
width={550}
|
||||
style={{ maxWidth: '90vw' }}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<div className='space-y-6'>
|
||||
{/* 警告提示 */}
|
||||
<div className="rounded-xl">
|
||||
<div className='rounded-xl'>
|
||||
<Banner
|
||||
type="warning"
|
||||
description={t('警告:禁用两步验证将永久删除您的验证设置和所有备用码,此操作不可撤销!')}
|
||||
className="!rounded-lg"
|
||||
type='warning'
|
||||
description={t(
|
||||
'警告:禁用两步验证将永久删除您的验证设置和所有备用码,此操作不可撤销!',
|
||||
)}
|
||||
className='!rounded-lg'
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 内容区域 */}
|
||||
<div className="space-y-4">
|
||||
<div className='space-y-4'>
|
||||
<div>
|
||||
<Text strong className="block mb-2 text-slate-700 dark:text-slate-200">
|
||||
<Text
|
||||
strong
|
||||
className='block mb-2 text-slate-700 dark:text-slate-200'
|
||||
>
|
||||
{t('禁用后的影响:')}
|
||||
</Text>
|
||||
<ul className="space-y-2 text-sm text-slate-600 dark:text-slate-300">
|
||||
<li className="flex items-start gap-2">
|
||||
<ul className='space-y-2 text-sm text-slate-600 dark:text-slate-300'>
|
||||
<li className='flex items-start gap-2'>
|
||||
<Badge dot type='warning' />
|
||||
{t('降低您账户的安全性')}
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<li className='flex items-start gap-2'>
|
||||
<Badge dot type='warning' />
|
||||
{t('需要重新完整设置才能再次启用')}
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<li className='flex items-start gap-2'>
|
||||
<Badge dot type='danger' />
|
||||
{t('永久删除您的两步验证设置')}
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<li className='flex items-start gap-2'>
|
||||
<Badge dot type='danger' />
|
||||
{t('永久删除所有备用码(包括未使用的)')}
|
||||
</li>
|
||||
@@ -557,17 +604,20 @@ const TwoFASetting = ({ t }) => {
|
||||
|
||||
<Divider margin={16} />
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className='space-y-4'>
|
||||
<div>
|
||||
<Text strong className="block mb-2 text-slate-700 dark:text-slate-200">
|
||||
<Text
|
||||
strong
|
||||
className='block mb-2 text-slate-700 dark:text-slate-200'
|
||||
>
|
||||
{t('验证身份')}
|
||||
</Text>
|
||||
<Input
|
||||
placeholder={t('请输入认证器验证码或备用码')}
|
||||
value={verificationCode}
|
||||
onChange={setVerificationCode}
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
size='large'
|
||||
className='!rounded-lg'
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -575,9 +625,11 @@ const TwoFASetting = ({ t }) => {
|
||||
<Checkbox
|
||||
checked={confirmDisable}
|
||||
onChange={(e) => setConfirmDisable(e.target.checked)}
|
||||
className="text-sm"
|
||||
className='text-sm'
|
||||
>
|
||||
{t('我已了解禁用两步验证将永久删除所有相关设置和备用码,此操作不可撤销')}
|
||||
{t(
|
||||
'我已了解禁用两步验证将永久删除所有相关设置和备用码,此操作不可撤销',
|
||||
)}
|
||||
</Checkbox>
|
||||
</div>
|
||||
</div>
|
||||
@@ -588,8 +640,8 @@ const TwoFASetting = ({ t }) => {
|
||||
{/* 重新生成备用码模态框 */}
|
||||
<Modal
|
||||
title={
|
||||
<div className="flex items-center">
|
||||
<IconRefresh className="mr-2 text-slate-600" />
|
||||
<div className='flex items-center'>
|
||||
<IconRefresh className='mr-2 text-slate-600' />
|
||||
{t('重新生成备用码')}
|
||||
</div>
|
||||
}
|
||||
@@ -603,30 +655,35 @@ const TwoFASetting = ({ t }) => {
|
||||
width={500}
|
||||
style={{ maxWidth: '90vw' }}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<div className='space-y-6'>
|
||||
{backupCodes.length === 0 ? (
|
||||
<>
|
||||
{/* 警告提示 */}
|
||||
<div className="rounded-xl">
|
||||
<div className='rounded-xl'>
|
||||
<Banner
|
||||
type="warning"
|
||||
description={t('重新生成备用码将使现有的备用码失效,请确保您已保存了当前的备用码。')}
|
||||
className="!rounded-lg"
|
||||
type='warning'
|
||||
description={t(
|
||||
'重新生成备用码将使现有的备用码失效,请确保您已保存了当前的备用码。',
|
||||
)}
|
||||
className='!rounded-lg'
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 验证区域 */}
|
||||
<div className="space-y-4">
|
||||
<div className='space-y-4'>
|
||||
<div>
|
||||
<Text strong className="block mb-2 text-slate-700 dark:text-slate-200">
|
||||
<Text
|
||||
strong
|
||||
className='block mb-2 text-slate-700 dark:text-slate-200'
|
||||
>
|
||||
{t('验证身份')}
|
||||
</Text>
|
||||
<Input
|
||||
placeholder={t('请输入认证器验证码')}
|
||||
value={verificationCode}
|
||||
onChange={setVerificationCode}
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
size='large'
|
||||
className='!rounded-lg'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -635,13 +692,16 @@ const TwoFASetting = ({ t }) => {
|
||||
<>
|
||||
{/* 成功提示 */}
|
||||
<Space vertical style={{ width: '100%' }}>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div className='flex items-center justify-center gap-2'>
|
||||
<Badge dot type='success' />
|
||||
<Text strong className="text-lg text-slate-700 dark:text-slate-200">
|
||||
<Text
|
||||
strong
|
||||
className='text-lg text-slate-700 dark:text-slate-200'
|
||||
>
|
||||
{t('新的备用码已生成')}
|
||||
</Text>
|
||||
</div>
|
||||
<Text className="text-slate-500 dark:text-slate-400 text-sm">
|
||||
<Text className='text-slate-500 dark:text-slate-400 text-sm'>
|
||||
{t('旧的备用码已失效,请保存新的备用码')}
|
||||
</Text>
|
||||
|
||||
@@ -660,4 +720,4 @@ const TwoFASetting = ({ t }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default TwoFASetting;
|
||||
export default TwoFASetting;
|
||||
|
||||
@@ -18,12 +18,23 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Avatar, Card, Tag, Divider, Typography, Badge } from '@douyinfe/semi-ui';
|
||||
import { isRoot, isAdmin, renderQuota, stringToColor } from '../../../../helpers';
|
||||
import {
|
||||
Avatar,
|
||||
Card,
|
||||
Tag,
|
||||
Divider,
|
||||
Typography,
|
||||
Badge,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
isRoot,
|
||||
isAdmin,
|
||||
renderQuota,
|
||||
stringToColor,
|
||||
} from '../../../../helpers';
|
||||
import { Coins, BarChart2, Users } from 'lucide-react';
|
||||
|
||||
const UserInfoHeader = ({ t, userState }) => {
|
||||
|
||||
const getUsername = () => {
|
||||
if (userState.user) {
|
||||
return userState.user.username;
|
||||
@@ -42,31 +53,33 @@ const UserInfoHeader = ({ t, userState }) => {
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="!rounded-2xl overflow-hidden"
|
||||
className='!rounded-2xl overflow-hidden'
|
||||
cover={
|
||||
<div
|
||||
className="relative h-32"
|
||||
className='relative h-32'
|
||||
style={{
|
||||
'--palette-primary-darkerChannel': '0 75 80',
|
||||
backgroundImage: `linear-gradient(0deg, rgba(var(--palette-primary-darkerChannel) / 80%), rgba(var(--palette-primary-darkerChannel) / 80%)), url('/cover-4.webp')`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat'
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}}
|
||||
>
|
||||
{/* 用户信息内容 */}
|
||||
<div className="relative z-10 h-full flex flex-col justify-end p-6">
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-stretch gap-3 sm:gap-4 flex-1 min-w-0">
|
||||
<Avatar
|
||||
size='large'
|
||||
color={stringToColor(getUsername())}
|
||||
>
|
||||
<div className='relative z-10 h-full flex flex-col justify-end p-6'>
|
||||
<div className='flex items-center'>
|
||||
<div className='flex items-stretch gap-3 sm:gap-4 flex-1 min-w-0'>
|
||||
<Avatar size='large' color={stringToColor(getUsername())}>
|
||||
{getAvatarText()}
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0 flex flex-col justify-between">
|
||||
<div className="text-3xl font-bold truncate" style={{ color: 'white' }}>{getUsername()}</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className='flex-1 min-w-0 flex flex-col justify-between'>
|
||||
<div
|
||||
className='text-3xl font-bold truncate'
|
||||
style={{ color: 'white' }}
|
||||
>
|
||||
{getUsername()}
|
||||
</div>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
{isRoot() ? (
|
||||
<Tag
|
||||
size='large'
|
||||
@@ -92,11 +105,7 @@ const UserInfoHeader = ({ t, userState }) => {
|
||||
{t('普通用户')}
|
||||
</Tag>
|
||||
)}
|
||||
<Tag
|
||||
size='large'
|
||||
shape='circle'
|
||||
style={{ color: 'white' }}
|
||||
>
|
||||
<Tag size='large' shape='circle' style={{ color: 'white' }}>
|
||||
ID: {userState?.user?.id}
|
||||
</Tag>
|
||||
</div>
|
||||
@@ -108,34 +117,50 @@ const UserInfoHeader = ({ t, userState }) => {
|
||||
}
|
||||
>
|
||||
{/* 当前余额和桌面版统计信息 */}
|
||||
<div className="flex items-start justify-between gap-6">
|
||||
<div className='flex items-start justify-between gap-6'>
|
||||
{/* 当前余额显示 */}
|
||||
<Badge count={t('当前余额')} position='rightTop' type='danger'>
|
||||
<div className="text-2xl sm:text-3xl md:text-4xl font-bold tracking-wide">
|
||||
<div className='text-2xl sm:text-3xl md:text-4xl font-bold tracking-wide'>
|
||||
{renderQuota(userState?.user?.quota)}
|
||||
</div>
|
||||
</Badge>
|
||||
|
||||
{/* 桌面版统计信息(Semi UI 卡片) */}
|
||||
<div className="hidden lg:block flex-shrink-0">
|
||||
<Card size="small" className="!rounded-xl" bodyStyle={{ padding: '12px 16px' }}>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className='hidden lg:block flex-shrink-0'>
|
||||
<Card
|
||||
size='small'
|
||||
className='!rounded-xl'
|
||||
bodyStyle={{ padding: '12px 16px' }}
|
||||
>
|
||||
<div className='flex items-center gap-4'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Coins size={16} />
|
||||
<Typography.Text size="small" type="tertiary">{t('历史消耗')}</Typography.Text>
|
||||
<Typography.Text size="small" type="tertiary" strong>{renderQuota(userState?.user?.used_quota)}</Typography.Text>
|
||||
<Typography.Text size='small' type='tertiary'>
|
||||
{t('历史消耗')}
|
||||
</Typography.Text>
|
||||
<Typography.Text size='small' type='tertiary' strong>
|
||||
{renderQuota(userState?.user?.used_quota)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Divider layout="vertical" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Divider layout='vertical' />
|
||||
<div className='flex items-center gap-2'>
|
||||
<BarChart2 size={16} />
|
||||
<Typography.Text size="small" type="tertiary">{t('请求次数')}</Typography.Text>
|
||||
<Typography.Text size="small" type="tertiary" strong>{userState.user?.request_count || 0}</Typography.Text>
|
||||
<Typography.Text size='small' type='tertiary'>
|
||||
{t('请求次数')}
|
||||
</Typography.Text>
|
||||
<Typography.Text size='small' type='tertiary' strong>
|
||||
{userState.user?.request_count || 0}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Divider layout="vertical" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Divider layout='vertical' />
|
||||
<div className='flex items-center gap-2'>
|
||||
<Users size={16} />
|
||||
<Typography.Text size="small" type="tertiary">{t('用户分组')}</Typography.Text>
|
||||
<Typography.Text size="small" type="tertiary" strong>{userState?.user?.group || t('默认')}</Typography.Text>
|
||||
<Typography.Text size='small' type='tertiary'>
|
||||
{t('用户分组')}
|
||||
</Typography.Text>
|
||||
<Typography.Text size='small' type='tertiary' strong>
|
||||
{userState?.user?.group || t('默认')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -143,31 +168,47 @@ const UserInfoHeader = ({ t, userState }) => {
|
||||
</div>
|
||||
|
||||
{/* 移动端和中等屏幕统计信息卡片 */}
|
||||
<div className="lg:hidden mt-2">
|
||||
<Card size="small" className="!rounded-xl" bodyStyle={{ padding: '12px 16px' }} >
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className='lg:hidden mt-2'>
|
||||
<Card
|
||||
size='small'
|
||||
className='!rounded-xl'
|
||||
bodyStyle={{ padding: '12px 16px' }}
|
||||
>
|
||||
<div className='space-y-3'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Coins size={16} />
|
||||
<Typography.Text size="small" type="tertiary">{t('历史消耗')}</Typography.Text>
|
||||
<Typography.Text size='small' type='tertiary'>
|
||||
{t('历史消耗')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Typography.Text size="small" type="tertiary" strong>{renderQuota(userState?.user?.used_quota)}</Typography.Text>
|
||||
<Typography.Text size='small' type='tertiary' strong>
|
||||
{renderQuota(userState?.user?.used_quota)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Divider margin='8px' />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<BarChart2 size={16} />
|
||||
<Typography.Text size="small" type="tertiary">{t('请求次数')}</Typography.Text>
|
||||
<Typography.Text size='small' type='tertiary'>
|
||||
{t('请求次数')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Typography.Text size="small" type="tertiary" strong>{userState.user?.request_count || 0}</Typography.Text>
|
||||
<Typography.Text size='small' type='tertiary' strong>
|
||||
{userState.user?.request_count || 0}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Divider margin='8px' />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Users size={16} />
|
||||
<Typography.Text size="small" type="tertiary">{t('用户分组')}</Typography.Text>
|
||||
<Typography.Text size='small' type='tertiary'>
|
||||
{t('用户分组')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Typography.Text size="small" type="tertiary" strong>{userState?.user?.group || t('默认')}</Typography.Text>
|
||||
<Typography.Text size='small' type='tertiary' strong>
|
||||
{userState?.user?.group || t('默认')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -176,4 +217,4 @@ const UserInfoHeader = ({ t, userState }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default UserInfoHeader;
|
||||
export default UserInfoHeader;
|
||||
|
||||
@@ -32,13 +32,13 @@ const AccountDeleteModal = ({
|
||||
userState,
|
||||
turnstileEnabled,
|
||||
turnstileSiteKey,
|
||||
setTurnstileToken
|
||||
setTurnstileToken,
|
||||
}) => {
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<div className="flex items-center">
|
||||
<IconDelete className="mr-2 text-red-500" />
|
||||
<div className='flex items-center'>
|
||||
<IconDelete className='mr-2 text-red-500' />
|
||||
{t('删除账户确认')}
|
||||
</div>
|
||||
}
|
||||
@@ -47,35 +47,37 @@ const AccountDeleteModal = ({
|
||||
onOk={deleteAccount}
|
||||
size={'small'}
|
||||
centered={true}
|
||||
className="modern-modal"
|
||||
className='modern-modal'
|
||||
>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className='space-y-4 py-4'>
|
||||
<Banner
|
||||
type='danger'
|
||||
description={t('您正在删除自己的帐户,将清空所有数据且不可恢复')}
|
||||
closeIcon={null}
|
||||
className="!rounded-lg"
|
||||
className='!rounded-lg'
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Typography.Text strong className="block mb-2 text-red-600">
|
||||
<Typography.Text strong className='block mb-2 text-red-600'>
|
||||
{t('请输入您的用户名以确认删除')}
|
||||
</Typography.Text>
|
||||
<Input
|
||||
placeholder={t('输入你的账户名{{username}}以确认删除', { username: ` ${userState?.user?.username} ` })}
|
||||
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"
|
||||
size='large'
|
||||
className='!rounded-lg'
|
||||
prefix={<IconUser />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{turnstileEnabled && (
|
||||
<div className="flex justify-center">
|
||||
<div className='flex justify-center'>
|
||||
<Turnstile
|
||||
sitekey={turnstileSiteKey}
|
||||
onVerify={(token) => {
|
||||
|
||||
@@ -31,13 +31,13 @@ const ChangePasswordModal = ({
|
||||
changePassword,
|
||||
turnstileEnabled,
|
||||
turnstileSiteKey,
|
||||
setTurnstileToken
|
||||
setTurnstileToken,
|
||||
}) => {
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<div className="flex items-center">
|
||||
<IconLock className="mr-2 text-orange-500" />
|
||||
<div className='flex items-center'>
|
||||
<IconLock className='mr-2 text-orange-500' />
|
||||
{t('修改密码')}
|
||||
</div>
|
||||
}
|
||||
@@ -46,43 +46,45 @@ const ChangePasswordModal = ({
|
||||
onOk={changePassword}
|
||||
size={'small'}
|
||||
centered={true}
|
||||
className="modern-modal"
|
||||
className='modern-modal'
|
||||
>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className='space-y-4 py-4'>
|
||||
<div>
|
||||
<Typography.Text strong className="block mb-2">{t('原密码')}</Typography.Text>
|
||||
<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"
|
||||
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>
|
||||
<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"
|
||||
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>
|
||||
<Typography.Text strong className='block mb-2'>
|
||||
{t('确认新密码')}
|
||||
</Typography.Text>
|
||||
<Input
|
||||
name='set_new_password_confirmation'
|
||||
placeholder={t('请再次输入新密码')}
|
||||
@@ -91,14 +93,14 @@ const ChangePasswordModal = ({
|
||||
onChange={(value) =>
|
||||
handleInputChange('set_new_password_confirmation', value)
|
||||
}
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
size='large'
|
||||
className='!rounded-lg'
|
||||
prefix={<IconLock />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{turnstileEnabled && (
|
||||
<div className="flex justify-center">
|
||||
<div className='flex justify-center'>
|
||||
<Turnstile
|
||||
sitekey={turnstileSiteKey}
|
||||
onVerify={(token) => {
|
||||
|
||||
@@ -35,13 +35,13 @@ const EmailBindModal = ({
|
||||
countdown,
|
||||
turnstileEnabled,
|
||||
turnstileSiteKey,
|
||||
setTurnstileToken
|
||||
setTurnstileToken,
|
||||
}) => {
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<div className="flex items-center">
|
||||
<IconMail className="mr-2 text-blue-500" />
|
||||
<div className='flex items-center'>
|
||||
<IconMail className='mr-2 text-blue-500' />
|
||||
{t('绑定邮箱地址')}
|
||||
</div>
|
||||
}
|
||||
@@ -51,28 +51,30 @@ const EmailBindModal = ({
|
||||
size={'small'}
|
||||
centered={true}
|
||||
maskClosable={false}
|
||||
className="modern-modal"
|
||||
className='modern-modal'
|
||||
>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="flex gap-3">
|
||||
<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"
|
||||
size='large'
|
||||
className='!rounded-lg flex-1'
|
||||
prefix={<IconMail />}
|
||||
/>
|
||||
<Button
|
||||
onClick={sendVerificationCode}
|
||||
disabled={disableButton || loading}
|
||||
className="!rounded-lg"
|
||||
type="primary"
|
||||
theme="outline"
|
||||
className='!rounded-lg'
|
||||
type='primary'
|
||||
theme='outline'
|
||||
size='large'
|
||||
>
|
||||
{disableButton ? `${t('重新发送')} (${countdown})` : t('获取验证码')}
|
||||
{disableButton
|
||||
? `${t('重新发送')} (${countdown})`
|
||||
: t('获取验证码')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -83,13 +85,13 @@ const EmailBindModal = ({
|
||||
onChange={(value) =>
|
||||
handleInputChange('email_verification_code', value)
|
||||
}
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
size='large'
|
||||
className='!rounded-lg'
|
||||
prefix={<IconKey />}
|
||||
/>
|
||||
|
||||
{turnstileEnabled && (
|
||||
<div className="flex justify-center">
|
||||
<div className='flex justify-center'>
|
||||
<Turnstile
|
||||
sitekey={turnstileSiteKey}
|
||||
onVerify={(token) => {
|
||||
|
||||
@@ -29,13 +29,13 @@ const WeChatBindModal = ({
|
||||
inputs,
|
||||
handleInputChange,
|
||||
bindWeChat,
|
||||
status
|
||||
status,
|
||||
}) => {
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<div className="flex items-center">
|
||||
<SiWechat className="mr-2 text-green-500" size={20} />
|
||||
<div className='flex items-center'>
|
||||
<SiWechat className='mr-2 text-green-500' size={20} />
|
||||
{t('绑定微信账户')}
|
||||
</div>
|
||||
}
|
||||
@@ -44,30 +44,30 @@ const WeChatBindModal = ({
|
||||
footer={null}
|
||||
size={'small'}
|
||||
centered={true}
|
||||
className="modern-modal"
|
||||
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 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"
|
||||
onChange={(v) => handleInputChange('wechat_verification_code', v)}
|
||||
size='large'
|
||||
className='!rounded-lg'
|
||||
prefix={<IconKey />}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
theme="solid"
|
||||
type='primary'
|
||||
theme='solid'
|
||||
size='large'
|
||||
onClick={bindWeChat}
|
||||
className="!rounded-lg w-full !bg-slate-600 hover:!bg-slate-700"
|
||||
className='!rounded-lg w-full !bg-slate-600 hover:!bg-slate-700'
|
||||
icon={<SiWechat size={16} />}
|
||||
>
|
||||
{t('绑定')}
|
||||
|
||||
@@ -130,7 +130,11 @@ const SetupWizard = () => {
|
||||
return true; // 如果已经初始化,可以继续
|
||||
}
|
||||
// 检查必填字段
|
||||
if (!formData.username || !formData.password || !formData.confirmPassword) {
|
||||
if (
|
||||
!formData.username ||
|
||||
!formData.password ||
|
||||
!formData.confirmPassword
|
||||
) {
|
||||
showError(t('请填写完整的管理员账号信息'));
|
||||
return false;
|
||||
}
|
||||
@@ -226,12 +230,7 @@ const SetupWizard = () => {
|
||||
const getStepContent = (step) => {
|
||||
switch (step) {
|
||||
case 0:
|
||||
return (
|
||||
<DatabaseStep
|
||||
setupStatus={setupStatus}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
return <DatabaseStep setupStatus={setupStatus} t={t} />;
|
||||
case 1:
|
||||
return (
|
||||
<AdminStep
|
||||
@@ -252,11 +251,7 @@ const SetupWizard = () => {
|
||||
);
|
||||
case 3:
|
||||
return (
|
||||
<CompleteStep
|
||||
setupStatus={setupStatus}
|
||||
formData={formData}
|
||||
t={t}
|
||||
/>
|
||||
<CompleteStep setupStatus={setupStatus} formData={formData} t={t} />
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
@@ -275,21 +270,25 @@ const SetupWizard = () => {
|
||||
|
||||
return (
|
||||
<div className='min-h-screen flex items-center justify-center px-4'>
|
||||
<div className="w-full max-w-4xl">
|
||||
<Card className="!rounded-2xl shadow-sm border-0">
|
||||
<div className="mb-4">
|
||||
<div className="text-xl font-semibold">{t('系统初始化')}</div>
|
||||
<div className="text-xs text-gray-600">
|
||||
<div className='w-full max-w-4xl'>
|
||||
<Card className='!rounded-2xl shadow-sm border-0'>
|
||||
<div className='mb-4'>
|
||||
<div className='text-xl font-semibold'>{t('系统初始化')}</div>
|
||||
<div className='text-xs text-gray-600'>
|
||||
{t('欢迎使用,请完成以下设置以开始使用系统')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-2 py-2">
|
||||
<Steps type="basic" current={currentStep}>
|
||||
<div className='px-2 py-2'>
|
||||
<Steps type='basic' current={currentStep}>
|
||||
{steps.map((item, index) => (
|
||||
<Steps.Step
|
||||
key={item.title}
|
||||
title={<span className={currentStep === index ? 'shine-text' : ''}>{item.title}</span>}
|
||||
title={
|
||||
<span className={currentStep === index ? 'shine-text' : ''}>
|
||||
{item.title}
|
||||
</span>
|
||||
}
|
||||
description={item.description}
|
||||
/>
|
||||
))}
|
||||
@@ -306,9 +305,12 @@ const SetupWizard = () => {
|
||||
initValues={formData}
|
||||
>
|
||||
{/* 步骤内容:保持所有字段挂载,仅隐藏非当前步骤 */}
|
||||
<div className="steps-content">
|
||||
<div className='steps-content'>
|
||||
{[0, 1, 2, 3].map((idx) => (
|
||||
<div key={idx} style={{ display: currentStep === idx ? 'block' : 'none' }}>
|
||||
<div
|
||||
key={idx}
|
||||
style={{ display: currentStep === idx ? 'block' : 'none' }}
|
||||
>
|
||||
{React.cloneElement(getStepContent(idx), {
|
||||
...stepNavigationProps,
|
||||
renderNavigationButtons: () => (
|
||||
|
||||
@@ -32,29 +32,22 @@ const StepNavigation = ({
|
||||
next,
|
||||
onSubmit,
|
||||
loading,
|
||||
t
|
||||
t,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex justify-between items-center pt-4">
|
||||
<div className='flex justify-between items-center pt-4'>
|
||||
{/* 上一步按钮 */}
|
||||
{currentStep > 0 && (
|
||||
<Button
|
||||
onClick={prev}
|
||||
className="!rounded-lg"
|
||||
>
|
||||
<Button onClick={prev} className='!rounded-lg'>
|
||||
{t('上一步')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="flex-1"></div>
|
||||
<div className='flex-1'></div>
|
||||
|
||||
{/* 下一步按钮 */}
|
||||
{currentStep < steps.length - 1 && (
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={next}
|
||||
className="!rounded-lg"
|
||||
>
|
||||
<Button type='primary' onClick={next} className='!rounded-lg'>
|
||||
{t('下一步')}
|
||||
</Button>
|
||||
)}
|
||||
@@ -62,10 +55,10 @@ const StepNavigation = ({
|
||||
{/* 完成按钮 */}
|
||||
{currentStep === steps.length - 1 && (
|
||||
<Button
|
||||
type="primary"
|
||||
type='primary'
|
||||
onClick={onSubmit}
|
||||
loading={loading}
|
||||
className="!rounded-lg"
|
||||
className='!rounded-lg'
|
||||
icon={<IconCheckCircleStroked />}
|
||||
>
|
||||
{t('初始化系统')}
|
||||
|
||||
@@ -31,7 +31,7 @@ const AdminStep = ({
|
||||
setFormData,
|
||||
formRef,
|
||||
renderNavigationButtons,
|
||||
t
|
||||
t,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
@@ -40,11 +40,11 @@ const AdminStep = ({
|
||||
type='info'
|
||||
closeIcon={null}
|
||||
description={
|
||||
<div className="flex items-center">
|
||||
<div className='flex items-center'>
|
||||
<span>{t('管理员账号已经初始化过,请继续设置其他参数')}</span>
|
||||
</div>
|
||||
}
|
||||
className="!rounded-lg"
|
||||
className='!rounded-lg'
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
@@ -55,7 +55,7 @@ const AdminStep = ({
|
||||
prefix={<IconUser />}
|
||||
showClear
|
||||
noLabel={false}
|
||||
validateStatus="default"
|
||||
validateStatus='default'
|
||||
rules={[{ required: true, message: t('请输入管理员用户名') }]}
|
||||
initValue={formData.username || ''}
|
||||
onChange={(value) => {
|
||||
@@ -70,11 +70,11 @@ const AdminStep = ({
|
||||
prefix={<IconLock />}
|
||||
showClear
|
||||
noLabel={false}
|
||||
mode="password"
|
||||
validateStatus="default"
|
||||
mode='password'
|
||||
validateStatus='default'
|
||||
rules={[
|
||||
{ required: true, message: t('请输入管理员密码') },
|
||||
{ min: 8, message: t('密码长度至少为8个字符') }
|
||||
{ min: 8, message: t('密码长度至少为8个字符') },
|
||||
]}
|
||||
initValue={formData.password || ''}
|
||||
onChange={(value) => {
|
||||
@@ -89,8 +89,8 @@ const AdminStep = ({
|
||||
prefix={<IconLock />}
|
||||
showClear
|
||||
noLabel={false}
|
||||
mode="password"
|
||||
validateStatus="default"
|
||||
mode='password'
|
||||
validateStatus='default'
|
||||
rules={[
|
||||
{ required: true, message: t('请确认管理员密码') },
|
||||
{
|
||||
@@ -102,8 +102,8 @@ const AdminStep = ({
|
||||
}
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
]}
|
||||
initValue={formData.confirmPassword || ''}
|
||||
onChange={(value) => {
|
||||
|
||||
@@ -31,29 +31,39 @@ const CompleteStep = ({
|
||||
setupStatus,
|
||||
formData,
|
||||
renderNavigationButtons,
|
||||
t
|
||||
t,
|
||||
}) => {
|
||||
return (
|
||||
<div className="text-center">
|
||||
<Avatar color="green" className="mx-auto mb-4 shadow-lg">
|
||||
<div className='text-center'>
|
||||
<Avatar color='green' className='mx-auto mb-4 shadow-lg'>
|
||||
<CheckCircle size={24} />
|
||||
</Avatar>
|
||||
<Title heading={3} className="mb-2">{t('准备完成初始化')}</Title>
|
||||
<Text type="secondary" className="mb-6 block">
|
||||
<Title heading={3} className='mb-2'>
|
||||
{t('准备完成初始化')}
|
||||
</Title>
|
||||
<Text type='secondary' className='mb-6 block'>
|
||||
{t('请确认以下设置信息,点击"初始化系统"开始配置')}
|
||||
</Text>
|
||||
|
||||
<Descriptions>
|
||||
<Descriptions.Item itemKey={t('数据库类型')}>
|
||||
{setupStatus.database_type === 'sqlite' ? 'SQLite' :
|
||||
setupStatus.database_type === 'mysql' ? 'MySQL' : 'PostgreSQL'}
|
||||
{setupStatus.database_type === 'sqlite'
|
||||
? 'SQLite'
|
||||
: setupStatus.database_type === 'mysql'
|
||||
? 'MySQL'
|
||||
: 'PostgreSQL'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item itemKey={t('管理员账号')}>
|
||||
{setupStatus.root_init ? t('已初始化') : (formData.username || t('未设置'))}
|
||||
{setupStatus.root_init
|
||||
? t('已初始化')
|
||||
: formData.username || t('未设置')}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item itemKey={t('使用模式')}>
|
||||
{formData.usageMode === 'external' ? t('对外运营模式') :
|
||||
formData.usageMode === 'self' ? t('自用模式') : t('演示站点模式')}
|
||||
{formData.usageMode === 'external'
|
||||
? t('对外运营模式')
|
||||
: formData.usageMode === 'self'
|
||||
? t('自用模式')
|
||||
: t('演示站点模式')}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
|
||||
@@ -40,14 +40,16 @@ const DatabaseStep = ({ setupStatus, renderNavigationButtons, t }) => {
|
||||
'您正在使用 SQLite 数据库。如果您在容器环境中运行,请确保已正确设置数据库文件的持久化映射,否则容器重启后所有数据将丢失!',
|
||||
)}
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
<strong>{t(
|
||||
'建议在生产环境中使用 MySQL 或 PostgreSQL 数据库,或确保 SQLite 数据库文件已映射到宿主机的持久化存储。',
|
||||
)}</strong>
|
||||
<p className='mt-1'>
|
||||
<strong>
|
||||
{t(
|
||||
'建议在生产环境中使用 MySQL 或 PostgreSQL 数据库,或确保 SQLite 数据库文件已映射到宿主机的持久化存储。',
|
||||
)}
|
||||
</strong>
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
className="!rounded-lg"
|
||||
className='!rounded-lg'
|
||||
fullMode={false}
|
||||
bordered
|
||||
/>
|
||||
@@ -68,7 +70,7 @@ const DatabaseStep = ({ setupStatus, renderNavigationButtons, t }) => {
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
className="!rounded-lg"
|
||||
className='!rounded-lg'
|
||||
fullMode={false}
|
||||
bordered
|
||||
/>
|
||||
@@ -89,7 +91,7 @@ const DatabaseStep = ({ setupStatus, renderNavigationButtons, t }) => {
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
className="!rounded-lg"
|
||||
className='!rounded-lg'
|
||||
fullMode={false}
|
||||
bordered
|
||||
/>
|
||||
|
||||
@@ -28,7 +28,7 @@ const UsageModeStep = ({
|
||||
formData,
|
||||
handleUsageModeChange,
|
||||
renderNavigationButtons,
|
||||
t
|
||||
t,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
@@ -37,9 +37,9 @@ const UsageModeStep = ({
|
||||
onChange={handleUsageModeChange}
|
||||
type='card'
|
||||
direction='horizontal'
|
||||
className="mt-4"
|
||||
aria-label="使用模式选择"
|
||||
name="usage-mode-selection"
|
||||
className='mt-4'
|
||||
aria-label='使用模式选择'
|
||||
name='usage-mode-selection'
|
||||
>
|
||||
<Radio
|
||||
value='external'
|
||||
|
||||
@@ -26,4 +26,4 @@ export { default as StepNavigation } from './components/StepNavigation';
|
||||
export { default as DatabaseStep } from './components/steps/DatabaseStep';
|
||||
export { default as AdminStep } from './components/steps/AdminStep';
|
||||
export { default as UsageModeStep } from './components/steps/UsageModeStep';
|
||||
export { default as CompleteStep } from './components/steps/CompleteStep';
|
||||
export { default as CompleteStep } from './components/steps/CompleteStep';
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
Modal,
|
||||
Switch,
|
||||
Typography,
|
||||
Select
|
||||
Select,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import CompactModeToggle from '../../common/ui/CompactModeToggle';
|
||||
|
||||
@@ -52,19 +52,19 @@ const ChannelsActions = ({
|
||||
activePage,
|
||||
pageSize,
|
||||
setActivePage,
|
||||
t
|
||||
t,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className='flex flex-col gap-2'>
|
||||
{/* 第一行:批量操作按钮 + 设置开关 */}
|
||||
<div className="flex flex-col md:flex-row justify-between gap-2">
|
||||
<div className='flex flex-col md:flex-row justify-between gap-2'>
|
||||
{/* 左侧:批量操作按钮 */}
|
||||
<div className="flex flex-wrap md:flex-nowrap items-center gap-2 w-full md:w-auto order-2 md:order-1">
|
||||
<div className='flex flex-wrap md:flex-nowrap items-center gap-2 w-full md:w-auto order-2 md:order-1'>
|
||||
<Button
|
||||
size='small'
|
||||
disabled={!enableBatchDelete}
|
||||
type='danger'
|
||||
className="w-full md:w-auto"
|
||||
className='w-full md:w-auto'
|
||||
onClick={() => {
|
||||
Modal.confirm({
|
||||
title: t('确定是否要删除所选通道?'),
|
||||
@@ -81,7 +81,7 @@ const ChannelsActions = ({
|
||||
disabled={!enableBatchDelete}
|
||||
type='tertiary'
|
||||
onClick={() => setShowBatchSetTag(true)}
|
||||
className="w-full md:w-auto"
|
||||
className='w-full md:w-auto'
|
||||
>
|
||||
{t('批量设置标签')}
|
||||
</Button>
|
||||
@@ -95,7 +95,7 @@ const ChannelsActions = ({
|
||||
<Button
|
||||
size='small'
|
||||
type='tertiary'
|
||||
className="w-full"
|
||||
className='w-full'
|
||||
onClick={() => {
|
||||
Modal.confirm({
|
||||
title: t('确定?'),
|
||||
@@ -112,11 +112,13 @@ const ChannelsActions = ({
|
||||
<Dropdown.Item>
|
||||
<Button
|
||||
size='small'
|
||||
className="w-full"
|
||||
className='w-full'
|
||||
onClick={() => {
|
||||
Modal.confirm({
|
||||
title: t('确定是否要修复数据库一致性?'),
|
||||
content: t('进行该操作时,可能导致渠道访问错误,请仅在数据库出现问题时使用'),
|
||||
content: t(
|
||||
'进行该操作时,可能导致渠道访问错误,请仅在数据库出现问题时使用',
|
||||
),
|
||||
onOk: () => fixChannelsAbilities(),
|
||||
size: 'sm',
|
||||
centered: true,
|
||||
@@ -130,7 +132,7 @@ const ChannelsActions = ({
|
||||
<Button
|
||||
size='small'
|
||||
type='secondary'
|
||||
className="w-full"
|
||||
className='w-full'
|
||||
onClick={() => {
|
||||
Modal.confirm({
|
||||
title: t('确定?'),
|
||||
@@ -148,7 +150,7 @@ const ChannelsActions = ({
|
||||
<Button
|
||||
size='small'
|
||||
type='danger'
|
||||
className="w-full"
|
||||
className='w-full'
|
||||
onClick={() => {
|
||||
Modal.confirm({
|
||||
title: t('确定是否要删除禁用通道?'),
|
||||
@@ -165,7 +167,12 @@ const ChannelsActions = ({
|
||||
</Dropdown.Menu>
|
||||
}
|
||||
>
|
||||
<Button size='small' theme='light' type='tertiary' className="w-full md:w-auto">
|
||||
<Button
|
||||
size='small'
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
className='w-full md:w-auto'
|
||||
>
|
||||
{t('批量操作')}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
@@ -178,9 +185,9 @@ const ChannelsActions = ({
|
||||
</div>
|
||||
|
||||
{/* 右侧:设置开关区域 */}
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center gap-2 w-full md:w-auto order-1 md:order-2">
|
||||
<div className="flex items-center justify-between w-full md:w-auto">
|
||||
<Typography.Text strong className="mr-2">
|
||||
<div className='flex flex-col md:flex-row items-start md:items-center gap-2 w-full md:w-auto order-1 md:order-2'>
|
||||
<div className='flex items-center justify-between w-full md:w-auto'>
|
||||
<Typography.Text strong className='mr-2'>
|
||||
{t('使用ID排序')}
|
||||
</Typography.Text>
|
||||
<Switch
|
||||
@@ -189,18 +196,30 @@ const ChannelsActions = ({
|
||||
onChange={(v) => {
|
||||
localStorage.setItem('id-sort', v + '');
|
||||
setIdSort(v);
|
||||
const { searchKeyword, searchGroup, searchModel } = getFormValues();
|
||||
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
|
||||
const { searchKeyword, searchGroup, searchModel } =
|
||||
getFormValues();
|
||||
if (
|
||||
searchKeyword === '' &&
|
||||
searchGroup === '' &&
|
||||
searchModel === ''
|
||||
) {
|
||||
loadChannels(activePage, pageSize, v, enableTagMode);
|
||||
} else {
|
||||
searchChannels(enableTagMode, activeTypeKey, statusFilter, activePage, pageSize, v);
|
||||
searchChannels(
|
||||
enableTagMode,
|
||||
activeTypeKey,
|
||||
statusFilter,
|
||||
activePage,
|
||||
pageSize,
|
||||
v,
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between w-full md:w-auto">
|
||||
<Typography.Text strong className="mr-2">
|
||||
<div className='flex items-center justify-between w-full md:w-auto'>
|
||||
<Typography.Text strong className='mr-2'>
|
||||
{t('开启批量操作')}
|
||||
</Typography.Text>
|
||||
<Switch
|
||||
@@ -213,8 +232,8 @@ const ChannelsActions = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between w-full md:w-auto">
|
||||
<Typography.Text strong className="mr-2">
|
||||
<div className='flex items-center justify-between w-full md:w-auto'>
|
||||
<Typography.Text strong className='mr-2'>
|
||||
{t('标签聚合模式')}
|
||||
</Typography.Text>
|
||||
<Switch
|
||||
@@ -229,8 +248,8 @@ const ChannelsActions = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between w-full md:w-auto">
|
||||
<Typography.Text strong className="mr-2">
|
||||
<div className='flex items-center justify-between w-full md:w-auto'>
|
||||
<Typography.Text strong className='mr-2'>
|
||||
{t('状态筛选')}
|
||||
</Typography.Text>
|
||||
<Select
|
||||
@@ -240,12 +259,19 @@ const ChannelsActions = ({
|
||||
localStorage.setItem('channel-status-filter', v);
|
||||
setStatusFilter(v);
|
||||
setActivePage(1);
|
||||
loadChannels(1, pageSize, idSort, enableTagMode, activeTypeKey, v);
|
||||
loadChannels(
|
||||
1,
|
||||
pageSize,
|
||||
idSort,
|
||||
enableTagMode,
|
||||
activeTypeKey,
|
||||
v,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Select.Option value="all">{t('全部')}</Select.Option>
|
||||
<Select.Option value="enabled">{t('已启用')}</Select.Option>
|
||||
<Select.Option value="disabled">{t('已禁用')}</Select.Option>
|
||||
<Select.Option value='all'>{t('全部')}</Select.Option>
|
||||
<Select.Option value='enabled'>{t('已启用')}</Select.Option>
|
||||
<Select.Option value='disabled'>{t('已禁用')}</Select.Option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -254,4 +280,4 @@ const ChannelsActions = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelsActions;
|
||||
export default ChannelsActions;
|
||||
|
||||
@@ -27,14 +27,14 @@ import {
|
||||
SplitButtonGroup,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography
|
||||
Typography,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
timestamp2string,
|
||||
renderGroup,
|
||||
renderQuota,
|
||||
getChannelIcon,
|
||||
renderQuotaWithAmount
|
||||
renderQuotaWithAmount,
|
||||
} from '../../../helpers';
|
||||
import { CHANNEL_OPTIONS } from '../../../constants';
|
||||
import { IconTreeTriangleDown, IconMore } from '@douyinfe/semi-icons';
|
||||
@@ -51,27 +51,22 @@ const renderType = (type, channelInfo = undefined, t) => {
|
||||
let icon = getChannelIcon(type);
|
||||
|
||||
if (channelInfo?.is_multi_key) {
|
||||
icon = (
|
||||
icon =
|
||||
channelInfo?.multi_key_mode === 'random' ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<FaRandom className="text-blue-500" />
|
||||
<div className='flex items-center gap-1'>
|
||||
<FaRandom className='text-blue-500' />
|
||||
{icon}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<IconTreeTriangleDown className="text-blue-500" />
|
||||
<div className='flex items-center gap-1'>
|
||||
<IconTreeTriangleDown className='text-blue-500' />
|
||||
{icon}
|
||||
</div>
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tag
|
||||
color={type2label[type]?.color}
|
||||
shape='circle'
|
||||
prefixIcon={icon}
|
||||
>
|
||||
<Tag color={type2label[type]?.color} shape='circle' prefixIcon={icon}>
|
||||
{type2label[type]?.label}
|
||||
</Tag>
|
||||
);
|
||||
@@ -79,11 +74,7 @@ const renderType = (type, channelInfo = undefined, t) => {
|
||||
|
||||
const renderTagType = (t) => {
|
||||
return (
|
||||
<Tag
|
||||
color='light-blue'
|
||||
shape='circle'
|
||||
type='light'
|
||||
>
|
||||
<Tag color='light-blue' shape='circle' type='light'>
|
||||
{t('标签聚合')}
|
||||
</Tag>
|
||||
);
|
||||
@@ -95,7 +86,8 @@ const renderStatus = (status, channelInfo = undefined, t) => {
|
||||
let keySize = channelInfo.multi_key_size;
|
||||
let enabledKeySize = keySize;
|
||||
if (channelInfo.multi_key_status_list) {
|
||||
enabledKeySize = keySize - Object.keys(channelInfo.multi_key_status_list).length;
|
||||
enabledKeySize =
|
||||
keySize - Object.keys(channelInfo.multi_key_status_list).length;
|
||||
}
|
||||
return renderMultiKeyStatus(status, keySize, enabledKeySize, t);
|
||||
}
|
||||
@@ -155,7 +147,7 @@ const renderMultiKeyStatus = (status, keySize, enabledKeySize, t) => {
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const renderResponseTime = (responseTime, t) => {
|
||||
let time = responseTime / 1000;
|
||||
@@ -212,7 +204,7 @@ export const getChannelsColumns = ({
|
||||
activePage,
|
||||
channels,
|
||||
setShowMultiKeyManageModal,
|
||||
setCurrentMultiKeyChannel
|
||||
setCurrentMultiKeyChannel,
|
||||
}) => {
|
||||
return [
|
||||
{
|
||||
@@ -276,7 +268,9 @@ export const getChannelsColumns = ({
|
||||
return (
|
||||
<div>
|
||||
<Tooltip
|
||||
content={t('原因:') + reason + t(',时间:') + timestamp2string(time)}
|
||||
content={
|
||||
t('原因:') + reason + t(',时间:') + timestamp2string(time)
|
||||
}
|
||||
>
|
||||
{renderStatus(text, record.channel_info, t)}
|
||||
</Tooltip>
|
||||
@@ -291,9 +285,7 @@ export const getChannelsColumns = ({
|
||||
key: COLUMN_KEYS.RESPONSE_TIME,
|
||||
title: t('响应时间'),
|
||||
dataIndex: 'response_time',
|
||||
render: (text, record, index) => (
|
||||
<div>{renderResponseTime(text, t)}</div>
|
||||
),
|
||||
render: (text, record, index) => <div>{renderResponseTime(text, t)}</div>,
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.BALANCE,
|
||||
@@ -309,7 +301,9 @@ export const getChannelsColumns = ({
|
||||
{renderQuota(record.used_quota)}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
<Tooltip content={t('剩余额度$') + record.balance + t(',点击更新')}>
|
||||
<Tooltip
|
||||
content={t('剩余额度$') + record.balance + t(',点击更新')}
|
||||
>
|
||||
<Tag
|
||||
color='white'
|
||||
type='ghost'
|
||||
@@ -351,7 +345,7 @@ export const getChannelsColumns = ({
|
||||
innerButtons
|
||||
defaultValue={record.priority}
|
||||
min={-999}
|
||||
size="small"
|
||||
size='small'
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -364,7 +358,10 @@ export const getChannelsColumns = ({
|
||||
onBlur={(e) => {
|
||||
Modal.warning({
|
||||
title: t('修改子渠道优先级'),
|
||||
content: t('确定要修改所有子渠道优先级为 ') + e.target.value + t(' 吗?'),
|
||||
content:
|
||||
t('确定要修改所有子渠道优先级为 ') +
|
||||
e.target.value +
|
||||
t(' 吗?'),
|
||||
onOk: () => {
|
||||
if (e.target.value === '') {
|
||||
return;
|
||||
@@ -379,7 +376,7 @@ export const getChannelsColumns = ({
|
||||
innerButtons
|
||||
defaultValue={record.priority}
|
||||
min={-999}
|
||||
size="small"
|
||||
size='small'
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -403,7 +400,7 @@ export const getChannelsColumns = ({
|
||||
innerButtons
|
||||
defaultValue={record.weight}
|
||||
min={0}
|
||||
size="small"
|
||||
size='small'
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -416,7 +413,10 @@ export const getChannelsColumns = ({
|
||||
onBlur={(e) => {
|
||||
Modal.warning({
|
||||
title: t('修改子渠道权重'),
|
||||
content: t('确定要修改所有子渠道权重为 ') + e.target.value + t(' 吗?'),
|
||||
content:
|
||||
t('确定要修改所有子渠道权重为 ') +
|
||||
e.target.value +
|
||||
t(' 吗?'),
|
||||
onOk: () => {
|
||||
if (e.target.value === '') {
|
||||
return;
|
||||
@@ -431,7 +431,7 @@ export const getChannelsColumns = ({
|
||||
innerButtons
|
||||
defaultValue={record.weight}
|
||||
min={-999}
|
||||
size="small"
|
||||
size='small'
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -484,18 +484,18 @@ export const getChannelsColumns = ({
|
||||
return (
|
||||
<Space wrap>
|
||||
<SplitButtonGroup
|
||||
className="overflow-hidden"
|
||||
className='overflow-hidden'
|
||||
aria-label={t('测试单个渠道操作项目组')}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
size='small'
|
||||
type='tertiary'
|
||||
onClick={() => testChannel(record, '')}
|
||||
>
|
||||
{t('测试')}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
size='small'
|
||||
type='tertiary'
|
||||
icon={<IconTreeTriangleDown />}
|
||||
onClick={() => {
|
||||
@@ -505,32 +505,28 @@ export const getChannelsColumns = ({
|
||||
/>
|
||||
</SplitButtonGroup>
|
||||
|
||||
{
|
||||
record.status === 1 ? (
|
||||
<Button
|
||||
type='danger'
|
||||
size="small"
|
||||
onClick={() => manageChannel(record.id, 'disable', record)}
|
||||
>
|
||||
{t('禁用')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => manageChannel(record.id, 'enable', record)}
|
||||
>
|
||||
{t('启用')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
{record.status === 1 ? (
|
||||
<Button
|
||||
type='danger'
|
||||
size='small'
|
||||
onClick={() => manageChannel(record.id, 'disable', record)}
|
||||
>
|
||||
{t('禁用')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size='small'
|
||||
onClick={() => manageChannel(record.id, 'enable', record)}
|
||||
>
|
||||
{t('启用')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{record.channel_info?.is_multi_key ? (
|
||||
<SplitButtonGroup
|
||||
aria-label={t('多密钥渠道操作项目组')}
|
||||
>
|
||||
<SplitButtonGroup aria-label={t('多密钥渠道操作项目组')}>
|
||||
<Button
|
||||
type='tertiary'
|
||||
size="small"
|
||||
size='small'
|
||||
onClick={() => {
|
||||
setEditingChannel(record);
|
||||
setShowEdit(true);
|
||||
@@ -549,12 +545,12 @@ export const getChannelsColumns = ({
|
||||
setCurrentMultiKeyChannel(record);
|
||||
setShowMultiKeyManageModal(true);
|
||||
},
|
||||
}
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Button
|
||||
type='tertiary'
|
||||
size="small"
|
||||
size='small'
|
||||
icon={<IconTreeTriangleDown />}
|
||||
/>
|
||||
</Dropdown>
|
||||
@@ -562,7 +558,7 @@ export const getChannelsColumns = ({
|
||||
) : (
|
||||
<Button
|
||||
type='tertiary'
|
||||
size="small"
|
||||
size='small'
|
||||
onClick={() => {
|
||||
setEditingChannel(record);
|
||||
setShowEdit(true);
|
||||
@@ -577,11 +573,7 @@ export const getChannelsColumns = ({
|
||||
position='bottomRight'
|
||||
menu={moreMenuItems}
|
||||
>
|
||||
<Button
|
||||
icon={<IconMore />}
|
||||
type='tertiary'
|
||||
size="small"
|
||||
/>
|
||||
<Button icon={<IconMore />} type='tertiary' size='small' />
|
||||
</Dropdown>
|
||||
</Space>
|
||||
);
|
||||
@@ -591,21 +583,21 @@ export const getChannelsColumns = ({
|
||||
<Space wrap>
|
||||
<Button
|
||||
type='tertiary'
|
||||
size="small"
|
||||
size='small'
|
||||
onClick={() => manageTag(record.key, 'enable')}
|
||||
>
|
||||
{t('启用全部')}
|
||||
</Button>
|
||||
<Button
|
||||
type='tertiary'
|
||||
size="small"
|
||||
size='small'
|
||||
onClick={() => manageTag(record.key, 'disable')}
|
||||
>
|
||||
{t('禁用全部')}
|
||||
</Button>
|
||||
<Button
|
||||
type='tertiary'
|
||||
size="small"
|
||||
size='small'
|
||||
onClick={() => {
|
||||
setShowEditTag(true);
|
||||
setEditingTag(record.key);
|
||||
@@ -619,4 +611,4 @@ export const getChannelsColumns = ({
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
@@ -34,16 +34,16 @@ const ChannelsFilters = ({
|
||||
groupOptions,
|
||||
loading,
|
||||
searching,
|
||||
t
|
||||
t,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex flex-col md:flex-row justify-between items-center gap-2 w-full">
|
||||
<div className="flex gap-2 w-full md:w-auto order-2 md:order-1">
|
||||
<div className='flex flex-col md:flex-row justify-between items-center gap-2 w-full'>
|
||||
<div className='flex gap-2 w-full md:w-auto order-2 md:order-1'>
|
||||
<Button
|
||||
size='small'
|
||||
theme='light'
|
||||
type='primary'
|
||||
className="w-full md:w-auto"
|
||||
className='w-full md:w-auto'
|
||||
onClick={() => {
|
||||
setEditingChannel({
|
||||
id: undefined,
|
||||
@@ -57,7 +57,7 @@ const ChannelsFilters = ({
|
||||
<Button
|
||||
size='small'
|
||||
type='tertiary'
|
||||
className="w-full md:w-auto"
|
||||
className='w-full md:w-auto'
|
||||
onClick={refresh}
|
||||
>
|
||||
{t('刷新')}
|
||||
@@ -67,54 +67,54 @@ const ChannelsFilters = ({
|
||||
size='small'
|
||||
type='tertiary'
|
||||
onClick={() => setShowColumnSelector(true)}
|
||||
className="w-full md:w-auto"
|
||||
className='w-full md:w-auto'
|
||||
>
|
||||
{t('列设置')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row items-center gap-2 w-full md:w-auto order-1 md:order-2">
|
||||
<div className='flex flex-col md:flex-row items-center gap-2 w-full md:w-auto order-1 md:order-2'>
|
||||
<Form
|
||||
initValues={formInitValues}
|
||||
getFormApi={(api) => setFormApi(api)}
|
||||
onSubmit={() => searchChannels(enableTagMode)}
|
||||
allowEmpty={true}
|
||||
autoComplete="off"
|
||||
layout="horizontal"
|
||||
trigger="change"
|
||||
autoComplete='off'
|
||||
layout='horizontal'
|
||||
trigger='change'
|
||||
stopValidateWithError={false}
|
||||
className="flex flex-col md:flex-row items-center gap-2 w-full"
|
||||
className='flex flex-col md:flex-row items-center gap-2 w-full'
|
||||
>
|
||||
<div className="relative w-full md:w-64">
|
||||
<div className='relative w-full md:w-64'>
|
||||
<Form.Input
|
||||
size='small'
|
||||
field="searchKeyword"
|
||||
field='searchKeyword'
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('渠道ID,名称,密钥,API地址')}
|
||||
showClear
|
||||
pure
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full md:w-48">
|
||||
<div className='w-full md:w-48'>
|
||||
<Form.Input
|
||||
size='small'
|
||||
field="searchModel"
|
||||
field='searchModel'
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('模型关键字')}
|
||||
showClear
|
||||
pure
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full md:w-32">
|
||||
<div className='w-full md:w-32'>
|
||||
<Form.Select
|
||||
size='small'
|
||||
field="searchGroup"
|
||||
field='searchGroup'
|
||||
placeholder={t('选择分组')}
|
||||
optionList={[
|
||||
{ label: t('选择分组'), value: null },
|
||||
...groupOptions,
|
||||
]}
|
||||
className="w-full"
|
||||
className='w-full'
|
||||
showClear
|
||||
pure
|
||||
onChange={() => {
|
||||
@@ -127,10 +127,10 @@ const ChannelsFilters = ({
|
||||
</div>
|
||||
<Button
|
||||
size='small'
|
||||
type="tertiary"
|
||||
htmlType="submit"
|
||||
type='tertiary'
|
||||
htmlType='submit'
|
||||
loading={loading || searching}
|
||||
className="w-full md:w-auto"
|
||||
className='w-full md:w-auto'
|
||||
>
|
||||
{t('查询')}
|
||||
</Button>
|
||||
@@ -146,7 +146,7 @@ const ChannelsFilters = ({
|
||||
}, 100);
|
||||
}
|
||||
}}
|
||||
className="w-full md:w-auto"
|
||||
className='w-full md:w-auto'
|
||||
>
|
||||
{t('重置')}
|
||||
</Button>
|
||||
@@ -156,4 +156,4 @@ const ChannelsFilters = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelsFilters;
|
||||
export default ChannelsFilters;
|
||||
|
||||
@@ -22,7 +22,7 @@ import { Empty } from '@douyinfe/semi-ui';
|
||||
import CardTable from '../../common/ui/CardTable';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark
|
||||
IllustrationNoResultDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import { getChannelsColumns } from './ChannelsColumnDefs';
|
||||
|
||||
@@ -142,25 +142,27 @@ const ChannelsTable = (channelsData) => {
|
||||
rowSelection={
|
||||
enableBatchDelete
|
||||
? {
|
||||
onChange: (selectedRowKeys, selectedRows) => {
|
||||
setSelectedChannels(selectedRows);
|
||||
},
|
||||
}
|
||||
onChange: (selectedRowKeys, selectedRows) => {
|
||||
setSelectedChannels(selectedRows);
|
||||
},
|
||||
}
|
||||
: null
|
||||
}
|
||||
empty={
|
||||
<Empty
|
||||
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
|
||||
darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
|
||||
darkModeImage={
|
||||
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
|
||||
}
|
||||
description={t('搜索无结果')}
|
||||
style={{ padding: 30 }}
|
||||
/>
|
||||
}
|
||||
className="rounded-xl overflow-hidden"
|
||||
size="middle"
|
||||
className='rounded-xl overflow-hidden'
|
||||
size='middle'
|
||||
loading={loading || searching}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelsTable;
|
||||
export default ChannelsTable;
|
||||
|
||||
@@ -33,7 +33,7 @@ const ChannelsTabs = ({
|
||||
pageSize,
|
||||
idSort,
|
||||
setActivePage,
|
||||
t
|
||||
t,
|
||||
}) => {
|
||||
if (enableTagMode) return null;
|
||||
|
||||
@@ -46,24 +46,29 @@ const ChannelsTabs = ({
|
||||
return (
|
||||
<Tabs
|
||||
activeKey={activeTypeKey}
|
||||
type="card"
|
||||
type='card'
|
||||
collapsible
|
||||
onChange={handleTabChange}
|
||||
className="mb-2"
|
||||
className='mb-2'
|
||||
>
|
||||
<TabPane
|
||||
itemKey="all"
|
||||
itemKey='all'
|
||||
tab={
|
||||
<span className="flex items-center gap-2">
|
||||
<span className='flex items-center gap-2'>
|
||||
{t('全部')}
|
||||
<Tag color={activeTypeKey === 'all' ? 'red' : 'grey'} shape='circle'>
|
||||
<Tag
|
||||
color={activeTypeKey === 'all' ? 'red' : 'grey'}
|
||||
shape='circle'
|
||||
>
|
||||
{channelTypeCounts['all'] || 0}
|
||||
</Tag>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
||||
{CHANNEL_OPTIONS.filter((opt) => availableTypeKeys.includes(String(opt.value))).map((option) => {
|
||||
{CHANNEL_OPTIONS.filter((opt) =>
|
||||
availableTypeKeys.includes(String(opt.value)),
|
||||
).map((option) => {
|
||||
const key = String(option.value);
|
||||
const count = channelTypeCounts[option.value] || 0;
|
||||
return (
|
||||
@@ -71,10 +76,13 @@ const ChannelsTabs = ({
|
||||
key={key}
|
||||
itemKey={key}
|
||||
tab={
|
||||
<span className="flex items-center gap-2">
|
||||
<span className='flex items-center gap-2'>
|
||||
{getChannelIcon(option.value)}
|
||||
{option.label}
|
||||
<Tag color={activeTypeKey === key ? 'red' : 'grey'} shape='circle'>
|
||||
<Tag
|
||||
color={activeTypeKey === key ? 'red' : 'grey'}
|
||||
shape='circle'
|
||||
>
|
||||
{count}
|
||||
</Tag>
|
||||
</span>
|
||||
@@ -86,4 +94,4 @@ const ChannelsTabs = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelsTabs;
|
||||
export default ChannelsTabs;
|
||||
|
||||
@@ -64,7 +64,7 @@ const ChannelsPage = () => {
|
||||
|
||||
{/* Main Content */}
|
||||
<CardPro
|
||||
type="type3"
|
||||
type='type3'
|
||||
tabsArea={<ChannelsTabs {...channelsData} />}
|
||||
actionsArea={<ChannelsActions {...channelsData} />}
|
||||
searchArea={<ChannelsFilters {...channelsData} />}
|
||||
@@ -85,4 +85,4 @@ const ChannelsPage = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelsPage;
|
||||
export default ChannelsPage;
|
||||
|
||||
@@ -27,7 +27,7 @@ const BatchTagModal = ({
|
||||
batchSetTagValue,
|
||||
setBatchSetTagValue,
|
||||
selectedChannels,
|
||||
t
|
||||
t,
|
||||
}) => {
|
||||
return (
|
||||
<Modal
|
||||
@@ -37,10 +37,10 @@ const BatchTagModal = ({
|
||||
onCancel={() => setShowBatchSetTag(false)}
|
||||
maskClosable={false}
|
||||
centered={true}
|
||||
size="small"
|
||||
className="!rounded-lg"
|
||||
size='small'
|
||||
className='!rounded-lg'
|
||||
>
|
||||
<div className="mb-5">
|
||||
<div className='mb-5'>
|
||||
<Typography.Text>{t('请输入要设置的标签名称')}</Typography.Text>
|
||||
</div>
|
||||
<Input
|
||||
@@ -48,13 +48,16 @@ const BatchTagModal = ({
|
||||
value={batchSetTagValue}
|
||||
onChange={(v) => setBatchSetTagValue(v)}
|
||||
/>
|
||||
<div className="mt-4">
|
||||
<div className='mt-4'>
|
||||
<Typography.Text type='secondary'>
|
||||
{t('已选择 ${count} 个渠道').replace('${count}', selectedChannels.length)}
|
||||
{t('已选择 ${count} 个渠道').replace(
|
||||
'${count}',
|
||||
selectedChannels.length,
|
||||
)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default BatchTagModal;
|
||||
export default BatchTagModal;
|
||||
|
||||
@@ -74,10 +74,8 @@ const ColumnSelectorModal = ({
|
||||
visible={showColumnSelector}
|
||||
onCancel={() => setShowColumnSelector(false)}
|
||||
footer={
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => initDefaultColumns()}>
|
||||
{t('重置')}
|
||||
</Button>
|
||||
<div className='flex justify-end'>
|
||||
<Button onClick={() => initDefaultColumns()}>{t('重置')}</Button>
|
||||
<Button onClick={() => setShowColumnSelector(false)}>
|
||||
{t('取消')}
|
||||
</Button>
|
||||
@@ -100,7 +98,7 @@ const ColumnSelectorModal = ({
|
||||
</Checkbox>
|
||||
</div>
|
||||
<div
|
||||
className="flex flex-wrap max-h-96 overflow-y-auto rounded-lg p-4"
|
||||
className='flex flex-wrap max-h-96 overflow-y-auto rounded-lg p-4'
|
||||
style={{ border: '1px solid var(--semi-color-border)' }}
|
||||
>
|
||||
{allColumns.map((column) => {
|
||||
@@ -110,10 +108,7 @@ const ColumnSelectorModal = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={column.key}
|
||||
className="w-1/2 mb-4 pr-2"
|
||||
>
|
||||
<div key={column.key} className='w-1/2 mb-4 pr-2'>
|
||||
<Checkbox
|
||||
checked={!!visibleColumns[column.key]}
|
||||
onChange={(e) =>
|
||||
@@ -130,4 +125,4 @@ const ColumnSelectorModal = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default ColumnSelectorModal;
|
||||
export default ColumnSelectorModal;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -289,7 +289,7 @@ const EditTagModal = (props) => {
|
||||
t('已新增 {{count}} 个模型:{{list}}', {
|
||||
count: addedModels.length,
|
||||
list: addedModels.join(', '),
|
||||
})
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
showInfo(t('未发现新增模型'));
|
||||
@@ -301,8 +301,10 @@ const EditTagModal = (props) => {
|
||||
placement='right'
|
||||
title={
|
||||
<Space>
|
||||
<Tag color="blue" shape="circle">{t('编辑')}</Tag>
|
||||
<Title heading={4} className="m-0">
|
||||
<Tag color='blue' shape='circle'>
|
||||
{t('编辑')}
|
||||
</Tag>
|
||||
<Title heading={4} className='m-0'>
|
||||
{t('编辑标签')}
|
||||
</Title>
|
||||
</Space>
|
||||
@@ -312,10 +314,10 @@ const EditTagModal = (props) => {
|
||||
width={600}
|
||||
onCancel={handleClose}
|
||||
footer={
|
||||
<div className="flex justify-end bg-white">
|
||||
<div className='flex justify-end bg-white'>
|
||||
<Space>
|
||||
<Button
|
||||
theme="solid"
|
||||
theme='solid'
|
||||
onClick={() => formApiRef.current?.submitForm()}
|
||||
loading={loading}
|
||||
icon={<IconSave />}
|
||||
@@ -323,8 +325,8 @@ const EditTagModal = (props) => {
|
||||
{t('保存')}
|
||||
</Button>
|
||||
<Button
|
||||
theme="light"
|
||||
type="primary"
|
||||
theme='light'
|
||||
type='primary'
|
||||
onClick={handleClose}
|
||||
icon={<IconClose />}
|
||||
>
|
||||
@@ -343,26 +345,28 @@ const EditTagModal = (props) => {
|
||||
>
|
||||
{() => (
|
||||
<Spin spinning={loading}>
|
||||
<div className="p-2">
|
||||
<Card className="!rounded-2xl shadow-sm border-0 mb-6">
|
||||
<div className='p-2'>
|
||||
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
|
||||
{/* Header: Tag Info */}
|
||||
<div className="flex items-center mb-2">
|
||||
<Avatar size="small" color="blue" className="mr-2 shadow-md">
|
||||
<div className='flex items-center mb-2'>
|
||||
<Avatar size='small' color='blue' className='mr-2 shadow-md'>
|
||||
<IconBookmark size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className="text-lg font-medium">{t('标签信息')}</Text>
|
||||
<div className="text-xs text-gray-600">{t('标签的基本配置')}</div>
|
||||
<Text className='text-lg font-medium'>{t('标签信息')}</Text>
|
||||
<div className='text-xs text-gray-600'>
|
||||
{t('标签的基本配置')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Banner
|
||||
type="warning"
|
||||
type='warning'
|
||||
description={t('所有编辑均为覆盖操作,留空则不更改')}
|
||||
className="!rounded-lg mb-4"
|
||||
className='!rounded-lg mb-4'
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className='space-y-4'>
|
||||
<Form.Input
|
||||
field='new_tag'
|
||||
label={t('标签名称')}
|
||||
@@ -372,23 +376,31 @@ const EditTagModal = (props) => {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="!rounded-2xl shadow-sm border-0 mb-6">
|
||||
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
|
||||
{/* Header: Model Config */}
|
||||
<div className="flex items-center mb-2">
|
||||
<Avatar size="small" color="purple" className="mr-2 shadow-md">
|
||||
<div className='flex items-center mb-2'>
|
||||
<Avatar
|
||||
size='small'
|
||||
color='purple'
|
||||
className='mr-2 shadow-md'
|
||||
>
|
||||
<IconCode size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className="text-lg font-medium">{t('模型配置')}</Text>
|
||||
<div className="text-xs text-gray-600">{t('模型选择和映射设置')}</div>
|
||||
<Text className='text-lg font-medium'>{t('模型配置')}</Text>
|
||||
<div className='text-xs text-gray-600'>
|
||||
{t('模型选择和映射设置')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className='space-y-4'>
|
||||
<Banner
|
||||
type="info"
|
||||
description={t('当前模型列表为该标签下所有渠道模型列表最长的一个,并非所有渠道的并集,请注意可能导致某些渠道模型丢失。')}
|
||||
className="!rounded-lg mb-4"
|
||||
type='info'
|
||||
description={t(
|
||||
'当前模型列表为该标签下所有渠道模型列表最长的一个,并非所有渠道的并集,请注意可能导致某些渠道模型丢失。',
|
||||
)}
|
||||
className='!rounded-lg mb-4'
|
||||
/>
|
||||
<Form.Select
|
||||
field='models'
|
||||
@@ -408,46 +420,87 @@ const EditTagModal = (props) => {
|
||||
label={t('自定义模型名称')}
|
||||
placeholder={t('输入自定义模型名称')}
|
||||
onChange={(value) => setCustomModel(value.trim())}
|
||||
suffix={<Button size='small' type='primary' onClick={addCustomModels}>{t('填入')}</Button>}
|
||||
suffix={
|
||||
<Button
|
||||
size='small'
|
||||
type='primary'
|
||||
onClick={addCustomModels}
|
||||
>
|
||||
{t('填入')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Form.TextArea
|
||||
field='model_mapping'
|
||||
label={t('模型重定向')}
|
||||
placeholder={t('此项可选,用于修改请求体中的模型名称,为一个 JSON 字符串,键为请求中模型名称,值为要替换的模型名称,留空则不更改')}
|
||||
autosize
|
||||
onChange={(value) => handleInputChange('model_mapping', value)}
|
||||
extraText={(
|
||||
<Space>
|
||||
<Text className="!text-semi-color-primary cursor-pointer" onClick={() => handleInputChange('model_mapping', JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2))}>{t('填入模板')}</Text>
|
||||
<Text className="!text-semi-color-primary cursor-pointer" onClick={() => handleInputChange('model_mapping', JSON.stringify({}, null, 2))}>{t('清空重定向')}</Text>
|
||||
<Text className="!text-semi-color-primary cursor-pointer" onClick={() => handleInputChange('model_mapping', '')}>{t('不更改')}</Text>
|
||||
</Space>
|
||||
placeholder={t(
|
||||
'此项可选,用于修改请求体中的模型名称,为一个 JSON 字符串,键为请求中模型名称,值为要替换的模型名称,留空则不更改',
|
||||
)}
|
||||
autosize
|
||||
onChange={(value) =>
|
||||
handleInputChange('model_mapping', value)
|
||||
}
|
||||
extraText={
|
||||
<Space>
|
||||
<Text
|
||||
className='!text-semi-color-primary cursor-pointer'
|
||||
onClick={() =>
|
||||
handleInputChange(
|
||||
'model_mapping',
|
||||
JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('填入模板')}
|
||||
</Text>
|
||||
<Text
|
||||
className='!text-semi-color-primary cursor-pointer'
|
||||
onClick={() =>
|
||||
handleInputChange(
|
||||
'model_mapping',
|
||||
JSON.stringify({}, null, 2),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('清空重定向')}
|
||||
</Text>
|
||||
<Text
|
||||
className='!text-semi-color-primary cursor-pointer'
|
||||
onClick={() => handleInputChange('model_mapping', '')}
|
||||
>
|
||||
{t('不更改')}
|
||||
</Text>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="!rounded-2xl shadow-sm border-0">
|
||||
<Card className='!rounded-2xl shadow-sm border-0'>
|
||||
{/* Header: Group Settings */}
|
||||
<div className="flex items-center mb-2">
|
||||
<Avatar size="small" color="green" className="mr-2 shadow-md">
|
||||
<div className='flex items-center mb-2'>
|
||||
<Avatar size='small' color='green' className='mr-2 shadow-md'>
|
||||
<IconUser size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className="text-lg font-medium">{t('分组设置')}</Text>
|
||||
<div className="text-xs text-gray-600">{t('用户分组配置')}</div>
|
||||
<Text className='text-lg font-medium'>{t('分组设置')}</Text>
|
||||
<div className='text-xs text-gray-600'>
|
||||
{t('用户分组配置')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className='space-y-4'>
|
||||
<Form.Select
|
||||
field='groups'
|
||||
label={t('分组')}
|
||||
placeholder={t('请选择可以使用该渠道的分组,留空则不更改')}
|
||||
multiple
|
||||
allowAdditions
|
||||
additionLabel={t('请在系统设置页面编辑分组倍率以添加新的分组:')}
|
||||
additionLabel={t(
|
||||
'请在系统设置页面编辑分组倍率以添加新的分组:',
|
||||
)}
|
||||
optionList={groupOptions}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(value) => handleInputChange('groups', value)}
|
||||
@@ -462,4 +515,4 @@ const EditTagModal = (props) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default EditTagModal;
|
||||
export default EditTagModal;
|
||||
|
||||
@@ -19,16 +19,31 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
||||
import { Modal, Checkbox, Spin, Input, Typography, Empty, Tabs, Collapse } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
Modal,
|
||||
Checkbox,
|
||||
Spin,
|
||||
Input,
|
||||
Typography,
|
||||
Empty,
|
||||
Tabs,
|
||||
Collapse,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark
|
||||
IllustrationNoResultDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import { IconSearch } from '@douyinfe/semi-icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getModelCategories } from '../../../../helpers/render';
|
||||
|
||||
const ModelSelectModal = ({ visible, models = [], selected = [], onConfirm, onCancel }) => {
|
||||
const ModelSelectModal = ({
|
||||
visible,
|
||||
models = [],
|
||||
selected = [],
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [checkedList, setCheckedList] = useState(selected);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
@@ -36,11 +51,15 @@ const ModelSelectModal = ({ visible, models = [], selected = [], onConfirm, onCa
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const filteredModels = models.filter((m) => m.toLowerCase().includes(keyword.toLowerCase()));
|
||||
const filteredModels = models.filter((m) =>
|
||||
m.toLowerCase().includes(keyword.toLowerCase()),
|
||||
);
|
||||
|
||||
// 分类模型:新获取的模型和已有模型
|
||||
const newModels = filteredModels.filter(model => !selected.includes(model));
|
||||
const existingModels = filteredModels.filter(model => selected.includes(model));
|
||||
const newModels = filteredModels.filter((model) => !selected.includes(model));
|
||||
const existingModels = filteredModels.filter((model) =>
|
||||
selected.includes(model),
|
||||
);
|
||||
|
||||
// 同步外部选中值
|
||||
useEffect(() => {
|
||||
@@ -68,7 +87,7 @@ const ModelSelectModal = ({ visible, models = [], selected = [], onConfirm, onCa
|
||||
const categorizedModels = {};
|
||||
const uncategorizedModels = [];
|
||||
|
||||
models.forEach(model => {
|
||||
models.forEach((model) => {
|
||||
let foundCategory = false;
|
||||
for (const [key, category] of Object.entries(categories)) {
|
||||
if (key !== 'all' && category.filter({ model_name: model })) {
|
||||
@@ -76,7 +95,7 @@ const ModelSelectModal = ({ visible, models = [], selected = [], onConfirm, onCa
|
||||
categorizedModels[key] = {
|
||||
label: category.label,
|
||||
icon: category.icon,
|
||||
models: []
|
||||
models: [],
|
||||
};
|
||||
}
|
||||
categorizedModels[key].models.push(model);
|
||||
@@ -94,7 +113,7 @@ const ModelSelectModal = ({ visible, models = [], selected = [], onConfirm, onCa
|
||||
categorizedModels['other'] = {
|
||||
label: t('其他'),
|
||||
icon: null,
|
||||
models: uncategorizedModels
|
||||
models: uncategorizedModels,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -106,14 +125,22 @@ const ModelSelectModal = ({ visible, models = [], selected = [], onConfirm, onCa
|
||||
|
||||
// Tab列表配置
|
||||
const tabList = [
|
||||
...(newModels.length > 0 ? [{
|
||||
tab: `${t('新获取的模型')} (${newModels.length})`,
|
||||
itemKey: 'new'
|
||||
}] : []),
|
||||
...(existingModels.length > 0 ? [{
|
||||
tab: `${t('已有的模型')} (${existingModels.length})`,
|
||||
itemKey: 'existing'
|
||||
}] : [])
|
||||
...(newModels.length > 0
|
||||
? [
|
||||
{
|
||||
tab: `${t('新获取的模型')} (${newModels.length})`,
|
||||
itemKey: 'new',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(existingModels.length > 0
|
||||
? [
|
||||
{
|
||||
tab: `${t('已有的模型')} (${existingModels.length})`,
|
||||
itemKey: 'existing',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
// 处理分类全选/取消全选
|
||||
@@ -122,14 +149,16 @@ const ModelSelectModal = ({ visible, models = [], selected = [], onConfirm, onCa
|
||||
|
||||
if (isChecked) {
|
||||
// 全选:添加该分类下所有未选中的模型
|
||||
categoryModels.forEach(model => {
|
||||
categoryModels.forEach((model) => {
|
||||
if (!newCheckedList.includes(model)) {
|
||||
newCheckedList.push(model);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 取消全选:移除该分类下所有已选中的模型
|
||||
newCheckedList = newCheckedList.filter(model => !categoryModels.includes(model));
|
||||
newCheckedList = newCheckedList.filter(
|
||||
(model) => !categoryModels.includes(model),
|
||||
);
|
||||
}
|
||||
|
||||
setCheckedList(newCheckedList);
|
||||
@@ -137,12 +166,17 @@ const ModelSelectModal = ({ visible, models = [], selected = [], onConfirm, onCa
|
||||
|
||||
// 检查分类是否全选
|
||||
const isCategoryAllSelected = (categoryModels) => {
|
||||
return categoryModels.length > 0 && categoryModels.every(model => checkedList.includes(model));
|
||||
return (
|
||||
categoryModels.length > 0 &&
|
||||
categoryModels.every((model) => checkedList.includes(model))
|
||||
);
|
||||
};
|
||||
|
||||
// 检查分类是否部分选中
|
||||
const isCategoryIndeterminate = (categoryModels) => {
|
||||
const selectedCount = categoryModels.filter(model => checkedList.includes(model)).length;
|
||||
const selectedCount = categoryModels.filter((model) =>
|
||||
checkedList.includes(model),
|
||||
).length;
|
||||
return selectedCount > 0 && selectedCount < categoryModels.length;
|
||||
};
|
||||
|
||||
@@ -151,10 +185,15 @@ const ModelSelectModal = ({ visible, models = [], selected = [], onConfirm, onCa
|
||||
if (categoryEntries.length === 0) return null;
|
||||
|
||||
// 生成所有面板的key,确保都展开
|
||||
const allActiveKeys = categoryEntries.map((_, index) => `${categoryKeyPrefix}_${index}`);
|
||||
const allActiveKeys = categoryEntries.map(
|
||||
(_, index) => `${categoryKeyPrefix}_${index}`,
|
||||
);
|
||||
|
||||
return (
|
||||
<Collapse key={`${categoryKeyPrefix}_${categoryEntries.length}`} defaultActiveKey={[]}>
|
||||
<Collapse
|
||||
key={`${categoryKeyPrefix}_${categoryEntries.length}`}
|
||||
defaultActiveKey={[]}
|
||||
>
|
||||
{categoryEntries.map(([key, categoryData], index) => (
|
||||
<Collapse.Panel
|
||||
key={`${categoryKeyPrefix}_${index}`}
|
||||
@@ -166,24 +205,29 @@ const ModelSelectModal = ({ visible, models = [], selected = [], onConfirm, onCa
|
||||
indeterminate={isCategoryIndeterminate(categoryData.models)}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation(); // 防止触发面板折叠
|
||||
handleCategorySelectAll(categoryData.models, e.target.checked);
|
||||
handleCategorySelectAll(
|
||||
categoryData.models,
|
||||
e.target.checked,
|
||||
);
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()} // 防止点击checkbox时折叠面板
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className='flex items-center gap-2 mb-3'>
|
||||
{categoryData.icon}
|
||||
<Typography.Text type="secondary" size="small">
|
||||
<Typography.Text type='secondary' size='small'>
|
||||
{t('已选择 {{selected}} / {{total}}', {
|
||||
selected: categoryData.models.filter(model => checkedList.includes(model)).length,
|
||||
total: categoryData.models.length
|
||||
selected: categoryData.models.filter((model) =>
|
||||
checkedList.includes(model),
|
||||
).length,
|
||||
total: categoryData.models.length,
|
||||
})}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-4">
|
||||
<div className='grid grid-cols-2 gap-x-4'>
|
||||
{categoryData.models.map((model) => (
|
||||
<Checkbox key={model} value={model} className="my-1">
|
||||
<Checkbox key={model} value={model} className='my-1'>
|
||||
{model}
|
||||
</Checkbox>
|
||||
))}
|
||||
@@ -197,14 +241,14 @@ const ModelSelectModal = ({ visible, models = [], selected = [], onConfirm, onCa
|
||||
return (
|
||||
<Modal
|
||||
header={
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-4 py-4">
|
||||
<Typography.Title heading={5} className="m-0">
|
||||
<div className='flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-4 py-4'>
|
||||
<Typography.Title heading={5} className='m-0'>
|
||||
{t('选择模型')}
|
||||
</Typography.Title>
|
||||
<div className="flex-shrink-0">
|
||||
<div className='flex-shrink-0'>
|
||||
<Tabs
|
||||
type="slash"
|
||||
size="small"
|
||||
type='slash'
|
||||
size='small'
|
||||
tabList={tabList}
|
||||
activeKey={activeTab}
|
||||
onChange={(key) => setActiveTab(key)}
|
||||
@@ -234,17 +278,22 @@ const ModelSelectModal = ({ visible, models = [], selected = [], onConfirm, onCa
|
||||
<div style={{ maxHeight: 400, overflowY: 'auto', paddingRight: 8 }}>
|
||||
{filteredModels.length === 0 ? (
|
||||
<Empty
|
||||
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
|
||||
darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
|
||||
image={
|
||||
<IllustrationNoResult style={{ width: 150, height: 150 }} />
|
||||
}
|
||||
darkModeImage={
|
||||
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
|
||||
}
|
||||
description={t('暂无匹配模型')}
|
||||
style={{ padding: 30 }}
|
||||
/>
|
||||
) : (
|
||||
<Checkbox.Group value={checkedList} onChange={(vals) => setCheckedList(vals)}>
|
||||
<Checkbox.Group
|
||||
value={checkedList}
|
||||
onChange={(vals) => setCheckedList(vals)}
|
||||
>
|
||||
{activeTab === 'new' && newModels.length > 0 && (
|
||||
<div>
|
||||
{renderModelsByCategory(newModelsByCategory, 'new')}
|
||||
</div>
|
||||
<div>{renderModelsByCategory(newModelsByCategory, 'new')}</div>
|
||||
)}
|
||||
{activeTab === 'existing' && existingModels.length > 0 && (
|
||||
<div>
|
||||
@@ -256,20 +305,30 @@ const ModelSelectModal = ({ visible, models = [], selected = [], onConfirm, onCa
|
||||
</div>
|
||||
</Spin>
|
||||
|
||||
<Typography.Text type="secondary" size="small" className="block text-right mt-4">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Typography.Text
|
||||
type='secondary'
|
||||
size='small'
|
||||
className='block text-right mt-4'
|
||||
>
|
||||
<div className='flex items-center justify-end gap-2'>
|
||||
{(() => {
|
||||
const currentModels = activeTab === 'new' ? newModels : existingModels;
|
||||
const currentSelected = currentModels.filter(model => checkedList.includes(model)).length;
|
||||
const isAllSelected = currentModels.length > 0 && currentSelected === currentModels.length;
|
||||
const isIndeterminate = currentSelected > 0 && currentSelected < currentModels.length;
|
||||
const currentModels =
|
||||
activeTab === 'new' ? newModels : existingModels;
|
||||
const currentSelected = currentModels.filter((model) =>
|
||||
checkedList.includes(model),
|
||||
).length;
|
||||
const isAllSelected =
|
||||
currentModels.length > 0 &&
|
||||
currentSelected === currentModels.length;
|
||||
const isIndeterminate =
|
||||
currentSelected > 0 && currentSelected < currentModels.length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<span>
|
||||
{t('已选择 {{selected}} / {{total}}', {
|
||||
selected: currentSelected,
|
||||
total: currentModels.length
|
||||
total: currentModels.length,
|
||||
})}
|
||||
</span>
|
||||
<Checkbox
|
||||
@@ -288,4 +347,4 @@ const ModelSelectModal = ({ visible, models = [], selected = [], onConfirm, onCa
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelSelectModal;
|
||||
export default ModelSelectModal;
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
Input,
|
||||
Table,
|
||||
Tag,
|
||||
Typography
|
||||
Typography,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { IconSearch } from '@douyinfe/semi-icons';
|
||||
import { copy, showError, showInfo, showSuccess } from '../../../../helpers';
|
||||
@@ -47,16 +47,16 @@ const ModelTestModal = ({
|
||||
setModelTablePage,
|
||||
allSelectingRef,
|
||||
isMobile,
|
||||
t
|
||||
t,
|
||||
}) => {
|
||||
const hasChannel = Boolean(currentTestChannel);
|
||||
|
||||
const filteredModels = hasChannel
|
||||
? currentTestChannel.models
|
||||
.split(',')
|
||||
.filter((model) =>
|
||||
model.toLowerCase().includes(modelSearchKeyword.toLowerCase())
|
||||
)
|
||||
.split(',')
|
||||
.filter((model) =>
|
||||
model.toLowerCase().includes(modelSearchKeyword.toLowerCase()),
|
||||
)
|
||||
: [];
|
||||
|
||||
const handleCopySelected = () => {
|
||||
@@ -66,7 +66,12 @@ const ModelTestModal = ({
|
||||
}
|
||||
copy(selectedModelKeys.join(',')).then((ok) => {
|
||||
if (ok) {
|
||||
showSuccess(t('已复制 ${count} 个模型').replace('${count}', selectedModelKeys.length));
|
||||
showSuccess(
|
||||
t('已复制 ${count} 个模型').replace(
|
||||
'${count}',
|
||||
selectedModelKeys.length,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
showError(t('复制失败,请手动复制'));
|
||||
}
|
||||
@@ -93,16 +98,17 @@ const ModelTestModal = ({
|
||||
title: t('模型名称'),
|
||||
dataIndex: 'model',
|
||||
render: (text) => (
|
||||
<div className="flex items-center">
|
||||
<div className='flex items-center'>
|
||||
<Typography.Text strong>{text}</Typography.Text>
|
||||
</div>
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('状态'),
|
||||
dataIndex: 'status',
|
||||
render: (text, record) => {
|
||||
const testResult = modelTestResults[`${currentTestChannel.id}-${record.model}`];
|
||||
const testResult =
|
||||
modelTestResults[`${currentTestChannel.id}-${record.model}`];
|
||||
const isTesting = testingModels.has(record.model);
|
||||
|
||||
if (isTesting) {
|
||||
@@ -122,21 +128,21 @@ const ModelTestModal = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Tag
|
||||
color={testResult.success ? 'green' : 'red'}
|
||||
shape='circle'
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Tag color={testResult.success ? 'green' : 'red'} shape='circle'>
|
||||
{testResult.success ? t('成功') : t('失败')}
|
||||
</Tag>
|
||||
{testResult.success && (
|
||||
<Typography.Text type="tertiary">
|
||||
{t('请求时长: ${time}s').replace('${time}', testResult.time.toFixed(2))}
|
||||
<Typography.Text type='tertiary'>
|
||||
{t('请求时长: ${time}s').replace(
|
||||
'${time}',
|
||||
testResult.time.toFixed(2),
|
||||
)}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
@@ -153,8 +159,8 @@ const ModelTestModal = ({
|
||||
{t('测试')}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const dataSource = (() => {
|
||||
@@ -169,108 +175,109 @@ const ModelTestModal = ({
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={hasChannel ? (
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
<Typography.Text strong className="!text-[var(--semi-color-text-0)] !text-base">
|
||||
{currentTestChannel.name} {t('渠道的模型测试')}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="tertiary" size="small">
|
||||
{t('共')} {currentTestChannel.models.split(',').length} {t('个模型')}
|
||||
</Typography.Text>
|
||||
title={
|
||||
hasChannel ? (
|
||||
<div className='flex flex-col gap-2 w-full'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Typography.Text
|
||||
strong
|
||||
className='!text-[var(--semi-color-text-0)] !text-base'
|
||||
>
|
||||
{currentTestChannel.name} {t('渠道的模型测试')}
|
||||
</Typography.Text>
|
||||
<Typography.Text type='tertiary' size='small'>
|
||||
{t('共')} {currentTestChannel.models.split(',').length}{' '}
|
||||
{t('个模型')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
) : null
|
||||
}
|
||||
visible={showModelTestModal}
|
||||
onCancel={handleCloseModal}
|
||||
footer={hasChannel ? (
|
||||
<div className="flex justify-end">
|
||||
{isBatchTesting ? (
|
||||
<Button
|
||||
type='danger'
|
||||
onClick={handleCloseModal}
|
||||
>
|
||||
{t('停止测试')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type='tertiary'
|
||||
onClick={handleCloseModal}
|
||||
>
|
||||
{t('取消')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={batchTestModels}
|
||||
loading={isBatchTesting}
|
||||
disabled={isBatchTesting}
|
||||
>
|
||||
{isBatchTesting ? t('测试中...') : t('批量测试${count}个模型').replace(
|
||||
'${count}',
|
||||
filteredModels.length
|
||||
footer={
|
||||
hasChannel ? (
|
||||
<div className='flex justify-end'>
|
||||
{isBatchTesting ? (
|
||||
<Button type='danger' onClick={handleCloseModal}>
|
||||
{t('停止测试')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button type='tertiary' onClick={handleCloseModal}>
|
||||
{t('取消')}
|
||||
</Button>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<Button
|
||||
onClick={batchTestModels}
|
||||
loading={isBatchTesting}
|
||||
disabled={isBatchTesting}
|
||||
>
|
||||
{isBatchTesting
|
||||
? t('测试中...')
|
||||
: t('批量测试${count}个模型').replace(
|
||||
'${count}',
|
||||
filteredModels.length,
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
maskClosable={!isBatchTesting}
|
||||
className="!rounded-lg"
|
||||
className='!rounded-lg'
|
||||
size={isMobile ? 'full-width' : 'large'}
|
||||
>
|
||||
{hasChannel && (<div className="model-test-scroll">
|
||||
{/* 搜索与操作按钮 */}
|
||||
<div className="flex items-center justify-end gap-2 w-full mb-2">
|
||||
<Input
|
||||
placeholder={t('搜索模型...')}
|
||||
value={modelSearchKeyword}
|
||||
onChange={(v) => {
|
||||
setModelSearchKeyword(v);
|
||||
setModelTablePage(1);
|
||||
{hasChannel && (
|
||||
<div className='model-test-scroll'>
|
||||
{/* 搜索与操作按钮 */}
|
||||
<div className='flex items-center justify-end gap-2 w-full mb-2'>
|
||||
<Input
|
||||
placeholder={t('搜索模型...')}
|
||||
value={modelSearchKeyword}
|
||||
onChange={(v) => {
|
||||
setModelSearchKeyword(v);
|
||||
setModelTablePage(1);
|
||||
}}
|
||||
className='!w-full'
|
||||
prefix={<IconSearch />}
|
||||
showClear
|
||||
/>
|
||||
|
||||
<Button onClick={handleCopySelected}>{t('复制已选')}</Button>
|
||||
|
||||
<Button type='tertiary' onClick={handleSelectSuccess}>
|
||||
{t('选择成功')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={dataSource}
|
||||
rowSelection={{
|
||||
selectedRowKeys: selectedModelKeys,
|
||||
onChange: (keys) => {
|
||||
if (allSelectingRef.current) {
|
||||
allSelectingRef.current = false;
|
||||
return;
|
||||
}
|
||||
setSelectedModelKeys(keys);
|
||||
},
|
||||
onSelectAll: (checked) => {
|
||||
allSelectingRef.current = true;
|
||||
setSelectedModelKeys(checked ? filteredModels : []);
|
||||
},
|
||||
}}
|
||||
pagination={{
|
||||
currentPage: modelTablePage,
|
||||
pageSize: MODEL_TABLE_PAGE_SIZE,
|
||||
total: filteredModels.length,
|
||||
showSizeChanger: false,
|
||||
onPageChange: (page) => setModelTablePage(page),
|
||||
}}
|
||||
className="!w-full"
|
||||
prefix={<IconSearch />}
|
||||
showClear
|
||||
/>
|
||||
|
||||
<Button onClick={handleCopySelected}>
|
||||
{t('复制已选')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type='tertiary'
|
||||
onClick={handleSelectSuccess}
|
||||
>
|
||||
{t('选择成功')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={dataSource}
|
||||
rowSelection={{
|
||||
selectedRowKeys: selectedModelKeys,
|
||||
onChange: (keys) => {
|
||||
if (allSelectingRef.current) {
|
||||
allSelectingRef.current = false;
|
||||
return;
|
||||
}
|
||||
setSelectedModelKeys(keys);
|
||||
},
|
||||
onSelectAll: (checked) => {
|
||||
allSelectingRef.current = true;
|
||||
setSelectedModelKeys(checked ? filteredModels : []);
|
||||
},
|
||||
}}
|
||||
pagination={{
|
||||
currentPage: modelTablePage,
|
||||
pageSize: MODEL_TABLE_PAGE_SIZE,
|
||||
total: filteredModels.length,
|
||||
showSizeChanger: false,
|
||||
onPageChange: (page) => setModelTablePage(page),
|
||||
}}
|
||||
/>
|
||||
</div>)}
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelTestModal;
|
||||
export default ModelTestModal;
|
||||
|
||||
@@ -35,19 +35,22 @@ import {
|
||||
Col,
|
||||
Badge,
|
||||
Progress,
|
||||
Card
|
||||
Card,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
||||
import { API, showError, showSuccess, timestamp2string } from '../../../../helpers';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import {
|
||||
API,
|
||||
showError,
|
||||
showSuccess,
|
||||
timestamp2string,
|
||||
} from '../../../../helpers';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const MultiKeyManageModal = ({
|
||||
visible,
|
||||
onCancel,
|
||||
channel,
|
||||
onRefresh
|
||||
}) => {
|
||||
const MultiKeyManageModal = ({ visible, onCancel, channel, onRefresh }) => {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [keyStatusList, setKeyStatusList] = useState([]);
|
||||
@@ -68,7 +71,11 @@ const MultiKeyManageModal = ({
|
||||
const [statusFilter, setStatusFilter] = useState(null); // null=all, 1=enabled, 2=manual_disabled, 3=auto_disabled
|
||||
|
||||
// Load key status data
|
||||
const loadKeyStatus = async (page = currentPage, size = pageSize, status = statusFilter) => {
|
||||
const loadKeyStatus = async (
|
||||
page = currentPage,
|
||||
size = pageSize,
|
||||
status = statusFilter,
|
||||
) => {
|
||||
if (!channel?.id) return;
|
||||
|
||||
setLoading(true);
|
||||
@@ -77,7 +84,7 @@ const MultiKeyManageModal = ({
|
||||
channel_id: channel.id,
|
||||
action: 'get_key_status',
|
||||
page: page,
|
||||
page_size: size
|
||||
page_size: size,
|
||||
};
|
||||
|
||||
// Add status filter if specified
|
||||
@@ -113,13 +120,13 @@ const MultiKeyManageModal = ({
|
||||
// Disable a specific key
|
||||
const handleDisableKey = async (keyIndex) => {
|
||||
const operationId = `disable_${keyIndex}`;
|
||||
setOperationLoading(prev => ({ ...prev, [operationId]: true }));
|
||||
setOperationLoading((prev) => ({ ...prev, [operationId]: true }));
|
||||
|
||||
try {
|
||||
const res = await API.post('/api/channel/multi_key/manage', {
|
||||
channel_id: channel.id,
|
||||
action: 'disable_key',
|
||||
key_index: keyIndex
|
||||
key_index: keyIndex,
|
||||
});
|
||||
|
||||
if (res.data.success) {
|
||||
@@ -132,20 +139,20 @@ const MultiKeyManageModal = ({
|
||||
} catch (error) {
|
||||
showError(t('禁用密钥失败'));
|
||||
} finally {
|
||||
setOperationLoading(prev => ({ ...prev, [operationId]: false }));
|
||||
setOperationLoading((prev) => ({ ...prev, [operationId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// Enable a specific key
|
||||
const handleEnableKey = async (keyIndex) => {
|
||||
const operationId = `enable_${keyIndex}`;
|
||||
setOperationLoading(prev => ({ ...prev, [operationId]: true }));
|
||||
setOperationLoading((prev) => ({ ...prev, [operationId]: true }));
|
||||
|
||||
try {
|
||||
const res = await API.post('/api/channel/multi_key/manage', {
|
||||
channel_id: channel.id,
|
||||
action: 'enable_key',
|
||||
key_index: keyIndex
|
||||
key_index: keyIndex,
|
||||
});
|
||||
|
||||
if (res.data.success) {
|
||||
@@ -158,18 +165,18 @@ const MultiKeyManageModal = ({
|
||||
} catch (error) {
|
||||
showError(t('启用密钥失败'));
|
||||
} finally {
|
||||
setOperationLoading(prev => ({ ...prev, [operationId]: false }));
|
||||
setOperationLoading((prev) => ({ ...prev, [operationId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// Enable all disabled keys
|
||||
const handleEnableAll = async () => {
|
||||
setOperationLoading(prev => ({ ...prev, enable_all: true }));
|
||||
setOperationLoading((prev) => ({ ...prev, enable_all: true }));
|
||||
|
||||
try {
|
||||
const res = await API.post('/api/channel/multi_key/manage', {
|
||||
channel_id: channel.id,
|
||||
action: 'enable_all_keys'
|
||||
action: 'enable_all_keys',
|
||||
});
|
||||
|
||||
if (res.data.success) {
|
||||
@@ -184,18 +191,18 @@ const MultiKeyManageModal = ({
|
||||
} catch (error) {
|
||||
showError(t('启用所有密钥失败'));
|
||||
} finally {
|
||||
setOperationLoading(prev => ({ ...prev, enable_all: false }));
|
||||
setOperationLoading((prev) => ({ ...prev, enable_all: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// Disable all enabled keys
|
||||
const handleDisableAll = async () => {
|
||||
setOperationLoading(prev => ({ ...prev, disable_all: true }));
|
||||
setOperationLoading((prev) => ({ ...prev, disable_all: true }));
|
||||
|
||||
try {
|
||||
const res = await API.post('/api/channel/multi_key/manage', {
|
||||
channel_id: channel.id,
|
||||
action: 'disable_all_keys'
|
||||
action: 'disable_all_keys',
|
||||
});
|
||||
|
||||
if (res.data.success) {
|
||||
@@ -210,18 +217,18 @@ const MultiKeyManageModal = ({
|
||||
} catch (error) {
|
||||
showError(t('禁用所有密钥失败'));
|
||||
} finally {
|
||||
setOperationLoading(prev => ({ ...prev, disable_all: false }));
|
||||
setOperationLoading((prev) => ({ ...prev, disable_all: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// Delete all disabled keys
|
||||
const handleDeleteDisabledKeys = async () => {
|
||||
setOperationLoading(prev => ({ ...prev, delete_disabled: true }));
|
||||
setOperationLoading((prev) => ({ ...prev, delete_disabled: true }));
|
||||
|
||||
try {
|
||||
const res = await API.post('/api/channel/multi_key/manage', {
|
||||
channel_id: channel.id,
|
||||
action: 'delete_disabled_keys'
|
||||
action: 'delete_disabled_keys',
|
||||
});
|
||||
|
||||
if (res.data.success) {
|
||||
@@ -236,7 +243,7 @@ const MultiKeyManageModal = ({
|
||||
} catch (error) {
|
||||
showError(t('删除禁用密钥失败'));
|
||||
} finally {
|
||||
setOperationLoading(prev => ({ ...prev, delete_disabled: false }));
|
||||
setOperationLoading((prev) => ({ ...prev, delete_disabled: false }));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -246,7 +253,7 @@ const MultiKeyManageModal = ({
|
||||
loadKeyStatus(page, pageSize);
|
||||
};
|
||||
|
||||
// Handle page size change
|
||||
// Handle page size change
|
||||
const handlePageSizeChange = (size) => {
|
||||
setPageSize(size);
|
||||
setCurrentPage(1); // Reset to first page
|
||||
@@ -283,9 +290,12 @@ const MultiKeyManageModal = ({
|
||||
}, [visible]);
|
||||
|
||||
// Percentages for progress display
|
||||
const enabledPercent = total > 0 ? Math.round((enabledCount / total) * 100) : 0;
|
||||
const manualDisabledPercent = total > 0 ? Math.round((manualDisabledCount / total) * 100) : 0;
|
||||
const autoDisabledPercent = total > 0 ? Math.round((autoDisabledCount / total) * 100) : 0;
|
||||
const enabledPercent =
|
||||
total > 0 ? Math.round((enabledCount / total) * 100) : 0;
|
||||
const manualDisabledPercent =
|
||||
total > 0 ? Math.round((manualDisabledCount / total) * 100) : 0;
|
||||
const autoDisabledPercent =
|
||||
total > 0 ? Math.round((autoDisabledCount / total) * 100) : 0;
|
||||
|
||||
// 取消饼图:不再需要图表数据与配置
|
||||
|
||||
@@ -293,13 +303,29 @@ const MultiKeyManageModal = ({
|
||||
const renderStatusTag = (status) => {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return <Tag color='green' shape='circle' size='small'>{t('已启用')}</Tag>;
|
||||
return (
|
||||
<Tag color='green' shape='circle' size='small'>
|
||||
{t('已启用')}
|
||||
</Tag>
|
||||
);
|
||||
case 2:
|
||||
return <Tag color='red' shape='circle' size='small'>{t('已禁用')}</Tag>;
|
||||
return (
|
||||
<Tag color='red' shape='circle' size='small'>
|
||||
{t('已禁用')}
|
||||
</Tag>
|
||||
);
|
||||
case 3:
|
||||
return <Tag color='orange' shape='circle' size='small'>{t('自动禁用')}</Tag>;
|
||||
return (
|
||||
<Tag color='orange' shape='circle' size='small'>
|
||||
{t('自动禁用')}
|
||||
</Tag>
|
||||
);
|
||||
default:
|
||||
return <Tag color='grey' shape='circle' size='small'>{t('未知状态')}</Tag>;
|
||||
return (
|
||||
<Tag color='grey' shape='circle' size='small'>
|
||||
{t('未知状态')}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -349,9 +375,7 @@ const MultiKeyManageModal = ({
|
||||
}
|
||||
return (
|
||||
<Tooltip content={timestamp2string(time)}>
|
||||
<Text style={{ fontSize: '12px' }}>
|
||||
{timestamp2string(time)}
|
||||
</Text>
|
||||
<Text style={{ fontSize: '12px' }}>{timestamp2string(time)}</Text>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
@@ -393,14 +417,18 @@ const MultiKeyManageModal = ({
|
||||
<Space>
|
||||
<Text>{t('多密钥管理')}</Text>
|
||||
{channel?.name && (
|
||||
<Tag size='small' shape='circle' color='white'>{channel.name}</Tag>
|
||||
<Tag size='small' shape='circle' color='white'>
|
||||
{channel.name}
|
||||
</Tag>
|
||||
)}
|
||||
<Tag size='small' shape='circle' color='white'>
|
||||
{t('总密钥数')}: {total}
|
||||
</Tag>
|
||||
{channel?.channel_info?.multi_key_mode && (
|
||||
<Tag size='small' shape='circle' color='white'>
|
||||
{channel.channel_info.multi_key_mode === 'random' ? t('随机模式') : t('轮询模式')}
|
||||
{channel.channel_info.multi_key_mode === 'random'
|
||||
? t('随机模式')
|
||||
: t('轮询模式')}
|
||||
</Tag>
|
||||
)}
|
||||
</Space>
|
||||
@@ -410,60 +438,123 @@ const MultiKeyManageModal = ({
|
||||
width={900}
|
||||
footer={null}
|
||||
>
|
||||
<div className="flex flex-col mb-5">
|
||||
<div className='flex flex-col mb-5'>
|
||||
{/* Stats & Mode */}
|
||||
<div
|
||||
className="rounded-xl p-4 mb-3"
|
||||
className='rounded-xl p-4 mb-3'
|
||||
style={{
|
||||
background: 'var(--semi-color-bg-1)',
|
||||
border: '1px solid var(--semi-color-border)'
|
||||
border: '1px solid var(--semi-color-border)',
|
||||
}}
|
||||
>
|
||||
<Row gutter={16} align="middle">
|
||||
<Row gutter={16} align='middle'>
|
||||
<Col span={8}>
|
||||
<div style={{ background: 'var(--semi-color-bg-0)', border: '1px solid var(--semi-color-border)', borderRadius: 12, padding: 12 }}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--semi-color-bg-0)',
|
||||
border: '1px solid var(--semi-color-border)',
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
}}
|
||||
>
|
||||
<div className='flex items-center gap-2 mb-2'>
|
||||
<Badge dot type='success' />
|
||||
<Text type='tertiary'>{t('已启用')}</Text>
|
||||
</div>
|
||||
<div className="flex items-end gap-2 mb-2">
|
||||
<Text style={{ fontSize: 18, fontWeight: 700, color: '#22c55e' }}>{enabledCount}</Text>
|
||||
<Text style={{ fontSize: 18, color: 'var(--semi-color-text-2)' }}>/ {total}</Text>
|
||||
<div className='flex items-end gap-2 mb-2'>
|
||||
<Text
|
||||
style={{ fontSize: 18, fontWeight: 700, color: '#22c55e' }}
|
||||
>
|
||||
{enabledCount}
|
||||
</Text>
|
||||
<Text
|
||||
style={{ fontSize: 18, color: 'var(--semi-color-text-2)' }}
|
||||
>
|
||||
/ {total}
|
||||
</Text>
|
||||
</div>
|
||||
<Progress percent={enabledPercent} showInfo={false} size="small" stroke="#22c55e" style={{ height: 6, borderRadius: 999 }} />
|
||||
<Progress
|
||||
percent={enabledPercent}
|
||||
showInfo={false}
|
||||
size='small'
|
||||
stroke='#22c55e'
|
||||
style={{ height: 6, borderRadius: 999 }}
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<div style={{ background: 'var(--semi-color-bg-0)', border: '1px solid var(--semi-color-border)', borderRadius: 12, padding: 12 }}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--semi-color-bg-0)',
|
||||
border: '1px solid var(--semi-color-border)',
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
}}
|
||||
>
|
||||
<div className='flex items-center gap-2 mb-2'>
|
||||
<Badge dot type='danger' />
|
||||
<Text type='tertiary'>{t('手动禁用')}</Text>
|
||||
</div>
|
||||
<div className="flex items-end gap-2 mb-2">
|
||||
<Text style={{ fontSize: 18, fontWeight: 700, color: '#ef4444' }}>{manualDisabledCount}</Text>
|
||||
<Text style={{ fontSize: 18, color: 'var(--semi-color-text-2)' }}>/ {total}</Text>
|
||||
<div className='flex items-end gap-2 mb-2'>
|
||||
<Text
|
||||
style={{ fontSize: 18, fontWeight: 700, color: '#ef4444' }}
|
||||
>
|
||||
{manualDisabledCount}
|
||||
</Text>
|
||||
<Text
|
||||
style={{ fontSize: 18, color: 'var(--semi-color-text-2)' }}
|
||||
>
|
||||
/ {total}
|
||||
</Text>
|
||||
</div>
|
||||
<Progress percent={manualDisabledPercent} showInfo={false} size="small" stroke="#ef4444" style={{ height: 6, borderRadius: 999 }} />
|
||||
<Progress
|
||||
percent={manualDisabledPercent}
|
||||
showInfo={false}
|
||||
size='small'
|
||||
stroke='#ef4444'
|
||||
style={{ height: 6, borderRadius: 999 }}
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<div style={{ background: 'var(--semi-color-bg-0)', border: '1px solid var(--semi-color-border)', borderRadius: 12, padding: 12 }}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--semi-color-bg-0)',
|
||||
border: '1px solid var(--semi-color-border)',
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
}}
|
||||
>
|
||||
<div className='flex items-center gap-2 mb-2'>
|
||||
<Badge dot type='warning' />
|
||||
<Text type='tertiary'>{t('自动禁用')}</Text>
|
||||
</div>
|
||||
<div className="flex items-end gap-2 mb-2">
|
||||
<Text style={{ fontSize: 18, fontWeight: 700, color: '#f59e0b' }}>{autoDisabledCount}</Text>
|
||||
<Text style={{ fontSize: 18, color: 'var(--semi-color-text-2)' }}>/ {total}</Text>
|
||||
<div className='flex items-end gap-2 mb-2'>
|
||||
<Text
|
||||
style={{ fontSize: 18, fontWeight: 700, color: '#f59e0b' }}
|
||||
>
|
||||
{autoDisabledCount}
|
||||
</Text>
|
||||
<Text
|
||||
style={{ fontSize: 18, color: 'var(--semi-color-text-2)' }}
|
||||
>
|
||||
/ {total}
|
||||
</Text>
|
||||
</div>
|
||||
<Progress percent={autoDisabledPercent} showInfo={false} size="small" stroke="#f59e0b" style={{ height: 6, borderRadius: 999 }} />
|
||||
<Progress
|
||||
percent={autoDisabledPercent}
|
||||
showInfo={false}
|
||||
size='small'
|
||||
stroke='#f59e0b'
|
||||
style={{ height: 6, borderRadius: 999 }}
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<div className='flex-1 flex flex-col min-h-0'>
|
||||
<Spin spinning={loading}>
|
||||
<Card className='!rounded-xl'>
|
||||
<Table
|
||||
@@ -478,15 +569,26 @@ const MultiKeyManageModal = ({
|
||||
size='small'
|
||||
placeholder={t('全部状态')}
|
||||
>
|
||||
<Select.Option value={null}>{t('全部状态')}</Select.Option>
|
||||
<Select.Option value={1}>{t('已启用')}</Select.Option>
|
||||
<Select.Option value={2}>{t('手动禁用')}</Select.Option>
|
||||
<Select.Option value={3}>{t('自动禁用')}</Select.Option>
|
||||
<Select.Option value={null}>
|
||||
{t('全部状态')}
|
||||
</Select.Option>
|
||||
<Select.Option value={1}>
|
||||
{t('已启用')}
|
||||
</Select.Option>
|
||||
<Select.Option value={2}>
|
||||
{t('手动禁用')}
|
||||
</Select.Option>
|
||||
<Select.Option value={3}>
|
||||
{t('自动禁用')}
|
||||
</Select.Option>
|
||||
</Select>
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
<Col span={10} style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Col
|
||||
span={10}
|
||||
style={{ display: 'flex', justifyContent: 'flex-end' }}
|
||||
>
|
||||
<Space>
|
||||
<Button
|
||||
size='small'
|
||||
@@ -496,7 +598,7 @@ const MultiKeyManageModal = ({
|
||||
>
|
||||
{t('刷新')}
|
||||
</Button>
|
||||
{(manualDisabledCount + autoDisabledCount) > 0 && (
|
||||
{manualDisabledCount + autoDisabledCount > 0 && (
|
||||
<Popconfirm
|
||||
title={t('确定要启用所有密钥吗?')}
|
||||
onConfirm={handleEnableAll}
|
||||
@@ -529,7 +631,9 @@ const MultiKeyManageModal = ({
|
||||
)}
|
||||
<Popconfirm
|
||||
title={t('确定要删除所有已自动禁用的密钥吗?')}
|
||||
content={t('此操作不可撤销,将永久删除已自动禁用的密钥')}
|
||||
content={t(
|
||||
'此操作不可撤销,将永久删除已自动禁用的密钥',
|
||||
)}
|
||||
onConfirm={handleDeleteDisabledKeys}
|
||||
okType={'danger'}
|
||||
position={'topRight'}
|
||||
@@ -562,7 +666,7 @@ const MultiKeyManageModal = ({
|
||||
onShowSizeChange: (current, size) => {
|
||||
setCurrentPage(1);
|
||||
handlePageSizeChange(size);
|
||||
}
|
||||
},
|
||||
}}
|
||||
size='small'
|
||||
bordered={false}
|
||||
@@ -570,8 +674,16 @@ const MultiKeyManageModal = ({
|
||||
scroll={{ x: 'max-content' }}
|
||||
empty={
|
||||
<Empty
|
||||
image={<IllustrationNoResult style={{ width: 140, height: 140 }} />}
|
||||
darkModeImage={<IllustrationNoResultDark style={{ width: 140, height: 140 }} />}
|
||||
image={
|
||||
<IllustrationNoResult
|
||||
style={{ width: 140, height: 140 }}
|
||||
/>
|
||||
}
|
||||
darkModeImage={
|
||||
<IllustrationNoResultDark
|
||||
style={{ width: 140, height: 140 }}
|
||||
/>
|
||||
}
|
||||
title={t('暂无密钥数据')}
|
||||
description={t('请检查渠道配置或刷新重试')}
|
||||
style={{ padding: 30 }}
|
||||
@@ -586,4 +698,4 @@ const MultiKeyManageModal = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default MultiKeyManageModal;
|
||||
export default MultiKeyManageModal;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user