🎨 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:
t0ng7u
2025-08-30 21:15:10 +08:00
parent 41cf516ec5
commit 0d57b1acd4
274 changed files with 11025 additions and 7659 deletions
+33 -25
View File
@@ -1,34 +1,42 @@
module.exports = { module.exports = {
root: true, root: true,
env: { browser: true, es2021: true, node: 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'], plugins: ['header', 'react-hooks'],
overrides: [ overrides: [
{ {
files: ['**/*.{js,jsx}'], files: ['**/*.{js,jsx}'],
rules: { rules: {
'header/header': [2, 'block', [ 'header/header': [
'', 2,
'Copyright (C) 2025 QuantumNous', 'block',
'', [
'This program is free software: you can redistribute it and/or modify', '',
'it under the terms of the GNU Affero General Public License as', 'Copyright (C) 2025 QuantumNous',
'published by the Free Software Foundation, either version 3 of the', '',
'License, or (at your option) any later version.', 'This program is free software: you can redistribute it and/or modify',
'', 'it under the terms of the GNU Affero General Public License as',
'This program is distributed in the hope that it will be useful,', 'published by the Free Software Foundation, either version 3 of the',
'but WITHOUT ANY WARRANTY; without even the implied warranty of', 'License, or (at your option) any later version.',
'MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the', '',
'GNU Affero General Public License for more details.', 'This program is distributed in the hope that it will be useful,',
'', 'but WITHOUT ANY WARRANTY; without even the implied warranty of',
'You should have received a copy of the GNU Affero General Public License', 'MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the',
'along with this program. If not, see <https://www.gnu.org/licenses/>.', 'GNU Affero General Public License for more details.',
'', '',
'For commercial licensing, please contact support@quantumnous.com', '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/>.',
]], '',
'no-multiple-empty-lines': ['error', { max: 1 }] 'For commercial licensing, please contact support@quantumnous.com',
} '',
} ],
] ],
'no-multiple-empty-lines': ['error', { max: 1 }],
},
},
],
}; };
+16 -15
View File
@@ -1,19 +1,20 @@
<!doctype html> <!doctype html>
<html lang="zh"> <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> <body>
<meta charset="utf-8" /> <noscript>You need to enable JavaScript to run this app.</noscript>
<link rel="icon" href="/logo.png" /> <div id="root"></div>
<meta name="viewport" content="width=device-width, initial-scale=1" /> <script type="module" src="/src/index.jsx"></script>
<meta name="theme-color" content="#ffffff" /> </body>
<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> </html>
+1 -1
View File
@@ -22,4 +22,4 @@ export default {
tailwindcss: {}, tailwindcss: {},
autoprefixer: {}, autoprefixer: {},
}, },
} };
+1 -4
View File
@@ -73,10 +73,7 @@ function App() {
</Suspense> </Suspense>
} }
/> />
<Route <Route path='/forbidden' element={<Forbidden />} />
path='/forbidden'
element={<Forbidden />}
/>
<Route <Route
path='/console/models' path='/console/models'
element={ element={
+129 -93
View File
@@ -31,17 +31,10 @@ import {
setUserData, setUserData,
onGitHubOAuthClicked, onGitHubOAuthClicked,
onOIDCClicked, onOIDCClicked,
onLinuxDOOAuthClicked onLinuxDOOAuthClicked,
} from '../../helpers'; } from '../../helpers';
import Turnstile from 'react-turnstile'; import Turnstile from 'react-turnstile';
import { import { Button, Card, Divider, Form, Icon, Modal } from '@douyinfe/semi-ui';
Button,
Card,
Divider,
Form,
Icon,
Modal,
} from '@douyinfe/semi-ui';
import Title from '@douyinfe/semi-ui/lib/es/typography/title'; import Title from '@douyinfe/semi-ui/lib/es/typography/title';
import Text from '@douyinfe/semi-ui/lib/es/typography/text'; import Text from '@douyinfe/semi-ui/lib/es/typography/text';
import TelegramLoginButton from 'react-telegram-login'; import TelegramLoginButton from 'react-telegram-login';
@@ -77,7 +70,8 @@ const LoginForm = () => {
const [emailLoginLoading, setEmailLoginLoading] = useState(false); const [emailLoginLoading, setEmailLoginLoading] = useState(false);
const [loginLoading, setLoginLoading] = useState(false); const [loginLoading, setLoginLoading] = useState(false);
const [resetPasswordLoading, setResetPasswordLoading] = useState(false); const [resetPasswordLoading, setResetPasswordLoading] = useState(false);
const [otherLoginOptionsLoading, setOtherLoginOptionsLoading] = useState(false); const [otherLoginOptionsLoading, setOtherLoginOptionsLoading] =
useState(false);
const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false); const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false);
const [showTwoFA, setShowTwoFA] = useState(false); const [showTwoFA, setShowTwoFA] = useState(false);
@@ -247,10 +241,7 @@ const LoginForm = () => {
const handleOIDCClick = () => { const handleOIDCClick = () => {
setOidcLoading(true); setOidcLoading(true);
try { try {
onOIDCClicked( onOIDCClicked(status.oidc_authorization_endpoint, status.oidc_client_id);
status.oidc_authorization_endpoint,
status.oidc_client_id
);
} finally { } finally {
// 由于重定向,这里不会执行到,但为了完整性添加 // 由于重定向,这里不会执行到,但为了完整性添加
setTimeout(() => setOidcLoading(false), 3000); setTimeout(() => setOidcLoading(false), 3000);
@@ -306,73 +297,87 @@ const LoginForm = () => {
const renderOAuthOptions = () => { const renderOAuthOptions = () => {
return ( return (
<div className="flex flex-col items-center"> <div className='flex flex-col items-center'>
<div className="w-full max-w-md"> <div className='w-full max-w-md'>
<div className="flex items-center justify-center mb-6 gap-2"> <div className='flex items-center justify-center mb-6 gap-2'>
<img src={logo} alt="Logo" className="h-10 rounded-full" /> <img src={logo} alt='Logo' className='h-10 rounded-full' />
<Title heading={3} className='!text-gray-800'>{systemName}</Title> <Title heading={3} className='!text-gray-800'>
{systemName}
</Title>
</div> </div>
<Card className="border-0 !rounded-2xl overflow-hidden"> <Card className='border-0 !rounded-2xl overflow-hidden'>
<div className="flex justify-center pt-6 pb-2"> <div className='flex justify-center pt-6 pb-2'>
<Title heading={3} className="text-gray-800 dark:text-gray-200">{t('登 录')}</Title> <Title heading={3} className='text-gray-800 dark:text-gray-200'>
{t('登 录')}
</Title>
</div> </div>
<div className="px-2 py-8"> <div className='px-2 py-8'>
<div className="space-y-3"> <div className='space-y-3'>
{status.wechat_login && ( {status.wechat_login && (
<Button <Button
theme='outline' theme='outline'
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors" className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
type="tertiary" type='tertiary'
icon={<Icon svg={<WeChatIcon />} style={{ color: '#07C160' }} />} icon={
<Icon svg={<WeChatIcon />} style={{ color: '#07C160' }} />
}
onClick={onWeChatLoginClicked} onClick={onWeChatLoginClicked}
loading={wechatLoading} loading={wechatLoading}
> >
<span className="ml-3">{t('使用 微信 继续')}</span> <span className='ml-3'>{t('使用 微信 继续')}</span>
</Button> </Button>
)} )}
{status.github_oauth && ( {status.github_oauth && (
<Button <Button
theme='outline' theme='outline'
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors" className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
type="tertiary" type='tertiary'
icon={<IconGithubLogo size="large" />} icon={<IconGithubLogo size='large' />}
onClick={handleGitHubClick} onClick={handleGitHubClick}
loading={githubLoading} loading={githubLoading}
> >
<span className="ml-3">{t('使用 GitHub 继续')}</span> <span className='ml-3'>{t('使用 GitHub 继续')}</span>
</Button> </Button>
)} )}
{status.oidc_enabled && ( {status.oidc_enabled && (
<Button <Button
theme='outline' theme='outline'
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors" className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
type="tertiary" type='tertiary'
icon={<OIDCIcon style={{ color: '#1877F2' }} />} icon={<OIDCIcon style={{ color: '#1877F2' }} />}
onClick={handleOIDCClick} onClick={handleOIDCClick}
loading={oidcLoading} loading={oidcLoading}
> >
<span className="ml-3">{t('使用 OIDC 继续')}</span> <span className='ml-3'>{t('使用 OIDC 继续')}</span>
</Button> </Button>
)} )}
{status.linuxdo_oauth && ( {status.linuxdo_oauth && (
<Button <Button
theme='outline' theme='outline'
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors" className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
type="tertiary" type='tertiary'
icon={<LinuxDoIcon style={{ color: '#E95420', width: '20px', height: '20px' }} />} icon={
<LinuxDoIcon
style={{
color: '#E95420',
width: '20px',
height: '20px',
}}
/>
}
onClick={handleLinuxDOClick} onClick={handleLinuxDOClick}
loading={linuxdoLoading} loading={linuxdoLoading}
> >
<span className="ml-3">{t('使用 LinuxDO 继续')}</span> <span className='ml-3'>{t('使用 LinuxDO 继续')}</span>
</Button> </Button>
)} )}
{status.telegram_oauth && ( {status.telegram_oauth && (
<div className="flex justify-center my-2"> <div className='flex justify-center my-2'>
<TelegramLoginButton <TelegramLoginButton
dataOnauth={onTelegramLoginClicked} dataOnauth={onTelegramLoginClicked}
botName={status.telegram_bot_name} botName={status.telegram_bot_name}
@@ -385,24 +390,24 @@ const LoginForm = () => {
</Divider> </Divider>
<Button <Button
theme="solid" theme='solid'
type="primary" type='primary'
className="w-full h-12 flex items-center justify-center bg-black text-white !rounded-full hover:bg-gray-800 transition-colors" 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" />} icon={<IconMail size='large' />}
onClick={handleEmailLoginClick} onClick={handleEmailLoginClick}
loading={emailLoginLoading} loading={emailLoginLoading}
> >
<span className="ml-3">{t('使用 邮箱或用户名 登录')}</span> <span className='ml-3'>{t('使用 邮箱或用户名 登录')}</span>
</Button> </Button>
</div> </div>
{!status.self_use_mode_enabled && ( {!status.self_use_mode_enabled && (
<div className="mt-6 text-center text-sm"> <div className='mt-6 text-center text-sm'>
<Text> <Text>
{t('没有账户?')}{' '} {t('没有账户?')}{' '}
<Link <Link
to="/register" to='/register'
className="text-blue-600 hover:text-blue-800 font-medium" className='text-blue-600 hover:text-blue-800 font-medium'
> >
{t('注册')} {t('注册')}
</Link> </Link>
@@ -418,44 +423,46 @@ const LoginForm = () => {
const renderEmailLoginForm = () => { const renderEmailLoginForm = () => {
return ( return (
<div className="flex flex-col items-center"> <div className='flex flex-col items-center'>
<div className="w-full max-w-md"> <div className='w-full max-w-md'>
<div className="flex items-center justify-center mb-6 gap-2"> <div className='flex items-center justify-center mb-6 gap-2'>
<img src={logo} alt="Logo" className="h-10 rounded-full" /> <img src={logo} alt='Logo' className='h-10 rounded-full' />
<Title heading={3}>{systemName}</Title> <Title heading={3}>{systemName}</Title>
</div> </div>
<Card className="border-0 !rounded-2xl overflow-hidden"> <Card className='border-0 !rounded-2xl overflow-hidden'>
<div className="flex justify-center pt-6 pb-2"> <div className='flex justify-center pt-6 pb-2'>
<Title heading={3} className="text-gray-800 dark:text-gray-200">{t('登 录')}</Title> <Title heading={3} className='text-gray-800 dark:text-gray-200'>
{t('登 录')}
</Title>
</div> </div>
<div className="px-2 py-8"> <div className='px-2 py-8'>
<Form className="space-y-3"> <Form className='space-y-3'>
<Form.Input <Form.Input
field="username" field='username'
label={t('用户名或邮箱')} label={t('用户名或邮箱')}
placeholder={t('请输入您的用户名或邮箱地址')} placeholder={t('请输入您的用户名或邮箱地址')}
name="username" name='username'
onChange={(value) => handleChange('username', value)} onChange={(value) => handleChange('username', value)}
prefix={<IconMail />} prefix={<IconMail />}
/> />
<Form.Input <Form.Input
field="password" field='password'
label={t('密码')} label={t('密码')}
placeholder={t('请输入您的密码')} placeholder={t('请输入您的密码')}
name="password" name='password'
mode="password" mode='password'
onChange={(value) => handleChange('password', value)} onChange={(value) => handleChange('password', value)}
prefix={<IconLock />} prefix={<IconLock />}
/> />
<div className="space-y-2 pt-2"> <div className='space-y-2 pt-2'>
<Button <Button
theme="solid" theme='solid'
className="w-full !rounded-full" className='w-full !rounded-full'
type="primary" type='primary'
htmlType="submit" htmlType='submit'
onClick={handleSubmit} onClick={handleSubmit}
loading={loginLoading} loading={loginLoading}
> >
@@ -463,9 +470,9 @@ const LoginForm = () => {
</Button> </Button>
<Button <Button
theme="borderless" theme='borderless'
type='tertiary' type='tertiary'
className="w-full !rounded-full" className='w-full !rounded-full'
onClick={handleResetPasswordClick} onClick={handleResetPasswordClick}
loading={resetPasswordLoading} loading={resetPasswordLoading}
> >
@@ -474,17 +481,21 @@ const LoginForm = () => {
</div> </div>
</Form> </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'> <Divider margin='12px' align='center'>
{t('或')} {t('或')}
</Divider> </Divider>
<div className="mt-4 text-center"> <div className='mt-4 text-center'>
<Button <Button
theme="outline" theme='outline'
type="tertiary" type='tertiary'
className="w-full !rounded-full" className='w-full !rounded-full'
onClick={handleOtherLoginOptionsClick} onClick={handleOtherLoginOptionsClick}
loading={otherLoginOptionsLoading} loading={otherLoginOptionsLoading}
> >
@@ -495,12 +506,12 @@ const LoginForm = () => {
)} )}
{!status.self_use_mode_enabled && ( {!status.self_use_mode_enabled && (
<div className="mt-6 text-center text-sm"> <div className='mt-6 text-center text-sm'>
<Text> <Text>
{t('没有账户?')}{' '} {t('没有账户?')}{' '}
<Link <Link
to="/register" to='/register'
className="text-blue-600 hover:text-blue-800 font-medium" className='text-blue-600 hover:text-blue-800 font-medium'
> >
{t('注册')} {t('注册')}
</Link> </Link>
@@ -529,21 +540,25 @@ const LoginForm = () => {
loading: wechatCodeSubmitLoading, loading: wechatCodeSubmitLoading,
}} }}
> >
<div className="flex flex-col items-center"> <div className='flex flex-col items-center'>
<img src={status.wechat_qrcode} alt="微信二维码" className="mb-4" /> <img src={status.wechat_qrcode} alt='微信二维码' className='mb-4' />
</div> </div>
<div className="text-center mb-4"> <div className='text-center mb-4'>
<p>{t('微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)')}</p> <p>
{t('微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)')}
</p>
</div> </div>
<Form> <Form>
<Form.Input <Form.Input
field="wechat_verification_code" field='wechat_verification_code'
placeholder={t('验证码')} placeholder={t('验证码')}
label={t('验证码')} label={t('验证码')}
value={inputs.wechat_verification_code} value={inputs.wechat_verification_code}
onChange={(value) => handleChange('wechat_verification_code', value)} onChange={(value) =>
handleChange('wechat_verification_code', value)
}
/> />
</Form> </Form>
</Modal> </Modal>
@@ -555,10 +570,18 @@ const LoginForm = () => {
return ( return (
<Modal <Modal
title={ title={
<div className="flex items-center"> <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"> <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"> <svg
<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" /> 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> </svg>
</div> </div>
两步验证 两步验证
@@ -580,19 +603,32 @@ const LoginForm = () => {
}; };
return ( 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
<div className="blur-ball blur-ball-teal" style={{ top: '50%', left: '-120px' }} /> className='blur-ball blur-ball-indigo'
<div className="w-full max-w-sm mt-[60px]"> style={{ top: '-80px', right: '-80px', transform: 'none' }}
{showEmailLogin || !(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth) />
<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() ? renderEmailLoginForm()
: renderOAuthOptions()} : renderOAuthOptions()}
{renderWeChatLoginModal()} {renderWeChatLoginModal()}
{render2FAModal()} {render2FAModal()}
{turnstileEnabled && ( {turnstileEnabled && (
<div className="flex justify-center mt-6"> <div className='flex justify-center mt-6'>
<Turnstile <Turnstile
sitekey={turnstileSiteKey} sitekey={turnstileSiteKey}
onVerify={(token) => { onVerify={(token) => {
+7 -1
View File
@@ -20,7 +20,13 @@ For commercial licensing, please contact support@quantumnous.com
import React, { useContext, useEffect } from 'react'; import React, { useContext, useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; 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 { UserContext } from '../../context/User';
import Loading from '../common/ui/Loading'; 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 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 { useSearchParams, Link } from 'react-router-dom';
import { Button, Card, Form, Typography, Banner } from '@douyinfe/semi-ui'; import { Button, Card, Form, Typography, Banner } from '@douyinfe/semi-ui';
import { IconMail, IconLock, IconCopy } from '@douyinfe/semi-icons'; import { IconMail, IconLock, IconCopy } from '@douyinfe/semi-icons';
@@ -55,7 +62,7 @@ const PasswordResetConfirm = () => {
if (formApi) { if (formApi) {
formApi.setValues({ formApi.setValues({
email: email || '', email: email || '',
newPassword: newPassword || '' newPassword: newPassword || '',
}); });
} }
}, [searchParams, newPassword, formApi]); }, [searchParams, newPassword, formApi]);
@@ -97,40 +104,53 @@ const PasswordResetConfirm = () => {
} }
return ( 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
<div className="blur-ball blur-ball-teal" style={{ top: '50%', left: '-120px' }} /> className='blur-ball blur-ball-indigo'
<div className="w-full max-w-sm mt-[60px]"> style={{ top: '-80px', right: '-80px', transform: 'none' }}
<div className="flex flex-col items-center"> />
<div className="w-full max-w-md"> <div
<div className="flex items-center justify-center mb-6 gap-2"> className='blur-ball blur-ball-teal'
<img src={logo} alt="Logo" className="h-10 rounded-full" /> style={{ top: '50%', left: '-120px' }}
<Title heading={3} className='!text-gray-800'>{systemName}</Title> />
<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> </div>
<Card className="border-0 !rounded-2xl overflow-hidden"> <Card className='border-0 !rounded-2xl overflow-hidden'>
<div className="flex justify-center pt-6 pb-2"> <div className='flex justify-center pt-6 pb-2'>
<Title heading={3} className="text-gray-800 dark:text-gray-200">{t('密码重置确认')}</Title> <Title heading={3} className='text-gray-800 dark:text-gray-200'>
{t('密码重置确认')}
</Title>
</div> </div>
<div className="px-2 py-8"> <div className='px-2 py-8'>
{!isValidResetLink && ( {!isValidResetLink && (
<Banner <Banner
type="danger" type='danger'
description={t('无效的重置链接,请重新发起密码重置请求')} description={t('无效的重置链接,请重新发起密码重置请求')}
className="mb-4 !rounded-lg" className='mb-4 !rounded-lg'
closeIcon={null} closeIcon={null}
/> />
)} )}
<Form <Form
getFormApi={(api) => setFormApi(api)} getFormApi={(api) => setFormApi(api)}
initValues={{ email: email || '', newPassword: newPassword || '' }} initValues={{
className="space-y-4" email: email || '',
newPassword: newPassword || '',
}}
className='space-y-4'
> >
<Form.Input <Form.Input
field="email" field='email'
label={t('邮箱')} label={t('邮箱')}
name="email" name='email'
disabled={true} disabled={true}
prefix={<IconMail />} prefix={<IconMail />}
placeholder={email ? '' : t('等待获取邮箱信息...')} placeholder={email ? '' : t('等待获取邮箱信息...')}
@@ -138,19 +158,21 @@ const PasswordResetConfirm = () => {
{newPassword && ( {newPassword && (
<Form.Input <Form.Input
field="newPassword" field='newPassword'
label={t('新密码')} label={t('新密码')}
name="newPassword" name='newPassword'
disabled={true} disabled={true}
prefix={<IconLock />} prefix={<IconLock />}
suffix={ suffix={
<Button <Button
icon={<IconCopy />} icon={<IconCopy />}
type="tertiary" type='tertiary'
theme="borderless" theme='borderless'
onClick={async () => { onClick={async () => {
await copy(newPassword); await copy(newPassword);
showNotice(`${t('密码已复制到剪贴板:')} ${newPassword}`); showNotice(
`${t('密码已复制到剪贴板:')} ${newPassword}`,
);
}} }}
> >
{t('复制')} {t('复制')}
@@ -159,23 +181,32 @@ const PasswordResetConfirm = () => {
/> />
)} )}
<div className="space-y-2 pt-2"> <div className='space-y-2 pt-2'>
<Button <Button
theme="solid" theme='solid'
className="w-full !rounded-full" className='w-full !rounded-full'
type="primary" type='primary'
htmlType="submit" htmlType='submit'
onClick={handleSubmit} onClick={handleSubmit}
loading={loading} loading={loading}
disabled={disableButton || newPassword || !isValidResetLink} disabled={
disableButton || newPassword || !isValidResetLink
}
> >
{newPassword ? t('密码重置完成') : t('确认重置密码')} {newPassword ? t('密码重置完成') : t('确认重置密码')}
</Button> </Button>
</div> </div>
</Form> </Form>
<div className="mt-6 text-center text-sm"> <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> <Text>
<Link
to='/login'
className='text-blue-600 hover:text-blue-800 font-medium'
>
{t('返回登录')}
</Link>
</Text>
</div> </div>
</div> </div>
</Card> </Card>
+53 -26
View File
@@ -18,7 +18,14 @@ For commercial licensing, please contact support@quantumnous.com
*/ */
import React, { useEffect, useState } from 'react'; 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 Turnstile from 'react-turnstile';
import { Button, Card, Form, Typography } from '@douyinfe/semi-ui'; import { Button, Card, Form, Typography } from '@douyinfe/semi-ui';
import { IconMail } from '@douyinfe/semi-icons'; import { IconMail } from '@douyinfe/semi-icons';
@@ -97,57 +104,77 @@ const PasswordResetForm = () => {
} }
return ( 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
<div className="blur-ball blur-ball-teal" style={{ top: '50%', left: '-120px' }} /> className='blur-ball blur-ball-indigo'
<div className="w-full max-w-sm mt-[60px]"> style={{ top: '-80px', right: '-80px', transform: 'none' }}
<div className="flex flex-col items-center"> />
<div className="w-full max-w-md"> <div
<div className="flex items-center justify-center mb-6 gap-2"> className='blur-ball blur-ball-teal'
<img src={logo} alt="Logo" className="h-10 rounded-full" /> style={{ top: '50%', left: '-120px' }}
<Title heading={3} className='!text-gray-800'>{systemName}</Title> />
<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> </div>
<Card className="border-0 !rounded-2xl overflow-hidden"> <Card className='border-0 !rounded-2xl overflow-hidden'>
<div className="flex justify-center pt-6 pb-2"> <div className='flex justify-center pt-6 pb-2'>
<Title heading={3} className="text-gray-800 dark:text-gray-200">{t('密码重置')}</Title> <Title heading={3} className='text-gray-800 dark:text-gray-200'>
{t('密码重置')}
</Title>
</div> </div>
<div className="px-2 py-8"> <div className='px-2 py-8'>
<Form className="space-y-3"> <Form className='space-y-3'>
<Form.Input <Form.Input
field="email" field='email'
label={t('邮箱')} label={t('邮箱')}
placeholder={t('请输入您的邮箱地址')} placeholder={t('请输入您的邮箱地址')}
name="email" name='email'
value={email} value={email}
onChange={handleChange} onChange={handleChange}
prefix={<IconMail />} prefix={<IconMail />}
/> />
<div className="space-y-2 pt-2"> <div className='space-y-2 pt-2'>
<Button <Button
theme="solid" theme='solid'
className="w-full !rounded-full" className='w-full !rounded-full'
type="primary" type='primary'
htmlType="submit" htmlType='submit'
onClick={handleSubmit} onClick={handleSubmit}
loading={loading} loading={loading}
disabled={disableButton} disabled={disableButton}
> >
{disableButton ? `${t('重试')} (${countdown})` : t('提交')} {disableButton
? `${t('重试')} (${countdown})`
: t('提交')}
</Button> </Button>
</div> </div>
</Form> </Form>
<div className="mt-6 text-center text-sm"> <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> <Text>
{t('想起来了?')}{' '}
<Link
to='/login'
className='text-blue-600 hover:text-blue-800 font-medium'
>
{t('登录')}
</Link>
</Text>
</div> </div>
</div> </div>
</Card> </Card>
{turnstileEnabled && ( {turnstileEnabled && (
<div className="flex justify-center mt-6"> <div className='flex justify-center mt-6'>
<Turnstile <Turnstile
sitekey={turnstileSiteKey} sitekey={turnstileSiteKey}
onVerify={(token) => { onVerify={(token) => {
+153 -97
View File
@@ -27,20 +27,19 @@ import {
showSuccess, showSuccess,
updateAPI, updateAPI,
getSystemName, getSystemName,
setUserData setUserData,
} from '../../helpers'; } from '../../helpers';
import Turnstile from 'react-turnstile'; import Turnstile from 'react-turnstile';
import { import { Button, Card, Divider, Form, Icon, Modal } from '@douyinfe/semi-ui';
Button,
Card,
Divider,
Form,
Icon,
Modal,
} from '@douyinfe/semi-ui';
import Title from '@douyinfe/semi-ui/lib/es/typography/title'; import Title from '@douyinfe/semi-ui/lib/es/typography/title';
import Text from '@douyinfe/semi-ui/lib/es/typography/text'; 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 { import {
onGitHubOAuthClicked, onGitHubOAuthClicked,
onLinuxDOOAuthClicked, onLinuxDOOAuthClicked,
@@ -78,7 +77,8 @@ const RegisterForm = () => {
const [emailRegisterLoading, setEmailRegisterLoading] = useState(false); const [emailRegisterLoading, setEmailRegisterLoading] = useState(false);
const [registerLoading, setRegisterLoading] = useState(false); const [registerLoading, setRegisterLoading] = useState(false);
const [verificationCodeLoading, setVerificationCodeLoading] = useState(false); const [verificationCodeLoading, setVerificationCodeLoading] = useState(false);
const [otherRegisterOptionsLoading, setOtherRegisterOptionsLoading] = useState(false); const [otherRegisterOptionsLoading, setOtherRegisterOptionsLoading] =
useState(false);
const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false); const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false);
const [disableButton, setDisableButton] = useState(false); const [disableButton, setDisableButton] = useState(false);
const [countdown, setCountdown] = useState(30); const [countdown, setCountdown] = useState(30);
@@ -236,10 +236,7 @@ const RegisterForm = () => {
const handleOIDCClick = () => { const handleOIDCClick = () => {
setOidcLoading(true); setOidcLoading(true);
try { try {
onOIDCClicked( onOIDCClicked(status.oidc_authorization_endpoint, status.oidc_client_id);
status.oidc_authorization_endpoint,
status.oidc_client_id
);
} finally { } finally {
setTimeout(() => setOidcLoading(false), 3000); setTimeout(() => setOidcLoading(false), 3000);
} }
@@ -303,73 +300,87 @@ const RegisterForm = () => {
const renderOAuthOptions = () => { const renderOAuthOptions = () => {
return ( return (
<div className="flex flex-col items-center"> <div className='flex flex-col items-center'>
<div className="w-full max-w-md"> <div className='w-full max-w-md'>
<div className="flex items-center justify-center mb-6 gap-2"> <div className='flex items-center justify-center mb-6 gap-2'>
<img src={logo} alt="Logo" className="h-10 rounded-full" /> <img src={logo} alt='Logo' className='h-10 rounded-full' />
<Title heading={3} className='!text-gray-800'>{systemName}</Title> <Title heading={3} className='!text-gray-800'>
{systemName}
</Title>
</div> </div>
<Card className="border-0 !rounded-2xl overflow-hidden"> <Card className='border-0 !rounded-2xl overflow-hidden'>
<div className="flex justify-center pt-6 pb-2"> <div className='flex justify-center pt-6 pb-2'>
<Title heading={3} className="text-gray-800 dark:text-gray-200">{t('注 册')}</Title> <Title heading={3} className='text-gray-800 dark:text-gray-200'>
{t('注 册')}
</Title>
</div> </div>
<div className="px-2 py-8"> <div className='px-2 py-8'>
<div className="space-y-3"> <div className='space-y-3'>
{status.wechat_login && ( {status.wechat_login && (
<Button <Button
theme='outline' theme='outline'
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors" className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
type="tertiary" type='tertiary'
icon={<Icon svg={<WeChatIcon />} style={{ color: '#07C160' }} />} icon={
<Icon svg={<WeChatIcon />} style={{ color: '#07C160' }} />
}
onClick={onWeChatLoginClicked} onClick={onWeChatLoginClicked}
loading={wechatLoading} loading={wechatLoading}
> >
<span className="ml-3">{t('使用 微信 继续')}</span> <span className='ml-3'>{t('使用 微信 继续')}</span>
</Button> </Button>
)} )}
{status.github_oauth && ( {status.github_oauth && (
<Button <Button
theme='outline' theme='outline'
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors" className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
type="tertiary" type='tertiary'
icon={<IconGithubLogo size="large" />} icon={<IconGithubLogo size='large' />}
onClick={handleGitHubClick} onClick={handleGitHubClick}
loading={githubLoading} loading={githubLoading}
> >
<span className="ml-3">{t('使用 GitHub 继续')}</span> <span className='ml-3'>{t('使用 GitHub 继续')}</span>
</Button> </Button>
)} )}
{status.oidc_enabled && ( {status.oidc_enabled && (
<Button <Button
theme='outline' theme='outline'
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors" className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
type="tertiary" type='tertiary'
icon={<OIDCIcon style={{ color: '#1877F2' }} />} icon={<OIDCIcon style={{ color: '#1877F2' }} />}
onClick={handleOIDCClick} onClick={handleOIDCClick}
loading={oidcLoading} loading={oidcLoading}
> >
<span className="ml-3">{t('使用 OIDC 继续')}</span> <span className='ml-3'>{t('使用 OIDC 继续')}</span>
</Button> </Button>
)} )}
{status.linuxdo_oauth && ( {status.linuxdo_oauth && (
<Button <Button
theme='outline' theme='outline'
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors" className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
type="tertiary" type='tertiary'
icon={<LinuxDoIcon style={{ color: '#E95420', width: '20px', height: '20px' }} />} icon={
<LinuxDoIcon
style={{
color: '#E95420',
width: '20px',
height: '20px',
}}
/>
}
onClick={handleLinuxDOClick} onClick={handleLinuxDOClick}
loading={linuxdoLoading} loading={linuxdoLoading}
> >
<span className="ml-3">{t('使用 LinuxDO 继续')}</span> <span className='ml-3'>{t('使用 LinuxDO 继续')}</span>
</Button> </Button>
)} )}
{status.telegram_oauth && ( {status.telegram_oauth && (
<div className="flex justify-center my-2"> <div className='flex justify-center my-2'>
<TelegramLoginButton <TelegramLoginButton
dataOnauth={onTelegramLoginClicked} dataOnauth={onTelegramLoginClicked}
botName={status.telegram_bot_name} botName={status.telegram_bot_name}
@@ -382,19 +393,27 @@ const RegisterForm = () => {
</Divider> </Divider>
<Button <Button
theme="solid" theme='solid'
type="primary" type='primary'
className="w-full h-12 flex items-center justify-center bg-black text-white !rounded-full hover:bg-gray-800 transition-colors" 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" />} icon={<IconMail size='large' />}
onClick={handleEmailRegisterClick} onClick={handleEmailRegisterClick}
loading={emailRegisterLoading} loading={emailRegisterLoading}
> >
<span className="ml-3">{t('使用 用户名 注册')}</span> <span className='ml-3'>{t('使用 用户名 注册')}</span>
</Button> </Button>
</div> </div>
<div className="mt-6 text-center text-sm"> <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> <Text>
{t('已有账户?')}{' '}
<Link
to='/login'
className='text-blue-600 hover:text-blue-800 font-medium'
>
{t('登录')}
</Link>
</Text>
</div> </div>
</div> </div>
</Card> </Card>
@@ -405,44 +424,48 @@ const RegisterForm = () => {
const renderEmailRegisterForm = () => { const renderEmailRegisterForm = () => {
return ( return (
<div className="flex flex-col items-center"> <div className='flex flex-col items-center'>
<div className="w-full max-w-md"> <div className='w-full max-w-md'>
<div className="flex items-center justify-center mb-6 gap-2"> <div className='flex items-center justify-center mb-6 gap-2'>
<img src={logo} alt="Logo" className="h-10 rounded-full" /> <img src={logo} alt='Logo' className='h-10 rounded-full' />
<Title heading={3} className='!text-gray-800'>{systemName}</Title> <Title heading={3} className='!text-gray-800'>
{systemName}
</Title>
</div> </div>
<Card className="border-0 !rounded-2xl overflow-hidden"> <Card className='border-0 !rounded-2xl overflow-hidden'>
<div className="flex justify-center pt-6 pb-2"> <div className='flex justify-center pt-6 pb-2'>
<Title heading={3} className="text-gray-800 dark:text-gray-200">{t('注 册')}</Title> <Title heading={3} className='text-gray-800 dark:text-gray-200'>
{t('注 册')}
</Title>
</div> </div>
<div className="px-2 py-8"> <div className='px-2 py-8'>
<Form className="space-y-3"> <Form className='space-y-3'>
<Form.Input <Form.Input
field="username" field='username'
label={t('用户名')} label={t('用户名')}
placeholder={t('请输入用户名')} placeholder={t('请输入用户名')}
name="username" name='username'
onChange={(value) => handleChange('username', value)} onChange={(value) => handleChange('username', value)}
prefix={<IconUser />} prefix={<IconUser />}
/> />
<Form.Input <Form.Input
field="password" field='password'
label={t('密码')} label={t('密码')}
placeholder={t('输入密码,最短 8 位,最长 20 位')} placeholder={t('输入密码,最短 8 位,最长 20 位')}
name="password" name='password'
mode="password" mode='password'
onChange={(value) => handleChange('password', value)} onChange={(value) => handleChange('password', value)}
prefix={<IconLock />} prefix={<IconLock />}
/> />
<Form.Input <Form.Input
field="password2" field='password2'
label={t('确认密码')} label={t('确认密码')}
placeholder={t('确认密码')} placeholder={t('确认密码')}
name="password2" name='password2'
mode="password" mode='password'
onChange={(value) => handleChange('password2', value)} onChange={(value) => handleChange('password2', value)}
prefix={<IconLock />} prefix={<IconLock />}
/> />
@@ -450,11 +473,11 @@ const RegisterForm = () => {
{showEmailVerification && ( {showEmailVerification && (
<> <>
<Form.Input <Form.Input
field="email" field='email'
label={t('邮箱')} label={t('邮箱')}
placeholder={t('输入邮箱地址')} placeholder={t('输入邮箱地址')}
name="email" name='email'
type="email" type='email'
onChange={(value) => handleChange('email', value)} onChange={(value) => handleChange('email', value)}
prefix={<IconMail />} prefix={<IconMail />}
suffix={ suffix={
@@ -463,27 +486,31 @@ const RegisterForm = () => {
loading={verificationCodeLoading} loading={verificationCodeLoading}
disabled={disableButton || verificationCodeLoading} disabled={disableButton || verificationCodeLoading}
> >
{disableButton ? `${t('重新发送')} (${countdown})` : t('获取验证码')} {disableButton
? `${t('重新发送')} (${countdown})`
: t('获取验证码')}
</Button> </Button>
} }
/> />
<Form.Input <Form.Input
field="verification_code" field='verification_code'
label={t('验证码')} label={t('验证码')}
placeholder={t('输入验证码')} placeholder={t('输入验证码')}
name="verification_code" name='verification_code'
onChange={(value) => handleChange('verification_code', value)} onChange={(value) =>
handleChange('verification_code', value)
}
prefix={<IconKey />} prefix={<IconKey />}
/> />
</> </>
)} )}
<div className="space-y-2 pt-2"> <div className='space-y-2 pt-2'>
<Button <Button
theme="solid" theme='solid'
className="w-full !rounded-full" className='w-full !rounded-full'
type="primary" type='primary'
htmlType="submit" htmlType='submit'
onClick={handleSubmit} onClick={handleSubmit}
loading={registerLoading} loading={registerLoading}
> >
@@ -492,17 +519,21 @@ const RegisterForm = () => {
</div> </div>
</Form> </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'> <Divider margin='12px' align='center'>
{t('或')} {t('或')}
</Divider> </Divider>
<div className="mt-4 text-center"> <div className='mt-4 text-center'>
<Button <Button
theme="outline" theme='outline'
type="tertiary" type='tertiary'
className="w-full !rounded-full" className='w-full !rounded-full'
onClick={handleOtherRegisterOptionsClick} onClick={handleOtherRegisterOptionsClick}
loading={otherRegisterOptionsLoading} loading={otherRegisterOptionsLoading}
> >
@@ -512,8 +543,16 @@ const RegisterForm = () => {
</> </>
)} )}
<div className="mt-6 text-center text-sm"> <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> <Text>
{t('已有账户?')}{' '}
<Link
to='/login'
className='text-blue-600 hover:text-blue-800 font-medium'
>
{t('登录')}
</Link>
</Text>
</div> </div>
</div> </div>
</Card> </Card>
@@ -536,21 +575,25 @@ const RegisterForm = () => {
loading: wechatCodeSubmitLoading, loading: wechatCodeSubmitLoading,
}} }}
> >
<div className="flex flex-col items-center"> <div className='flex flex-col items-center'>
<img src={status.wechat_qrcode} alt="微信二维码" className="mb-4" /> <img src={status.wechat_qrcode} alt='微信二维码' className='mb-4' />
</div> </div>
<div className="text-center mb-4"> <div className='text-center mb-4'>
<p>{t('微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)')}</p> <p>
{t('微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)')}
</p>
</div> </div>
<Form> <Form>
<Form.Input <Form.Input
field="wechat_verification_code" field='wechat_verification_code'
placeholder={t('验证码')} placeholder={t('验证码')}
label={t('验证码')} label={t('验证码')}
value={inputs.wechat_verification_code} value={inputs.wechat_verification_code}
onChange={(value) => handleChange('wechat_verification_code', value)} onChange={(value) =>
handleChange('wechat_verification_code', value)
}
/> />
</Form> </Form>
</Modal> </Modal>
@@ -558,18 +601,31 @@ const RegisterForm = () => {
}; };
return ( 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
<div className="blur-ball blur-ball-teal" style={{ top: '50%', left: '-120px' }} /> className='blur-ball blur-ball-indigo'
<div className="w-full max-w-sm mt-[60px]"> style={{ top: '-80px', right: '-80px', transform: 'none' }}
{showEmailRegister || !(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth) />
<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() ? renderEmailRegisterForm()
: renderOAuthOptions()} : renderOAuthOptions()}
{renderWeChatLoginModal()} {renderWeChatLoginModal()}
{turnstileEnabled && ( {turnstileEnabled && (
<div className="flex justify-center mt-6"> <div className='flex justify-center mt-6'>
<Turnstile <Turnstile
sitekey={turnstileSiteKey} sitekey={turnstileSiteKey}
onVerify={(token) => { onVerify={(token) => {
+55 -41
View File
@@ -17,7 +17,14 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { API, showError, showSuccess } from '../../helpers'; 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'; import React, { useState } from 'react';
const { Title, Text, Paragraph } = Typography; const { Title, Text, Paragraph } = Typography;
@@ -44,7 +51,7 @@ const TwoFAVerification = ({ onSuccess, onBack, isModal = false }) => {
setLoading(true); setLoading(true);
try { try {
const res = await API.post('/api/user/login/2fa', { const res = await API.post('/api/user/login/2fa', {
code: verificationCode code: verificationCode,
}); });
if (res.data.success) { if (res.data.success) {
@@ -72,30 +79,30 @@ const TwoFAVerification = ({ onSuccess, onBack, isModal = false }) => {
if (isModal) { if (isModal) {
return ( return (
<div className="space-y-4"> <div className='space-y-4'>
<Paragraph className="text-gray-600 dark:text-gray-300"> <Paragraph className='text-gray-600 dark:text-gray-300'>
请输入认证器应用显示的验证码完成登录 请输入认证器应用显示的验证码完成登录
</Paragraph> </Paragraph>
<Form onSubmit={handleSubmit}> <Form onSubmit={handleSubmit}>
<Form.Input <Form.Input
field="code" field='code'
label={useBackupCode ? "备用码" : "验证码"} label={useBackupCode ? '备用码' : '验证码'}
placeholder={useBackupCode ? "请输入8位备用码" : "请输入6位验证码"} placeholder={useBackupCode ? '请输入8位备用码' : '请输入6位验证码'}
value={verificationCode} value={verificationCode}
onChange={setVerificationCode} onChange={setVerificationCode}
onKeyPress={handleKeyPress} onKeyPress={handleKeyPress}
size="large" size='large'
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
autoFocus autoFocus
/> />
<Button <Button
htmlType="submit" htmlType='submit'
type="primary" type='primary'
loading={loading} loading={loading}
block block
size="large" size='large'
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
> >
验证并登录 验证并登录
@@ -106,8 +113,8 @@ const TwoFAVerification = ({ onSuccess, onBack, isModal = false }) => {
<div style={{ textAlign: 'center' }}> <div style={{ textAlign: 'center' }}>
<Button <Button
theme="borderless" theme='borderless'
type="tertiary" type='tertiary'
onClick={() => { onClick={() => {
setUseBackupCode(!useBackupCode); setUseBackupCode(!useBackupCode);
setVerificationCode(''); setVerificationCode('');
@@ -119,8 +126,8 @@ const TwoFAVerification = ({ onSuccess, onBack, isModal = false }) => {
{onBack && ( {onBack && (
<Button <Button
theme="borderless" theme='borderless'
type="tertiary" type='tertiary'
onClick={onBack} onClick={onBack}
style={{ color: '#1890ff', padding: 0 }} style={{ color: '#1890ff', padding: 0 }}
> >
@@ -129,15 +136,14 @@ const TwoFAVerification = ({ onSuccess, onBack, isModal = false }) => {
)} )}
</div> </div>
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-3"> <div className='bg-gray-50 dark:bg-gray-800 rounded-lg p-3'>
<Text size="small" type="secondary"> <Text size='small' type='secondary'>
<strong>提示</strong> <strong>提示</strong>
<br /> <br />
验证码每30秒更新一次 验证码每30秒更新一次
<br /> <br />
如果无法获取验证码请使用备用码 如果无法获取验证码请使用备用码
<br /> <br /> 每个备用码只能使用一次
每个备用码只能使用一次
</Text> </Text>
</div> </div>
</div> </div>
@@ -145,39 +151,41 @@ const TwoFAVerification = ({ onSuccess, onBack, isModal = false }) => {
} }
return ( return (
<div style={{ <div
display: 'flex', style={{
justifyContent: 'center', display: 'flex',
alignItems: 'center', justifyContent: 'center',
minHeight: '60vh' alignItems: 'center',
}}> minHeight: '60vh',
}}
>
<Card style={{ width: 400, padding: 24 }}> <Card style={{ width: 400, padding: 24 }}>
<div style={{ textAlign: 'center', marginBottom: 24 }}> <div style={{ textAlign: 'center', marginBottom: 24 }}>
<Title heading={3}>两步验证</Title> <Title heading={3}>两步验证</Title>
<Paragraph type="secondary"> <Paragraph type='secondary'>
请输入认证器应用显示的验证码完成登录 请输入认证器应用显示的验证码完成登录
</Paragraph> </Paragraph>
</div> </div>
<Form onSubmit={handleSubmit}> <Form onSubmit={handleSubmit}>
<Form.Input <Form.Input
field="code" field='code'
label={useBackupCode ? "备用码" : "验证码"} label={useBackupCode ? '备用码' : '验证码'}
placeholder={useBackupCode ? "请输入8位备用码" : "请输入6位验证码"} placeholder={useBackupCode ? '请输入8位备用码' : '请输入6位验证码'}
value={verificationCode} value={verificationCode}
onChange={setVerificationCode} onChange={setVerificationCode}
onKeyPress={handleKeyPress} onKeyPress={handleKeyPress}
size="large" size='large'
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
autoFocus autoFocus
/> />
<Button <Button
htmlType="submit" htmlType='submit'
type="primary" type='primary'
loading={loading} loading={loading}
block block
size="large" size='large'
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
> >
验证并登录 验证并登录
@@ -188,8 +196,8 @@ const TwoFAVerification = ({ onSuccess, onBack, isModal = false }) => {
<div style={{ textAlign: 'center' }}> <div style={{ textAlign: 'center' }}>
<Button <Button
theme="borderless" theme='borderless'
type="tertiary" type='tertiary'
onClick={() => { onClick={() => {
setUseBackupCode(!useBackupCode); setUseBackupCode(!useBackupCode);
setVerificationCode(''); setVerificationCode('');
@@ -201,8 +209,8 @@ const TwoFAVerification = ({ onSuccess, onBack, isModal = false }) => {
{onBack && ( {onBack && (
<Button <Button
theme="borderless" theme='borderless'
type="tertiary" type='tertiary'
onClick={onBack} onClick={onBack}
style={{ color: '#1890ff', padding: 0 }} style={{ color: '#1890ff', padding: 0 }}
> >
@@ -211,15 +219,21 @@ const TwoFAVerification = ({ onSuccess, onBack, isModal = false }) => {
)} )}
</div> </div>
<div style={{ marginTop: 24, padding: 16, background: '#f6f8fa', borderRadius: 6 }}> <div
<Text size="small" type="secondary"> style={{
marginTop: 24,
padding: 16,
background: '#f6f8fa',
borderRadius: 6,
}}
>
<Text size='small' type='secondary'>
<strong>提示</strong> <strong>提示</strong>
<br /> <br />
验证码每30秒更新一次 验证码每30秒更新一次
<br /> <br />
如果无法获取验证码请使用备用码 如果无法获取验证码请使用备用码
<br /> <br /> 每个备用码只能使用一次
每个备用码只能使用一次
</Text> </Text>
</div> </div>
</Card> </Card>
@@ -160,7 +160,7 @@ export function PreCode(props) {
}} }}
> >
<div <div
className="copy-code-button" className='copy-code-button'
style={{ style={{
position: 'absolute', position: 'absolute',
top: '8px', top: '8px',
@@ -174,14 +174,15 @@ export function PreCode(props) {
> >
<Tooltip content={t('复制代码')}> <Tooltip content={t('复制代码')}>
<Button <Button
size="small" size='small'
theme="borderless" theme='borderless'
icon={<IconCopy />} icon={<IconCopy />}
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (ref.current) { if (ref.current) {
const code = ref.current.querySelector('code')?.innerText ?? ''; const code =
ref.current.querySelector('code')?.innerText ?? '';
copy(code).then((success) => { copy(code).then((success) => {
if (success) { if (success) {
Toast.success(t('代码已复制到剪贴板')); Toast.success(t('代码已复制到剪贴板'));
@@ -217,7 +218,13 @@ export function PreCode(props) {
backgroundColor: 'var(--semi-color-bg-1)', 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预览: HTML预览:
</div> </div>
<div dangerouslySetInnerHTML={{ __html: htmlCode }} /> <div dangerouslySetInnerHTML={{ __html: htmlCode }} />
@@ -258,7 +265,7 @@ function CustomCode(props) {
justifyContent: 'center', justifyContent: 'center',
}} }}
> >
<Button size="small" onClick={toggleCollapsed} theme="solid"> <Button size='small' onClick={toggleCollapsed} theme='solid'>
{t('显示更多')} {t('显示更多')}
</Button> </Button>
</div> </div>
@@ -367,7 +374,16 @@ function _MarkdownContent(props) {
components={{ components={{
pre: PreCode, pre: PreCode,
code: CustomCode, 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) => { a: (aProps) => {
const href = aProps.href || ''; const href = aProps.href || '';
if (/\.(aac|mp3|opus|wav)$/.test(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)) { if (/\.(3gp|3g2|webm|ogv|mpeg|mp4|avi)$/.test(href)) {
return ( return (
<video controls style={{ width: '100%', maxWidth: '100%', margin: '12px 0' }}> <video
controls
style={{ width: '100%', maxWidth: '100%', margin: '12px 0' }}
>
<source src={href} /> <source src={href} />
</video> </video>
); );
} }
const isInternal = /^\/#/i.test(href); const isInternal = /^\/#/i.test(href);
const target = isInternal ? '_self' : aProps.target ?? '_blank'; const target = isInternal ? '_self' : (aProps.target ?? '_blank');
return ( return (
<a <a
{...aProps} {...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)' }} />, h1: (props) => (
h2: (props) => <h2 {...props} style={{ fontSize: '20px', fontWeight: 'bold', margin: '18px 0 10px 0', color: isUserMessage ? 'white' : 'var(--semi-color-text-0)' }} />, <h1
h3: (props) => <h3 {...props} style={{ fontSize: '18px', fontWeight: 'bold', margin: '16px 0 8px 0', color: isUserMessage ? 'white' : 'var(--semi-color-text-0)' }} />, {...props}
h4: (props) => <h4 {...props} style={{ fontSize: '16px', fontWeight: 'bold', margin: '14px 0 6px 0', color: isUserMessage ? 'white' : 'var(--semi-color-text-0)' }} />, style={{
h5: (props) => <h5 {...props} style={{ fontSize: '14px', fontWeight: 'bold', margin: '12px 0 4px 0', color: isUserMessage ? 'white' : 'var(--semi-color-text-0)' }} />, fontSize: '24px',
h6: (props) => <h6 {...props} style={{ fontSize: '13px', fontWeight: 'bold', margin: '10px 0 4px 0', color: isUserMessage ? 'white' : 'var(--semi-color-text-0)' }} />, 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) => (
<blockquote <blockquote
{...props} {...props}
style={{ 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', paddingLeft: '16px',
margin: '12px 0', 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', padding: '8px 16px',
borderRadius: '0 4px 4px 0', borderRadius: '0 4px 4px 0',
fontStyle: 'italic', fontStyle: 'italic',
@@ -424,9 +507,36 @@ function _MarkdownContent(props) {
}} }}
/> />
), ),
ul: (props) => <ul {...props} style={{ margin: '8px 0', paddingLeft: '20px', color: isUserMessage ? 'white' : 'inherit' }} />, ul: (props) => (
ol: (props) => <ol {...props} style={{ margin: '8px 0', paddingLeft: '20px', color: isUserMessage ? 'white' : 'inherit' }} />, <ul
li: (props) => <li {...props} style={{ margin: '4px 0', lineHeight: '1.6', color: isUserMessage ? 'white' : 'inherit' }} />, {...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) => ( table: (props) => (
<div style={{ overflow: 'auto', margin: '12px 0' }}> <div style={{ overflow: 'auto', margin: '12px 0' }}>
<table <table
@@ -434,7 +544,9 @@ function _MarkdownContent(props) {
style={{ style={{
width: '100%', width: '100%',
borderCollapse: 'collapse', 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', borderRadius: '6px',
overflow: 'hidden', overflow: 'hidden',
}} }}
@@ -446,8 +558,12 @@ function _MarkdownContent(props) {
{...props} {...props}
style={{ style={{
padding: '8px 12px', padding: '8px 12px',
backgroundColor: isUserMessage ? 'rgba(255, 255, 255, 0.2)' : 'var(--semi-color-fill-1)', backgroundColor: isUserMessage
border: isUserMessage ? '1px solid rgba(255, 255, 255, 0.3)' : '1px solid var(--semi-color-border)', ? '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', fontWeight: 'bold',
textAlign: 'left', textAlign: 'left',
color: isUserMessage ? 'white' : 'inherit', color: isUserMessage ? 'white' : 'inherit',
@@ -459,7 +575,9 @@ function _MarkdownContent(props) {
{...props} {...props}
style={{ style={{
padding: '8px 12px', 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', color: isUserMessage ? 'white' : 'inherit',
}} }}
/> />
@@ -496,25 +614,29 @@ export function MarkdownRenderer(props) {
color: 'var(--semi-color-text-0)', color: 'var(--semi-color-text-0)',
...style, ...style,
}} }}
dir="auto" dir='auto'
{...otherProps} {...otherProps}
> >
{loading ? ( {loading ? (
<div style={{ <div
display: 'flex', style={{
alignItems: 'center', display: 'flex',
gap: '8px', alignItems: 'center',
padding: '16px', gap: '8px',
color: 'var(--semi-color-text-2)', padding: '16px',
}}> color: 'var(--semi-color-text-2)',
<div style={{ }}
width: '16px', >
height: '16px', <div
border: '2px solid var(--semi-color-border)', style={{
borderTop: '2px solid var(--semi-color-primary)', width: '16px',
borderRadius: '50%', height: '16px',
animation: 'spin 1s linear infinite', border: '2px solid var(--semi-color-border)',
}} /> borderTop: '2px solid var(--semi-color-primary)',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
}}
/>
正在渲染... 正在渲染...
</div> </div>
) : ( ) : (
@@ -59,12 +59,12 @@
} }
.user-message a { .user-message a {
color: #87CEEB !important; color: #87ceeb !important;
/* 浅蓝色链接 */ /* 浅蓝色链接 */
} }
.user-message a:hover { .user-message a:hover {
color: #B0E0E6 !important; color: #b0e0e6 !important;
/* hover时更浅的蓝色 */ /* hover时更浅的蓝色 */
} }
@@ -298,7 +298,12 @@ pre:hover .copy-code-button {
.markdown-body hr { .markdown-body hr {
border: none; border: none;
height: 1px; 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; 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; margin-right: 8px;
transform: scale(1.1); transform: scale(1.1);
} }
@@ -43,7 +43,7 @@ const TwoFactorAuthModal = ({
onCancel, onCancel,
title, title,
description, description,
placeholder placeholder,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -56,10 +56,18 @@ const TwoFactorAuthModal = ({
return ( return (
<Modal <Modal
title={ title={
<div className="flex items-center"> <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"> <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"> <svg
<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" /> 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> </svg>
</div> </div>
{title || t('安全验证')} {title || t('安全验证')}
@@ -69,11 +77,9 @@ const TwoFactorAuthModal = ({
onCancel={onCancel} onCancel={onCancel}
footer={ footer={
<> <>
<Button onClick={onCancel}> <Button onClick={onCancel}>{t('取消')}</Button>
{t('取消')}
</Button>
<Button <Button
type="primary" type='primary'
loading={loading} loading={loading}
disabled={!code || loading} disabled={!code || loading}
onClick={onVerify} onClick={onVerify}
@@ -85,18 +91,29 @@ const TwoFactorAuthModal = ({
width={500} width={500}
style={{ maxWidth: '90vw' }} 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='bg-blue-50 dark:bg-blue-900 rounded-lg p-4'>
<div className="flex items-start"> <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"> <svg
<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" /> 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> </svg>
<div> <div>
<Typography.Text strong className="text-blue-800 dark:text-blue-200"> <Typography.Text
strong
className='text-blue-800 dark:text-blue-200'
>
{t('安全验证')} {t('安全验证')}
</Typography.Text> </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('为了保护账户安全,请验证您的两步验证码。')} {description || t('为了保护账户安全,请验证您的两步验证码。')}
</Typography.Text> </Typography.Text>
</div> </div>
@@ -105,19 +122,19 @@ const TwoFactorAuthModal = ({
{/* 验证码输入 */} {/* 验证码输入 */}
<div> <div>
<Typography.Text strong className="block mb-2"> <Typography.Text strong className='block mb-2'>
{t('验证身份')} {t('验证身份')}
</Typography.Text> </Typography.Text>
<Input <Input
placeholder={placeholder || t('请输入认证器验证码或备用码')} placeholder={placeholder || t('请输入认证器验证码或备用码')}
value={code} value={code}
onChange={onCodeChange} onChange={onCodeChange}
size="large" size='large'
maxLength={8} maxLength={8}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
autoFocus 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位备用码')} {t('支持6位TOTP验证码或8位备用码')}
</Typography.Text> </Typography.Text>
</div> </div>
+19 -36
View File
@@ -71,47 +71,38 @@ const CardPro = ({
const hasMobileHideableContent = actionsArea || searchArea; const hasMobileHideableContent = actionsArea || searchArea;
const renderHeader = () => { const renderHeader = () => {
const hasContent = statsArea || descriptionArea || tabsArea || actionsArea || searchArea; const hasContent =
statsArea || descriptionArea || tabsArea || actionsArea || searchArea;
if (!hasContent) return null; if (!hasContent) return null;
return ( return (
<div className="flex flex-col w-full"> <div className='flex flex-col w-full'>
{/* 统计信息区域 - 用于type2 */} {/* 统计信息区域 - 用于type2 */}
{type === 'type2' && statsArea && ( {type === 'type2' && statsArea && <>{statsArea}</>}
<>
{statsArea}
</>
)}
{/* 描述信息区域 - 用于type1和type3 */} {/* 描述信息区域 - 用于type1和type3 */}
{(type === 'type1' || type === 'type3') && descriptionArea && ( {(type === 'type1' || type === 'type3') && descriptionArea && (
<> <>{descriptionArea}</>
{descriptionArea}
</>
)} )}
{/* 第一个分隔线 - 在描述信息或统计信息后面 */} {/* 第一个分隔线 - 在描述信息或统计信息后面 */}
{((type === 'type1' || type === 'type3') && descriptionArea) || {((type === 'type1' || type === 'type3') && descriptionArea) ||
(type === 'type2' && statsArea) ? ( (type === 'type2' && statsArea) ? (
<Divider margin="12px" /> <Divider margin='12px' />
) : null} ) : null}
{/* 类型切换/标签区域 - 主要用于type3 */} {/* 类型切换/标签区域 - 主要用于type3 */}
{type === 'type3' && tabsArea && ( {type === 'type3' && tabsArea && <>{tabsArea}</>}
<>
{tabsArea}
</>
)}
{/* 移动端操作切换按钮 */} {/* 移动端操作切换按钮 */}
{isMobile && hasMobileHideableContent && ( {isMobile && hasMobileHideableContent && (
<> <>
<div className="w-full mb-2"> <div className='w-full mb-2'>
<Button <Button
onClick={toggleMobileActions} onClick={toggleMobileActions}
icon={showMobileActions ? <IconEyeClosed /> : <IconEyeOpened />} icon={showMobileActions ? <IconEyeClosed /> : <IconEyeOpened />}
type="tertiary" type='tertiary'
size="small" size='small'
theme='outline' theme='outline'
block block
> >
@@ -126,32 +117,24 @@ const CardPro = ({
className={`flex flex-col gap-2 ${isMobile && !showMobileActions ? 'hidden' : ''}`} className={`flex flex-col gap-2 ${isMobile && !showMobileActions ? 'hidden' : ''}`}
> >
{/* 操作按钮区域 - 用于type1和type3 */} {/* 操作按钮区域 - 用于type1和type3 */}
{(type === 'type1' || type === 'type3') && actionsArea && ( {(type === 'type1' || type === 'type3') &&
Array.isArray(actionsArea) ? ( actionsArea &&
(Array.isArray(actionsArea) ? (
actionsArea.map((area, idx) => ( actionsArea.map((area, idx) => (
<React.Fragment key={idx}> <React.Fragment key={idx}>
{idx !== 0 && <Divider />} {idx !== 0 && <Divider />}
<div className="w-full"> <div className='w-full'>{area}</div>
{area}
</div>
</React.Fragment> </React.Fragment>
)) ))
) : ( ) : (
<div className="w-full"> <div className='w-full'>{actionsArea}</div>
{actionsArea} ))}
</div>
)
)}
{/* 当同时存在操作区和搜索区时,插入分隔线 */} {/* 当同时存在操作区和搜索区时,插入分隔线 */}
{(actionsArea && searchArea) && <Divider />} {actionsArea && searchArea && <Divider />}
{/* 搜索表单区域 - 所有类型都可能有 */} {/* 搜索表单区域 - 所有类型都可能有 */}
{searchArea && ( {searchArea && <div className='w-full'>{searchArea}</div>}
<div className="w-full">
{searchArea}
</div>
)}
</div> </div>
</div> </div>
); );
+40 -19
View File
@@ -19,7 +19,15 @@ For commercial licensing, please contact support@quantumnous.com
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next'; 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 { IconChevronDown, IconChevronUp } from '@douyinfe/semi-icons';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useIsMobile } from '../../../hooks/common/useIsMobile'; import { useIsMobile } from '../../../hooks/common/useIsMobile';
@@ -75,18 +83,22 @@ const CardTable = ({
const renderSkeletonCard = (key) => { const renderSkeletonCard = (key) => {
const placeholder = ( const placeholder = (
<div className="p-2"> <div className='p-2'>
{visibleCols.map((col, idx) => { {visibleCols.map((col, idx) => {
if (!col.title) { if (!col.title) {
return ( 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 }} /> <Skeleton.Title active style={{ width: 100, height: 24 }} />
</div> </div>
); );
} }
return ( 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 style={{ width: 80, height: 14 }} />
<Skeleton.Title <Skeleton.Title
active active
@@ -103,14 +115,14 @@ const CardTable = ({
); );
return ( return (
<Card key={key} className="!rounded-2xl shadow-sm"> <Card key={key} className='!rounded-2xl shadow-sm'>
<Skeleton loading={true} active placeholder={placeholder}></Skeleton> <Skeleton loading={true} active placeholder={placeholder}></Skeleton>
</Card> </Card>
); );
}; };
return ( return (
<div className="flex flex-col gap-2"> <div className='flex flex-col gap-2'>
{[1, 2, 3].map((i) => renderSkeletonCard(i))} {[1, 2, 3].map((i) => renderSkeletonCard(i))}
</div> </div>
); );
@@ -127,9 +139,12 @@ const CardTable = ({
(!tableProps.rowExpandable || tableProps.rowExpandable(record)); (!tableProps.rowExpandable || tableProps.rowExpandable(record));
return ( return (
<Card key={rowKeyVal} className="!rounded-2xl shadow-sm"> <Card key={rowKeyVal} className='!rounded-2xl shadow-sm'>
{columns.map((col, colIdx) => { {columns.map((col, colIdx) => {
if (tableProps?.visibleColumns && !tableProps.visibleColumns[col.key]) { if (
tableProps?.visibleColumns &&
!tableProps.visibleColumns[col.key]
) {
return null; return null;
} }
@@ -140,7 +155,7 @@ const CardTable = ({
if (!title) { if (!title) {
return ( return (
<div key={col.key || colIdx} className="mt-2 flex justify-end"> <div key={col.key || colIdx} className='mt-2 flex justify-end'>
{cellContent} {cellContent}
</div> </div>
); );
@@ -149,14 +164,16 @@ const CardTable = ({
return ( return (
<div <div
key={col.key || colIdx} 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)' }} 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} {title}
</span> </span>
<div className="flex-1 break-all flex justify-end items-center gap-1"> <div className='flex-1 break-all flex justify-end items-center gap-1'>
{cellContent !== undefined && cellContent !== null ? cellContent : '-'} {cellContent !== undefined && cellContent !== null
? cellContent
: '-'}
</div> </div>
</div> </div>
); );
@@ -177,7 +194,7 @@ const CardTable = ({
{showDetails ? t('收起') : t('详情')} {showDetails ? t('收起') : t('详情')}
</Button> </Button>
<Collapsible isOpen={showDetails} keepDOM> <Collapsible isOpen={showDetails} keepDOM>
<div className="pt-2"> <div className='pt-2'>
{tableProps.expandedRowRender(record, index)} {tableProps.expandedRowRender(record, index)}
</div> </div>
</Collapsible> </Collapsible>
@@ -190,19 +207,23 @@ const CardTable = ({
if (isEmpty) { if (isEmpty) {
if (tableProps.empty) return tableProps.empty; if (tableProps.empty) return tableProps.empty;
return ( return (
<div className="flex justify-center p-4"> <div className='flex justify-center p-4'>
<Empty description="No Data" /> <Empty description='No Data' />
</div> </div>
); );
} }
return ( return (
<div className="flex flex-col gap-2"> <div className='flex flex-col gap-2'>
{dataSource.map((record, index) => ( {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 && ( {!hidePagination && tableProps.pagination && dataSource.length > 0 && (
<div className="mt-2 flex justify-center"> <div className='mt-2 flex justify-center'>
<Pagination {...tableProps.pagination} /> <Pagination {...tableProps.pagination} />
</div> </div>
)} )}
@@ -40,9 +40,10 @@ const parseChannelKeys = (keyData, t) => {
if (Array.isArray(parsed)) { if (Array.isArray(parsed)) {
return parsed.map((item, index) => ({ return parsed.map((item, index) => ({
id: 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', type: typeof item === 'string' ? 'text' : 'json',
label: `${t('密钥')} ${index + 1}` label: `${t('密钥')} ${index + 1}`,
})); }));
} }
} catch (e) { } catch (e) {
@@ -52,23 +53,25 @@ const parseChannelKeys = (keyData, t) => {
} }
// 检查是否是多行密钥(按换行符分割) // 检查是否是多行密钥(按换行符分割)
const lines = trimmed.split('\n').filter(line => line.trim()); const lines = trimmed.split('\n').filter((line) => line.trim());
if (lines.length > 1) { if (lines.length > 1) {
return lines.map((line, index) => ({ return lines.map((line, index) => ({
id: index, id: index,
content: line.trim(), content: line.trim(),
type: 'text', type: 'text',
label: `${t('密钥')} ${index + 1}` label: `${t('密钥')} ${index + 1}`,
})); }));
} }
// 单个密钥 // 单个密钥
return [{ return [
id: 0, {
content: trimmed, id: 0,
type: trimmed.startsWith('{') ? 'json' : 'text', content: trimmed,
label: t('密钥') type: trimmed.startsWith('{') ? 'json' : 'text',
}]; label: t('密钥'),
},
];
}; };
/** /**
@@ -85,7 +88,7 @@ const ChannelKeyDisplay = ({
showSuccessIcon = true, showSuccessIcon = true,
successText, successText,
showWarning = true, showWarning = true,
warningText warningText,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -103,34 +106,42 @@ const ChannelKeyDisplay = ({
}; };
return ( return (
<div className="space-y-4"> <div className='space-y-4'>
{/* 成功状态 */} {/* 成功状态 */}
{showSuccessIcon && ( {showSuccessIcon && (
<div className="flex items-center gap-2"> <div className='flex items-center gap-2'>
<svg className="w-5 h-5 text-green-600" fill="currentColor" viewBox="0 0 20 20"> <svg
<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" /> 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> </svg>
<Typography.Text strong className="text-green-700"> <Typography.Text strong className='text-green-700'>
{successText || t('验证成功')} {successText || t('验证成功')}
</Typography.Text> </Typography.Text>
</div> </div>
)} )}
{/* 密钥内容 */} {/* 密钥内容 */}
<div className="space-y-3"> <div className='space-y-3'>
<div className="flex items-center justify-between"> <div className='flex items-center justify-between'>
<Typography.Text strong> <Typography.Text strong>
{isMultipleKeys ? t('渠道密钥列表') : t('渠道密钥')} {isMultipleKeys ? t('渠道密钥列表') : t('渠道密钥')}
</Typography.Text> </Typography.Text>
{isMultipleKeys && ( {isMultipleKeys && (
<div className="flex items-center gap-2"> <div className='flex items-center gap-2'>
<Typography.Text type="tertiary" size="small"> <Typography.Text type='tertiary' size='small'>
{t('共 {{count}} 个密钥', { count: parsedKeys.length })} {t('共 {{count}} 个密钥', { count: parsedKeys.length })}
</Typography.Text> </Typography.Text>
<Button <Button
size="small" size='small'
type="primary" type='primary'
theme="outline" theme='outline'
onClick={handleCopyAll} onClick={handleCopyAll}
> >
{t('复制全部')} {t('复制全部')}
@@ -139,26 +150,39 @@ 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) => ( {parsedKeys.map((keyItem) => (
<Card key={keyItem.id} className="!rounded-lg !border !border-gray-200 dark:!border-gray-700"> <Card
<div className="space-y-2"> key={keyItem.id}
<div className="flex items-center justify-between"> className='!rounded-lg !border !border-gray-200 dark:!border-gray-700'
<Typography.Text strong size="small" className="text-gray-700 dark:text-gray-300"> >
<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} {keyItem.label}
</Typography.Text> </Typography.Text>
<div className="flex items-center gap-2"> <div className='flex items-center gap-2'>
{keyItem.type === 'json' && ( {keyItem.type === 'json' && (
<Tag size="small" color="blue">{t('JSON')}</Tag> <Tag size='small' color='blue'>
{t('JSON')}
</Tag>
)} )}
<Button <Button
size="small" size='small'
type="primary" type='primary'
theme="outline" theme='outline'
icon={ icon={
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20"> <svg
<path d="M8 3a1 1 0 011-1h2a1 1 0 110 2H9a1 1 0 01-1-1z" /> className='w-3 h-3'
<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" /> 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> </svg>
} }
onClick={() => handleCopyKey(keyItem.content)} onClick={() => handleCopyKey(keyItem.content)}
@@ -168,17 +192,21 @@ const ChannelKeyDisplay = ({
</div> </div>
</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 <Typography.Text
code 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} {keyItem.content}
</Typography.Text> </Typography.Text>
</div> </div>
{keyItem.type === 'json' && ( {keyItem.type === 'json' && (
<Typography.Text type="tertiary" size="small" className="block"> <Typography.Text
type='tertiary'
size='small'
className='block'
>
{t('JSON格式密钥,请确保格式正确')} {t('JSON格式密钥,请确保格式正确')}
</Typography.Text> </Typography.Text>
)} )}
@@ -188,12 +216,26 @@ const ChannelKeyDisplay = ({
</div> </div>
{isMultipleKeys && ( {isMultipleKeys && (
<div className="bg-blue-50 dark:bg-blue-900 rounded-lg p-3"> <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"> <Typography.Text
<svg className="w-4 h-4 inline mr-1" fill="currentColor" viewBox="0 0 20 20"> type='tertiary'
<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" /> 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> </svg>
{t('检测到多个密钥,您可以单独复制每个密钥,或点击复制全部获取完整内容。')} {t(
'检测到多个密钥,您可以单独复制每个密钥,或点击复制全部获取完整内容。',
)}
</Typography.Text> </Typography.Text>
</div> </div>
)} )}
@@ -201,17 +243,31 @@ const ChannelKeyDisplay = ({
{/* 安全警告 */} {/* 安全警告 */}
{showWarning && ( {showWarning && (
<div className="bg-yellow-50 dark:bg-yellow-900 rounded-lg p-4"> <div className='bg-yellow-50 dark:bg-yellow-900 rounded-lg p-4'>
<div className="flex items-start"> <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"> <svg
<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" /> 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> </svg>
<div> <div>
<Typography.Text strong className="text-yellow-800 dark:text-yellow-200"> <Typography.Text
strong
className='text-yellow-800 dark:text-yellow-200'
>
{t('安全提醒')} {t('安全提醒')}
</Typography.Text> </Typography.Text>
<Typography.Text className="block text-yellow-700 dark:text-yellow-300 text-sm mt-1"> <Typography.Text className='block text-yellow-700 dark:text-yellow-300 text-sm mt-1'>
{warningText || t('请妥善保管密钥信息,不要泄露给他人。如有安全疑虑,请及时更换密钥。')} {warningText ||
t(
'请妥善保管密钥信息,不要泄露给他人。如有安全疑虑,请及时更换密钥。',
)}
</Typography.Text> </Typography.Text>
</div> </div>
</div> </div>
+145 -115
View File
@@ -36,11 +36,7 @@ import {
Divider, Divider,
Tooltip, Tooltip,
} from '@douyinfe/semi-ui'; } from '@douyinfe/semi-ui';
import { import { IconPlus, IconDelete, IconAlertTriangle } from '@douyinfe/semi-icons';
IconPlus,
IconDelete,
IconAlertTriangle,
} from '@douyinfe/semi-icons';
const { Text } = Typography; const { Text } = Typography;
@@ -88,7 +84,7 @@ const JSONEditor = ({
// 将键值对数组转换为对象(重复键时后面的会覆盖前面的) // 将键值对数组转换为对象(重复键时后面的会覆盖前面的)
const keyValueArrayToObject = useCallback((arr) => { const keyValueArrayToObject = useCallback((arr) => {
const result = {}; const result = {};
arr.forEach(item => { arr.forEach((item) => {
if (item.key) { if (item.key) {
result[item.key] = item.value; result[item.key] = item.value;
} }
@@ -115,7 +111,8 @@ const JSONEditor = ({
// 手动模式下的本地文本缓冲 // 手动模式下的本地文本缓冲
const [manualText, setManualText] = useState(() => { const [manualText, setManualText] = useState(() => {
if (typeof value === 'string') return value; 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 ''; return '';
}); });
@@ -140,7 +137,7 @@ const JSONEditor = ({
const keyCount = {}; const keyCount = {};
const duplicates = new Set(); const duplicates = new Set();
keyValuePairs.forEach(pair => { keyValuePairs.forEach((pair) => {
if (pair.key) { if (pair.key) {
keyCount[pair.key] = (keyCount[pair.key] || 0) + 1; keyCount[pair.key] = (keyCount[pair.key] || 0) + 1;
if (keyCount[pair.key] > 1) { if (keyCount[pair.key] > 1) {
@@ -178,51 +175,65 @@ const JSONEditor = ({
useEffect(() => { useEffect(() => {
if (editMode !== 'manual') { if (editMode !== 'manual') {
if (typeof value === 'string') setManualText(value); 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(''); else setManualText('');
} }
}, [value, editMode]); }, [value, editMode]);
// 处理可视化编辑的数据变化 // 处理可视化编辑的数据变化
const handleVisualChange = useCallback((newPairs) => { const handleVisualChange = useCallback(
setKeyValuePairs(newPairs); (newPairs) => {
const jsonObject = keyValueArrayToObject(newPairs); setKeyValuePairs(newPairs);
const jsonString = Object.keys(jsonObject).length === 0 ? '' : JSON.stringify(jsonObject, null, 2); const jsonObject = keyValueArrayToObject(newPairs);
const jsonString =
Object.keys(jsonObject).length === 0
? ''
: JSON.stringify(jsonObject, null, 2);
setJsonError(''); setJsonError('');
// 通过formApi设置值 // 通过formApi设置值
if (formApi && field) { if (formApi && field) {
formApi.setValue(field, jsonString); formApi.setValue(field, jsonString);
} }
onChange?.(jsonString); onChange?.(jsonString);
}, [onChange, formApi, field, keyValueArrayToObject]); },
[onChange, formApi, field, keyValueArrayToObject],
);
// 处理手动编辑的数据变化 // 处理手动编辑的数据变化
const handleManualChange = useCallback((newValue) => { const handleManualChange = useCallback(
setManualText(newValue); (newValue) => {
if (newValue && newValue.trim()) { setManualText(newValue);
try { if (newValue && newValue.trim()) {
const parsed = JSON.parse(newValue); try {
setKeyValuePairs(objectToKeyValueArray(parsed, keyValuePairs)); const parsed = JSON.parse(newValue);
setKeyValuePairs(objectToKeyValueArray(parsed, keyValuePairs));
setJsonError('');
onChange?.(newValue);
} catch (error) {
setJsonError(error.message);
}
} else {
setKeyValuePairs([]);
setJsonError(''); setJsonError('');
onChange?.(newValue); onChange?.('');
} catch (error) {
setJsonError(error.message);
} }
} else { },
setKeyValuePairs([]); [onChange, objectToKeyValueArray, keyValuePairs],
setJsonError(''); );
onChange?.('');
}
}, [onChange, objectToKeyValueArray, keyValuePairs]);
// 切换编辑模式 // 切换编辑模式
const toggleEditMode = useCallback(() => { const toggleEditMode = useCallback(() => {
if (editMode === 'visual') { if (editMode === 'visual') {
const jsonObject = keyValueArrayToObject(keyValuePairs); 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'); setEditMode('manual');
} else { } else {
try { try {
@@ -242,12 +253,19 @@ const JSONEditor = ({
return; return;
} }
} }
}, [editMode, value, manualText, keyValuePairs, keyValueArrayToObject, objectToKeyValueArray]); }, [
editMode,
value,
manualText,
keyValuePairs,
keyValueArrayToObject,
objectToKeyValueArray,
]);
// 添加键值对 // 添加键值对
const addKeyValue = useCallback(() => { const addKeyValue = useCallback(() => {
const newPairs = [...keyValuePairs]; const newPairs = [...keyValuePairs];
const existingKeys = newPairs.map(p => p.key); const existingKeys = newPairs.map((p) => p.key);
let counter = 1; let counter = 1;
let newKey = `field_${counter}`; let newKey = `field_${counter}`;
while (existingKeys.includes(newKey)) { while (existingKeys.includes(newKey)) {
@@ -257,32 +275,41 @@ const JSONEditor = ({
newPairs.push({ newPairs.push({
id: generateUniqueId(), id: generateUniqueId(),
key: newKey, key: newKey,
value: '' value: '',
}); });
handleVisualChange(newPairs); handleVisualChange(newPairs);
}, [keyValuePairs, handleVisualChange]); }, [keyValuePairs, handleVisualChange]);
// 删除键值对 // 删除键值对
const removeKeyValue = useCallback((id) => { const removeKeyValue = useCallback(
const newPairs = keyValuePairs.filter(pair => pair.id !== id); (id) => {
handleVisualChange(newPairs); const newPairs = keyValuePairs.filter((pair) => pair.id !== id);
}, [keyValuePairs, handleVisualChange]); handleVisualChange(newPairs);
},
[keyValuePairs, handleVisualChange],
);
// 更新键名 // 更新键名
const updateKey = useCallback((id, newKey) => { const updateKey = useCallback(
const newPairs = keyValuePairs.map(pair => (id, newKey) => {
pair.id === id ? { ...pair, key: newKey } : pair const newPairs = keyValuePairs.map((pair) =>
); pair.id === id ? { ...pair, key: newKey } : pair,
handleVisualChange(newPairs); );
}, [keyValuePairs, handleVisualChange]); handleVisualChange(newPairs);
},
[keyValuePairs, handleVisualChange],
);
// 更新值 // 更新值
const updateValue = useCallback((id, newValue) => { const updateValue = useCallback(
const newPairs = keyValuePairs.map(pair => (id, newValue) => {
pair.id === id ? { ...pair, value: newValue } : pair const newPairs = keyValuePairs.map((pair) =>
); pair.id === id ? { ...pair, value: newValue } : pair,
handleVisualChange(newPairs); );
}, [keyValuePairs, handleVisualChange]); handleVisualChange(newPairs);
},
[keyValuePairs, handleVisualChange],
);
// 填入模板 // 填入模板
const fillTemplate = useCallback(() => { const fillTemplate = useCallback(() => {
@@ -298,7 +325,14 @@ const JSONEditor = ({
onChange?.(templateString); onChange?.(templateString);
setJsonError(''); setJsonError('');
} }
}, [template, onChange, formApi, field, objectToKeyValueArray, keyValuePairs]); }, [
template,
onChange,
formApi,
field,
objectToKeyValueArray,
keyValuePairs,
]);
// 渲染值输入控件(支持嵌套) // 渲染值输入控件(支持嵌套)
const renderValueInput = (pairId, value) => { const renderValueInput = (pairId, value) => {
@@ -306,12 +340,12 @@ const JSONEditor = ({
if (valueType === 'boolean') { if (valueType === 'boolean') {
return ( return (
<div className="flex items-center"> <div className='flex items-center'>
<Switch <Switch
checked={value} checked={value}
onChange={(newValue) => updateValue(pairId, newValue)} onChange={(newValue) => updateValue(pairId, newValue)}
/> />
<Text type="tertiary" className="ml-2"> <Text type='tertiary' className='ml-2'>
{value ? t('true') : t('false')} {value ? t('true') : t('false')}
</Text> </Text>
</div> </div>
@@ -373,29 +407,29 @@ const JSONEditor = ({
// 渲染键值对编辑器 // 渲染键值对编辑器
const renderKeyValueEditor = () => { const renderKeyValueEditor = () => {
return ( return (
<div className="space-y-1"> <div className='space-y-1'>
{/* 重复键警告 */} {/* 重复键警告 */}
{duplicateKeys.size > 0 && ( {duplicateKeys.size > 0 && (
<Banner <Banner
type="warning" type='warning'
icon={<IconAlertTriangle />} icon={<IconAlertTriangle />}
description={ description={
<div> <div>
<Text strong>{t('存在重复的键名:')}</Text> <Text strong>{t('存在重复的键名:')}</Text>
<Text>{Array.from(duplicateKeys).join(', ')}</Text> <Text>{Array.from(duplicateKeys).join(', ')}</Text>
<br /> <br />
<Text type="tertiary" size="small"> <Text type='tertiary' size='small'>
{t('注意:JSON中重复的键只会保留最后一个同名键的值')} {t('注意:JSON中重复的键只会保留最后一个同名键的值')}
</Text> </Text>
</div> </div>
} }
className="mb-3" className='mb-3'
/> />
)} )}
{keyValuePairs.length === 0 && ( {keyValuePairs.length === 0 && (
<div className="text-center py-6 px-4"> <div className='text-center py-6 px-4'>
<Text type="tertiary" className="text-gray-500 text-sm"> <Text type='tertiary' className='text-gray-500 text-sm'>
{t('暂无数据,点击下方按钮添加键值对')} {t('暂无数据,点击下方按钮添加键值对')}
</Text> </Text>
</div> </div>
@@ -403,13 +437,14 @@ const JSONEditor = ({
{keyValuePairs.map((pair, index) => { {keyValuePairs.map((pair, index) => {
const isDuplicate = duplicateKeys.has(pair.key); const isDuplicate = duplicateKeys.has(pair.key);
const isLastDuplicate = isDuplicate && const isLastDuplicate =
keyValuePairs.slice(index + 1).every(p => p.key !== pair.key); isDuplicate &&
keyValuePairs.slice(index + 1).every((p) => p.key !== pair.key);
return ( return (
<Row key={pair.id} gutter={8} align="middle"> <Row key={pair.id} gutter={8} align='middle'>
<Col span={6}> <Col span={6}>
<div className="relative"> <div className='relative'>
<Input <Input
placeholder={t('键名')} placeholder={t('键名')}
value={pair.key} value={pair.key}
@@ -425,24 +460,22 @@ const JSONEditor = ({
} }
> >
<IconAlertTriangle <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={{ style={{
color: isLastDuplicate ? '#ff7d00' : '#faad14', color: isLastDuplicate ? '#ff7d00' : '#faad14',
fontSize: '14px' fontSize: '14px',
}} }}
/> />
</Tooltip> </Tooltip>
)} )}
</div> </div>
</Col> </Col>
<Col span={16}> <Col span={16}>{renderValueInput(pair.id, pair.value)}</Col>
{renderValueInput(pair.id, pair.value)}
</Col>
<Col span={2}> <Col span={2}>
<Button <Button
icon={<IconDelete />} icon={<IconDelete />}
type="danger" type='danger'
theme="borderless" theme='borderless'
onClick={() => removeKeyValue(pair.id)} onClick={() => removeKeyValue(pair.id)}
style={{ width: '100%' }} style={{ width: '100%' }}
/> />
@@ -451,11 +484,11 @@ const JSONEditor = ({
); );
})} })}
<div className="mt-2 flex justify-center"> <div className='mt-2 flex justify-center'>
<Button <Button
icon={<IconPlus />} icon={<IconPlus />}
type="primary" type='primary'
theme="outline" theme='outline'
onClick={addKeyValue} onClick={addKeyValue}
> >
{t('添加键值对')} {t('添加键值对')}
@@ -467,27 +500,27 @@ const JSONEditor = ({
// 渲染区域编辑器(特殊格式)- 也需要改造以支持重复键 // 渲染区域编辑器(特殊格式)- 也需要改造以支持重复键
const renderRegionEditor = () => { const renderRegionEditor = () => {
const defaultPair = keyValuePairs.find(pair => pair.key === 'default'); const defaultPair = keyValuePairs.find((pair) => pair.key === 'default');
const modelPairs = keyValuePairs.filter(pair => pair.key !== 'default'); const modelPairs = keyValuePairs.filter((pair) => pair.key !== 'default');
return ( return (
<div className="space-y-2"> <div className='space-y-2'>
{/* 重复键警告 */} {/* 重复键警告 */}
{duplicateKeys.size > 0 && ( {duplicateKeys.size > 0 && (
<Banner <Banner
type="warning" type='warning'
icon={<IconAlertTriangle />} icon={<IconAlertTriangle />}
description={ description={
<div> <div>
<Text strong>{t('存在重复的键名:')}</Text> <Text strong>{t('存在重复的键名:')}</Text>
<Text>{Array.from(duplicateKeys).join(', ')}</Text> <Text>{Array.from(duplicateKeys).join(', ')}</Text>
<br /> <br />
<Text type="tertiary" size="small"> <Text type='tertiary' size='small'>
{t('注意:JSON中重复的键只会保留最后一个同名键的值')} {t('注意:JSON中重复的键只会保留最后一个同名键的值')}
</Text> </Text>
</div> </div>
} }
className="mb-3" className='mb-3'
/> />
)} )}
@@ -500,11 +533,14 @@ const JSONEditor = ({
if (defaultPair) { if (defaultPair) {
updateValue(defaultPair.id, value); updateValue(defaultPair.id, value);
} else { } else {
const newPairs = [...keyValuePairs, { const newPairs = [
id: generateUniqueId(), ...keyValuePairs,
key: 'default', {
value: value id: generateUniqueId(),
}]; key: 'default',
value: value,
},
];
handleVisualChange(newPairs); handleVisualChange(newPairs);
} }
}} }}
@@ -517,9 +553,9 @@ const JSONEditor = ({
{modelPairs.map((pair) => { {modelPairs.map((pair) => {
const isDuplicate = duplicateKeys.has(pair.key); const isDuplicate = duplicateKeys.has(pair.key);
return ( 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}> <Col span={10}>
<div className="relative"> <div className='relative'>
<Input <Input
placeholder={t('模型名称')} placeholder={t('模型名称')}
value={pair.key} value={pair.key}
@@ -529,7 +565,7 @@ const JSONEditor = ({
{isDuplicate && ( {isDuplicate && (
<Tooltip content={t('重复的键名')}> <Tooltip content={t('重复的键名')}>
<IconAlertTriangle <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' }} style={{ color: '#faad14', fontSize: '14px' }}
/> />
</Tooltip> </Tooltip>
@@ -546,8 +582,8 @@ const JSONEditor = ({
<Col span={2}> <Col span={2}>
<Button <Button
icon={<IconDelete />} icon={<IconDelete />}
type="danger" type='danger'
theme="borderless" theme='borderless'
onClick={() => removeKeyValue(pair.id)} onClick={() => removeKeyValue(pair.id)}
style={{ width: '100%' }} style={{ width: '100%' }}
/> />
@@ -556,12 +592,12 @@ const JSONEditor = ({
); );
})} })}
<div className="mt-2 flex justify-center"> <div className='mt-2 flex justify-center'>
<Button <Button
icon={<IconPlus />} icon={<IconPlus />}
onClick={addKeyValue} onClick={addKeyValue}
type="primary" type='primary'
theme="outline" theme='outline'
> >
{t('添加模型区域')} {t('添加模型区域')}
</Button> </Button>
@@ -590,9 +626,9 @@ const JSONEditor = ({
<Form.Slot label={label}> <Form.Slot label={label}>
<Card <Card
header={ header={
<div className="flex justify-between items-center"> <div className='flex justify-between items-center'>
<Tabs <Tabs
type="slash" type='slash'
activeKey={editMode} activeKey={editMode}
onChange={(key) => { onChange={(key) => {
if (key === 'manual' && editMode === 'visual') { if (key === 'manual' && editMode === 'visual') {
@@ -602,16 +638,12 @@ const JSONEditor = ({
} }
}} }}
> >
<TabPane tab={t('可视化')} itemKey="visual" /> <TabPane tab={t('可视化')} itemKey='visual' />
<TabPane tab={t('手动编辑')} itemKey="manual" /> <TabPane tab={t('手动编辑')} itemKey='manual' />
</Tabs> </Tabs>
{template && templateLabel && ( {template && templateLabel && (
<Button <Button type='tertiary' onClick={fillTemplate} size='small'>
type="tertiary"
onClick={fillTemplate}
size="small"
>
{templateLabel} {templateLabel}
</Button> </Button>
)} )}
@@ -619,14 +651,14 @@ const JSONEditor = ({
} }
headerStyle={{ padding: '12px 16px' }} headerStyle={{ padding: '12px 16px' }}
bodyStyle={{ padding: '16px' }} bodyStyle={{ padding: '16px' }}
className="!rounded-2xl" className='!rounded-2xl'
> >
{/* JSON错误提示 */} {/* JSON错误提示 */}
{hasJsonError && ( {hasJsonError && (
<Banner <Banner
type="danger" type='danger'
description={`JSON 格式错误: ${jsonError}`} description={`JSON 格式错误: ${jsonError}`}
className="mb-3" className='mb-3'
/> />
)} )}
@@ -668,14 +700,12 @@ const JSONEditor = ({
{/* 额外文本显示在卡片底部 */} {/* 额外文本显示在卡片底部 */}
{extraText && ( {extraText && (
<Divider margin='12px' align='center'> <Divider margin='12px' align='center'>
<Text type="tertiary" size="small">{extraText}</Text> <Text type='tertiary' size='small'>
{extraText}
</Text>
</Divider> </Divider>
)} )}
{extraFooter && ( {extraFooter && <div className='mt-1'>{extraFooter}</div>}
<div className="mt-1">
{extraFooter}
</div>
)}
</Card> </Card>
</Form.Slot> </Form.Slot>
); );
+2 -6
View File
@@ -21,13 +21,9 @@ import React from 'react';
import { Spin } from '@douyinfe/semi-ui'; import { Spin } from '@douyinfe/semi-ui';
const Loading = ({ size = 'small' }) => { const Loading = ({ size = 'small' }) => {
return ( return (
<div className="fixed inset-0 w-screen h-screen flex items-center justify-center"> <div className='fixed inset-0 w-screen h-screen flex items-center justify-center'>
<Spin <Spin size={size} spinning={true} />
size={size}
spinning={true}
/>
</div> </div>
); );
}; };
@@ -24,7 +24,7 @@ import React, {
useCallback, useCallback,
useMemo, useMemo,
useImperativeHandle, useImperativeHandle,
forwardRef forwardRef,
} from 'react'; } from 'react';
/** /**
@@ -34,186 +34,208 @@ import React, {
* 当内容超出容器高度且未滚动到底部时,会显示底部渐变指示器 * 当内容超出容器高度且未滚动到底部时,会显示底部渐变指示器
* *
*/ */
const ScrollableContainer = forwardRef(({ const ScrollableContainer = forwardRef(
children, (
maxHeight = '24rem', {
className = '', children,
contentClassName = '', maxHeight = '24rem',
fadeIndicatorClassName = '', className = '',
checkInterval = 100, contentClassName = '',
scrollThreshold = 5, fadeIndicatorClassName = '',
debounceDelay = 16, // ~60fps checkInterval = 100,
onScroll, scrollThreshold = 5,
onScrollStateChange, debounceDelay = 16, // ~60fps
...props onScroll,
}, ref) => { onScrollStateChange,
const scrollRef = useRef(null); ...props
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();
}, },
scrollToTop: () => { ref,
if (scrollRef.current) { ) => {
scrollRef.current.scrollTop = 0; const scrollRef = useRef(null);
} const containerRef = useRef(null);
}, const debounceTimerRef = useRef(null);
scrollToBottom: () => { const resizeObserverRef = useRef(null);
if (scrollRef.current) { const onScrollStateChangeRef = useRef(onScrollStateChange);
scrollRef.current.scrollTop = scrollRef.current.scrollHeight; const onScrollRef = useRef(onScroll);
}
}, const [showScrollHint, setShowScrollHint] = useState(false);
getScrollInfo: () => {
if (!scrollRef.current) return null; useEffect(() => {
const element = scrollRef.current; onScrollStateChangeRef.current = onScrollStateChange;
return { }, [onScrollStateChange]);
scrollTop: element.scrollTop,
scrollHeight: element.scrollHeight, useEffect(() => {
clientHeight: element.clientHeight, onScrollRef.current = onScroll;
isScrollable: element.scrollHeight > element.clientHeight, }, [onScroll]);
isAtBottom: element.scrollTop + element.clientHeight >= element.scrollHeight - scrollThreshold
const debounce = useCallback((func, delay) => {
return (...args) => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
debounceTimerRef.current = setTimeout(() => func(...args), delay);
}; };
} }, []);
}), [checkScrollable, scrollThreshold]);
useEffect(() => { const checkScrollable = useCallback(() => {
const timer = setTimeout(() => { if (!scrollRef.current) return;
checkScrollable();
}, checkInterval);
return () => clearTimeout(timer);
}, [checkScrollable, checkInterval]);
useEffect(() => { const element = scrollRef.current;
if (!scrollRef.current) return; const isScrollable = element.scrollHeight > element.clientHeight;
const isAtBottom =
element.scrollTop + element.clientHeight >=
element.scrollHeight - scrollThreshold;
const shouldShowHint = isScrollable && !isAtBottom;
if (typeof ResizeObserver === 'undefined') { setShowScrollHint(shouldShowHint);
if (typeof MutationObserver !== 'undefined') {
const observer = new MutationObserver(() => { if (onScrollStateChangeRef.current) {
debouncedCheckScrollable(); 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) => { const debouncedCheckScrollable = useMemo(
for (const entry of entries) { () => debounce(checkScrollable, debounceDelay),
[debounce, checkScrollable, debounceDelay],
);
const handleScroll = useCallback(
(e) => {
debouncedCheckScrollable(); 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 () => { resizeObserverRef.current.observe(scrollRef.current);
if (resizeObserverRef.current) {
resizeObserverRef.current.disconnect();
}
};
}, [debouncedCheckScrollable]);
useEffect(() => { return () => {
return () => { if (resizeObserverRef.current) {
if (debounceTimerRef.current) { resizeObserverRef.current.disconnect();
clearTimeout(debounceTimerRef.current); }
} };
}; }, [debouncedCheckScrollable]);
}, []);
const containerStyle = useMemo(() => ({ useEffect(() => {
maxHeight return () => {
}), [maxHeight]); if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
};
}, []);
const fadeIndicatorStyle = useMemo(() => ({ const containerStyle = useMemo(
opacity: showScrollHint ? 1 : 0 () => ({
}), [showScrollHint]); maxHeight,
}),
[maxHeight],
);
return ( const fadeIndicatorStyle = useMemo(
<div () => ({
ref={containerRef} opacity: showScrollHint ? 1 : 0,
className={`card-content-container ${className}`} }),
{...props} [showScrollHint],
> );
return (
<div <div
ref={scrollRef} ref={containerRef}
className={`overflow-y-auto card-content-scroll ${contentClassName}`} className={`card-content-container ${className}`}
style={containerStyle} {...props}
onScroll={handleScroll}
> >
{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>
<div );
className={`card-content-fade-indicator ${fadeIndicatorClassName}`} },
style={fadeIndicatorStyle} );
/>
</div>
);
});
ScrollableContainer.displayName = 'ScrollableContainer'; ScrollableContainer.displayName = 'ScrollableContainer';
@@ -20,7 +20,17 @@ For commercial licensing, please contact support@quantumnous.com
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { useMinimumLoadingTime } from '../../../hooks/common/useMinimumLoadingTime'; import { useMinimumLoadingTime } from '../../../hooks/common/useMinimumLoadingTime';
import { useContainerWidth } from '../../../hooks/common/useContainerWidth'; 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'; import { IconChevronDown, IconChevronUp } from '@douyinfe/semi-icons';
/** /**
@@ -47,7 +57,7 @@ const SelectableButtonGroup = ({
collapsible = true, collapsible = true,
collapseHeight = 200, collapseHeight = 200,
withCheckbox = false, withCheckbox = false,
loading = false loading = false,
}) => { }) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [skeletonCount] = useState(12); const [skeletonCount] = useState(12);
@@ -64,15 +74,13 @@ const SelectableButtonGroup = ({
}, [text, containerWidth]); }, [text, containerWidth]);
const textElement = ( const textElement = (
<span ref={textRef} className="sbg-ellipsis"> <span ref={textRef} className='sbg-ellipsis'>
{text} {text}
</span> </span>
); );
return isOverflowing ? ( return isOverflowing ? (
<Tooltip content={text}> <Tooltip content={text}>{textElement}</Tooltip>
{textElement}
</Tooltip>
) : ( ) : (
textElement textElement
); );
@@ -80,10 +88,10 @@ const SelectableButtonGroup = ({
// 基于容器宽度计算响应式列数和标签显示策略 // 基于容器宽度计算响应式列数和标签显示策略
const getResponsiveConfig = () => { const getResponsiveConfig = () => {
if (containerWidth <= 280) return { columns: 1, showTags: true }; // 极窄:1列+标签 if (containerWidth <= 280) return { columns: 1, showTags: true }; // 极窄:1列+标签
if (containerWidth <= 380) return { columns: 2, showTags: true }; // 窄屏:2列+标签 if (containerWidth <= 380) return { columns: 2, showTags: true }; // 窄屏:2列+标签
if (containerWidth <= 460) return { columns: 3, showTags: false }; // 中等:3列不加标签 if (containerWidth <= 460) return { columns: 3, showTags: false }; // 中等:3列不加标签
return { columns: 3, showTags: true }; // 最宽:3列+标签 return { columns: 3, showTags: true }; // 最宽:3列+标签
}; };
const { columns: perRow, showTags: shouldShowTags } = getResponsiveConfig(); const { columns: perRow, showTags: shouldShowTags } = getResponsiveConfig();
@@ -102,9 +110,9 @@ const SelectableButtonGroup = ({
const maskStyle = isOpen const maskStyle = isOpen
? {} ? {}
: { : {
WebkitMaskImage: WebkitMaskImage:
'linear-gradient(to bottom, black 0%, rgba(0, 0, 0, 1) 60%, rgba(0, 0, 0, 0.2) 80%, transparent 100%)', 'linear-gradient(to bottom, black 0%, rgba(0, 0, 0, 1) 60%, rgba(0, 0, 0, 0.2) 80%, transparent 100%)',
}; };
const toggle = () => { const toggle = () => {
setIsOpen(!isOpen); setIsOpen(!isOpen);
@@ -127,25 +135,23 @@ const SelectableButtonGroup = ({
}; };
const renderSkeletonButtons = () => { const renderSkeletonButtons = () => {
const placeholder = ( const placeholder = (
<Row gutter={gutterSize} style={{ lineHeight: '32px', ...style }}> <Row gutter={gutterSize} style={{ lineHeight: '32px', ...style }}>
{Array.from({ length: skeletonCount }).map((_, index) => ( {Array.from({ length: skeletonCount }).map((_, index) => (
<Col <Col span={getColSpan()} key={index}>
span={getColSpan()} <div
key={index} style={{
> width: '100%',
<div style={{ height: '32px',
width: '100%', display: 'flex',
height: '32px', alignItems: 'center',
display: 'flex', justifyContent: 'flex-start',
alignItems: 'center', border: '1px solid var(--semi-color-border)',
justifyContent: 'flex-start', borderRadius: 'var(--semi-border-radius-medium)',
border: '1px solid var(--semi-color-border)', padding: '0 12px',
borderRadius: 'var(--semi-border-radius-medium)', gap: '6px',
padding: '0 12px', }}
gap: '6px' >
}}>
{withCheckbox && ( {withCheckbox && (
<Skeleton.Title active style={{ width: 14, height: 14 }} /> <Skeleton.Title active style={{ width: 14, height: 14 }} />
)} )}
@@ -153,7 +159,7 @@ const SelectableButtonGroup = ({
active active
style={{ style={{
width: `${60 + (index % 3) * 20}px`, width: `${60 + (index % 3) * 20}px`,
height: 14 height: 14,
}} }}
/> />
</div> </div>
@@ -167,26 +173,29 @@ const SelectableButtonGroup = ({
); );
}; };
const contentElement = showSkeleton ? renderSkeletonButtons() : ( const contentElement = showSkeleton ? (
renderSkeletonButtons()
) : (
<Row gutter={gutterSize} style={{ lineHeight: '32px', ...style }}> <Row gutter={gutterSize} style={{ lineHeight: '32px', ...style }}>
{items.map((item) => { {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) const isActive = Array.isArray(activeValue)
? activeValue.includes(item.value) ? activeValue.includes(item.value)
: activeValue === item.value; : activeValue === item.value;
if (withCheckbox) { if (withCheckbox) {
return ( return (
<Col <Col span={getColSpan()} key={item.value}>
span={getColSpan()}
key={item.value}
>
<Button <Button
onClick={() => { /* disabled */ }} onClick={() => {
/* disabled */
}}
theme={isActive ? 'light' : 'outline'} theme={isActive ? 'light' : 'outline'}
type={isActive ? 'primary' : 'tertiary'} type={isActive ? 'primary' : 'tertiary'}
disabled={isDisabled} disabled={isDisabled}
className="sbg-button" className='sbg-button'
icon={ icon={
<Checkbox <Checkbox
checked={isActive} checked={isActive}
@@ -197,11 +206,18 @@ const SelectableButtonGroup = ({
} }
style={{ width: '100%', cursor: 'default' }} style={{ width: '100%', cursor: 'default' }}
> >
<div className="sbg-content"> <div className='sbg-content'>
{item.icon && (<span className="sbg-icon">{item.icon}</span>)} {item.icon && <span className='sbg-icon'>{item.icon}</span>}
<ConditionalTooltipText text={item.label} /> <ConditionalTooltipText text={item.label} />
{item.tagCount !== undefined && shouldShowTags && ( {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> </div>
</Button> </Button>
@@ -210,23 +226,27 @@ const SelectableButtonGroup = ({
} }
return ( return (
<Col <Col span={getColSpan()} key={item.value}>
span={getColSpan()}
key={item.value}
>
<Button <Button
onClick={() => onChange(item.value)} onClick={() => onChange(item.value)}
theme={isActive ? 'light' : 'outline'} theme={isActive ? 'light' : 'outline'}
type={isActive ? 'primary' : 'tertiary'} type={isActive ? 'primary' : 'tertiary'}
disabled={isDisabled} disabled={isDisabled}
className="sbg-button" className='sbg-button'
style={{ width: '100%' }} style={{ width: '100%' }}
> >
<div className="sbg-content"> <div className='sbg-content'>
{item.icon && (<span className="sbg-icon">{item.icon}</span>)} {item.icon && <span className='sbg-icon'>{item.icon}</span>}
<ConditionalTooltipText text={item.label} /> <ConditionalTooltipText text={item.label} />
{item.tagCount !== undefined && shouldShowTags && ( {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> </div>
</Button> </Button>
@@ -237,9 +257,12 @@ const SelectableButtonGroup = ({
); );
return ( return (
<div className={`mb-8 ${containerWidth <= 400 ? 'sbg-compact' : ''}`} ref={containerRef}> <div
className={`mb-8 ${containerWidth <= 400 ? 'sbg-compact' : ''}`}
ref={containerRef}
>
{title && ( {title && (
<Divider margin="12px" align="left"> <Divider margin='12px' align='left'>
{showSkeleton ? ( {showSkeleton ? (
<Skeleton.Title active style={{ width: 80, height: 14 }} /> <Skeleton.Title active style={{ width: 80, height: 14 }} />
) : ( ) : (
@@ -249,23 +272,30 @@ const SelectableButtonGroup = ({
)} )}
{needCollapse && !showSkeleton ? ( {needCollapse && !showSkeleton ? (
<div style={{ position: 'relative' }}> <div style={{ position: 'relative' }}>
<Collapsible isOpen={isOpen} collapseHeight={collapseHeight} style={{ ...maskStyle }}> <Collapsible
isOpen={isOpen}
collapseHeight={collapseHeight}
style={{ ...maskStyle }}
>
{contentElement} {contentElement}
</Collapsible> </Collapsible>
{isOpen ? null : ( {isOpen ? null : (
<div onClick={toggle} style={{ ...linkStyle }}> <div onClick={toggle} style={{ ...linkStyle }}>
<IconChevronDown size="small" /> <IconChevronDown size='small' />
<span>{t('展开更多')}</span> <span>{t('展开更多')}</span>
</div> </div>
)} )}
{isOpen && ( {isOpen && (
<div onClick={toggle} style={{ <div
...linkStyle, onClick={toggle}
position: 'static', style={{
marginTop: 8, ...linkStyle,
bottom: 'auto' position: 'static',
}}> marginTop: 8,
<IconChevronUp size="small" /> bottom: 'auto',
}}
>
<IconChevronUp size='small' />
<span>{t('收起')}</span> <span>{t('收起')}</span>
</div> </div>
)} )}
@@ -21,7 +21,10 @@ import React from 'react';
import { Card, Tag, Timeline, Empty } from '@douyinfe/semi-ui'; import { Card, Tag, Timeline, Empty } from '@douyinfe/semi-ui';
import { Bell } from 'lucide-react'; import { Bell } from 'lucide-react';
import { marked } from 'marked'; import { marked } from 'marked';
import { IllustrationConstruction, IllustrationConstructionDark } from '@douyinfe/semi-illustrations'; import {
IllustrationConstruction,
IllustrationConstructionDark,
} from '@douyinfe/semi-illustrations';
import ScrollableContainer from '../common/ui/ScrollableContainer'; import ScrollableContainer from '../common/ui/ScrollableContainer';
const AnnouncementsPanel = ({ const AnnouncementsPanel = ({
@@ -29,36 +32,43 @@ const AnnouncementsPanel = ({
announcementLegendData, announcementLegendData,
CARD_PROPS, CARD_PROPS,
ILLUSTRATION_SIZE, ILLUSTRATION_SIZE,
t t,
}) => { }) => {
return ( return (
<Card <Card
{...CARD_PROPS} {...CARD_PROPS}
className="shadow-sm !rounded-2xl lg:col-span-2" className='shadow-sm !rounded-2xl lg:col-span-2'
title={ title={
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-2 w-full"> <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 items-center gap-2'>
<Bell size={16} /> <Bell size={16} />
{t('系统公告')} {t('系统公告')}
<Tag color="white" shape="circle"> <Tag color='white' shape='circle'>
{t('显示最新20条')} {t('显示最新20条')}
</Tag> </Tag>
</div> </div>
{/* 图例 */} {/* 图例 */}
<div className="flex flex-wrap gap-3 text-xs"> <div className='flex flex-wrap gap-3 text-xs'>
{announcementLegendData.map((legend, index) => ( {announcementLegendData.map((legend, index) => (
<div key={index} className="flex items-center gap-1"> <div key={index} className='flex items-center gap-1'>
<div <div
className="w-2 h-2 rounded-full" className='w-2 h-2 rounded-full'
style={{ style={{
backgroundColor: legend.color === 'grey' ? '#8b9aa7' : backgroundColor:
legend.color === 'blue' ? '#3b82f6' : legend.color === 'grey'
legend.color === 'green' ? '#10b981' : ? '#8b9aa7'
legend.color === 'orange' ? '#f59e0b' : : legend.color === 'blue'
legend.color === 'red' ? '#ef4444' : '#8b9aa7' ? '#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>
))} ))}
</div> </div>
@@ -66,9 +76,9 @@ const AnnouncementsPanel = ({
} }
bodyStyle={{ padding: 0 }} bodyStyle={{ padding: 0 }}
> >
<ScrollableContainer maxHeight="24rem"> <ScrollableContainer maxHeight='24rem'>
{announcementData.length > 0 ? ( {announcementData.length > 0 ? (
<Timeline mode="left"> <Timeline mode='left'>
{announcementData.map((item, idx) => { {announcementData.map((item, idx) => {
const htmlExtra = item.extra ? marked.parse(item.extra) : ''; const htmlExtra = item.extra ? marked.parse(item.extra) : '';
return ( return (
@@ -76,16 +86,20 @@ const AnnouncementsPanel = ({
key={idx} key={idx}
type={item.type || 'default'} type={item.type || 'default'}
time={`${item.relative ? item.relative + ' ' : ''}${item.time}`} time={`${item.relative ? item.relative + ' ' : ''}${item.time}`}
extra={item.extra ? ( extra={
<div item.extra ? (
className="text-xs text-gray-500" <div
dangerouslySetInnerHTML={{ __html: htmlExtra }} className='text-xs text-gray-500'
/> dangerouslySetInnerHTML={{ __html: htmlExtra }}
) : null} />
) : null
}
> >
<div> <div>
<div <div
dangerouslySetInnerHTML={{ __html: marked.parse(item.content || '') }} dangerouslySetInnerHTML={{
__html: marked.parse(item.content || ''),
}}
/> />
</div> </div>
</Timeline.Item> </Timeline.Item>
@@ -93,10 +107,12 @@ const AnnouncementsPanel = ({
})} })}
</Timeline> </Timeline>
) : ( ) : (
<div className="flex justify-center items-center py-8"> <div className='flex justify-center items-center py-8'>
<Empty <Empty
image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />} image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
darkModeImage={<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />} darkModeImage={
<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />
}
title={t('暂无系统公告')} title={t('暂无系统公告')}
description={t('请联系管理员在系统设置中配置公告信息')} description={t('请联系管理员在系统设置中配置公告信息')}
/> />
+29 -27
View File
@@ -20,7 +20,10 @@ For commercial licensing, please contact support@quantumnous.com
import React from 'react'; import React from 'react';
import { Card, Avatar, Tag, Divider, Empty } from '@douyinfe/semi-ui'; import { Card, Avatar, Tag, Divider, Empty } from '@douyinfe/semi-ui';
import { Server, Gauge, ExternalLink } from 'lucide-react'; 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'; import ScrollableContainer from '../common/ui/ScrollableContainer';
const ApiInfoPanel = ({ const ApiInfoPanel = ({
@@ -30,12 +33,12 @@ const ApiInfoPanel = ({
CARD_PROPS, CARD_PROPS,
FLEX_CENTER_GAP2, FLEX_CENTER_GAP2,
ILLUSTRATION_SIZE, ILLUSTRATION_SIZE,
t t,
}) => { }) => {
return ( return (
<Card <Card
{...CARD_PROPS} {...CARD_PROPS}
className="bg-gray-50 border-0 !rounded-2xl" className='bg-gray-50 border-0 !rounded-2xl'
title={ title={
<div className={FLEX_CENTER_GAP2}> <div className={FLEX_CENTER_GAP2}>
<Server size={16} /> <Server size={16} />
@@ -44,66 +47,65 @@ const ApiInfoPanel = ({
} }
bodyStyle={{ padding: 0 }} bodyStyle={{ padding: 0 }}
> >
<ScrollableContainer maxHeight="24rem"> <ScrollableContainer maxHeight='24rem'>
{apiInfoData.length > 0 ? ( {apiInfoData.length > 0 ? (
apiInfoData.map((api) => ( apiInfoData.map((api) => (
<React.Fragment key={api.id}> <React.Fragment key={api.id}>
<div className="flex p-2 hover:bg-white rounded-lg transition-colors cursor-pointer"> <div className='flex p-2 hover:bg-white rounded-lg transition-colors cursor-pointer'>
<div className="flex-shrink-0 mr-3"> <div className='flex-shrink-0 mr-3'>
<Avatar <Avatar size='extra-small' color={api.color}>
size="extra-small"
color={api.color}
>
{api.route.substring(0, 2)} {api.route.substring(0, 2)}
</Avatar> </Avatar>
</div> </div>
<div className="flex-1"> <div className='flex-1'>
<div className="flex flex-wrap items-center justify-between mb-1 w-full gap-2"> <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"> <span className='text-sm font-medium text-gray-900 !font-bold break-all'>
{api.route} {api.route}
</span> </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 <Tag
prefixIcon={<Gauge size={12} />} prefixIcon={<Gauge size={12} />}
size="small" size='small'
color="white" color='white'
shape='circle' shape='circle'
onClick={() => handleSpeedTest(api.url)} onClick={() => handleSpeedTest(api.url)}
className="cursor-pointer hover:opacity-80 text-xs" className='cursor-pointer hover:opacity-80 text-xs'
> >
{t('测速')} {t('测速')}
</Tag> </Tag>
<Tag <Tag
prefixIcon={<ExternalLink size={12} />} prefixIcon={<ExternalLink size={12} />}
size="small" size='small'
color="white" color='white'
shape='circle' shape='circle'
onClick={() => window.open(api.url, '_blank', 'noopener,noreferrer')} onClick={() =>
className="cursor-pointer hover:opacity-80 text-xs" window.open(api.url, '_blank', 'noopener,noreferrer')
}
className='cursor-pointer hover:opacity-80 text-xs'
> >
{t('跳转')} {t('跳转')}
</Tag> </Tag>
</div> </div>
</div> </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)} onClick={() => handleCopyUrl(api.url)}
> >
{api.url} {api.url}
</div> </div>
<div className="text-gray-500"> <div className='text-gray-500'>{api.description}</div>
{api.description}
</div>
</div> </div>
</div> </div>
<Divider /> <Divider />
</React.Fragment> </React.Fragment>
)) ))
) : ( ) : (
<div className="flex justify-center items-center py-8"> <div className='flex justify-center items-center py-8'>
<Empty <Empty
image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />} image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
darkModeImage={<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />} darkModeImage={
<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />
}
title={t('暂无API信息')} title={t('暂无API信息')}
description={t('请联系管理员在系统设置中配置API信息')} description={t('请联系管理员在系统设置中配置API信息')}
/> />
+45 -45
View File
@@ -23,7 +23,7 @@ import { PieChart } from 'lucide-react';
import { import {
IconHistogram, IconHistogram,
IconPulse, IconPulse,
IconPieChart2Stroked IconPieChart2Stroked,
} from '@douyinfe/semi-icons'; } from '@douyinfe/semi-icons';
import { VChart } from '@visactor/react-vchart'; import { VChart } from '@visactor/react-vchart';
@@ -38,76 +38,76 @@ const ChartsPanel = ({
CHART_CONFIG, CHART_CONFIG,
FLEX_CENTER_GAP2, FLEX_CENTER_GAP2,
hasApiInfoPanel, hasApiInfoPanel,
t t,
}) => { }) => {
return ( return (
<Card <Card
{...CARD_PROPS} {...CARD_PROPS}
className={`!rounded-2xl ${hasApiInfoPanel ? 'lg:col-span-3' : ''}`} className={`!rounded-2xl ${hasApiInfoPanel ? 'lg:col-span-3' : ''}`}
title={ 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}> <div className={FLEX_CENTER_GAP2}>
<PieChart size={16} /> <PieChart size={16} />
{t('模型数据分析')} {t('模型数据分析')}
</div> </div>
<Tabs <Tabs
type="button" type='button'
activeKey={activeChartTab} activeKey={activeChartTab}
onChange={setActiveChartTab} onChange={setActiveChartTab}
> >
<TabPane tab={ <TabPane
<span> tab={
<IconHistogram /> <span>
{t('消耗分布')} <IconHistogram />
</span> {t('消耗分布')}
} itemKey="1" /> </span>
<TabPane tab={ }
<span> itemKey='1'
<IconPulse /> />
{t('消耗趋势')} <TabPane
</span> tab={
} itemKey="2" /> <span>
<TabPane tab={ <IconPulse />
<span> {t('消耗趋势')}
<IconPieChart2Stroked /> </span>
{t('调用次数分布')} }
</span> itemKey='2'
} itemKey="3" /> />
<TabPane tab={ <TabPane
<span> tab={
<IconHistogram /> <span>
{t('调用次数排行')} <IconPieChart2Stroked />
</span> {t('调用次数分布')}
} itemKey="4" /> </span>
}
itemKey='3'
/>
<TabPane
tab={
<span>
<IconHistogram />
{t('调用次数排行')}
</span>
}
itemKey='4'
/>
</Tabs> </Tabs>
</div> </div>
} }
bodyStyle={{ padding: 0 }} bodyStyle={{ padding: 0 }}
> >
<div className="h-96 p-2"> <div className='h-96 p-2'>
{activeChartTab === '1' && ( {activeChartTab === '1' && (
<VChart <VChart spec={spec_line} option={CHART_CONFIG} />
spec={spec_line}
option={CHART_CONFIG}
/>
)} )}
{activeChartTab === '2' && ( {activeChartTab === '2' && (
<VChart <VChart spec={spec_model_line} option={CHART_CONFIG} />
spec={spec_model_line}
option={CHART_CONFIG}
/>
)} )}
{activeChartTab === '3' && ( {activeChartTab === '3' && (
<VChart <VChart spec={spec_pie} option={CHART_CONFIG} />
spec={spec_pie}
option={CHART_CONFIG}
/>
)} )}
{activeChartTab === '4' && ( {activeChartTab === '4' && (
<VChart <VChart spec={spec_rank_bar} option={CHART_CONFIG} />
spec={spec_rank_bar}
option={CHART_CONFIG}
/>
)} )}
</div> </div>
</Card> </Card>
@@ -27,19 +27,19 @@ const DashboardHeader = ({
showSearchModal, showSearchModal,
refresh, refresh,
loading, 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 ( return (
<div className="flex items-center justify-between mb-4"> <div className='flex items-center justify-between mb-4'>
<h2 <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 }} style={{ opacity: greetingVisible ? 1 : 0 }}
> >
{getGreeting} {getGreeting}
</h2> </h2>
<div className="flex gap-3"> <div className='flex gap-3'>
<Button <Button
type='tertiary' type='tertiary'
icon={<Search size={16} />} icon={<Search size={16} />}
+14 -7
View File
@@ -22,7 +22,10 @@ import { Card, Collapse, Empty } from '@douyinfe/semi-ui';
import { HelpCircle } from 'lucide-react'; import { HelpCircle } from 'lucide-react';
import { IconPlus, IconMinus } from '@douyinfe/semi-icons'; import { IconPlus, IconMinus } from '@douyinfe/semi-icons';
import { marked } from 'marked'; import { marked } from 'marked';
import { IllustrationConstruction, IllustrationConstructionDark } from '@douyinfe/semi-illustrations'; import {
IllustrationConstruction,
IllustrationConstructionDark,
} from '@douyinfe/semi-illustrations';
import ScrollableContainer from '../common/ui/ScrollableContainer'; import ScrollableContainer from '../common/ui/ScrollableContainer';
const FaqPanel = ({ const FaqPanel = ({
@@ -30,12 +33,12 @@ const FaqPanel = ({
CARD_PROPS, CARD_PROPS,
FLEX_CENTER_GAP2, FLEX_CENTER_GAP2,
ILLUSTRATION_SIZE, ILLUSTRATION_SIZE,
t t,
}) => { }) => {
return ( return (
<Card <Card
{...CARD_PROPS} {...CARD_PROPS}
className="shadow-sm !rounded-2xl lg:col-span-1" className='shadow-sm !rounded-2xl lg:col-span-1'
title={ title={
<div className={FLEX_CENTER_GAP2}> <div className={FLEX_CENTER_GAP2}>
<HelpCircle size={16} /> <HelpCircle size={16} />
@@ -44,7 +47,7 @@ const FaqPanel = ({
} }
bodyStyle={{ padding: 0 }} bodyStyle={{ padding: 0 }}
> >
<ScrollableContainer maxHeight="24rem"> <ScrollableContainer maxHeight='24rem'>
{faqData.length > 0 ? ( {faqData.length > 0 ? (
<Collapse <Collapse
accordion accordion
@@ -58,16 +61,20 @@ const FaqPanel = ({
itemKey={index.toString()} itemKey={index.toString()}
> >
<div <div
dangerouslySetInnerHTML={{ __html: marked.parse(item.answer || '') }} dangerouslySetInnerHTML={{
__html: marked.parse(item.answer || ''),
}}
/> />
</Collapse.Panel> </Collapse.Panel>
))} ))}
</Collapse> </Collapse>
) : ( ) : (
<div className="flex justify-center items-center py-8"> <div className='flex justify-center items-center py-8'>
<Empty <Empty
image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />} image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
darkModeImage={<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />} darkModeImage={
<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />
}
title={t('暂无常见问答')} title={t('暂无常见问答')}
description={t('请联系管理员在系统设置中配置常见问答')} description={t('请联系管理员在系统设置中配置常见问答')}
/> />
+27 -20
View File
@@ -28,13 +28,13 @@ const StatsCards = ({
loading, loading,
getTrendSpec, getTrendSpec,
CARD_PROPS, CARD_PROPS,
CHART_CONFIG CHART_CONFIG,
}) => { }) => {
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div className="mb-4"> <div className='mb-4'>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> <div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4'>
{groupedStatsData.map((group, idx) => ( {groupedStatsData.map((group, idx) => (
<Card <Card
key={idx} key={idx}
@@ -42,24 +42,24 @@ const StatsCards = ({
className={`${group.color} border-0 !rounded-2xl w-full`} className={`${group.color} border-0 !rounded-2xl w-full`}
title={group.title} title={group.title}
> >
<div className="space-y-4"> <div className='space-y-4'>
{group.items.map((item, itemIdx) => ( {group.items.map((item, itemIdx) => (
<div <div
key={itemIdx} key={itemIdx}
className="flex items-center justify-between cursor-pointer" className='flex items-center justify-between cursor-pointer'
onClick={item.onClick} onClick={item.onClick}
> >
<div className="flex items-center"> <div className='flex items-center'>
<Avatar <Avatar
className="mr-3" className='mr-3'
size="small" size='small'
color={item.avatarColor} color={item.avatarColor}
> >
{item.icon} {item.icon}
</Avatar> </Avatar>
<div> <div>
<div className="text-xs text-gray-500">{item.title}</div> <div className='text-xs text-gray-500'>{item.title}</div>
<div className="text-lg font-semibold"> <div className='text-lg font-semibold'>
<Skeleton <Skeleton
loading={loading} loading={loading}
active active
@@ -67,7 +67,11 @@ const StatsCards = ({
<Skeleton.Paragraph <Skeleton.Paragraph
active active
rows={1} rows={1}
style={{ width: '65px', height: '24px', marginTop: '4px' }} style={{
width: '65px',
height: '24px',
marginTop: '4px',
}}
/> />
} }
> >
@@ -78,9 +82,9 @@ const StatsCards = ({
</div> </div>
{item.title === t('当前余额') ? ( {item.title === t('当前余额') ? (
<Tag <Tag
color="white" color='white'
shape='circle' shape='circle'
size="large" size='large'
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
navigate('/console/topup'); navigate('/console/topup');
@@ -88,13 +92,16 @@ const StatsCards = ({
> >
{t('充值')} {t('充值')}
</Tag> </Tag>
) : (loading || (item.trendData && item.trendData.length > 0)) && ( ) : (
<div className="w-24 h-10"> (loading ||
<VChart (item.trendData && item.trendData.length > 0)) && (
spec={getTrendSpec(item.trendData, item.trendColor)} <div className='w-24 h-10'>
option={CHART_CONFIG} <VChart
/> spec={getTrendSpec(item.trendData, item.trendColor)}
</div> option={CHART_CONFIG}
/>
</div>
)
)} )}
</div> </div>
))} ))}
+40 -23
View File
@@ -18,9 +18,20 @@ For commercial licensing, please contact support@quantumnous.com
*/ */
import React from 'react'; 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 { 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'; import ScrollableContainer from '../common/ui/ScrollableContainer';
const UptimePanel = ({ const UptimePanel = ({
@@ -33,15 +44,15 @@ const UptimePanel = ({
renderMonitorList, renderMonitorList,
CARD_PROPS, CARD_PROPS,
ILLUSTRATION_SIZE, ILLUSTRATION_SIZE,
t t,
}) => { }) => {
return ( return (
<Card <Card
{...CARD_PROPS} {...CARD_PROPS}
className="shadow-sm !rounded-2xl lg:col-span-1" className='shadow-sm !rounded-2xl lg:col-span-1'
title={ title={
<div className="flex items-center justify-between w-full gap-2"> <div className='flex items-center justify-between w-full gap-2'>
<div className="flex items-center gap-2"> <div className='flex items-center gap-2'>
<Gauge size={16} /> <Gauge size={16} />
{t('服务可用性')} {t('服务可用性')}
</div> </div>
@@ -49,39 +60,43 @@ const UptimePanel = ({
icon={<RefreshCw size={14} />} icon={<RefreshCw size={14} />}
onClick={loadUptimeData} onClick={loadUptimeData}
loading={uptimeLoading} loading={uptimeLoading}
size="small" size='small'
theme="borderless" theme='borderless'
type='tertiary' 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> </div>
} }
bodyStyle={{ padding: 0 }} bodyStyle={{ padding: 0 }}
> >
{/* 内容区域 */} {/* 内容区域 */}
<div className="relative"> <div className='relative'>
<Spin spinning={uptimeLoading}> <Spin spinning={uptimeLoading}>
{uptimeData.length > 0 ? ( {uptimeData.length > 0 ? (
uptimeData.length === 1 ? ( uptimeData.length === 1 ? (
<ScrollableContainer maxHeight="24rem"> <ScrollableContainer maxHeight='24rem'>
{renderMonitorList(uptimeData[0].monitors)} {renderMonitorList(uptimeData[0].monitors)}
</ScrollableContainer> </ScrollableContainer>
) : ( ) : (
<Tabs <Tabs
type="card" type='card'
collapsible collapsible
activeKey={activeUptimeTab} activeKey={activeUptimeTab}
onChange={setActiveUptimeTab} onChange={setActiveUptimeTab}
size="small" size='small'
> >
{uptimeData.map((group, groupIdx) => ( {uptimeData.map((group, groupIdx) => (
<TabPane <TabPane
tab={ tab={
<span className="flex items-center gap-2"> <span className='flex items-center gap-2'>
<Gauge size={14} /> <Gauge size={14} />
{group.categoryName} {group.categoryName}
<Tag <Tag
color={activeUptimeTab === group.categoryName ? 'red' : 'grey'} color={
activeUptimeTab === group.categoryName
? 'red'
: 'grey'
}
size='small' size='small'
shape='circle' shape='circle'
> >
@@ -92,7 +107,7 @@ const UptimePanel = ({
itemKey={group.categoryName} itemKey={group.categoryName}
key={groupIdx} key={groupIdx}
> >
<ScrollableContainer maxHeight="21.5rem"> <ScrollableContainer maxHeight='21.5rem'>
{renderMonitorList(group.monitors)} {renderMonitorList(group.monitors)}
</ScrollableContainer> </ScrollableContainer>
</TabPane> </TabPane>
@@ -100,10 +115,12 @@ const UptimePanel = ({
</Tabs> </Tabs>
) )
) : ( ) : (
<div className="flex justify-center items-center py-8"> <div className='flex justify-center items-center py-8'>
<Empty <Empty
image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />} image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
darkModeImage={<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />} darkModeImage={
<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />
}
title={t('暂无监控数据')} title={t('暂无监控数据')}
description={t('请联系管理员在系统设置中配置Uptime')} description={t('请联系管理员在系统设置中配置Uptime')}
/> />
@@ -114,15 +131,15 @@ const UptimePanel = ({
{/* 图例 */} {/* 图例 */}
{uptimeData.length > 0 && ( {uptimeData.length > 0 && (
<div className="p-3 bg-gray-50 rounded-b-2xl"> <div className='p-3 bg-gray-50 rounded-b-2xl'>
<div className="flex flex-wrap gap-3 text-xs justify-center"> <div className='flex flex-wrap gap-3 text-xs justify-center'>
{uptimeLegendData.map((legend, index) => ( {uptimeLegendData.map((legend, index) => (
<div key={index} className="flex items-center gap-1"> <div key={index} className='flex items-center gap-1'>
<div <div
className="w-2 h-2 rounded-full" className='w-2 h-2 rounded-full'
style={{ backgroundColor: legend.color }} style={{ backgroundColor: legend.color }}
/> />
<span className="text-gray-600">{legend.label}</span> <span className='text-gray-600'>{legend.label}</span>
</div> </div>
))} ))}
</div> </div>
+53 -37
View File
@@ -41,7 +41,7 @@ import {
FLEX_CENTER_GAP2, FLEX_CENTER_GAP2,
ILLUSTRATION_SIZE, ILLUSTRATION_SIZE,
ANNOUNCEMENT_LEGEND_DATA, ANNOUNCEMENT_LEGEND_DATA,
UPTIME_STATUS_MAP UPTIME_STATUS_MAP,
} from '../../constants/dashboard.constants'; } from '../../constants/dashboard.constants';
import { import {
getTrendSpec, getTrendSpec,
@@ -49,7 +49,7 @@ import {
handleSpeedTest, handleSpeedTest,
getUptimeStatusColor, getUptimeStatusColor,
getUptimeStatusText, getUptimeStatusText,
renderMonitorList renderMonitorList,
} from '../../helpers/dashboard'; } from '../../helpers/dashboard';
const Dashboard = () => { const Dashboard = () => {
@@ -70,7 +70,7 @@ const Dashboard = () => {
dashboardData.setPieData, dashboardData.setPieData,
dashboardData.setLineData, dashboardData.setLineData,
dashboardData.setModelColors, dashboardData.setModelColors,
dashboardData.t dashboardData.t,
); );
// ========== 统计数据 ========== // ========== 统计数据 ==========
@@ -82,12 +82,12 @@ const Dashboard = () => {
dashboardData.trendData, dashboardData.trendData,
dashboardData.performanceMetrics, dashboardData.performanceMetrics,
dashboardData.navigate, dashboardData.navigate,
dashboardData.t dashboardData.t,
); );
// ========== 数据处理 ========== // ========== 数据处理 ==========
const initChart = async () => { const initChart = async () => {
await dashboardData.loadQuotaData().then(data => { await dashboardData.loadQuotaData().then((data) => {
if (data && data.length > 0) { if (data && data.length > 0) {
dashboardCharts.updateChartData(data); dashboardCharts.updateChartData(data);
} }
@@ -108,25 +108,30 @@ const Dashboard = () => {
// ========== 数据准备 ========== // ========== 数据准备 ==========
const apiInfoData = statusState?.status?.api_info || []; const apiInfoData = statusState?.status?.api_info || [];
const announcementData = (statusState?.status?.announcements || []).map(item => { const announcementData = (statusState?.status?.announcements || []).map(
const pubDate = item?.publishDate ? new Date(item.publishDate) : null; (item) => {
const absoluteTime = pubDate && !isNaN(pubDate.getTime()) const pubDate = item?.publishDate ? new Date(item.publishDate) : null;
? `${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')}` const absoluteTime =
: (item?.publishDate || ''); pubDate && !isNaN(pubDate.getTime())
const relativeTime = getRelativeTime(item.publishDate); ? `${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')}`
return ({ : item?.publishDate || '';
...item, const relativeTime = getRelativeTime(item.publishDate);
time: absoluteTime, return {
relative: relativeTime ...item,
}); time: absoluteTime,
}); relative: relativeTime,
};
},
);
const faqData = statusState?.status?.faq || []; const faqData = statusState?.status?.faq || [];
const uptimeLegendData = Object.entries(UPTIME_STATUS_MAP).map(([status, info]) => ({ const uptimeLegendData = Object.entries(UPTIME_STATUS_MAP).map(
status: Number(status), ([status, info]) => ({
color: info.color, status: Number(status),
label: dashboardData.t(info.label) color: info.color,
})); label: dashboardData.t(info.label),
}),
);
// ========== Effects ========== // ========== Effects ==========
useEffect(() => { useEffect(() => {
@@ -134,7 +139,7 @@ const Dashboard = () => {
}, []); }, []);
return ( return (
<div className="h-full"> <div className='h-full'>
<DashboardHeader <DashboardHeader
getGreeting={dashboardData.getGreeting} getGreeting={dashboardData.getGreeting}
greetingVisible={dashboardData.greetingVisible} greetingVisible={dashboardData.greetingVisible}
@@ -166,8 +171,10 @@ const Dashboard = () => {
/> />
{/* API信息和图表面板 */} {/* API信息和图表面板 */}
<div className="mb-4"> <div className='mb-4'>
<div className={`grid grid-cols-1 gap-4 ${dashboardData.hasApiInfoPanel ? 'lg:grid-cols-4' : ''}`}> <div
className={`grid grid-cols-1 gap-4 ${dashboardData.hasApiInfoPanel ? 'lg:grid-cols-4' : ''}`}
>
<ChartsPanel <ChartsPanel
activeChartTab={dashboardData.activeChartTab} activeChartTab={dashboardData.activeChartTab}
setActiveChartTab={dashboardData.setActiveChartTab} setActiveChartTab={dashboardData.setActiveChartTab}
@@ -198,16 +205,18 @@ const Dashboard = () => {
{/* 系统公告和常见问答卡片 */} {/* 系统公告和常见问答卡片 */}
{dashboardData.hasInfoPanels && ( {dashboardData.hasInfoPanels && (
<div className="mb-4"> <div className='mb-4'>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4"> <div className='grid grid-cols-1 lg:grid-cols-4 gap-4'>
{/* 公告卡片 */} {/* 公告卡片 */}
{dashboardData.announcementsEnabled && ( {dashboardData.announcementsEnabled && (
<AnnouncementsPanel <AnnouncementsPanel
announcementData={announcementData} announcementData={announcementData}
announcementLegendData={ANNOUNCEMENT_LEGEND_DATA.map(item => ({ announcementLegendData={ANNOUNCEMENT_LEGEND_DATA.map(
...item, (item) => ({
label: dashboardData.t(item.label) ...item,
}))} label: dashboardData.t(item.label),
}),
)}
CARD_PROPS={CARD_PROPS} CARD_PROPS={CARD_PROPS}
ILLUSTRATION_SIZE={ILLUSTRATION_SIZE} ILLUSTRATION_SIZE={ILLUSTRATION_SIZE}
t={dashboardData.t} t={dashboardData.t}
@@ -234,12 +243,19 @@ const Dashboard = () => {
setActiveUptimeTab={dashboardData.setActiveUptimeTab} setActiveUptimeTab={dashboardData.setActiveUptimeTab}
loadUptimeData={dashboardData.loadUptimeData} loadUptimeData={dashboardData.loadUptimeData}
uptimeLegendData={uptimeLegendData} uptimeLegendData={uptimeLegendData}
renderMonitorList={(monitors) => renderMonitorList( renderMonitorList={(monitors) =>
monitors, renderMonitorList(
(status) => getUptimeStatusColor(status, UPTIME_STATUS_MAP), monitors,
(status) => getUptimeStatusText(status, UPTIME_STATUS_MAP, dashboardData.t), (status) => getUptimeStatusColor(status, UPTIME_STATUS_MAP),
dashboardData.t (status) =>
)} getUptimeStatusText(
status,
UPTIME_STATUS_MAP,
dashboardData.t,
),
dashboardData.t,
)
}
CARD_PROPS={CARD_PROPS} CARD_PROPS={CARD_PROPS}
ILLUSTRATION_SIZE={ILLUSTRATION_SIZE} ILLUSTRATION_SIZE={ILLUSTRATION_SIZE}
t={dashboardData.t} t={dashboardData.t}
@@ -30,12 +30,12 @@ const SearchModal = ({
dataExportDefaultTime, dataExportDefaultTime,
timeOptions, timeOptions,
handleInputChange, handleInputChange,
t t,
}) => { }) => {
const formRef = useRef(); const formRef = useRef();
const FORM_FIELD_PROPS = { const FORM_FIELD_PROPS = {
className: "w-full mb-2 !rounded-lg", className: 'w-full mb-2 !rounded-lg',
}; };
const createFormField = (Component, props) => ( const createFormField = (Component, props) => (
@@ -54,7 +54,7 @@ const SearchModal = ({
size={isMobile ? 'full-width' : 'small'} size={isMobile ? 'full-width' : 'small'}
centered centered
> >
<Form ref={formRef} layout='vertical' className="w-full"> <Form ref={formRef} layout='vertical' className='w-full'>
{createFormField(Form.DatePicker, { {createFormField(Form.DatePicker, {
field: 'start_timestamp', field: 'start_timestamp',
label: t('起始时间'), label: t('起始时间'),
@@ -62,7 +62,7 @@ const SearchModal = ({
value: start_timestamp, value: start_timestamp,
type: 'dateTime', type: 'dateTime',
name: 'start_timestamp', name: 'start_timestamp',
onChange: (value) => handleInputChange(value, 'start_timestamp') onChange: (value) => handleInputChange(value, 'start_timestamp'),
})} })}
{createFormField(Form.DatePicker, { {createFormField(Form.DatePicker, {
@@ -72,7 +72,7 @@ const SearchModal = ({
value: end_timestamp, value: end_timestamp,
type: 'dateTime', type: 'dateTime',
name: 'end_timestamp', name: 'end_timestamp',
onChange: (value) => handleInputChange(value, 'end_timestamp') onChange: (value) => handleInputChange(value, 'end_timestamp'),
})} })}
{createFormField(Form.Select, { {createFormField(Form.Select, {
@@ -82,17 +82,19 @@ const SearchModal = ({
placeholder: t('时间粒度'), placeholder: t('时间粒度'),
name: 'data_export_default_time', name: 'data_export_default_time',
optionList: timeOptions, optionList: timeOptions,
onChange: (value) => handleInputChange(value, 'data_export_default_time') onChange: (value) =>
handleInputChange(value, 'data_export_default_time'),
})} })}
{isAdminUser && createFormField(Form.Input, { {isAdminUser &&
field: 'username', createFormField(Form.Input, {
label: t('用户名称'), field: 'username',
value: username, label: t('用户名称'),
placeholder: t('可选值'), value: username,
name: 'username', placeholder: t('可选值'),
onChange: (value) => handleInputChange(value, 'username') name: 'username',
})} onChange: (value) => handleInputChange(value, 'username'),
})}
</Form> </Form>
</Modal> </Modal>
); );
+164 -58
View File
@@ -40,85 +40,191 @@ const FooterBar = () => {
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
const customFooter = useMemo(() => ( 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> <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 md:hidden bottom-[20px] left-[-50px] w-[80px] h-[80px] rounded-full bg-[#FFD166] opacity-60"></div> <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 && ( {isDemoSiteMode && (
<div className="flex flex-col md:flex-row justify-between w-full max-w-[1110px] mb-10 gap-8"> <div className='flex flex-col md:flex-row justify-between w-full max-w-[1110px] mb-10 gap-8'>
<div className="flex-shrink-0"> <div className='flex-shrink-0'>
<img <img
src={logo} src={logo}
alt={systemName} alt={systemName}
className="w-16 h-16 rounded-full bg-gray-800 p-1.5 object-contain" 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>
</div> </div>
<div className="text-left"> <div className='grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-8 w-full'>
<p className="!text-semi-color-text-0 font-semibold mb-5">{t('文档')}</p> <div className='text-left'>
<div className="flex flex-col gap-4"> <p className='!text-semi-color-text-0 font-semibold mb-5'>
<a href="https://docs.newapi.pro/getting-started/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">{t('快速开始')}</a> {t('关于我们')}
<a href="https://docs.newapi.pro/installation/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">{t('安装指南')}</a> </p>
<a href="https://docs.newapi.pro/api/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">{t('API 文档')}</a> <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>
<div className="text-left"> <div className='text-left'>
<p className="!text-semi-color-text-0 font-semibold mb-5">{t('相关项目')}</p> <p className='!text-semi-color-text-0 font-semibold mb-5'>
<div className="flex flex-col gap-4"> {t('文档')}
<a href="https://github.com/songquanpeng/one-api" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">One API</a> </p>
<a href="https://github.com/novicezk/midjourney-proxy" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">Midjourney-Proxy</a> <div className='flex flex-col gap-4'>
<a href="https://github.com/Deeptrain-Community/chatnio" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">chatnio</a> <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> 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>
<div className="text-left"> <div className='text-left'>
<p className="!text-semi-color-text-0 font-semibold mb-5">{t('基于New API的项目')}</p> <p className='!text-semi-color-text-0 font-semibold mb-5'>
<div className="flex flex-col gap-4"> {t('相关项目')}
<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> </p>
{/* <a href="https://github.com/VoAPI/VoAPI" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">VoAPI</a> */} <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> </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-col md:flex-row items-center justify-between w-full max-w-[1110px] gap-6'>
<div className="flex flex-wrap items-center gap-2"> <div className='flex flex-wrap items-center gap-2'>
<Typography.Text className="text-sm !text-semi-color-text-1">© {currentYear} {systemName}. {t('版权所有')}</Typography.Text> <Typography.Text className='text-sm !text-semi-color-text-1'>
</div> © {currentYear} {systemName}. {t('版权所有')}
</Typography.Text>
</div>
<div className="text-sm"> <div className='text-sm'>
<span className="!text-semi-color-text-1">{t('设计与开发由')} </span> <span className='!text-semi-color-text-1'>
<a href="https://github.com/QuantumNous/new-api" target="_blank" rel="noopener noreferrer" className="!text-semi-color-primary font-medium">New API</a> {t('设计与开发由')}{' '}
<span className="!text-semi-color-text-1"> & </span> </span>
<a href="https://github.com/songquanpeng/one-api" target="_blank" rel="noopener noreferrer" className="!text-semi-color-primary font-medium">One API</a> <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>
</div> </footer>
</footer> ),
), [logo, systemName, t, currentYear, isDemoSiteMode]); [logo, systemName, t, currentYear, isDemoSiteMode],
);
useEffect(() => { useEffect(() => {
loadFooter(); loadFooter();
}, []); }, []);
return ( return (
<div className="w-full"> <div className='w-full'>
{footer ? ( {footer ? (
<div <div
className="custom-footer" className='custom-footer'
dangerouslySetInnerHTML={{ __html: footer }} dangerouslySetInnerHTML={{ __html: footer }}
></div> ></div>
) : ( ) : (
@@ -41,7 +41,7 @@ const ActionButtons = ({
t, t,
}) => { }) => {
return ( return (
<div className="flex items-center gap-2 md:gap-3"> <div className='flex items-center gap-2 md:gap-3'>
<NewYearButton isNewYear={isNewYear} /> <NewYearButton isNewYear={isNewYear} />
<NotificationButton <NotificationButton
@@ -50,11 +50,7 @@ const ActionButtons = ({
t={t} t={t}
/> />
<ThemeToggle <ThemeToggle theme={theme} onThemeToggle={onThemeToggle} t={t} />
theme={theme}
onThemeToggle={onThemeToggle}
t={t}
/>
<LanguageSelector <LanguageSelector
currentLang={currentLang} currentLang={currentLang}
@@ -38,35 +38,35 @@ const HeaderLogo = ({
} }
return ( return (
<Link to="/" className="group flex items-center gap-2"> <Link to='/' className='group flex items-center gap-2'>
<div className="relative w-8 h-8 md:w-8 md:h-8"> <div className='relative w-8 h-8 md:w-8 md:h-8'>
<SkeletonWrapper <SkeletonWrapper loading={isLoading || !logoLoaded} type='image' />
loading={isLoading || !logoLoaded}
type="image"
/>
<img <img
src={logo} src={logo}
alt="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'}`} 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>
<div className="hidden md:flex items-center gap-2"> <div className='hidden md:flex items-center gap-2'>
<div className="flex items-center gap-2"> <div className='flex items-center gap-2'>
<SkeletonWrapper <SkeletonWrapper
loading={isLoading} loading={isLoading}
type="title" type='title'
width={120} width={120}
height={24} 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} {systemName}
</Typography.Title> </Typography.Title>
</SkeletonWrapper> </SkeletonWrapper>
{(isSelfUseMode || isDemoSiteMode) && !isLoading && ( {(isSelfUseMode || isDemoSiteMode) && !isLoading && (
<Tag <Tag
color={isSelfUseMode ? 'purple' : 'blue'} color={isSelfUseMode ? 'purple' : 'blue'}
className="text-xs px-1.5 py-0.5 rounded whitespace-nowrap shadow-sm" className='text-xs px-1.5 py-0.5 rounded whitespace-nowrap shadow-sm'
size="small" size='small'
shape='circle' shape='circle'
> >
{isSelfUseMode ? t('自用模式') : t('演示站点')} {isSelfUseMode ? t('自用模式') : t('演示站点')}
@@ -25,21 +25,21 @@ import { CN, GB } from 'country-flag-icons/react/3x2';
const LanguageSelector = ({ currentLang, onLanguageChange, t }) => { const LanguageSelector = ({ currentLang, onLanguageChange, t }) => {
return ( return (
<Dropdown <Dropdown
position="bottomRight" position='bottomRight'
render={ 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 <Dropdown.Item
onClick={() => onLanguageChange('zh')} 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'}`} 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> <span>中文</span>
</Dropdown.Item> </Dropdown.Item>
<Dropdown.Item <Dropdown.Item
onClick={() => onLanguageChange('en')} 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'}`} 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> <span>English</span>
</Dropdown.Item> </Dropdown.Item>
</Dropdown.Menu> </Dropdown.Menu>
@@ -48,9 +48,9 @@ const LanguageSelector = ({ currentLang, onLanguageChange, t }) => {
<Button <Button
icon={<Languages size={18} />} icon={<Languages size={18} />}
aria-label={t('切换语言')} aria-label={t('切换语言')}
theme="borderless" theme='borderless'
type="tertiary" 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" 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> </Dropdown>
); );
@@ -36,13 +36,19 @@ const MobileMenuButton = ({
return ( return (
<Button <Button
icon={ 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} onClick={onToggle}
theme="borderless" theme='borderless'
type="tertiary" type='tertiary'
className="!p-2 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700" 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 { Link } from 'react-router-dom';
import SkeletonWrapper from './SkeletonWrapper'; import SkeletonWrapper from './SkeletonWrapper';
const Navigation = ({ const Navigation = ({ mainNavLinks, isMobile, isLoading, userState }) => {
mainNavLinks,
isMobile,
isLoading,
userState
}) => {
const renderNavLinks = () => { 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 hoverClasses = 'hover:text-semi-color-primary';
const spacingClasses = isMobile ? 'p-1' : 'p-2'; const spacingClasses = isMobile ? 'p-1' : 'p-2';
const commonLinkClasses = `${baseClasses} ${spacingClasses} ${hoverClasses}`; const commonLinkClasses = `${baseClasses} ${spacingClasses} ${hoverClasses}`;
return mainNavLinks.map((link) => { return mainNavLinks.map((link) => {
const linkContent = <span>{link.text}</span>; const linkContent = <span>{link.text}</span>;
if (link.isExternal) { if (link.isExternal) {
@@ -58,11 +53,7 @@ const Navigation = ({
} }
return ( return (
<Link <Link key={link.itemKey} to={targetPath} className={commonLinkClasses}>
key={link.itemKey}
to={targetPath}
className={commonLinkClasses}
>
{linkContent} {linkContent}
</Link> </Link>
); );
@@ -70,10 +61,10 @@ const Navigation = ({
}; };
return ( 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 <SkeletonWrapper
loading={isLoading} loading={isLoading}
type="navigation" type='navigation'
count={4} count={4}
width={60} width={60}
height={16} height={16}
@@ -36,21 +36,24 @@ const NewYearButton = ({ isNewYear }) => {
return ( return (
<Dropdown <Dropdown
position="bottomRight" position='bottomRight'
render={ 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={handleNewYearClick} className="!text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-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!!! 🎉 Happy New Year!!! 🎉
</Dropdown.Item> </Dropdown.Item>
</Dropdown.Menu> </Dropdown.Menu>
} }
> >
<Button <Button
theme="borderless" theme='borderless'
type="tertiary" type='tertiary'
icon={<span className="text-xl">🎉</span>} icon={<span className='text-xl'>🎉</span>}
aria-label="New Year" aria-label='New Year'
className="!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 rounded-full" className='!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 rounded-full'
/> />
</Dropdown> </Dropdown>
); );
@@ -26,14 +26,15 @@ const NotificationButton = ({ unreadCount, onNoticeOpen, t }) => {
icon: <Bell size={18} />, icon: <Bell size={18} />,
'aria-label': t('系统公告'), 'aria-label': t('系统公告'),
onClick: onNoticeOpen, onClick: onNoticeOpen,
theme: "borderless", theme: 'borderless',
type: "tertiary", 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", 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) { if (unreadCount > 0) {
return ( return (
<Badge count={unreadCount} type="danger" overflowCount={99}> <Badge count={unreadCount} type='danger' overflowCount={99}>
<Button {...buttonProps} /> <Button {...buttonProps} />
</Badge> </Badge>
); );
@@ -62,13 +62,17 @@ const SkeletonWrapper = ({
// 用户区域骨架屏 (头像 + 文本) // 用户区域骨架屏 (头像 + 文本)
const renderUserAreaSkeleton = () => { const renderUserAreaSkeleton = () => {
return ( 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 <Skeleton
loading={true} loading={true}
active 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 <Skeleton
loading={true} loading={true}
active active
@@ -107,12 +111,7 @@ const SkeletonWrapper = ({
<Skeleton <Skeleton
loading={true} loading={true}
active active
placeholder={ placeholder={<Skeleton.Title active style={{ width, height: 24 }} />}
<Skeleton.Title
active
style={{ width, height: 24 }}
/>
}
/> />
); );
}; };
@@ -124,12 +123,7 @@ const SkeletonWrapper = ({
<Skeleton <Skeleton
loading={true} loading={true}
active active
placeholder={ placeholder={<Skeleton.Title active style={{ width, height }} />}
<Skeleton.Title
active
style={{ width, height }}
/>
}
/> />
</div> </div>
); );
@@ -25,29 +25,32 @@ import { useActualTheme } from '../../../context/Theme';
const ThemeToggle = ({ theme, onThemeToggle, t }) => { const ThemeToggle = ({ theme, onThemeToggle, t }) => {
const actualTheme = useActualTheme(); const actualTheme = useActualTheme();
const themeOptions = useMemo(() => ([ const themeOptions = useMemo(
{ () => [
key: 'light', {
icon: <Sun size={18} />, key: 'light',
buttonIcon: <Sun size={18} />, icon: <Sun size={18} />,
label: t('浅色模式'), buttonIcon: <Sun size={18} />,
description: t('始终使用浅色主题') label: t('浅色模式'),
}, description: t('始终使用浅色主题'),
{ },
key: 'dark', {
icon: <Moon size={18} />, key: 'dark',
buttonIcon: <Moon size={18} />, icon: <Moon size={18} />,
label: t('深色模式'), buttonIcon: <Moon size={18} />,
description: t('始终使用深色主题') label: t('深色模式'),
}, description: t('始终使用深色主题'),
{ },
key: 'auto', {
icon: <Monitor size={18} />, key: 'auto',
buttonIcon: <Monitor size={18} />, icon: <Monitor size={18} />,
label: t('自动模式'), buttonIcon: <Monitor size={18} />,
description: t('跟随系统主题设置') label: t('自动模式'),
} description: t('跟随系统主题设置'),
]), [t]); },
],
[t],
);
const getItemClassName = (isSelected) => const getItemClassName = (isSelected) =>
isSelected isSelected
@@ -55,13 +58,13 @@ const ThemeToggle = ({ theme, onThemeToggle, t }) => {
: 'hover:!bg-semi-color-fill-1'; : 'hover:!bg-semi-color-fill-1';
const currentButtonIcon = useMemo(() => { 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; return currentOption?.buttonIcon || themeOptions[2].buttonIcon;
}, [theme, themeOptions]); }, [theme, themeOptions]);
return ( return (
<Dropdown <Dropdown
position="bottomRight" position='bottomRight'
render={ render={
<Dropdown.Menu> <Dropdown.Menu>
{themeOptions.map((option) => ( {themeOptions.map((option) => (
@@ -71,9 +74,9 @@ const ThemeToggle = ({ theme, onThemeToggle, t }) => {
onClick={() => onThemeToggle(option.key)} onClick={() => onThemeToggle(option.key)}
className={getItemClassName(theme === option.key)} className={getItemClassName(theme === option.key)}
> >
<div className="flex flex-col"> <div className='flex flex-col'>
<span>{option.label}</span> <span>{option.label}</span>
<span className="text-xs text-semi-color-text-2"> <span className='text-xs text-semi-color-text-2'>
{option.description} {option.description}
</span> </span>
</div> </div>
@@ -83,8 +86,9 @@ const ThemeToggle = ({ theme, onThemeToggle, t }) => {
{theme === 'auto' && ( {theme === 'auto' && (
<> <>
<Dropdown.Divider /> <Dropdown.Divider />
<div className="px-3 py-2 text-xs text-semi-color-text-2"> <div className='px-3 py-2 text-xs text-semi-color-text-2'>
{t('当前跟随系统')}{actualTheme === 'dark' ? t('深色') : t('浅色')} {t('当前跟随系统')}
{actualTheme === 'dark' ? t('深色') : t('浅色')}
</div> </div>
</> </>
)} )}
@@ -94,9 +98,9 @@ const ThemeToggle = ({ theme, onThemeToggle, t }) => {
<Button <Button
icon={currentButtonIcon} icon={currentButtonIcon}
aria-label={t('切换主题')} aria-label={t('切换主题')}
theme="borderless" theme='borderless'
type="tertiary" 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" 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> </Dropdown>
); );
@@ -19,12 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
import React from 'react'; import React from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { import { Avatar, Button, Dropdown, Typography } from '@douyinfe/semi-ui';
Avatar,
Button,
Dropdown,
Typography,
} from '@douyinfe/semi-ui';
import { ChevronDown } from 'lucide-react'; import { ChevronDown } from 'lucide-react';
import { import {
IconExit, IconExit,
@@ -48,7 +43,7 @@ const UserArea = ({
return ( return (
<SkeletonWrapper <SkeletonWrapper
loading={true} loading={true}
type="userArea" type='userArea'
width={50} width={50}
isMobile={isMobile} isMobile={isMobile}
/> />
@@ -58,17 +53,20 @@ const UserArea = ({
if (userState.user) { if (userState.user) {
return ( return (
<Dropdown <Dropdown
position="bottomRight" position='bottomRight'
render={ 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 <Dropdown.Item
onClick={() => { onClick={() => {
navigate('/console/personal'); 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"> <div className='flex items-center gap-2'>
<IconUserSetting size="small" className="text-gray-500 dark:text-gray-400" /> <IconUserSetting
size='small'
className='text-gray-500 dark:text-gray-400'
/>
<span>{t('个人设置')}</span> <span>{t('个人设置')}</span>
</div> </div>
</Dropdown.Item> </Dropdown.Item>
@@ -76,10 +74,13 @@ const UserArea = ({
onClick={() => { onClick={() => {
navigate('/console/token'); 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"> <div className='flex items-center gap-2'>
<IconKey size="small" className="text-gray-500 dark:text-gray-400" /> <IconKey
size='small'
className='text-gray-500 dark:text-gray-400'
/>
<span>{t('令牌管理')}</span> <span>{t('令牌管理')}</span>
</div> </div>
</Dropdown.Item> </Dropdown.Item>
@@ -87,16 +88,25 @@ const UserArea = ({
onClick={() => { onClick={() => {
navigate('/console/topup'); 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"> <div className='flex items-center gap-2'>
<IconCreditCard size="small" className="text-gray-500 dark:text-gray-400" /> <IconCreditCard
size='small'
className='text-gray-500 dark:text-gray-400'
/>
<span>{t('钱包管理')}</span> <span>{t('钱包管理')}</span>
</div> </div>
</Dropdown.Item> </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"> <Dropdown.Item
<div className="flex items-center gap-2"> onClick={logout}
<IconExit size="small" className="text-gray-500 dark:text-gray-400" /> 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> <span>{t('退出')}</span>
</div> </div>
</Dropdown.Item> </Dropdown.Item>
@@ -104,74 +114,76 @@ const UserArea = ({
} }
> >
<Button <Button
theme="borderless" theme='borderless'
type="tertiary" 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" 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 <Avatar
size="extra-small" size='extra-small'
color={stringToColor(userState.user.username)} color={stringToColor(userState.user.username)}
className="mr-1" className='mr-1'
> >
{userState.user.username[0].toUpperCase()} {userState.user.username[0].toUpperCase()}
</Avatar> </Avatar>
<span className="hidden md:inline"> <span className='hidden md:inline'>
<Typography.Text className="!text-xs !font-medium !text-semi-color-text-1 dark:!text-gray-300 mr-1"> <Typography.Text className='!text-xs !font-medium !text-semi-color-text-1 dark:!text-gray-300 mr-1'>
{userState.user.username} {userState.user.username}
</Typography.Text> </Typography.Text>
</span> </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> </Button>
</Dropdown> </Dropdown>
); );
} else { } else {
const showRegisterButton = !isSelfUseMode; 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 loginButtonClasses = `${commonSizingAndLayoutClass} ${loginButtonSpecificStyling}`;
let registerButtonClasses = `${commonSizingAndLayoutClass}`; let registerButtonClasses = `${commonSizingAndLayoutClass}`;
const loginButtonTextSpanClass = "!text-xs !text-semi-color-text-1 dark:!text-gray-300 !p-1.5"; const loginButtonTextSpanClass =
const registerButtonTextSpanClass = "!text-xs !text-white !p-1.5"; '!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 (showRegisterButton) {
if (isMobile) { if (isMobile) {
loginButtonClasses += " !rounded-full"; loginButtonClasses += ' !rounded-full';
} else { } 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 { } else {
loginButtonClasses += " !rounded-full"; loginButtonClasses += ' !rounded-full';
} }
return ( return (
<div className="flex items-center"> <div className='flex items-center'>
<Link to="/login" className="flex"> <Link to='/login' className='flex'>
<Button <Button
theme="borderless" theme='borderless'
type="tertiary" type='tertiary'
className={loginButtonClasses} className={loginButtonClasses}
> >
<span className={loginButtonTextSpanClass}> <span className={loginButtonTextSpanClass}>{t('登录')}</span>
{t('登录')}
</span>
</Button> </Button>
</Link> </Link>
{showRegisterButton && ( {showRegisterButton && (
<div className="hidden md:block"> <div className='hidden md:block'>
<Link to="/register" className="flex -ml-px"> <Link to='/register' className='flex -ml-px'>
<Button <Button
theme="solid" theme='solid'
type="primary" type='primary'
className={registerButtonClasses} className={registerButtonClasses}
> >
<span className={registerButtonTextSpanClass}> <span className={registerButtonTextSpanClass}>{t('注册')}</span>
{t('注册')}
</span>
</Button> </Button>
</Link> </Link>
</div> </div>
@@ -63,7 +63,7 @@ const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
const { mainNavLinks } = useNavigation(t, docsLink); const { mainNavLinks } = useNavigation(t, docsLink);
return ( 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 <NoticeModal
visible={noticeVisible} visible={noticeVisible}
onClose={handleNoticeClose} onClose={handleNoticeClose}
@@ -72,9 +72,9 @@ const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
unreadKeys={getUnreadKeys()} unreadKeys={getUnreadKeys()}
/> />
<div className="w-full px-2"> <div className='w-full px-2'>
<div className="flex items-center justify-between h-16"> <div className='flex items-center justify-between h-16'>
<div className="flex items-center"> <div className='flex items-center'>
<MobileMenuButton <MobileMenuButton
isConsoleRoute={isConsoleRoute} isConsoleRoute={isConsoleRoute}
isMobile={isMobile} isMobile={isMobile}
+86 -40
View File
@@ -18,15 +18,31 @@ For commercial licensing, please contact support@quantumnous.com
*/ */
import React, { useEffect, useState, useContext, useMemo } from 'react'; 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 { useTranslation } from 'react-i18next';
import { API, showError, getRelativeTime } from '../../helpers'; import { API, showError, getRelativeTime } from '../../helpers';
import { marked } from 'marked'; import { marked } from 'marked';
import { IllustrationNoContent, IllustrationNoContentDark } from '@douyinfe/semi-illustrations'; import {
IllustrationNoContent,
IllustrationNoContentDark,
} from '@douyinfe/semi-illustrations';
import { StatusContext } from '../../context/Status'; import { StatusContext } from '../../context/Status';
import { Bell, Megaphone } from 'lucide-react'; 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 { t } = useTranslation();
const [noticeContent, setNoticeContent] = useState(''); const [noticeContent, setNoticeContent] = useState('');
const [loading, setLoading] = useState(false); 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 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(() => { 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 pubDate = item?.publishDate ? new Date(item.publishDate) : null;
const absoluteTime = pubDate && !isNaN(pubDate.getTime()) const absoluteTime =
? `${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')}` pubDate && !isNaN(pubDate.getTime())
: (item?.publishDate || ''); ? `${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')}`
return ({ : item?.publishDate || '';
return {
key: getKeyForItem(item), key: getKeyForItem(item),
type: item.type || 'default', type: item.type || 'default',
time: absoluteTime, time: absoluteTime,
content: item.content, content: item.content,
extra: item.extra, extra: item.extra,
relative: getRelativeTime(item.publishDate), relative: getRelativeTime(item.publishDate),
isUnread: unreadSet.has(getKeyForItem(item)) isUnread: unreadSet.has(getKeyForItem(item)),
}); };
}); });
}, [announcements, unreadSet]); }, [announcements, unreadSet]);
@@ -100,15 +118,23 @@ const NoticeModal = ({ visible, onClose, isMobile, defaultTab = 'inApp', unreadK
const renderMarkdownNotice = () => { const renderMarkdownNotice = () => {
if (loading) { if (loading) {
return <div className="py-12"><Empty description={t('加载中...')} /></div>; return (
<div className='py-12'>
<Empty description={t('加载中...')} />
</div>
);
} }
if (!noticeContent) { if (!noticeContent) {
return ( return (
<div className="py-12"> <div className='py-12'>
<Empty <Empty
image={<IllustrationNoContent style={{ width: 150, height: 150 }} />} image={
darkModeImage={<IllustrationNoContentDark style={{ width: 150, height: 150 }} />} <IllustrationNoContent style={{ width: 150, height: 150 }} />
}
darkModeImage={
<IllustrationNoContentDark style={{ width: 150, height: 150 }} />
}
description={t('暂无公告')} description={t('暂无公告')}
/> />
</div> </div>
@@ -118,7 +144,7 @@ const NoticeModal = ({ visible, onClose, isMobile, defaultTab = 'inApp', unreadK
return ( return (
<div <div
dangerouslySetInnerHTML={{ __html: noticeContent }} 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 = () => { const renderAnnouncementTimeline = () => {
if (processedAnnouncements.length === 0) { if (processedAnnouncements.length === 0) {
return ( return (
<div className="py-12"> <div className='py-12'>
<Empty <Empty
image={<IllustrationNoContent style={{ width: 150, height: 150 }} />} image={
darkModeImage={<IllustrationNoContentDark style={{ width: 150, height: 150 }} />} <IllustrationNoContent style={{ width: 150, height: 150 }} />
}
darkModeImage={
<IllustrationNoContentDark style={{ width: 150, height: 150 }} />
}
description={t('暂无系统公告')} description={t('暂无系统公告')}
/> />
</div> </div>
@@ -137,8 +167,8 @@ const NoticeModal = ({ visible, onClose, isMobile, defaultTab = 'inApp', unreadK
} }
return ( return (
<div className="max-h-[55vh] overflow-y-auto pr-2 card-content-scroll"> <div className='max-h-[55vh] overflow-y-auto pr-2 card-content-scroll'>
<Timeline mode="left"> <Timeline mode='left'>
{processedAnnouncements.map((item, idx) => { {processedAnnouncements.map((item, idx) => {
const htmlContent = marked.parse(item.content || ''); const htmlContent = marked.parse(item.content || '');
const htmlExtra = item.extra ? marked.parse(item.extra) : ''; const htmlExtra = item.extra ? marked.parse(item.extra) : '';
@@ -147,12 +177,14 @@ const NoticeModal = ({ visible, onClose, isMobile, defaultTab = 'inApp', unreadK
key={idx} key={idx}
type={item.type} type={item.type}
time={`${item.relative ? item.relative + ' ' : ''}${item.time}`} time={`${item.relative ? item.relative + ' ' : ''}${item.time}`}
extra={item.extra ? ( extra={
<div item.extra ? (
className="text-xs text-gray-500" <div
dangerouslySetInnerHTML={{ __html: htmlExtra }} className='text-xs text-gray-500'
/> dangerouslySetInnerHTML={{ __html: htmlExtra }}
) : null} />
) : null
}
className={item.isUnread ? '' : ''} className={item.isUnread ? '' : ''}
> >
<div> <div>
@@ -179,26 +211,40 @@ const NoticeModal = ({ visible, onClose, isMobile, defaultTab = 'inApp', unreadK
return ( return (
<Modal <Modal
title={ title={
<div className="flex items-center justify-between w-full"> <div className='flex items-center justify-between w-full'>
<span>{t('系统公告')}</span> <span>{t('系统公告')}</span>
<Tabs <Tabs activeKey={activeTab} onChange={setActiveTab} type='button'>
activeKey={activeTab} <TabPane
onChange={setActiveTab} tab={
type='button' <span className='flex items-center gap-1'>
> <Bell size={14} /> {t('通知')}
<TabPane tab={<span className="flex items-center gap-1"><Bell size={14} /> {t('通知')}</span>} itemKey='inApp' /> </span>
<TabPane tab={<span className="flex items-center gap-1"><Megaphone size={14} /> {t('系统公告')}</span>} itemKey='system' /> }
itemKey='inApp'
/>
<TabPane
tab={
<span className='flex items-center gap-1'>
<Megaphone size={14} /> {t('系统公告')}
</span>
}
itemKey='system'
/>
</Tabs> </Tabs>
</div> </div>
} }
visible={visible} visible={visible}
onCancel={onClose} onCancel={onClose}
footer={( footer={
<div className="flex justify-end"> <div className='flex justify-end'>
<Button type='secondary' onClick={handleCloseTodayNotice}>{t('今日关闭')}</Button> <Button type='secondary' onClick={handleCloseTodayNotice}>
<Button type="primary" onClick={onClose}>{t('关闭公告')}</Button> {t('今日关闭')}
</Button>
<Button type='primary' onClick={onClose}>
{t('关闭公告')}
</Button>
</div> </div>
)} }
size={isMobile ? 'full-width' : 'large'} size={isMobile ? 'full-width' : 'large'}
> >
{renderBody()} {renderBody()}
+26 -6
View File
@@ -27,7 +27,13 @@ import React, { useContext, useEffect, useState } from 'react';
import { useIsMobile } from '../../hooks/common/useIsMobile'; import { useIsMobile } from '../../hooks/common/useIsMobile';
import { useSidebarCollapsed } from '../../hooks/common/useSidebarCollapsed'; import { useSidebarCollapsed } from '../../hooks/common/useSidebarCollapsed';
import { useTranslation } from 'react-i18next'; 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 { UserContext } from '../../context/User';
import { StatusContext } from '../../context/Status'; import { StatusContext } from '../../context/Status';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
@@ -42,9 +48,12 @@ const PageLayout = () => {
const { i18n } = useTranslation(); const { i18n } = useTranslation();
const location = useLocation(); 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.startsWith('/console/chat') &&
location.pathname !== '/console/playground'; location.pathname !== '/console/playground';
@@ -120,7 +129,10 @@ const PageLayout = () => {
zIndex: 100, zIndex: 100,
}} }}
> >
<HeaderBar onMobileMenuToggle={() => setDrawerOpen(prev => !prev)} drawerOpen={drawerOpen} /> <HeaderBar
onMobileMenuToggle={() => setDrawerOpen((prev) => !prev)}
drawerOpen={drawerOpen}
/>
</Header> </Header>
<Layout <Layout
style={{ style={{
@@ -142,12 +154,20 @@ const PageLayout = () => {
width: 'var(--sidebar-current-width)', width: 'var(--sidebar-current-width)',
}} }}
> >
<SiderBar onNavigate={() => { if (isMobile) setDrawerOpen(false); }} /> <SiderBar
onNavigate={() => {
if (isMobile) setDrawerOpen(false);
}}
/>
</Sider> </Sider>
)} )}
<Layout <Layout
style={{ style={{
marginLeft: isMobile ? '0' : showSider ? 'var(--sidebar-current-width)' : '0', marginLeft: isMobile
? '0'
: showSider
? 'var(--sidebar-current-width)'
: '0',
flex: '1 1 auto', flex: '1 1 auto',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
+4 -1
View File
@@ -26,7 +26,10 @@ const SetupCheck = ({ children }) => {
const location = useLocation(); const location = useLocation();
useEffect(() => { useEffect(() => {
if (statusState?.status?.setup === false && location.pathname !== '/setup') { if (
statusState?.status?.setup === false &&
location.pathname !== '/setup'
) {
window.location.href = '/setup'; window.location.href = '/setup';
} }
}, [statusState?.status?.setup, location.pathname]); }, [statusState?.status?.setup, location.pathname]);
+45 -40
View File
@@ -23,17 +23,9 @@ import { useTranslation } from 'react-i18next';
import { getLucideIcon } from '../../helpers/render'; import { getLucideIcon } from '../../helpers/render';
import { ChevronLeft } from 'lucide-react'; import { ChevronLeft } from 'lucide-react';
import { useSidebarCollapsed } from '../../hooks/common/useSidebarCollapsed'; import { useSidebarCollapsed } from '../../hooks/common/useSidebarCollapsed';
import { import { isAdmin, isRoot, showError } from '../../helpers';
isAdmin,
isRoot,
showError
} from '../../helpers';
import { import { Nav, Divider, Button } from '@douyinfe/semi-ui';
Nav,
Divider,
Button,
} from '@douyinfe/semi-ui';
const routerMap = { const routerMap = {
home: '/', home: '/',
@@ -54,7 +46,7 @@ const routerMap = {
personal: '/console/personal', personal: '/console/personal',
}; };
const SiderBar = ({ onNavigate = () => { } }) => { const SiderBar = ({ onNavigate = () => {} }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [collapsed, toggleCollapsed] = useSidebarCollapsed(); const [collapsed, toggleCollapsed] = useSidebarCollapsed();
@@ -275,14 +267,17 @@ const SiderBar = ({ onNavigate = () => { } }) => {
key={item.itemKey} key={item.itemKey}
itemKey={item.itemKey} itemKey={item.itemKey}
text={ text={
<div className="flex items-center"> <div className='flex items-center'>
<span className="truncate font-medium text-sm" style={{ color: textColor }}> <span
className='truncate font-medium text-sm'
style={{ color: textColor }}
>
{item.text} {item.text}
</span> </span>
</div> </div>
} }
icon={ icon={
<div className="sidebar-icon-container flex-shrink-0"> <div className='sidebar-icon-container flex-shrink-0'>
{getLucideIcon(item.itemKey, isSelected)} {getLucideIcon(item.itemKey, isSelected)}
</div> </div>
} }
@@ -302,14 +297,17 @@ const SiderBar = ({ onNavigate = () => { } }) => {
key={item.itemKey} key={item.itemKey}
itemKey={item.itemKey} itemKey={item.itemKey}
text={ text={
<div className="flex items-center"> <div className='flex items-center'>
<span className="truncate font-medium text-sm" style={{ color: textColor }}> <span
className='truncate font-medium text-sm'
style={{ color: textColor }}
>
{item.text} {item.text}
</span> </span>
</div> </div>
} }
icon={ icon={
<div className="sidebar-icon-container flex-shrink-0"> <div className='sidebar-icon-container flex-shrink-0'>
{getLucideIcon(item.itemKey, isSelected)} {getLucideIcon(item.itemKey, isSelected)}
</div> </div>
} }
@@ -323,7 +321,10 @@ const SiderBar = ({ onNavigate = () => { } }) => {
key={subItem.itemKey} key={subItem.itemKey}
itemKey={subItem.itemKey} itemKey={subItem.itemKey}
text={ text={
<span className="truncate font-medium text-sm" style={{ color: subTextColor }}> <span
className='truncate font-medium text-sm'
style={{ color: subTextColor }}
>
{subItem.text} {subItem.text}
</span> </span>
} }
@@ -339,18 +340,18 @@ const SiderBar = ({ onNavigate = () => { } }) => {
return ( return (
<div <div
className="sidebar-container" className='sidebar-container'
style={{ width: 'var(--sidebar-current-width)' }} style={{ width: 'var(--sidebar-current-width)' }}
> >
<Nav <Nav
className="sidebar-nav" className='sidebar-nav'
defaultIsCollapsed={collapsed} defaultIsCollapsed={collapsed}
isCollapsed={collapsed} isCollapsed={collapsed}
onCollapseChange={toggleCollapsed} onCollapseChange={toggleCollapsed}
selectedKeys={selectedKeys} selectedKeys={selectedKeys}
itemStyle="sidebar-nav-item" itemStyle='sidebar-nav-item'
hoverStyle="sidebar-nav-item:hover" hoverStyle='sidebar-nav-item:hover'
selectedStyle="sidebar-nav-item-selected" selectedStyle='sidebar-nav-item-selected'
renderWrapper={({ itemElement, props }) => { renderWrapper={({ itemElement, props }) => {
const to = routerMapState[props.itemKey] || routerMap[props.itemKey]; const to = routerMapState[props.itemKey] || routerMap[props.itemKey];
@@ -381,27 +382,25 @@ const SiderBar = ({ onNavigate = () => { } }) => {
}} }}
> >
{/* 聊天区域 */} {/* 聊天区域 */}
<div className="sidebar-section"> <div className='sidebar-section'>
{!collapsed && ( {!collapsed && <div className='sidebar-group-label'>{t('聊天')}</div>}
<div className="sidebar-group-label">{t('聊天')}</div>
)}
{chatMenuItems.map((item) => renderSubItem(item))} {chatMenuItems.map((item) => renderSubItem(item))}
</div> </div>
{/* 控制台区域 */} {/* 控制台区域 */}
<Divider className="sidebar-divider" /> <Divider className='sidebar-divider' />
<div> <div>
{!collapsed && ( {!collapsed && (
<div className="sidebar-group-label">{t('控制台')}</div> <div className='sidebar-group-label'>{t('控制台')}</div>
)} )}
{workspaceItems.map((item) => renderNavItem(item))} {workspaceItems.map((item) => renderNavItem(item))}
</div> </div>
{/* 个人中心区域 */} {/* 个人中心区域 */}
<Divider className="sidebar-divider" /> <Divider className='sidebar-divider' />
<div> <div>
{!collapsed && ( {!collapsed && (
<div className="sidebar-group-label">{t('个人中心')}</div> <div className='sidebar-group-label'>{t('个人中心')}</div>
)} )}
{financeItems.map((item) => renderNavItem(item))} {financeItems.map((item) => renderNavItem(item))}
</div> </div>
@@ -409,10 +408,10 @@ const SiderBar = ({ onNavigate = () => { } }) => {
{/* 管理员区域 - 只在管理员时显示 */} {/* 管理员区域 - 只在管理员时显示 */}
{isAdmin() && ( {isAdmin() && (
<> <>
<Divider className="sidebar-divider" /> <Divider className='sidebar-divider' />
<div> <div>
{!collapsed && ( {!collapsed && (
<div className="sidebar-group-label">{t('管理员')}</div> <div className='sidebar-group-label'>{t('管理员')}</div>
)} )}
{adminItems.map((item) => renderNavItem(item))} {adminItems.map((item) => renderNavItem(item))}
</div> </div>
@@ -421,22 +420,28 @@ const SiderBar = ({ onNavigate = () => { } }) => {
</Nav> </Nav>
{/* 底部折叠按钮 */} {/* 底部折叠按钮 */}
<div className="sidebar-collapse-button"> <div className='sidebar-collapse-button'>
<Button <Button
theme="outline" theme='outline'
type="tertiary" type='tertiary'
size="small" size='small'
icon={ icon={
<ChevronLeft <ChevronLeft
size={16} size={16}
strokeWidth={2.5} strokeWidth={2.5}
color="var(--semi-color-text-2)" color='var(--semi-color-text-2)'
style={{ transform: collapsed ? 'rotate(180deg)' : 'rotate(0deg)' }} style={{
transform: collapsed ? 'rotate(180deg)' : 'rotate(0deg)',
}}
/> />
} }
onClick={toggleCollapsed} onClick={toggleCollapsed}
icononly={collapsed} 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} {!collapsed ? t('收起侧边栏') : null}
</Button> </Button>
+26 -29
View File
@@ -18,17 +18,8 @@ For commercial licensing, please contact support@quantumnous.com
*/ */
import React from 'react'; import React from 'react';
import { import { Card, Chat, Typography, Button } from '@douyinfe/semi-ui';
Card, import { MessageSquare, Eye, EyeOff } from 'lucide-react';
Chat,
Typography,
Button,
} from '@douyinfe/semi-ui';
import {
MessageSquare,
Eye,
EyeOff,
} from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import CustomInputRender from './CustomInputRender'; import CustomInputRender from './CustomInputRender';
@@ -57,37 +48,43 @@ const ChatArea = ({
return ( return (
<Card <Card
className="h-full" className='h-full'
bordered={false} 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 ? ( {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='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 justify-between'>
<div className="flex items-center gap-3"> <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"> <div className='w-10 h-10 rounded-full bg-white/20 backdrop-blur flex items-center justify-center'>
<MessageSquare size={20} className="text-white" /> <MessageSquare size={20} className='text-white' />
</div> </div>
<div> <div>
<Typography.Title heading={5} className="!text-white mb-0"> <Typography.Title heading={5} className='!text-white mb-0'>
{t('AI 对话')} {t('AI 对话')}
</Typography.Title> </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('选择模型开始对话')} {inputs.model || t('选择模型开始对话')}
</Typography.Text> </Typography.Text>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className='flex items-center gap-2'>
<Button <Button
icon={showDebugPanel ? <EyeOff size={14} /> : <Eye size={14} />} icon={showDebugPanel ? <EyeOff size={14} /> : <Eye size={14} />}
onClick={onToggleDebugPanel} onClick={onToggleDebugPanel}
theme="borderless" theme='borderless'
type="primary" type='primary'
size="small" size='small'
className="!rounded-lg !text-white/80 hover:!text-white hover:!bg-white/10" className='!rounded-lg !text-white/80 hover:!text-white hover:!bg-white/10'
> >
{showDebugPanel ? t('隐藏调试') : t('显示调试')} {showDebugPanel ? t('隐藏调试') : t('显示调试')}
</Button> </Button>
@@ -97,7 +94,7 @@ const ChatArea = ({
)} )}
{/* 聊天内容区域 */} {/* 聊天内容区域 */}
<div className="flex-1 overflow-hidden"> <div className='flex-1 overflow-hidden'>
<Chat <Chat
ref={chatRef} ref={chatRef}
chatBoxRenderConfig={{ chatBoxRenderConfig={{
@@ -110,7 +107,7 @@ const ChatArea = ({
style={{ style={{
height: '100%', height: '100%',
maxWidth: '100%', maxWidth: '100%',
overflow: 'hidden' overflow: 'hidden',
}} }}
chats={message} chats={message}
onMessageSend={onMessageSend} onMessageSend={onMessageSend}
@@ -121,7 +118,7 @@ const ChatArea = ({
showStopGenerate showStopGenerate
onStopGenerator={onStopGenerator} onStopGenerator={onStopGenerator}
onClear={onClearMessages} onClear={onClearMessages}
className="h-full" className='h-full'
placeholder={t('请输入您的问题...')} placeholder={t('请输入您的问题...')}
/> />
</div> </div>
+70 -45
View File
@@ -102,15 +102,17 @@ const highlightJson = (str) => {
color = '#569cd6'; color = '#569cd6';
} }
return `<span style="color: ${color}">${match}</span>`; return `<span style="color: ${color}">${match}</span>`;
} },
); );
}; };
const isJsonLike = (content, language) => { const isJsonLike = (content, language) => {
if (language === 'json') return true; if (language === 'json') return true;
const trimmed = content.trim(); const trimmed = content.trim();
return (trimmed.startsWith('{') && trimmed.endsWith('}')) || return (
(trimmed.startsWith('[') && trimmed.endsWith(']')); (trimmed.startsWith('{') && trimmed.endsWith('}')) ||
(trimmed.startsWith('[') && trimmed.endsWith(']'))
);
}; };
const formatContent = (content) => { const formatContent = (content) => {
@@ -148,7 +150,10 @@ const CodeViewer = ({ content, title, language = 'json' }) => {
const contentMetrics = useMemo(() => { const contentMetrics = useMemo(() => {
const length = formattedContent.length; const length = formattedContent.length;
const isLarge = length > PERFORMANCE_CONFIG.MAX_DISPLAY_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 }; return { length, isLarge, isVeryLarge };
}, [formattedContent.length]); }, [formattedContent.length]);
@@ -156,8 +161,10 @@ const CodeViewer = ({ content, title, language = 'json' }) => {
if (!contentMetrics.isLarge || isExpanded) { if (!contentMetrics.isLarge || isExpanded) {
return formattedContent; return formattedContent;
} }
return formattedContent.substring(0, PERFORMANCE_CONFIG.PREVIEW_LENGTH) + return (
'\n\n// ... 内容被截断以提升性能 ...'; formattedContent.substring(0, PERFORMANCE_CONFIG.PREVIEW_LENGTH) +
'\n\n// ... 内容被截断以提升性能 ...'
);
}, [formattedContent, contentMetrics.isLarge, isExpanded]); }, [formattedContent, contentMetrics.isLarge, isExpanded]);
const highlightedContent = useMemo(() => { const highlightedContent = useMemo(() => {
@@ -174,9 +181,10 @@ const CodeViewer = ({ content, title, language = 'json' }) => {
const handleCopy = useCallback(async () => { const handleCopy = useCallback(async () => {
try { try {
const textToCopy = typeof content === 'object' && content !== null const textToCopy =
? JSON.stringify(content, null, 2) typeof content === 'object' && content !== null
: content; ? JSON.stringify(content, null, 2)
: content;
const success = await copy(textToCopy); const success = await copy(textToCopy);
setCopied(true); setCopied(true);
@@ -205,11 +213,12 @@ const CodeViewer = ({ content, title, language = 'json' }) => {
}, [isExpanded, contentMetrics.isVeryLarge]); }, [isExpanded, contentMetrics.isVeryLarge]);
if (!content) { if (!content) {
const placeholderText = { const placeholderText =
preview: t('正在构造请求体预览...'), {
request: t('暂无请求数据'), preview: t('正在构造请求体预览...'),
response: t('暂无响应数据') request: t('暂无请求数据'),
}[title] || t('暂无数据'); response: t('暂无响应数据'),
}[title] || t('暂无数据');
return ( return (
<div style={codeThemeStyles.noContent}> <div style={codeThemeStyles.noContent}>
@@ -222,7 +231,7 @@ const CodeViewer = ({ content, title, language = 'json' }) => {
const contentPadding = contentMetrics.isLarge ? '52px' : '16px'; const contentPadding = contentMetrics.isLarge ? '52px' : '16px';
return ( return (
<div style={codeThemeStyles.container} className="h-full"> <div style={codeThemeStyles.container} className='h-full'>
{/* 性能警告 */} {/* 性能警告 */}
{contentMetrics.isLarge && ( {contentMetrics.isLarge && (
<div style={codeThemeStyles.performanceWarning}> <div style={codeThemeStyles.performanceWarning}>
@@ -250,8 +259,8 @@ const CodeViewer = ({ content, title, language = 'json' }) => {
<Button <Button
icon={<Copy size={14} />} icon={<Copy size={14} />}
onClick={handleCopy} onClick={handleCopy}
size="small" size='small'
theme="borderless" theme='borderless'
style={{ style={{
backgroundColor: 'transparent', backgroundColor: 'transparent',
border: 'none', border: 'none',
@@ -268,25 +277,29 @@ const CodeViewer = ({ content, title, language = 'json' }) => {
...codeThemeStyles.content, ...codeThemeStyles.content,
paddingTop: contentPadding, paddingTop: contentPadding,
}} }}
className="model-settings-scroll" className='model-settings-scroll'
> >
{isProcessing ? ( {isProcessing ? (
<div style={{ <div
display: 'flex', style={{
alignItems: 'center', display: 'flex',
justifyContent: 'center', alignItems: 'center',
height: '200px', justifyContent: 'center',
color: '#888' height: '200px',
}}> color: '#888',
<div style={{ }}
width: '20px', >
height: '20px', <div
border: '2px solid #444', style={{
borderTop: '2px solid #888', width: '20px',
borderRadius: '50%', height: '20px',
animation: 'spin 1s linear infinite', border: '2px solid #444',
marginRight: '8px' borderTop: '2px solid #888',
}} /> borderRadius: '50%',
animation: 'spin 1s linear infinite',
marginRight: '8px',
}}
/>
{t('正在处理大内容...')} {t('正在处理大内容...')}
</div> </div>
) : ( ) : (
@@ -296,18 +309,22 @@ const CodeViewer = ({ content, title, language = 'json' }) => {
{/* 展开/收起按钮 */} {/* 展开/收起按钮 */}
{contentMetrics.isLarge && !isProcessing && ( {contentMetrics.isLarge && !isProcessing && (
<div style={{ <div
...codeThemeStyles.actionButton, style={{
bottom: '12px', ...codeThemeStyles.actionButton,
left: '50%', bottom: '12px',
transform: 'translateX(-50%)', left: '50%',
}}> transform: 'translateX(-50%)',
}}
>
<Tooltip content={isExpanded ? t('收起内容') : t('显示完整内容')}> <Tooltip content={isExpanded ? t('收起内容') : t('显示完整内容')}>
<Button <Button
icon={isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />} icon={
isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />
}
onClick={handleToggleExpand} onClick={handleToggleExpand}
size="small" size='small'
theme="borderless" theme='borderless'
style={{ style={{
backgroundColor: 'transparent', backgroundColor: 'transparent',
border: 'none', border: 'none',
@@ -317,8 +334,16 @@ const CodeViewer = ({ content, title, language = 'json' }) => {
> >
{isExpanded ? t('收起') : t('展开')} {isExpanded ? t('收起') : t('展开')}
{!isExpanded && ( {!isExpanded && (
<span style={{ fontSize: '11px', opacity: 0.7, marginLeft: '4px' }}> <span
(+{Math.round((contentMetrics.length - PERFORMANCE_CONFIG.PREVIEW_LENGTH) / 1000)}K) style={{ fontSize: '11px', opacity: 0.7, marginLeft: '4px' }}
>
(+
{Math.round(
(contentMetrics.length -
PERFORMANCE_CONFIG.PREVIEW_LENGTH) /
1000,
)}
K)
</span> </span>
)} )}
</Button> </Button>
+48 -46
View File
@@ -18,21 +18,16 @@ For commercial licensing, please contact support@quantumnous.com
*/ */
import React, { useRef } from 'react'; import React, { useRef } from 'react';
import { import { Button, Typography, Toast, Modal, Dropdown } from '@douyinfe/semi-ui';
Button, import { Download, Upload, RotateCcw, Settings2 } from 'lucide-react';
Typography,
Toast,
Modal,
Dropdown,
} from '@douyinfe/semi-ui';
import {
Download,
Upload,
RotateCcw,
Settings2,
} from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { exportConfig, importConfig, clearConfig, hasStoredConfig, getConfigTimestamp } from './configStorage'; import {
exportConfig,
importConfig,
clearConfig,
hasStoredConfig,
getConfigTimestamp,
} from './configStorage';
const ConfigManager = ({ const ConfigManager = ({
currentConfig, currentConfig,
@@ -51,7 +46,10 @@ const ConfigManager = ({
...currentConfig, ...currentConfig,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}; };
localStorage.setItem('playground_config', JSON.stringify(configWithTimestamp)); localStorage.setItem(
'playground_config',
JSON.stringify(configWithTimestamp),
);
exportConfig(currentConfig, messages); exportConfig(currentConfig, messages);
Toast.success({ Toast.success({
@@ -104,7 +102,9 @@ const ConfigManager = ({
const handleReset = () => { const handleReset = () => {
Modal.confirm({ Modal.confirm({
title: t('重置配置'), title: t('重置配置'),
content: t('将清除所有保存的配置并恢复默认设置,此操作不可撤销。是否继续?'), content: t(
'将清除所有保存的配置并恢复默认设置,此操作不可撤销。是否继续?',
),
okText: t('确定重置'), okText: t('确定重置'),
cancelText: t('取消'), cancelText: t('取消'),
okButtonProps: { okButtonProps: {
@@ -114,7 +114,9 @@ const ConfigManager = ({
// 询问是否同时重置消息 // 询问是否同时重置消息
Modal.confirm({ Modal.confirm({
title: t('重置选项'), title: t('重置选项'),
content: t('是否同时重置对话消息?选择"是"将清空所有对话记录并恢复默认示例;选择"否"将保留当前对话记录。'), content: t(
'是否同时重置对话消息?选择"是"将清空所有对话记录并恢复默认示例;选择"否"将保留当前对话记录。',
),
okText: t('同时重置消息'), okText: t('同时重置消息'),
cancelText: t('仅重置配置'), cancelText: t('仅重置配置'),
okButtonProps: { okButtonProps: {
@@ -159,7 +161,7 @@ const ConfigManager = ({
name: 'export', name: 'export',
onClick: handleExport, onClick: handleExport,
children: ( children: (
<div className="flex items-center gap-2"> <div className='flex items-center gap-2'>
<Download size={14} /> <Download size={14} />
{t('导出配置')} {t('导出配置')}
</div> </div>
@@ -170,7 +172,7 @@ const ConfigManager = ({
name: 'import', name: 'import',
onClick: handleImportClick, onClick: handleImportClick,
children: ( children: (
<div className="flex items-center gap-2"> <div className='flex items-center gap-2'>
<Upload size={14} /> <Upload size={14} />
{t('导入配置')} {t('导入配置')}
</div> </div>
@@ -184,7 +186,7 @@ const ConfigManager = ({
name: 'reset', name: 'reset',
onClick: handleReset, onClick: handleReset,
children: ( children: (
<div className="flex items-center gap-2 text-red-600"> <div className='flex items-center gap-2 text-red-600'>
<RotateCcw size={14} /> <RotateCcw size={14} />
{t('重置配置')} {t('重置配置')}
</div> </div>
@@ -197,24 +199,24 @@ const ConfigManager = ({
return ( return (
<> <>
<Dropdown <Dropdown
trigger="click" trigger='click'
position="bottomLeft" position='bottomLeft'
showTick showTick
menu={dropdownItems} menu={dropdownItems}
> >
<Button <Button
icon={<Settings2 size={14} />} icon={<Settings2 size={14} />}
theme="borderless" theme='borderless'
type="tertiary" type='tertiary'
size="small" size='small'
className="!rounded-lg !text-gray-600 hover:!text-blue-600 hover:!bg-blue-50" className='!rounded-lg !text-gray-600 hover:!text-blue-600 hover:!bg-blue-50'
/> />
</Dropdown> </Dropdown>
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type='file'
accept=".json" accept='.json'
onChange={handleFileChange} onChange={handleFileChange}
style={{ display: 'none' }} style={{ display: 'none' }}
/> />
@@ -224,42 +226,42 @@ const ConfigManager = ({
// 桌面端显示紧凑的按钮组 // 桌面端显示紧凑的按钮组
return ( return (
<div className="space-y-3"> <div className='space-y-3'>
{/* 配置状态信息和重置按钮 */} {/* 配置状态信息和重置按钮 */}
<div className="flex items-center justify-between"> <div className='flex items-center justify-between'>
<Typography.Text className="text-xs text-gray-500"> <Typography.Text className='text-xs text-gray-500'>
{getConfigStatus()} {getConfigStatus()}
</Typography.Text> </Typography.Text>
<Button <Button
icon={<RotateCcw size={12} />} icon={<RotateCcw size={12} />}
size="small" size='small'
theme="borderless" theme='borderless'
type="danger" type='danger'
onClick={handleReset} onClick={handleReset}
className="!rounded-full !text-xs !px-2" className='!rounded-full !text-xs !px-2'
/> />
</div> </div>
{/* 导出和导入按钮 */} {/* 导出和导入按钮 */}
<div className="flex gap-2"> <div className='flex gap-2'>
<Button <Button
icon={<Download size={12} />} icon={<Download size={12} />}
size="small" size='small'
theme="solid" theme='solid'
type="primary" type='primary'
onClick={handleExport} onClick={handleExport}
className="!rounded-lg flex-1 !text-xs !h-7" className='!rounded-lg flex-1 !text-xs !h-7'
> >
{t('导出')} {t('导出')}
</Button> </Button>
<Button <Button
icon={<Upload size={12} />} icon={<Upload size={12} />}
size="small" size='small'
theme="outline" theme='outline'
type="primary" type='primary'
onClick={handleImportClick} onClick={handleImportClick}
className="!rounded-lg flex-1 !text-xs !h-7" className='!rounded-lg flex-1 !text-xs !h-7'
> >
{t('导入')} {t('导入')}
</Button> </Button>
@@ -267,8 +269,8 @@ const ConfigManager = ({
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type='file'
accept=".json" accept='.json'
onChange={handleFileChange} onChange={handleFileChange}
style={{ display: 'none' }} style={{ display: 'none' }}
/> />
@@ -21,23 +21,24 @@ import React from 'react';
const CustomInputRender = (props) => { const CustomInputRender = (props) => {
const { detailProps } = props; const { detailProps } = props;
const { clearContextNode, uploadNode, inputNode, sendNode, onClick } = detailProps; const { clearContextNode, uploadNode, inputNode, sendNode, onClick } =
detailProps;
// 清空按钮 // 清空按钮
const styledClearNode = clearContextNode const styledClearNode = clearContextNode
? React.cloneElement(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 || ''}`, className: `!rounded-full !bg-gray-100 hover:!bg-red-500 hover:!text-white flex-shrink-0 transition-all ${clearContextNode.props.className || ''}`,
style: { style: {
...clearContextNode.props.style, ...clearContextNode.props.style,
width: '32px', width: '32px',
height: '32px', height: '32px',
minWidth: '32px', minWidth: '32px',
padding: 0, padding: 0,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
} },
}) })
: null; : null;
// 发送按钮 // 发送按钮
@@ -52,21 +53,19 @@ const CustomInputRender = (props) => {
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
} },
}); });
return ( return (
<div className="p-2 sm:p-4"> <div className='p-2 sm:p-4'>
<div <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)' }} style={{ border: '1px solid var(--semi-color-border)' }}
onClick={onClick} onClick={onClick}
> >
{/* 清空对话按钮 - 左边 */} {/* 清空对话按钮 - 左边 */}
{styledClearNode} {styledClearNode}
<div className="flex-1"> <div className='flex-1'>{inputNode}</div>
{inputNode}
</div>
{/* 发送按钮 - 右边 */} {/* 发送按钮 - 右边 */}
{styledSendNode} {styledSendNode}
</div> </div>
@@ -25,13 +25,7 @@ import {
Switch, Switch,
Banner, Banner,
} from '@douyinfe/semi-ui'; } from '@douyinfe/semi-ui';
import { import { Code, Edit, Check, X, AlertTriangle } from 'lucide-react';
Code,
Edit,
Check,
X,
AlertTriangle,
} from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
const CustomRequestEditor = ({ const CustomRequestEditor = ({
@@ -48,12 +42,22 @@ const CustomRequestEditor = ({
// 当切换到自定义模式时,用默认payload初始化 // 当切换到自定义模式时,用默认payload初始化
useEffect(() => { useEffect(() => {
if (customRequestMode && (!customRequestBody || customRequestBody.trim() === '')) { if (
const defaultJson = defaultPayload ? JSON.stringify(defaultPayload, null, 2) : ''; customRequestMode &&
(!customRequestBody || customRequestBody.trim() === '')
) {
const defaultJson = defaultPayload
? JSON.stringify(defaultPayload, null, 2)
: '';
setLocalValue(defaultJson); setLocalValue(defaultJson);
onCustomRequestBodyChange(defaultJson); onCustomRequestBodyChange(defaultJson);
} }
}, [customRequestMode, defaultPayload, customRequestBody, onCustomRequestBodyChange]); }, [
customRequestMode,
defaultPayload,
customRequestBody,
onCustomRequestBodyChange,
]);
// 同步外部传入的customRequestBody到本地状态 // 同步外部传入的customRequestBody到本地状态
useEffect(() => { useEffect(() => {
@@ -113,21 +117,21 @@ const CustomRequestEditor = ({
}; };
return ( return (
<div className="space-y-4"> <div className='space-y-4'>
{/* 自定义模式开关 */} {/* 自定义模式开关 */}
<div className="flex items-center justify-between"> <div className='flex items-center justify-between'>
<div className="flex items-center gap-2"> <div className='flex items-center gap-2'>
<Code size={16} className="text-gray-500" /> <Code size={16} className='text-gray-500' />
<Typography.Text strong className="text-sm"> <Typography.Text strong className='text-sm'>
自定义请求体模式 自定义请求体模式
</Typography.Text> </Typography.Text>
</div> </div>
<Switch <Switch
checked={customRequestMode} checked={customRequestMode}
onChange={handleModeToggle} onChange={handleModeToggle}
checkedText="开" checkedText='开'
uncheckedText="关" uncheckedText='关'
size="small" size='small'
/> />
</div> </div>
@@ -135,43 +139,43 @@ const CustomRequestEditor = ({
<> <>
{/* 提示信息 */} {/* 提示信息 */}
<Banner <Banner
type="warning" type='warning'
description="启用此模式后,将使用您自定义的请求体发送API请求,模型配置面板的参数设置将被忽略。" description='启用此模式后,将使用您自定义的请求体发送API请求,模型配置面板的参数设置将被忽略。'
icon={<AlertTriangle size={16} />} icon={<AlertTriangle size={16} />}
className="!rounded-lg" className='!rounded-lg'
closeIcon={null} closeIcon={null}
/> />
{/* JSON编辑器 */} {/* JSON编辑器 */}
<div> <div>
<div className="flex items-center justify-between mb-2"> <div className='flex items-center justify-between mb-2'>
<Typography.Text strong className="text-sm"> <Typography.Text strong className='text-sm'>
请求体 JSON 请求体 JSON
</Typography.Text> </Typography.Text>
<div className="flex items-center gap-2"> <div className='flex items-center gap-2'>
{isValid ? ( {isValid ? (
<div className="flex items-center gap-1 text-green-600"> <div className='flex items-center gap-1 text-green-600'>
<Check size={14} /> <Check size={14} />
<Typography.Text className="text-xs"> <Typography.Text className='text-xs'>
格式正确 格式正确
</Typography.Text> </Typography.Text>
</div> </div>
) : ( ) : (
<div className="flex items-center gap-1 text-red-600"> <div className='flex items-center gap-1 text-red-600'>
<X size={14} /> <X size={14} />
<Typography.Text className="text-xs"> <Typography.Text className='text-xs'>
格式错误 格式错误
</Typography.Text> </Typography.Text>
</div> </div>
)} )}
<Button <Button
theme="borderless" theme='borderless'
type="tertiary" type='tertiary'
size="small" size='small'
icon={<Edit size={14} />} icon={<Edit size={14} />}
onClick={formatJson} onClick={formatJson}
disabled={!isValid} disabled={!isValid}
className="!rounded-lg" className='!rounded-lg'
> >
格式化 格式化
</Button> </Button>
@@ -191,12 +195,12 @@ const CustomRequestEditor = ({
/> />
{!isValid && errorMessage && ( {!isValid && errorMessage && (
<Typography.Text type="danger" className="text-xs mt-1 block"> <Typography.Text type='danger' className='text-xs mt-1 block'>
{errorMessage} {errorMessage}
</Typography.Text> </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格式的请求体您可以参考预览面板中的默认请求体格式 请输入有效的JSON格式的请求体您可以参考预览面板中的默认请求体格式
</Typography.Text> </Typography.Text>
</div> </div>
+58 -56
View File
@@ -26,14 +26,7 @@ import {
Button, Button,
Dropdown, Dropdown,
} from '@douyinfe/semi-ui'; } from '@douyinfe/semi-ui';
import { import { Code, Zap, Clock, X, Eye, Send } from 'lucide-react';
Code,
Zap,
Clock,
X,
Eye,
Send,
} from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import CodeViewer from './CodeViewer'; import CodeViewer from './CodeViewer';
@@ -76,7 +69,7 @@ const DebugPanel = ({
<Dropdown <Dropdown
render={ render={
<Dropdown.Menu> <Dropdown.Menu>
{items.map(item => { {items.map((item) => {
return ( return (
<Dropdown.Item <Dropdown.Item
key={item.itemKey} key={item.itemKey}
@@ -104,21 +97,21 @@ const DebugPanel = ({
return ( return (
<Card <Card
className="h-full flex flex-col" className='h-full flex flex-col'
bordered={false} bordered={false}
bodyStyle={{ bodyStyle={{
padding: styleState.isMobile ? '16px' : '24px', padding: styleState.isMobile ? '16px' : '24px',
height: '100%', height: '100%',
display: 'flex', display: 'flex',
flexDirection: 'column' flexDirection: 'column',
}} }}
> >
<div className="flex items-center justify-between mb-6 flex-shrink-0"> <div className='flex items-center justify-between mb-6 flex-shrink-0'>
<div className="flex items-center"> <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"> <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" /> <Code size={20} className='text-white' />
</div> </div>
<Typography.Title heading={5} className="mb-0"> <Typography.Title heading={5} className='mb-0'>
{t('调试信息')} {t('调试信息')}
</Typography.Title> </Typography.Title>
</div> </div>
@@ -127,75 +120,84 @@ const DebugPanel = ({
<Button <Button
icon={<X size={16} />} icon={<X size={16} />}
onClick={onCloseDebugPanel} onClick={onCloseDebugPanel}
theme="borderless" theme='borderless'
type="tertiary" type='tertiary'
size="small" size='small'
className="!rounded-lg" className='!rounded-lg'
/> />
)} )}
</div> </div>
<div className="flex-1 overflow-hidden debug-panel"> <div className='flex-1 overflow-hidden debug-panel'>
<Tabs <Tabs
renderArrow={renderArrow} renderArrow={renderArrow}
type="card" type='card'
collapsible collapsible
className="h-full" className='h-full'
style={{ height: '100%', display: 'flex', flexDirection: 'column' }} style={{ height: '100%', display: 'flex', flexDirection: 'column' }}
activeKey={activeKey} activeKey={activeKey}
onChange={handleTabChange} onChange={handleTabChange}
> >
<TabPane tab={ <TabPane
<div className="flex items-center gap-2"> tab={
<Eye size={16} /> <div className='flex items-center gap-2'>
{t('预览请求体')} <Eye size={16} />
{customRequestMode && ( {t('预览请求体')}
<span className="px-1.5 py-0.5 text-xs bg-orange-100 text-orange-600 rounded-full"> {customRequestMode && (
自定义 <span className='px-1.5 py-0.5 text-xs bg-orange-100 text-orange-600 rounded-full'>
</span> 自定义
)} </span>
</div> )}
} itemKey="preview"> </div>
}
itemKey='preview'
>
<CodeViewer <CodeViewer
content={debugData.previewRequest} content={debugData.previewRequest}
title="preview" title='preview'
language="json" language='json'
/> />
</TabPane> </TabPane>
<TabPane tab={ <TabPane
<div className="flex items-center gap-2"> tab={
<Send size={16} /> <div className='flex items-center gap-2'>
{t('实际请求体')} <Send size={16} />
</div> {t('实际请求体')}
} itemKey="request"> </div>
}
itemKey='request'
>
<CodeViewer <CodeViewer
content={debugData.request} content={debugData.request}
title="request" title='request'
language="json" language='json'
/> />
</TabPane> </TabPane>
<TabPane tab={ <TabPane
<div className="flex items-center gap-2"> tab={
<Zap size={16} /> <div className='flex items-center gap-2'>
{t('响应')} <Zap size={16} />
</div> {t('响应')}
} itemKey="response"> </div>
}
itemKey='response'
>
<CodeViewer <CodeViewer
content={debugData.response} content={debugData.response}
title="response" title='response'
language="json" language='json'
/> />
</TabPane> </TabPane>
</Tabs> </Tabs>
</div> </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) && ( {(debugData.timestamp || debugData.previewTimestamp) && (
<div className="flex items-center gap-2"> <div className='flex items-center gap-2'>
<Clock size={14} className="text-gray-500" /> <Clock size={14} className='text-gray-500' />
<Typography.Text className="text-xs text-gray-500"> <Typography.Text className='text-xs text-gray-500'>
{activeKey === 'preview' && debugData.previewTimestamp {activeKey === 'preview' && debugData.previewTimestamp
? `${t('预览更新')}: ${new Date(debugData.previewTimestamp).toLocaleString()}` ? `${t('预览更新')}: ${new Date(debugData.previewTimestamp).toLocaleString()}`
: debugData.timestamp : debugData.timestamp
@@ -19,11 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
import React from 'react'; import React from 'react';
import { Button } from '@douyinfe/semi-ui'; import { Button } from '@douyinfe/semi-ui';
import { import { Settings, Eye, EyeOff } from 'lucide-react';
Settings,
Eye,
EyeOff,
} from 'lucide-react';
const FloatingButtons = ({ const FloatingButtons = ({
styleState, styleState,
@@ -55,7 +51,7 @@ const FloatingButtons = ({
onClick={onToggleSettings} onClick={onToggleSettings}
theme='solid' theme='solid'
type='primary' type='primary'
className="lg:hidden" className='lg:hidden'
/> />
)} )}
@@ -64,8 +60,8 @@ const FloatingButtons = ({
<Button <Button
icon={showDebugPanel ? <EyeOff size={18} /> : <Eye size={18} />} icon={showDebugPanel ? <EyeOff size={18} /> : <Eye size={18} />}
onClick={onToggleDebugPanel} onClick={onToggleDebugPanel}
theme="solid" theme='solid'
type={showDebugPanel ? "danger" : "primary"} type={showDebugPanel ? 'danger' : 'primary'}
style={{ style={{
position: 'fixed', position: 'fixed',
right: 16, right: 16,
@@ -80,7 +76,7 @@ const FloatingButtons = ({
? 'linear-gradient(to right, #e11d48, #be123c)' ? 'linear-gradient(to right, #e11d48, #be123c)'
: 'linear-gradient(to right, #4f46e5, #6366f1)', : 'linear-gradient(to right, #4f46e5, #6366f1)',
}} }}
className="lg:hidden" className='lg:hidden'
/> />
)} )}
</> </>
+50 -42
View File
@@ -18,21 +18,17 @@ For commercial licensing, please contact support@quantumnous.com
*/ */
import React from 'react'; import React from 'react';
import { import { Input, Typography, Button, Switch } from '@douyinfe/semi-ui';
Input,
Typography,
Button,
Switch,
} from '@douyinfe/semi-ui';
import { IconFile } from '@douyinfe/semi-icons'; import { IconFile } from '@douyinfe/semi-icons';
import { import { FileText, Plus, X, Image } from 'lucide-react';
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 handleAddImageUrl = () => {
const newUrls = [...imageUrls, '']; const newUrls = [...imageUrls, ''];
onImageUrlsChange(newUrls); onImageUrlsChange(newUrls);
@@ -51,75 +47,87 @@ const ImageUrlInput = ({ imageUrls, imageEnabled, onImageUrlsChange, onImageEnab
return ( return (
<div className={disabled ? 'opacity-50' : ''}> <div className={disabled ? 'opacity-50' : ''}>
<div className="flex items-center justify-between mb-2"> <div className='flex items-center justify-between mb-2'>
<div className="flex items-center gap-2"> <div className='flex items-center gap-2'>
<Image size={16} className={imageEnabled && !disabled ? "text-blue-500" : "text-gray-400"} /> <Image
<Typography.Text strong className="text-sm"> size={16}
className={
imageEnabled && !disabled ? 'text-blue-500' : 'text-gray-400'
}
/>
<Typography.Text strong className='text-sm'>
图片地址 图片地址
</Typography.Text> </Typography.Text>
{disabled && ( {disabled && (
<Typography.Text className="text-xs text-orange-600"> <Typography.Text className='text-xs text-orange-600'>
(已在自定义模式中忽略) (已在自定义模式中忽略)
</Typography.Text> </Typography.Text>
)} )}
</div> </div>
<div className="flex items-center gap-2"> <div className='flex items-center gap-2'>
<Switch <Switch
checked={imageEnabled} checked={imageEnabled}
onChange={onImageEnabledChange} onChange={onImageEnabledChange}
checkedText="启用" checkedText='启用'
uncheckedText="停用" uncheckedText='停用'
size="small" size='small'
className="flex-shrink-0" className='flex-shrink-0'
disabled={disabled} disabled={disabled}
/> />
<Button <Button
icon={<Plus size={14} />} icon={<Plus size={14} />}
size="small" size='small'
theme="solid" theme='solid'
type="primary" type='primary'
onClick={handleAddImageUrl} 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} disabled={!imageEnabled || disabled}
/> />
</div> </div>
</div> </div>
{!imageEnabled ? ( {!imageEnabled ? (
<Typography.Text className="text-xs text-gray-500 mb-2 block"> <Typography.Text className='text-xs text-gray-500 mb-2 block'>
{disabled ? '图片功能在自定义请求体模式下不可用' : '启用后可添加图片URL进行多模态对话'} {disabled
? '图片功能在自定义请求体模式下不可用'
: '启用后可添加图片URL进行多模态对话'}
</Typography.Text> </Typography.Text>
) : imageUrls.length === 0 ? ( ) : imageUrls.length === 0 ? (
<Typography.Text className="text-xs text-gray-500 mb-2 block"> <Typography.Text className='text-xs text-gray-500 mb-2 block'>
{disabled ? '图片功能在自定义请求体模式下不可用' : '点击 + 按钮添加图片URL进行多模态对话'} {disabled
? '图片功能在自定义请求体模式下不可用'
: '点击 + 按钮添加图片URL进行多模态对话'}
</Typography.Text> </Typography.Text>
) : ( ) : (
<Typography.Text className="text-xs text-gray-500 mb-2 block"> <Typography.Text className='text-xs text-gray-500 mb-2 block'>
已添加 {imageUrls.length} 张图片{disabled ? ' (自定义模式下不可用)' : ''} 已添加 {imageUrls.length} 张图片
{disabled ? ' (自定义模式下不可用)' : ''}
</Typography.Text> </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) => ( {imageUrls.map((url, index) => (
<div key={index} className="flex items-center gap-2"> <div key={index} className='flex items-center gap-2'>
<div className="flex-1"> <div className='flex-1'>
<Input <Input
placeholder={`https://example.com/image${index + 1}.jpg`} placeholder={`https://example.com/image${index + 1}.jpg`}
value={url} value={url}
onChange={(value) => handleUpdateImageUrl(index, value)} onChange={(value) => handleUpdateImageUrl(index, value)}
className="!rounded-lg" className='!rounded-lg'
size="small" size='small'
prefix={<IconFile size='small' />} prefix={<IconFile size='small' />}
disabled={!imageEnabled || disabled} disabled={!imageEnabled || disabled}
/> />
</div> </div>
<Button <Button
icon={<X size={12} />} icon={<X size={12} />}
size="small" size='small'
theme="borderless" theme='borderless'
type="danger" type='danger'
onClick={() => handleRemoveImageUrl(index)} 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} disabled={!imageEnabled || disabled}
/> />
</div> </div>
@@ -18,17 +18,8 @@ For commercial licensing, please contact support@quantumnous.com
*/ */
import React from 'react'; import React from 'react';
import { import { Button, Tooltip } from '@douyinfe/semi-ui';
Button, import { RefreshCw, Copy, Trash2, UserCheck, Edit } from 'lucide-react';
Tooltip,
} from '@douyinfe/semi-ui';
import {
RefreshCw,
Copy,
Trash2,
UserCheck,
Edit,
} from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
const MessageActions = ({ const MessageActions = ({
@@ -40,23 +31,32 @@ const MessageActions = ({
onRoleToggle, onRoleToggle,
onMessageEdit, onMessageEdit,
isAnyMessageGenerating = false, isAnyMessageGenerating = false,
isEditing = false isEditing = false,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const isLoading = message.status === 'loading' || message.status === 'incomplete'; const isLoading =
message.status === 'loading' || message.status === 'incomplete';
const shouldDisableActions = isAnyMessageGenerating || isEditing; const shouldDisableActions = isAnyMessageGenerating || isEditing;
const canToggleRole = message.role === 'assistant' || message.role === 'system'; const canToggleRole =
const canEdit = !isLoading && message.content && typeof onMessageEdit === 'function' && !isEditing; message.role === 'assistant' || message.role === 'system';
const canEdit =
!isLoading &&
message.content &&
typeof onMessageEdit === 'function' &&
!isEditing;
return ( return (
<div className="flex items-center gap-0.5"> <div className='flex items-center gap-0.5'>
{!isLoading && ( {!isLoading && (
<Tooltip content={shouldDisableActions ? t('操作暂时被禁用') : t('重试')} position="top"> <Tooltip
content={shouldDisableActions ? t('操作暂时被禁用') : t('重试')}
position='top'
>
<Button <Button
theme="borderless" theme='borderless'
type="tertiary" type='tertiary'
size="small" size='small'
icon={<RefreshCw size={styleState.isMobile ? 12 : 14} />} icon={<RefreshCw size={styleState.isMobile ? 12 : 14} />}
onClick={() => !shouldDisableActions && onMessageReset(message)} onClick={() => !shouldDisableActions && onMessageReset(message)}
disabled={shouldDisableActions} disabled={shouldDisableActions}
@@ -67,11 +67,11 @@ const MessageActions = ({
)} )}
{message.content && ( {message.content && (
<Tooltip content={t('复制')} position="top"> <Tooltip content={t('复制')} position='top'>
<Button <Button
theme="borderless" theme='borderless'
type="tertiary" type='tertiary'
size="small" size='small'
icon={<Copy size={styleState.isMobile ? 12 : 14} />} icon={<Copy size={styleState.isMobile ? 12 : 14} />}
onClick={() => onMessageCopy(message)} 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`} 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 && ( {canEdit && (
<Tooltip content={shouldDisableActions ? t('操作暂时被禁用') : t('编辑')} position="top"> <Tooltip
content={shouldDisableActions ? t('操作暂时被禁用') : t('编辑')}
position='top'
>
<Button <Button
theme="borderless" theme='borderless'
type="tertiary" type='tertiary'
size="small" size='small'
icon={<Edit size={styleState.isMobile ? 12 : 14} />} icon={<Edit size={styleState.isMobile ? 12 : 14} />}
onClick={() => !shouldDisableActions && onMessageEdit(message)} onClick={() => !shouldDisableActions && onMessageEdit(message)}
disabled={shouldDisableActions} disabled={shouldDisableActions}
@@ -104,27 +107,36 @@ const MessageActions = ({
? t('切换为System角色') ? t('切换为System角色')
: t('切换为Assistant角色') : t('切换为Assistant角色')
} }
position="top" position='top'
> >
<Button <Button
theme="borderless" theme='borderless'
type="tertiary" type='tertiary'
size="small" size='small'
icon={<UserCheck size={styleState.isMobile ? 12 : 14} />} icon={<UserCheck size={styleState.isMobile ? 12 : 14} />}
onClick={() => !shouldDisableActions && onRoleToggle && onRoleToggle(message)} onClick={() =>
!shouldDisableActions && onRoleToggle && onRoleToggle(message)
}
disabled={shouldDisableActions} 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`} 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> </Tooltip>
)} )}
{!isLoading && ( {!isLoading && (
<Tooltip content={shouldDisableActions ? t('操作暂时被禁用') : t('删除')} position="top"> <Tooltip
content={shouldDisableActions ? t('操作暂时被禁用') : t('删除')}
position='top'
>
<Button <Button
theme="borderless" theme='borderless'
type="tertiary" type='tertiary'
size="small" size='small'
icon={<Trash2 size={styleState.isMobile ? 12 : 14} />} icon={<Trash2 size={styleState.isMobile ? 12 : 14} />}
onClick={() => !shouldDisableActions && onMessageDelete(message)} onClick={() => !shouldDisableActions && onMessageDelete(message)}
disabled={shouldDisableActions} disabled={shouldDisableActions}
+113 -72
View File
@@ -18,18 +18,10 @@ For commercial licensing, please contact support@quantumnous.com
*/ */
import React, { useRef, useEffect } from 'react'; import React, { useRef, useEffect } from 'react';
import { import { Typography, TextArea, Button } from '@douyinfe/semi-ui';
Typography,
TextArea,
Button,
} from '@douyinfe/semi-ui';
import MarkdownRenderer from '../common/markdown/MarkdownRenderer'; import MarkdownRenderer from '../common/markdown/MarkdownRenderer';
import ThinkingContent from './ThinkingContent'; import ThinkingContent from './ThinkingContent';
import { import { Loader2, Check, X } from 'lucide-react';
Loader2,
Check,
X,
} from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
const MessageContent = ({ const MessageContent = ({
@@ -41,13 +33,14 @@ const MessageContent = ({
onEditSave, onEditSave,
onEditCancel, onEditCancel,
editValue, editValue,
onEditValueChange onEditValueChange,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const previousContentLengthRef = useRef(0); const previousContentLengthRef = useRef(0);
const lastContentRef = useRef(''); const lastContentRef = useRef('');
const isThinkingStatus = message.status === 'loading' || message.status === 'incomplete'; const isThinkingStatus =
message.status === 'loading' || message.status === 'incomplete';
useEffect(() => { useEffect(() => {
if (!isThinkingStatus) { if (!isThinkingStatus) {
@@ -60,10 +53,11 @@ const MessageContent = ({
let errorText; let errorText;
if (Array.isArray(message.content)) { if (Array.isArray(message.content)) {
const textContent = message.content.find(item => item.type === 'text'); const textContent = message.content.find((item) => item.type === 'text');
errorText = textContent && textContent.text && typeof textContent.text === 'string' errorText =
? textContent.text textContent && textContent.text && typeof textContent.text === 'string'
: t('请求发生错误'); ? textContent.text
: t('请求发生错误');
} else if (typeof message.content === 'string') { } else if (typeof message.content === 'string') {
errorText = message.content; errorText = message.content;
} else { } else {
@@ -72,21 +66,21 @@ const MessageContent = ({
return ( return (
<div className={`${className}`}> <div className={`${className}`}>
<Typography.Text className="text-white"> <Typography.Text className='text-white'>{errorText}</Typography.Text>
{errorText}
</Typography.Text>
</div> </div>
); );
} }
let currentExtractedThinkingContent = null; let currentExtractedThinkingContent = null;
let currentDisplayableFinalContent = ""; let currentDisplayableFinalContent = '';
let thinkingSource = null; let thinkingSource = null;
const getTextContent = (content) => { const getTextContent = (content) => {
if (Array.isArray(content)) { if (Array.isArray(content)) {
const textItem = content.find(item => item.type === 'text'); const textItem = content.find((item) => item.type === 'text');
return textItem && textItem.text && typeof textItem.text === 'string' ? textItem.text : ''; return textItem && textItem.text && typeof textItem.text === 'string'
? textItem.text
: '';
} else if (typeof content === 'string') { } else if (typeof content === 'string') {
return content; return content;
} }
@@ -97,7 +91,7 @@ const MessageContent = ({
if (message.role === 'assistant') { if (message.role === 'assistant') {
let baseContentForDisplay = getTextContent(message.content); let baseContentForDisplay = getTextContent(message.content);
let combinedThinkingContent = ""; let combinedThinkingContent = '';
if (message.reasoningContent) { if (message.reasoningContent) {
combinedThinkingContent = message.reasoningContent; combinedThinkingContent = message.reasoningContent;
@@ -112,7 +106,9 @@ const MessageContent = ({
let lastIndex = 0; let lastIndex = 0;
while ((match = thinkTagRegex.exec(baseContentForDisplay)) !== null) { while ((match = thinkTagRegex.exec(baseContentForDisplay)) !== null) {
replyParts.push(baseContentForDisplay.substring(lastIndex, match.index)); replyParts.push(
baseContentForDisplay.substring(lastIndex, match.index),
);
thoughtsFromPairedTags.push(match[1]); thoughtsFromPairedTags.push(match[1]);
lastIndex = match.index + match[0].length; lastIndex = match.index + match[0].length;
} }
@@ -125,7 +121,9 @@ const MessageContent = ({
} else { } else {
combinedThinkingContent = pairedThoughtsStr; combinedThinkingContent = pairedThoughtsStr;
} }
thinkingSource = thinkingSource ? thinkingSource + ' & <think> tags' : '<think> tags'; thinkingSource = thinkingSource
? thinkingSource + ' & <think> tags'
: '<think> tags';
} }
baseContentForDisplay = replyParts.join(''); baseContentForDisplay = replyParts.join('');
@@ -134,37 +132,55 @@ const MessageContent = ({
if (isThinkingStatus) { if (isThinkingStatus) {
const lastOpenThinkIndex = baseContentForDisplay.lastIndexOf('<think>'); const lastOpenThinkIndex = baseContentForDisplay.lastIndexOf('<think>');
if (lastOpenThinkIndex !== -1) { if (lastOpenThinkIndex !== -1) {
const fragmentAfterLastOpen = baseContentForDisplay.substring(lastOpenThinkIndex); const fragmentAfterLastOpen =
baseContentForDisplay.substring(lastOpenThinkIndex);
if (!fragmentAfterLastOpen.includes('</think>')) { if (!fragmentAfterLastOpen.includes('</think>')) {
const unclosedThought = fragmentAfterLastOpen.substring('<think>'.length).trim(); const unclosedThought = fragmentAfterLastOpen
.substring('<think>'.length)
.trim();
if (unclosedThought) { if (unclosedThought) {
if (combinedThinkingContent) { if (combinedThinkingContent) {
combinedThinkingContent += '\n\n---\n\n' + unclosedThought; combinedThinkingContent += '\n\n---\n\n' + unclosedThought;
} else { } else {
combinedThinkingContent = unclosedThought; 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; currentExtractedThinkingContent = combinedThinkingContent || null;
currentDisplayableFinalContent = baseContentForDisplay.replace(/<\/?think>/g, '').trim(); currentDisplayableFinalContent = baseContentForDisplay
.replace(/<\/?think>/g, '')
.trim();
} }
const finalExtractedThinkingContent = currentExtractedThinkingContent; const finalExtractedThinkingContent = currentExtractedThinkingContent;
const finalDisplayableFinalContent = currentDisplayableFinalContent; const finalDisplayableFinalContent = currentDisplayableFinalContent;
if (message.role === 'assistant' && if (
message.role === 'assistant' &&
isThinkingStatus && isThinkingStatus &&
!finalExtractedThinkingContent && !finalExtractedThinkingContent &&
(!finalDisplayableFinalContent || finalDisplayableFinalContent.trim() === '')) { (!finalDisplayableFinalContent ||
finalDisplayableFinalContent.trim() === '')
) {
return ( return (
<div className={`${className} flex items-center gap-2 sm:gap-4 bg-gradient-to-r from-purple-50 to-indigo-50`}> <div
<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"> className={`${className} flex items-center gap-2 sm:gap-4 bg-gradient-to-r from-purple-50 to-indigo-50`}
<Loader2 className="animate-spin text-white" size={styleState.isMobile ? 16 : 20} /> >
<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>
</div> </div>
); );
@@ -173,12 +189,17 @@ const MessageContent = ({
return ( return (
<div className={className}> <div className={className}>
{message.role === 'system' && ( {message.role === 'system' && (
<div className="mb-2 sm:mb-4"> <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
<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"> className='flex items-center gap-2 p-2 sm:p-3 bg-gradient-to-r from-amber-50 to-orange-50 rounded-lg'
<Typography.Text className="text-white text-xs font-bold">S</Typography.Text> 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> </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('系统消息')} {t('系统消息')}
</Typography.Text> </Typography.Text>
</div> </div>
@@ -196,7 +217,7 @@ const MessageContent = ({
)} )}
{isEditing ? ( {isEditing ? (
<div className="space-y-3"> <div className='space-y-3'>
<TextArea <TextArea
value={editValue} value={editValue}
onChange={(value) => onEditValueChange(value)} onChange={(value) => onEditValueChange(value)}
@@ -207,27 +228,27 @@ const MessageContent = ({
fontSize: styleState.isMobile ? '14px' : '15px', fontSize: styleState.isMobile ? '14px' : '15px',
lineHeight: '1.6', 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 <Button
size="small" size='small'
type="danger" type='danger'
theme="light" theme='light'
icon={<X size={14} />} icon={<X size={14} />}
onClick={onEditCancel} onClick={onEditCancel}
className="flex-1" className='flex-1'
> >
{t('取消')} {t('取消')}
</Button> </Button>
<Button <Button
size="small" size='small'
type="warning" type='warning'
theme="solid" theme='solid'
icon={<Check size={14} />} icon={<Check size={14} />}
onClick={onEditSave} onClick={onEditSave}
disabled={!editValue || editValue.trim() === ''} disabled={!editValue || editValue.trim() === ''}
className="flex-1" className='flex-1'
> >
{t('保存')} {t('保存')}
</Button> </Button>
@@ -236,19 +257,23 @@ const MessageContent = ({
) : ( ) : (
(() => { (() => {
if (Array.isArray(message.content)) { if (Array.isArray(message.content)) {
const textContent = message.content.find(item => item.type === 'text'); const textContent = message.content.find(
const imageContents = message.content.filter(item => item.type === 'image_url'); (item) => item.type === 'text',
);
const imageContents = message.content.filter(
(item) => item.type === 'image_url',
);
return ( return (
<div> <div>
{imageContents.length > 0 && ( {imageContents.length > 0 && (
<div className="mb-3 space-y-2"> <div className='mb-3 space-y-2'>
{imageContents.map((imgItem, index) => ( {imageContents.map((imgItem, index) => (
<div key={index} className="max-w-sm"> <div key={index} className='max-w-sm'>
<img <img
src={imgItem.image_url.url} src={imgItem.image_url.url}
alt={`用户上传的图片 ${index + 1}`} 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' }} style={{ maxHeight: '300px' }}
onError={(e) => { onError={(e) => {
e.target.style.display = 'none'; e.target.style.display = 'none';
@@ -256,7 +281,7 @@ const MessageContent = ({
}} }}
/> />
<div <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' }} style={{ display: 'none' }}
> >
图片加载失败: {imgItem.image_url.url} 图片加载失败: {imgItem.image_url.url}
@@ -266,28 +291,42 @@ const MessageContent = ({
</div> </div>
)} )}
{textContent && textContent.text && typeof textContent.text === 'string' && textContent.text.trim() !== '' && ( {textContent &&
<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' : ''}`}> textContent.text &&
<MarkdownRenderer typeof textContent.text === 'string' &&
content={textContent.text} textContent.text.trim() !== '' && (
className={message.role === 'user' ? 'user-message' : ''} <div
animated={false} 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' : ''}`}
previousContentLength={0} >
/> <MarkdownRenderer
</div> content={textContent.text}
)} className={
message.role === 'user' ? 'user-message' : ''
}
animated={false}
previousContentLength={0}
/>
</div>
)}
</div> </div>
); );
} }
if (typeof message.content === 'string') { if (typeof message.content === 'string') {
if (message.role === 'assistant') { if (message.role === 'assistant') {
if (finalDisplayableFinalContent && finalDisplayableFinalContent.trim() !== '') { if (
finalDisplayableFinalContent &&
finalDisplayableFinalContent.trim() !== ''
) {
// 获取上一次的内容长度 // 获取上一次的内容长度
let prevLength = 0; let prevLength = 0;
if (isThinkingStatus && lastContentRef.current) { if (isThinkingStatus && lastContentRef.current) {
// 只有当前内容包含上一次内容时,才使用上一次的长度 // 只有当前内容包含上一次内容时,才使用上一次的长度
if (finalDisplayableFinalContent.startsWith(lastContentRef.current)) { if (
finalDisplayableFinalContent.startsWith(
lastContentRef.current,
)
) {
prevLength = lastContentRef.current.length; prevLength = lastContentRef.current.length;
} }
} }
@@ -298,10 +337,10 @@ const MessageContent = ({
} }
return ( 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 <MarkdownRenderer
content={finalDisplayableFinalContent} content={finalDisplayableFinalContent}
className="" className=''
animated={isThinkingStatus} animated={isThinkingStatus}
previousContentLength={prevLength} previousContentLength={prevLength}
/> />
@@ -310,7 +349,9 @@ const MessageContent = ({
} }
} else { } else {
return ( 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 <MarkdownRenderer
content={message.content} content={message.content}
className={message.role === 'user' ? 'user-message' : ''} className={message.role === 'user' ? 'user-message' : ''}
@@ -24,56 +24,74 @@ import SettingsPanel from './SettingsPanel';
import DebugPanel from './DebugPanel'; import DebugPanel from './DebugPanel';
// 优化的消息内容组件 // 优化的消息内容组件
export const OptimizedMessageContent = React.memo(MessageContent, (prevProps, nextProps) => { export const OptimizedMessageContent = React.memo(
// 只有这些属性变化时才重新渲染 MessageContent,
return ( (prevProps, nextProps) => {
prevProps.message.id === nextProps.message.id && // 只有这些属性变化时才重新渲染
prevProps.message.content === nextProps.message.content && return (
prevProps.message.status === nextProps.message.status && prevProps.message.id === nextProps.message.id &&
prevProps.message.role === nextProps.message.role && prevProps.message.content === nextProps.message.content &&
prevProps.message.reasoningContent === nextProps.message.reasoningContent && prevProps.message.status === nextProps.message.status &&
prevProps.message.isReasoningExpanded === nextProps.message.isReasoningExpanded && prevProps.message.role === nextProps.message.role &&
prevProps.isEditing === nextProps.isEditing && prevProps.message.reasoningContent ===
prevProps.editValue === nextProps.editValue && nextProps.message.reasoningContent &&
prevProps.styleState.isMobile === nextProps.styleState.isMobile 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) => { export const OptimizedMessageActions = React.memo(
return ( MessageActions,
prevProps.message.id === nextProps.message.id && (prevProps, nextProps) => {
prevProps.message.role === nextProps.message.role && return (
prevProps.isAnyMessageGenerating === nextProps.isAnyMessageGenerating && prevProps.message.id === nextProps.message.id &&
prevProps.isEditing === nextProps.isEditing && prevProps.message.role === nextProps.message.role &&
prevProps.onMessageReset === nextProps.onMessageReset prevProps.isAnyMessageGenerating === nextProps.isAnyMessageGenerating &&
); prevProps.isEditing === nextProps.isEditing &&
}); prevProps.onMessageReset === nextProps.onMessageReset
);
},
);
// 优化的设置面板组件 // 优化的设置面板组件
export const OptimizedSettingsPanel = React.memo(SettingsPanel, (prevProps, nextProps) => { export const OptimizedSettingsPanel = React.memo(
return ( SettingsPanel,
JSON.stringify(prevProps.inputs) === JSON.stringify(nextProps.inputs) && (prevProps, nextProps) => {
JSON.stringify(prevProps.parameterEnabled) === JSON.stringify(nextProps.parameterEnabled) && return (
JSON.stringify(prevProps.models) === JSON.stringify(nextProps.models) && JSON.stringify(prevProps.inputs) === JSON.stringify(nextProps.inputs) &&
JSON.stringify(prevProps.groups) === JSON.stringify(nextProps.groups) && JSON.stringify(prevProps.parameterEnabled) ===
prevProps.customRequestMode === nextProps.customRequestMode && JSON.stringify(nextProps.parameterEnabled) &&
prevProps.customRequestBody === nextProps.customRequestBody && JSON.stringify(prevProps.models) === JSON.stringify(nextProps.models) &&
prevProps.showDebugPanel === nextProps.showDebugPanel && JSON.stringify(prevProps.groups) === JSON.stringify(nextProps.groups) &&
prevProps.showSettings === nextProps.showSettings && prevProps.customRequestMode === nextProps.customRequestMode &&
JSON.stringify(prevProps.previewPayload) === JSON.stringify(nextProps.previewPayload) && prevProps.customRequestBody === nextProps.customRequestBody &&
JSON.stringify(prevProps.messages) === JSON.stringify(nextProps.messages) 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) => { export const OptimizedDebugPanel = React.memo(
return ( DebugPanel,
prevProps.show === nextProps.show && (prevProps, nextProps) => {
prevProps.activeTab === nextProps.activeTab && return (
JSON.stringify(prevProps.debugData) === JSON.stringify(nextProps.debugData) && prevProps.show === nextProps.show &&
JSON.stringify(prevProps.previewPayload) === JSON.stringify(nextProps.previewPayload) && prevProps.activeTab === nextProps.activeTab &&
prevProps.customRequestMode === nextProps.customRequestMode && JSON.stringify(prevProps.debugData) ===
prevProps.showDebugPanel === nextProps.showDebugPanel 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 React from 'react';
import { import { Input, Slider, Typography, Button, Tag } from '@douyinfe/semi-ui';
Input,
Slider,
Typography,
Button,
Tag,
} from '@douyinfe/semi-ui';
import { import {
Hash, Hash,
Thermometer, Thermometer,
@@ -46,28 +40,36 @@ const ParameterControl = ({
return ( return (
<> <>
{/* Temperature */} {/* Temperature */}
<div className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.temperature || disabled ? 'opacity-50' : ''}`}> <div
<div className="flex items-center justify-between mb-2"> className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.temperature || disabled ? 'opacity-50' : ''}`}
<div className="flex items-center gap-2"> >
<Thermometer size={16} className="text-gray-500" /> <div className='flex items-center justify-between mb-2'>
<Typography.Text strong className="text-sm"> <div className='flex items-center gap-2'>
<Thermometer size={16} className='text-gray-500' />
<Typography.Text strong className='text-sm'>
Temperature Temperature
</Typography.Text> </Typography.Text>
<Tag size="small" shape='circle'> <Tag size='small' shape='circle'>
{inputs.temperature} {inputs.temperature}
</Tag> </Tag>
</div> </div>
<Button <Button
theme={parameterEnabled.temperature ? 'solid' : 'borderless'} theme={parameterEnabled.temperature ? 'solid' : 'borderless'}
type={parameterEnabled.temperature ? 'primary' : 'tertiary'} type={parameterEnabled.temperature ? 'primary' : 'tertiary'}
size="small" size='small'
icon={parameterEnabled.temperature ? <Check size={10} /> : <X size={10} />} icon={
parameterEnabled.temperature ? (
<Check size={10} />
) : (
<X size={10} />
)
}
onClick={() => onParameterToggle('temperature')} 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} disabled={disabled}
/> />
</div> </div>
<Typography.Text className="text-xs text-gray-500 mb-2"> <Typography.Text className='text-xs text-gray-500 mb-2'>
控制输出的随机性和创造性 控制输出的随机性和创造性
</Typography.Text> </Typography.Text>
<Slider <Slider
@@ -76,34 +78,38 @@ const ParameterControl = ({
max={1} max={1}
value={inputs.temperature} value={inputs.temperature}
onChange={(value) => onInputChange('temperature', value)} onChange={(value) => onInputChange('temperature', value)}
className="mt-2" className='mt-2'
disabled={!parameterEnabled.temperature || disabled} disabled={!parameterEnabled.temperature || disabled}
/> />
</div> </div>
{/* Top P */} {/* Top P */}
<div className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.top_p || disabled ? 'opacity-50' : ''}`}> <div
<div className="flex items-center justify-between mb-2"> className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.top_p || disabled ? 'opacity-50' : ''}`}
<div className="flex items-center gap-2"> >
<Target size={16} className="text-gray-500" /> <div className='flex items-center justify-between mb-2'>
<Typography.Text strong className="text-sm"> <div className='flex items-center gap-2'>
<Target size={16} className='text-gray-500' />
<Typography.Text strong className='text-sm'>
Top P Top P
</Typography.Text> </Typography.Text>
<Tag size="small" shape='circle'> <Tag size='small' shape='circle'>
{inputs.top_p} {inputs.top_p}
</Tag> </Tag>
</div> </div>
<Button <Button
theme={parameterEnabled.top_p ? 'solid' : 'borderless'} theme={parameterEnabled.top_p ? 'solid' : 'borderless'}
type={parameterEnabled.top_p ? 'primary' : 'tertiary'} type={parameterEnabled.top_p ? 'primary' : 'tertiary'}
size="small" size='small'
icon={parameterEnabled.top_p ? <Check size={10} /> : <X size={10} />} icon={
parameterEnabled.top_p ? <Check size={10} /> : <X size={10} />
}
onClick={() => onParameterToggle('top_p')} 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} disabled={disabled}
/> />
</div> </div>
<Typography.Text className="text-xs text-gray-500 mb-2"> <Typography.Text className='text-xs text-gray-500 mb-2'>
核采样控制词汇选择的多样性 核采样控制词汇选择的多样性
</Typography.Text> </Typography.Text>
<Slider <Slider
@@ -112,34 +118,42 @@ const ParameterControl = ({
max={1} max={1}
value={inputs.top_p} value={inputs.top_p}
onChange={(value) => onInputChange('top_p', value)} onChange={(value) => onInputChange('top_p', value)}
className="mt-2" className='mt-2'
disabled={!parameterEnabled.top_p || disabled} disabled={!parameterEnabled.top_p || disabled}
/> />
</div> </div>
{/* Frequency Penalty */} {/* Frequency Penalty */}
<div className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.frequency_penalty || disabled ? 'opacity-50' : ''}`}> <div
<div className="flex items-center justify-between mb-2"> className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.frequency_penalty || disabled ? 'opacity-50' : ''}`}
<div className="flex items-center gap-2"> >
<Repeat size={16} className="text-gray-500" /> <div className='flex items-center justify-between mb-2'>
<Typography.Text strong className="text-sm"> <div className='flex items-center gap-2'>
<Repeat size={16} className='text-gray-500' />
<Typography.Text strong className='text-sm'>
Frequency Penalty Frequency Penalty
</Typography.Text> </Typography.Text>
<Tag size="small" shape='circle'> <Tag size='small' shape='circle'>
{inputs.frequency_penalty} {inputs.frequency_penalty}
</Tag> </Tag>
</div> </div>
<Button <Button
theme={parameterEnabled.frequency_penalty ? 'solid' : 'borderless'} theme={parameterEnabled.frequency_penalty ? 'solid' : 'borderless'}
type={parameterEnabled.frequency_penalty ? 'primary' : 'tertiary'} type={parameterEnabled.frequency_penalty ? 'primary' : 'tertiary'}
size="small" size='small'
icon={parameterEnabled.frequency_penalty ? <Check size={10} /> : <X size={10} />} icon={
parameterEnabled.frequency_penalty ? (
<Check size={10} />
) : (
<X size={10} />
)
}
onClick={() => onParameterToggle('frequency_penalty')} 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} disabled={disabled}
/> />
</div> </div>
<Typography.Text className="text-xs text-gray-500 mb-2"> <Typography.Text className='text-xs text-gray-500 mb-2'>
频率惩罚减少重复词汇的出现 频率惩罚减少重复词汇的出现
</Typography.Text> </Typography.Text>
<Slider <Slider
@@ -148,34 +162,42 @@ const ParameterControl = ({
max={2} max={2}
value={inputs.frequency_penalty} value={inputs.frequency_penalty}
onChange={(value) => onInputChange('frequency_penalty', value)} onChange={(value) => onInputChange('frequency_penalty', value)}
className="mt-2" className='mt-2'
disabled={!parameterEnabled.frequency_penalty || disabled} disabled={!parameterEnabled.frequency_penalty || disabled}
/> />
</div> </div>
{/* Presence Penalty */} {/* Presence Penalty */}
<div className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.presence_penalty || disabled ? 'opacity-50' : ''}`}> <div
<div className="flex items-center justify-between mb-2"> className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.presence_penalty || disabled ? 'opacity-50' : ''}`}
<div className="flex items-center gap-2"> >
<Ban size={16} className="text-gray-500" /> <div className='flex items-center justify-between mb-2'>
<Typography.Text strong className="text-sm"> <div className='flex items-center gap-2'>
<Ban size={16} className='text-gray-500' />
<Typography.Text strong className='text-sm'>
Presence Penalty Presence Penalty
</Typography.Text> </Typography.Text>
<Tag size="small" shape='circle'> <Tag size='small' shape='circle'>
{inputs.presence_penalty} {inputs.presence_penalty}
</Tag> </Tag>
</div> </div>
<Button <Button
theme={parameterEnabled.presence_penalty ? 'solid' : 'borderless'} theme={parameterEnabled.presence_penalty ? 'solid' : 'borderless'}
type={parameterEnabled.presence_penalty ? 'primary' : 'tertiary'} type={parameterEnabled.presence_penalty ? 'primary' : 'tertiary'}
size="small" size='small'
icon={parameterEnabled.presence_penalty ? <Check size={10} /> : <X size={10} />} icon={
parameterEnabled.presence_penalty ? (
<Check size={10} />
) : (
<X size={10} />
)
}
onClick={() => onParameterToggle('presence_penalty')} 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} disabled={disabled}
/> />
</div> </div>
<Typography.Text className="text-xs text-gray-500 mb-2"> <Typography.Text className='text-xs text-gray-500 mb-2'>
存在惩罚鼓励讨论新话题 存在惩罚鼓励讨论新话题
</Typography.Text> </Typography.Text>
<Slider <Slider
@@ -184,27 +206,35 @@ const ParameterControl = ({
max={2} max={2}
value={inputs.presence_penalty} value={inputs.presence_penalty}
onChange={(value) => onInputChange('presence_penalty', value)} onChange={(value) => onInputChange('presence_penalty', value)}
className="mt-2" className='mt-2'
disabled={!parameterEnabled.presence_penalty || disabled} disabled={!parameterEnabled.presence_penalty || disabled}
/> />
</div> </div>
{/* MaxTokens */} {/* MaxTokens */}
<div className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.max_tokens || disabled ? 'opacity-50' : ''}`}> <div
<div className="flex items-center justify-between mb-2"> className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.max_tokens || disabled ? 'opacity-50' : ''}`}
<div className="flex items-center gap-2"> >
<Hash size={16} className="text-gray-500" /> <div className='flex items-center justify-between mb-2'>
<Typography.Text strong className="text-sm"> <div className='flex items-center gap-2'>
<Hash size={16} className='text-gray-500' />
<Typography.Text strong className='text-sm'>
Max Tokens Max Tokens
</Typography.Text> </Typography.Text>
</div> </div>
<Button <Button
theme={parameterEnabled.max_tokens ? 'solid' : 'borderless'} theme={parameterEnabled.max_tokens ? 'solid' : 'borderless'}
type={parameterEnabled.max_tokens ? 'primary' : 'tertiary'} type={parameterEnabled.max_tokens ? 'primary' : 'tertiary'}
size="small" size='small'
icon={parameterEnabled.max_tokens ? <Check size={10} /> : <X size={10} />} icon={
parameterEnabled.max_tokens ? (
<Check size={10} />
) : (
<X size={10} />
)
}
onClick={() => onParameterToggle('max_tokens')} 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} disabled={disabled}
/> />
</div> </div>
@@ -216,30 +246,32 @@ const ParameterControl = ({
defaultValue={0} defaultValue={0}
value={inputs.max_tokens} value={inputs.max_tokens}
onChange={(value) => onInputChange('max_tokens', value)} onChange={(value) => onInputChange('max_tokens', value)}
className="!rounded-lg" className='!rounded-lg'
disabled={!parameterEnabled.max_tokens || disabled} disabled={!parameterEnabled.max_tokens || disabled}
/> />
</div> </div>
{/* Seed */} {/* Seed */}
<div className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.seed || disabled ? 'opacity-50' : ''}`}> <div
<div className="flex items-center justify-between mb-2"> className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.seed || disabled ? 'opacity-50' : ''}`}
<div className="flex items-center gap-2"> >
<Shuffle size={16} className="text-gray-500" /> <div className='flex items-center justify-between mb-2'>
<Typography.Text strong className="text-sm"> <div className='flex items-center gap-2'>
<Shuffle size={16} className='text-gray-500' />
<Typography.Text strong className='text-sm'>
Seed Seed
</Typography.Text> </Typography.Text>
<Typography.Text className="text-xs text-gray-400"> <Typography.Text className='text-xs text-gray-400'>
(可选用于复现结果) (可选用于复现结果)
</Typography.Text> </Typography.Text>
</div> </div>
<Button <Button
theme={parameterEnabled.seed ? 'solid' : 'borderless'} theme={parameterEnabled.seed ? 'solid' : 'borderless'}
type={parameterEnabled.seed ? 'primary' : 'tertiary'} type={parameterEnabled.seed ? 'primary' : 'tertiary'}
size="small" size='small'
icon={parameterEnabled.seed ? <Check size={10} /> : <X size={10} />} icon={parameterEnabled.seed ? <Check size={10} /> : <X size={10} />}
onClick={() => onParameterToggle('seed')} 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} disabled={disabled}
/> />
</div> </div>
@@ -248,8 +280,10 @@ const ParameterControl = ({
name='seed' name='seed'
autoComplete='new-password' autoComplete='new-password'
value={inputs.seed || ''} value={inputs.seed || ''}
onChange={(value) => onInputChange('seed', value === '' ? null : value)} onChange={(value) =>
className="!rounded-lg" onInputChange('seed', value === '' ? null : value)
}
className='!rounded-lg'
disabled={!parameterEnabled.seed || disabled} disabled={!parameterEnabled.seed || disabled}
/> />
</div> </div>
+37 -47
View File
@@ -18,20 +18,8 @@ For commercial licensing, please contact support@quantumnous.com
*/ */
import React from 'react'; import React from 'react';
import { import { Card, Select, Typography, Button, Switch } from '@douyinfe/semi-ui';
Card, import { Sparkles, Users, ToggleLeft, X, Settings } from 'lucide-react';
Select,
Typography,
Button,
Switch,
} from '@douyinfe/semi-ui';
import {
Sparkles,
Users,
ToggleLeft,
X,
Settings,
} from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { renderGroupOption, selectFilter } from '../../helpers'; import { renderGroupOption, selectFilter } from '../../helpers';
import ParameterControl from './ParameterControl'; import ParameterControl from './ParameterControl';
@@ -70,22 +58,22 @@ const SettingsPanel = ({
return ( return (
<Card <Card
className="h-full flex flex-col" className='h-full flex flex-col'
bordered={false} bordered={false}
bodyStyle={{ bodyStyle={{
padding: styleState.isMobile ? '16px' : '24px', padding: styleState.isMobile ? '16px' : '24px',
height: '100%', height: '100%',
display: 'flex', display: 'flex',
flexDirection: 'column' flexDirection: 'column',
}} }}
> >
{/* 标题区域 - 与调试面板保持一致 */} {/* 标题区域 - 与调试面板保持一致 */}
<div className="flex items-center justify-between mb-6 flex-shrink-0"> <div className='flex items-center justify-between mb-6 flex-shrink-0'>
<div className="flex items-center"> <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"> <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" /> <Settings size={20} className='text-white' />
</div> </div>
<Typography.Title heading={5} className="mb-0"> <Typography.Title heading={5} className='mb-0'>
{t('模型配置')} {t('模型配置')}
</Typography.Title> </Typography.Title>
</div> </div>
@@ -94,17 +82,17 @@ const SettingsPanel = ({
<Button <Button
icon={<X size={16} />} icon={<X size={16} />}
onClick={onCloseSettings} onClick={onCloseSettings}
theme="borderless" theme='borderless'
type="tertiary" type='tertiary'
size="small" size='small'
className="!rounded-lg" className='!rounded-lg'
/> />
)} )}
</div> </div>
{/* 移动端配置管理 */} {/* 移动端配置管理 */}
{styleState.isMobile && ( {styleState.isMobile && (
<div className="mb-4 flex-shrink-0"> <div className='mb-4 flex-shrink-0'>
<ConfigManager <ConfigManager
currentConfig={currentConfig} currentConfig={currentConfig}
onConfigImport={onConfigImport} onConfigImport={onConfigImport}
@@ -115,7 +103,7 @@ const SettingsPanel = ({
</div> </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 <CustomRequestEditor
customRequestMode={customRequestMode} customRequestMode={customRequestMode}
@@ -127,13 +115,13 @@ const SettingsPanel = ({
{/* 分组选择 */} {/* 分组选择 */}
<div className={customRequestMode ? 'opacity-50' : ''}> <div className={customRequestMode ? 'opacity-50' : ''}>
<div className="flex items-center gap-2 mb-2"> <div className='flex items-center gap-2 mb-2'>
<Users size={16} className="text-gray-500" /> <Users size={16} className='text-gray-500' />
<Typography.Text strong className="text-sm"> <Typography.Text strong className='text-sm'>
{t('分组')} {t('分组')}
</Typography.Text> </Typography.Text>
{customRequestMode && ( {customRequestMode && (
<Typography.Text className="text-xs text-orange-600"> <Typography.Text className='text-xs text-orange-600'>
(已在自定义模式中忽略) (已在自定义模式中忽略)
</Typography.Text> </Typography.Text>
)} )}
@@ -152,20 +140,20 @@ const SettingsPanel = ({
renderOptionItem={renderGroupOption} renderOptionItem={renderGroupOption}
style={{ width: '100%' }} style={{ width: '100%' }}
dropdownStyle={{ width: '100%', maxWidth: '100%' }} dropdownStyle={{ width: '100%', maxWidth: '100%' }}
className="!rounded-lg" className='!rounded-lg'
disabled={customRequestMode} disabled={customRequestMode}
/> />
</div> </div>
{/* 模型选择 */} {/* 模型选择 */}
<div className={customRequestMode ? 'opacity-50' : ''}> <div className={customRequestMode ? 'opacity-50' : ''}>
<div className="flex items-center gap-2 mb-2"> <div className='flex items-center gap-2 mb-2'>
<Sparkles size={16} className="text-gray-500" /> <Sparkles size={16} className='text-gray-500' />
<Typography.Text strong className="text-sm"> <Typography.Text strong className='text-sm'>
{t('模型')} {t('模型')}
</Typography.Text> </Typography.Text>
{customRequestMode && ( {customRequestMode && (
<Typography.Text className="text-xs text-orange-600"> <Typography.Text className='text-xs text-orange-600'>
(已在自定义模式中忽略) (已在自定义模式中忽略)
</Typography.Text> </Typography.Text>
)} )}
@@ -183,7 +171,7 @@ const SettingsPanel = ({
optionList={models} optionList={models}
style={{ width: '100%' }} style={{ width: '100%' }}
dropdownStyle={{ width: '100%', maxWidth: '100%' }} dropdownStyle={{ width: '100%', maxWidth: '100%' }}
className="!rounded-lg" className='!rounded-lg'
disabled={customRequestMode} disabled={customRequestMode}
/> />
</div> </div>
@@ -194,7 +182,9 @@ const SettingsPanel = ({
imageUrls={inputs.imageUrls} imageUrls={inputs.imageUrls}
imageEnabled={inputs.imageEnabled} imageEnabled={inputs.imageEnabled}
onImageUrlsChange={(urls) => onInputChange('imageUrls', urls)} onImageUrlsChange={(urls) => onInputChange('imageUrls', urls)}
onImageEnabledChange={(enabled) => onInputChange('imageEnabled', enabled)} onImageEnabledChange={(enabled) =>
onInputChange('imageEnabled', enabled)
}
disabled={customRequestMode} disabled={customRequestMode}
/> />
</div> </div>
@@ -212,14 +202,14 @@ const SettingsPanel = ({
{/* 流式输出开关 */} {/* 流式输出开关 */}
<div className={customRequestMode ? 'opacity-50' : ''}> <div className={customRequestMode ? 'opacity-50' : ''}>
<div className="flex items-center justify-between"> <div className='flex items-center justify-between'>
<div className="flex items-center gap-2"> <div className='flex items-center gap-2'>
<ToggleLeft size={16} className="text-gray-500" /> <ToggleLeft size={16} className='text-gray-500' />
<Typography.Text strong className="text-sm"> <Typography.Text strong className='text-sm'>
流式输出 流式输出
</Typography.Text> </Typography.Text>
{customRequestMode && ( {customRequestMode && (
<Typography.Text className="text-xs text-orange-600"> <Typography.Text className='text-xs text-orange-600'>
(已在自定义模式中忽略) (已在自定义模式中忽略)
</Typography.Text> </Typography.Text>
)} )}
@@ -227,9 +217,9 @@ const SettingsPanel = ({
<Switch <Switch
checked={inputs.stream} checked={inputs.stream}
onChange={(checked) => onInputChange('stream', checked)} onChange={(checked) => onInputChange('stream', checked)}
checkedText="开" checkedText='开'
uncheckedText="关" uncheckedText='关'
size="small" size='small'
disabled={customRequestMode} disabled={customRequestMode}
/> />
</div> </div>
@@ -238,7 +228,7 @@ const SettingsPanel = ({
{/* 桌面端的配置管理放在底部 */} {/* 桌面端的配置管理放在底部 */}
{!styleState.isMobile && ( {!styleState.isMobile && (
<div className="flex-shrink-0 pt-3"> <div className='flex-shrink-0 pt-3'>
<ConfigManager <ConfigManager
currentConfig={currentConfig} currentConfig={currentConfig}
onConfigImport={onConfigImport} onConfigImport={onConfigImport}
@@ -28,17 +28,25 @@ const ThinkingContent = ({
finalExtractedThinkingContent, finalExtractedThinkingContent,
thinkingSource, thinkingSource,
styleState, styleState,
onToggleReasoningExpansion onToggleReasoningExpansion,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const scrollRef = useRef(null); const scrollRef = useRef(null);
const lastContentRef = useRef(''); const lastContentRef = useRef('');
const isThinkingStatus = message.status === 'loading' || message.status === 'incomplete'; const isThinkingStatus =
const headerText = (isThinkingStatus && !message.isThinkingComplete) ? t('思考中...') : t('思考过程'); message.status === 'loading' || message.status === 'incomplete';
const headerText =
isThinkingStatus && !message.isThinkingComplete
? t('思考中...')
: t('思考过程');
useEffect(() => { useEffect(() => {
if (scrollRef.current && finalExtractedThinkingContent && message.isReasoningExpanded) { if (
scrollRef.current &&
finalExtractedThinkingContent &&
message.isReasoningExpanded
) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight; scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
} }
}, [finalExtractedThinkingContent, message.isReasoningExpanded]); }, [finalExtractedThinkingContent, message.isReasoningExpanded]);
@@ -63,72 +71,100 @@ const ThinkingContent = ({
} }
return ( 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 <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={{ style={{
background: 'linear-gradient(135deg, #4c1d95 0%, #6d28d9 50%, #7c3aed 100%)', background:
position: 'relative' 'linear-gradient(135deg, #4c1d95 0%, #6d28d9 50%, #7c3aed 100%)',
position: 'relative',
}} }}
onClick={() => onToggleReasoningExpansion(message.id)} onClick={() => onToggleReasoningExpansion(message.id)}
> >
<div className="absolute inset-0 overflow-hidden"> <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 -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 -bottom-8 -left-8 w-24 h-24 bg-white opacity-10 rounded-full'></div>
</div> </div>
<div className="flex items-center gap-2 sm:gap-4 relative"> <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"> <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} /> <Brain
style={{ color: 'white' }}
size={styleState.isMobile ? 12 : 16}
/>
</div> </div>
<div className="flex flex-col"> <div className='flex flex-col'>
<Typography.Text strong style={{ color: 'white' }} className="text-sm sm:text-base"> <Typography.Text
strong
style={{ color: 'white' }}
className='text-sm sm:text-base'
>
{headerText} {headerText}
</Typography.Text> </Typography.Text>
{thinkingSource && ( {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} 来源: {thinkingSource}
</Typography.Text> </Typography.Text>
)} )}
</div> </div>
</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 && ( {isThinkingStatus && !message.isThinkingComplete && (
<div className="flex items-center gap-1 sm:gap-2"> <div className='flex items-center gap-1 sm:gap-2'>
<Loader2 style={{ color: 'white' }} className="animate-spin" size={styleState.isMobile ? 14 : 18} /> <Loader2
<Typography.Text style={{ color: 'white' }} className="text-xs sm:text-sm font-medium opacity-90"> 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> </Typography.Text>
</div> </div>
)} )}
{(!isThinkingStatus || message.isThinkingComplete) && ( {(!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"> <div className='w-5 h-5 sm:w-6 sm:h-6 rounded-full bg-white/20 flex items-center justify-center'>
{message.isReasoningExpanded ? {message.isReasoningExpanded ? (
<ChevronUp size={styleState.isMobile ? 12 : 16} style={{ color: 'white' }} /> : <ChevronUp
<ChevronRight size={styleState.isMobile ? 12 : 16} style={{ color: 'white' }} /> size={styleState.isMobile ? 12 : 16}
} style={{ color: 'white' }}
/>
) : (
<ChevronRight
size={styleState.isMobile ? 12 : 16}
style={{ color: 'white' }}
/>
)}
</div> </div>
)} )}
</div> </div>
</div> </div>
<div <div
className={`transition-all duration-500 ease-out ${message.isReasoningExpanded ? 'max-h-96 opacity-100' : 'max-h-0 opacity-0' className={`transition-all duration-500 ease-out ${
} overflow-hidden bg-gradient-to-br from-purple-50 via-indigo-50 to-violet-50`} 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 && ( {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 <div
ref={scrollRef} 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={{ style={{
maxHeight: '200px', maxHeight: '200px',
scrollbarWidth: 'thin', 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 <MarkdownRenderer
content={finalExtractedThinkingContent} content={finalExtractedThinkingContent}
className="" className=''
animated={isThinkingStatus} animated={isThinkingStatus}
previousContentLength={prevLength} previousContentLength={prevLength}
/> />
+14 -6
View File
@@ -17,7 +17,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com 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'; const MESSAGES_STORAGE_KEY = 'playground_messages';
@@ -72,9 +75,12 @@ export const loadConfig = () => {
...DEFAULT_CONFIG.parameterEnabled, ...DEFAULT_CONFIG.parameterEnabled,
...parsedConfig.parameterEnabled, ...parsedConfig.parameterEnabled,
}, },
showDebugPanel: parsedConfig.showDebugPanel || DEFAULT_CONFIG.showDebugPanel, showDebugPanel:
customRequestMode: parsedConfig.customRequestMode || DEFAULT_CONFIG.customRequestMode, parsedConfig.showDebugPanel || DEFAULT_CONFIG.showDebugPanel,
customRequestBody: parsedConfig.customRequestBody || DEFAULT_CONFIG.customRequestBody, customRequestMode:
parsedConfig.customRequestMode || DEFAULT_CONFIG.customRequestMode,
customRequestBody:
parsedConfig.customRequestBody || DEFAULT_CONFIG.customRequestBody,
}; };
return mergedConfig; return mergedConfig;
@@ -180,7 +186,6 @@ export const exportConfig = (config, messages = null) => {
link.click(); link.click();
URL.revokeObjectURL(link.href); URL.revokeObjectURL(link.href);
} catch (error) { } catch (error) {
console.error('导出配置失败:', error); console.error('导出配置失败:', error);
} }
@@ -201,7 +206,10 @@ export const importConfig = (file) => {
if (importedConfig.inputs && importedConfig.parameterEnabled) { if (importedConfig.inputs && importedConfig.parameterEnabled) {
// 如果导入的配置包含消息,也一起导入 // 如果导入的配置包含消息,也一起导入
if (importedConfig.messages && Array.isArray(importedConfig.messages)) { if (
importedConfig.messages &&
Array.isArray(importedConfig.messages)
) {
saveMessages(importedConfig.messages); saveMessages(importedConfig.messages);
} }
@@ -17,7 +17,12 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com 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 { useIsMobile } from '../../hooks/common/useIsMobile';
import { import {
Modal, Modal,
@@ -31,220 +36,241 @@ import {
import { IconSearch } from '@douyinfe/semi-icons'; import { IconSearch } from '@douyinfe/semi-icons';
import { CheckCircle, XCircle, AlertCircle, HelpCircle } from 'lucide-react'; import { CheckCircle, XCircle, AlertCircle, HelpCircle } from 'lucide-react';
const ChannelSelectorModal = forwardRef(({ const ChannelSelectorModal = forwardRef(
visible, (
onCancel, {
onOk, visible,
allChannels, onCancel,
selectedChannelIds, onOk,
setSelectedChannelIds, allChannels,
channelEndpoints, selectedChannelIds,
updateChannelEndpoint, setSelectedChannelIds,
t, channelEndpoints,
}, ref) => { updateChannelEndpoint,
const [searchText, setSearchText] = useState(''); t,
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const isMobile = useIsMobile();
const [filteredData, setFilteredData] = useState([]);
useImperativeHandle(ref, () => ({
resetPagination: () => {
setCurrentPage(1);
setSearchText('');
}, },
})); ref,
) => {
const [searchText, setSearchText] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const isMobile = useIsMobile();
useEffect(() => { const [filteredData, setFilteredData] = useState([]);
if (!allChannels) return;
const searchLower = searchText.trim().toLowerCase(); useImperativeHandle(ref, () => ({
const matched = searchLower resetPagination: () => {
? allChannels.filter((item) => { setCurrentPage(1);
const name = (item.label || '').toLowerCase(); setSearchText('');
const baseUrl = (item._originalData?.base_url || '').toLowerCase(); },
return name.includes(searchLower) || baseUrl.includes(searchLower); }));
})
: allChannels;
setFilteredData(matched); useEffect(() => {
}, [allChannels, searchText]); 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( setFilteredData(matched);
(currentPage - 1) * pageSize, }, [allChannels, searchText]);
currentPage * pageSize,
);
const updateEndpoint = (channelId, endpoint) => { const total = filteredData.length;
if (typeof updateChannelEndpoint === 'function') {
updateChannelEndpoint(channelId, endpoint);
}
};
const renderEndpointCell = (text, record) => { const paginatedData = filteredData.slice(
const channelId = record.key || record.value; (currentPage - 1) * pageSize,
const currentEndpoint = channelEndpoints[channelId] || ''; currentPage * pageSize,
);
const getEndpointType = (ep) => { const updateEndpoint = (channelId, endpoint) => {
if (ep === '/api/ratio_config') return 'ratio_config'; if (typeof updateChannelEndpoint === 'function') {
if (ep === '/api/pricing') return 'pricing'; updateChannelEndpoint(channelId, endpoint);
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 ( const renderEndpointCell = (text, record) => {
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}> const channelId = record.key || record.value;
<Select const currentEndpoint = channelEndpoints[channelId] || '';
size="small"
value={currentType} const getEndpointType = (ep) => {
onChange={handleTypeChange} if (ep === '/api/ratio_config') return 'ratio_config';
style={{ width: 120 }} if (ep === '/api/pricing') return 'pricing';
optionList={[ return 'custom';
{ label: 'ratio_config', value: 'ratio_config' }, };
{ label: 'pricing', value: 'pricing' },
{ label: 'custom', value: 'custom' }, const currentType = getEndpointType(currentEndpoint);
]}
/> const handleTypeChange = (val) => {
{currentType === 'custom' && ( if (val === 'ratio_config') {
<Input updateEndpoint(channelId, '/api/ratio_config');
size="small" } else if (val === 'pricing') {
value={currentEndpoint} updateEndpoint(channelId, '/api/pricing');
onChange={(val) => updateEndpoint(channelId, val)} } else {
placeholder="/your/endpoint" if (currentType !== 'custom') {
style={{ width: 160, fontSize: 12 }} 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' },
]}
/> />
)} {currentType === 'custom' && (
</div> <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) => { const renderBaseUrlCell = (text) => (
switch (status) { <Highlight sourceString={text} searchWords={[searchText]} />
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) => ( const columns = [
<Highlight sourceString={text} searchWords={[searchText]} /> {
); 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) => ( const rowSelection = {
<Highlight sourceString={text} searchWords={[searchText]} /> selectedRowKeys: selectedChannelIds,
); onChange: (keys) => setSelectedChannelIds(keys),
};
const columns = [ return (
{ <Modal
title: t('名称'), visible={visible}
dataIndex: 'label', onCancel={onCancel}
render: renderNameCell, onOk={onOk}
}, title={
{ <span className='text-lg font-semibold'>{t('选择同步渠道')}</span>
title: t('源地址'), }
dataIndex: '_originalData.base_url', size={isMobile ? 'full-width' : 'large'}
render: (_, record) => renderBaseUrlCell(record._originalData?.base_url || ''), keepDOM
}, lazyRender={false}
{ >
title: t('状态'), <Space vertical style={{ width: '100%' }}>
dataIndex: '_originalData.status', <Input
render: (_, record) => renderStatusCell(record._originalData?.status || 0), prefix={<IconSearch size={14} />}
}, placeholder={t('搜索渠道名称或地址')}
{ value={searchText}
title: t('同步接口'), onChange={setSearchText}
dataIndex: 'endpoint', showClear
fixed: 'right', />
render: renderEndpointCell,
},
];
const rowSelection = { <Table
selectedRowKeys: selectedChannelIds, columns={columns}
onChange: (keys) => setSelectedChannelIds(keys), dataSource={paginatedData}
}; rowKey='key'
rowSelection={rowSelection}
return ( pagination={{
<Modal currentPage: currentPage,
visible={visible} pageSize: pageSize,
onCancel={onCancel} total: total,
onOk={onOk} showSizeChanger: true,
title={<span className="text-lg font-semibold">{t('选择同步渠道')}</span>} showQuickJumper: true,
size={isMobile ? 'full-width' : 'large'} pageSizeOptions: ['10', '20', '50', '100'],
keepDOM onChange: (page, size) => {
lazyRender={false} setCurrentPage(page);
> setPageSize(size);
<Space vertical style={{ width: '100%' }}> },
<Input onShowSizeChange: (curr, size) => {
prefix={<IconSearch size={14} />} setCurrentPage(1);
placeholder={t('搜索渠道名称或地址')} setPageSize(size);
value={searchText} },
onChange={setSearchText} }}
showClear size='small'
/> />
</Space>
<Table </Modal>
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;
@@ -62,8 +62,7 @@ const DashboardSetting = () => {
if (item.key in inputs) { if (item.key in inputs) {
newInputs[item.key] = item.value; newInputs[item.key] = item.value;
} }
if (item.key.endsWith('Enabled') && if (item.key.endsWith('Enabled') && item.key === 'DataExportEnabled') {
(item.key === 'DataExportEnabled')) {
newInputs[item.key] = toBoolean(item.value); newInputs[item.key] = toBoolean(item.value);
} }
}); });
@@ -91,8 +90,14 @@ const DashboardSetting = () => {
// 用于迁移检测的旧键,下个版本会删除 // 用于迁移检测的旧键,下个版本会删除
const hasLegacyData = useMemo(() => { const hasLegacyData = useMemo(() => {
const legacyKeys = ['ApiInfo', 'Announcements', 'FAQ', 'UptimeKumaUrl', 'UptimeKumaSlug']; const legacyKeys = [
return legacyKeys.some(k => inputs[k]); 'ApiInfo',
'Announcements',
'FAQ',
'UptimeKumaUrl',
'UptimeKumaSlug',
];
return legacyKeys.some((k) => inputs[k]);
}, [inputs]); }, [inputs]);
useEffect(() => { useEffect(() => {
@@ -121,17 +126,18 @@ const DashboardSetting = () => {
<Spin spinning={loading} size='large'> <Spin spinning={loading} size='large'>
{/* 用于迁移检测的旧键模态框,下个版本会删除 */} {/* 用于迁移检测的旧键模态框,下个版本会删除 */}
<Modal <Modal
title="配置迁移确认" title='配置迁移确认'
visible={showMigrateModal} visible={showMigrateModal}
onOk={handleMigrate} onOk={handleMigrate}
onCancel={() => setShowMigrateModal(false)} onCancel={() => setShowMigrateModal(false)}
confirmLoading={loading} confirmLoading={loading}
okText="确认迁移" okText='确认迁移'
cancelText="取消" cancelText='取消'
> >
<p>检测到旧版本的配置数据是否要迁移到新的配置格式</p> <p>检测到旧版本的配置数据是否要迁移到新的配置格式</p>
<p style={{ color: '#f57c00', marginTop: '10px' }}> <p style={{ color: '#f57c00', marginTop: '10px' }}>
<strong>注意</strong>迁移过程中会自动处理数据格式转换迁移完成后旧配置将被清除请在迁移前在数据库中备份好旧配置 <strong>注意</strong>
迁移过程中会自动处理数据格式转换迁移完成后旧配置将被清除请在迁移前在数据库中备份好旧配置
</p> </p>
</Modal> </Modal>
@@ -56,7 +56,11 @@ const PaymentSetting = () => {
switch (item.key) { switch (item.key) {
case 'TopupGroupRatio': case 'TopupGroupRatio':
try { 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) { } catch (error) {
console.error('解析TopupGroupRatio出错:', error); console.error('解析TopupGroupRatio出错:', error);
newInputs[item.key] = item.value; newInputs[item.key] = item.value;
+10 -12
View File
@@ -19,13 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
import React, { useContext, useEffect, useState } from 'react'; import React, { useContext, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { import { API, copy, showError, showInfo, showSuccess } from '../../helpers';
API,
copy,
showError,
showInfo,
showSuccess
} from '../../helpers';
import { UserContext } from '../../context/User'; import { UserContext } from '../../context/User';
import { Modal } from '@douyinfe/semi-ui'; import { Modal } from '@douyinfe/semi-ui';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -271,7 +265,11 @@ const PersonalSetting = () => {
const handleNotificationSettingChange = (type, value) => { const handleNotificationSettingChange = (type, value) => {
setNotificationSettings((prev) => ({ setNotificationSettings((prev) => ({
...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 ( return (
<div className="mt-[60px]"> <div className='mt-[60px]'>
<div className="flex justify-center"> <div className='flex justify-center'>
<div className="w-full max-w-7xl mx-auto px-2"> <div className='w-full max-w-7xl mx-auto px-2'>
{/* 顶部用户信息区域 */} {/* 顶部用户信息区域 */}
<UserInfoHeader t={t} userState={userState} /> <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 <AccountManagement
t={t} t={t}
+5 -20
View File
@@ -103,34 +103,19 @@ const RatioSetting = () => {
<Card style={{ marginTop: '10px' }}> <Card style={{ marginTop: '10px' }}>
<Tabs type='card'> <Tabs type='card'>
<Tabs.TabPane tab={t('模型倍率设置')} itemKey='model'> <Tabs.TabPane tab={t('模型倍率设置')} itemKey='model'>
<ModelRatioSettings <ModelRatioSettings options={inputs} refresh={onRefresh} />
options={inputs}
refresh={onRefresh}
/>
</Tabs.TabPane> </Tabs.TabPane>
<Tabs.TabPane tab={t('分组倍率设置')} itemKey='group'> <Tabs.TabPane tab={t('分组倍率设置')} itemKey='group'>
<GroupRatioSettings <GroupRatioSettings options={inputs} refresh={onRefresh} />
options={inputs}
refresh={onRefresh}
/>
</Tabs.TabPane> </Tabs.TabPane>
<Tabs.TabPane tab={t('可视化倍率设置')} itemKey='visual'> <Tabs.TabPane tab={t('可视化倍率设置')} itemKey='visual'>
<ModelSettingsVisualEditor <ModelSettingsVisualEditor options={inputs} refresh={onRefresh} />
options={inputs}
refresh={onRefresh}
/>
</Tabs.TabPane> </Tabs.TabPane>
<Tabs.TabPane tab={t('未设置倍率模型')} itemKey='unset_models'> <Tabs.TabPane tab={t('未设置倍率模型')} itemKey='unset_models'>
<ModelRatioNotSetEditor <ModelRatioNotSetEditor options={inputs} refresh={onRefresh} />
options={inputs}
refresh={onRefresh}
/>
</Tabs.TabPane> </Tabs.TabPane>
<Tabs.TabPane tab={t('上游倍率同步')} itemKey='upstream_sync'> <Tabs.TabPane tab={t('上游倍率同步')} itemKey='upstream_sync'>
<UpstreamRatioSync <UpstreamRatioSync options={inputs} refresh={onRefresh} />
options={inputs}
refresh={onRefresh}
/>
</Tabs.TabPane> </Tabs.TabPane>
</Tabs> </Tabs>
</Card> </Card>
+35 -10
View File
@@ -473,7 +473,10 @@ const SystemSetting = () => {
value: inputs.LinuxDOClientSecret, value: inputs.LinuxDOClientSecret,
}); });
} }
if (originInputs['LinuxDOMinimumTrustLevel'] !== inputs.LinuxDOMinimumTrustLevel) { if (
originInputs['LinuxDOMinimumTrustLevel'] !==
inputs.LinuxDOMinimumTrustLevel
) {
options.push({ options.push({
key: 'LinuxDOMinimumTrustLevel', key: 'LinuxDOMinimumTrustLevel',
value: inputs.LinuxDOMinimumTrustLevel, value: inputs.LinuxDOMinimumTrustLevel,
@@ -530,11 +533,15 @@ const SystemSetting = () => {
field='ServerAddress' field='ServerAddress'
label={t('服务器地址')} label={t('服务器地址')}
placeholder='https://yourdomain.com' placeholder='https://yourdomain.com'
extraText={t('该服务器地址将影响支付回调地址以及默认首页展示的地址,请确保正确配置')} extraText={t(
'该服务器地址将影响支付回调地址以及默认首页展示的地址,请确保正确配置',
)}
/> />
</Col> </Col>
</Row> </Row>
<Button onClick={submitServerAddress}>{t('更新服务器地址')}</Button> <Button onClick={submitServerAddress}>
{t('更新服务器地址')}
</Button>
</Form.Section> </Form.Section>
</Card> </Card>
@@ -755,7 +762,10 @@ const SystemSetting = () => {
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }} gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
> >
<Col xs={24} sm={24} md={8} lg={8} xl={8}> <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>
<Col xs={24} sm={24} md={8} lg={8} xl={8}> <Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Input field='SMTPPort' label={t('SMTP 端口')} /> <Form.Input field='SMTPPort' label={t('SMTP 端口')} />
@@ -769,7 +779,10 @@ const SystemSetting = () => {
style={{ marginTop: 16 }} style={{ marginTop: 16 }}
> >
<Col xs={24} sm={24} md={8} lg={8} xl={8}> <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>
<Col xs={24} sm={24} md={8} lg={8} xl={8}> <Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Input <Form.Input
@@ -797,7 +810,9 @@ const SystemSetting = () => {
<Card> <Card>
<Form.Section text={t('配置 OIDC')}> <Form.Section text={t('配置 OIDC')}>
<Text> <Text>
{t('用以支持通过 OIDC 登录,例如 Okta、Auth0 等兼容 OIDC 协议的 IdP')} {t(
'用以支持通过 OIDC 登录,例如 Okta、Auth0 等兼容 OIDC 协议的 IdP',
)}
</Text> </Text>
<Banner <Banner
type='info' type='info'
@@ -805,7 +820,9 @@ const SystemSetting = () => {
style={{ marginBottom: 20, marginTop: 16 }} style={{ marginBottom: 20, marginTop: 16 }}
/> />
<Text> <Text>
{t('若你的 OIDC Provider 支持 Discovery Endpoint,你可以仅填写 OIDC Well-Known URL,系统会自动获取 OIDC 配置')} {t(
'若你的 OIDC Provider 支持 Discovery Endpoint,你可以仅填写 OIDC Well-Known URL,系统会自动获取 OIDC 配置',
)}
</Text> </Text>
<Row <Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }} gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
@@ -862,7 +879,9 @@ const SystemSetting = () => {
/> />
</Col> </Col>
</Row> </Row>
<Button onClick={submitOIDCSettings}>{t('保存 OIDC 设置')}</Button> <Button onClick={submitOIDCSettings}>
{t('保存 OIDC 设置')}
</Button>
</Form.Section> </Form.Section>
</Card> </Card>
@@ -1033,7 +1052,9 @@ const SystemSetting = () => {
/> />
</Col> </Col>
</Row> </Row>
<Button onClick={submitTurnstile}>{t('保存 Turnstile 设置')}</Button> <Button onClick={submitTurnstile}>
{t('保存 Turnstile 设置')}
</Button>
</Form.Section> </Form.Section>
</Card> </Card>
@@ -1048,7 +1069,11 @@ const SystemSetting = () => {
okText={t('确认')} okText={t('确认')}
cancelText={t('取消')} cancelText={t('取消')}
> >
<p>{t('您确定要取消密码登录功能吗?这可能会影响用户的登录方式。')}</p> <p>
{t(
'您确定要取消密码登录功能吗?这可能会影响用户的登录方式。',
)}
</p>
</Modal> </Modal>
</div> </div>
)} )}
@@ -27,7 +27,7 @@ import {
Avatar, Avatar,
Tabs, Tabs,
TabPane, TabPane,
Popover Popover,
} from '@douyinfe/semi-ui'; } from '@douyinfe/semi-ui';
import { import {
IconMail, IconMail,
@@ -35,7 +35,7 @@ import {
IconGithubLogo, IconGithubLogo,
IconKey, IconKey,
IconLock, IconLock,
IconDelete IconDelete,
} from '@douyinfe/semi-icons'; } from '@douyinfe/semi-icons';
import { SiTelegram, SiWechat, SiLinux } from 'react-icons/si'; import { SiTelegram, SiWechat, SiLinux } from 'react-icons/si';
import { UserPlus, ShieldCheck } from 'lucide-react'; import { UserPlus, ShieldCheck } from 'lucide-react';
@@ -43,7 +43,7 @@ import TelegramLoginButton from 'react-telegram-login';
import { import {
onGitHubOAuthClicked, onGitHubOAuthClicked,
onOIDCClicked, onOIDCClicked,
onLinuxDOOAuthClicked onLinuxDOOAuthClicked,
} from '../../../../helpers'; } from '../../../../helpers';
import TwoFASetting from '../components/TwoFASetting'; import TwoFASetting from '../components/TwoFASetting';
@@ -57,77 +57,89 @@ const AccountManagement = ({
generateAccessToken, generateAccessToken,
handleSystemTokenClick, handleSystemTokenClick,
setShowChangePasswordModal, setShowChangePasswordModal,
setShowAccountDeleteModal setShowAccountDeleteModal,
}) => { }) => {
const renderAccountInfo = (accountId, label) => { const renderAccountInfo = (accountId, label) => {
if (!accountId || accountId === '') { if (!accountId || accountId === '') {
return <span className="text-gray-500">{t('未绑定')}</span>; return <span className='text-gray-500'>{t('未绑定')}</span>;
} }
const popContent = ( const popContent = (
<div className="text-xs p-2"> <div className='text-xs p-2'>
<Typography.Paragraph copyable={{ content: accountId }}> <Typography.Paragraph copyable={{ content: accountId }}>
{accountId} {accountId}
</Typography.Paragraph> </Typography.Paragraph>
{label ? ( {label ? (
<div className="mt-1 text-[11px] text-gray-500">{label}</div> <div className='mt-1 text-[11px] text-gray-500'>{label}</div>
) : null} ) : null}
</div> </div>
); );
return ( return (
<Popover content={popContent} position="top" trigger="hover"> <Popover content={popContent} position='top' trigger='hover'>
<span className="block max-w-full truncate text-gray-600 hover:text-blue-600 cursor-pointer"> <span className='block max-w-full truncate text-gray-600 hover:text-blue-600 cursor-pointer'>
{accountId} {accountId}
</span> </span>
</Popover> </Popover>
); );
}; };
return ( return (
<Card className="!rounded-2xl"> <Card className='!rounded-2xl'>
{/* 卡片头部 */} {/* 卡片头部 */}
<div className="flex items-center mb-4"> <div className='flex items-center mb-4'>
<Avatar size="small" color="teal" className="mr-3 shadow-md"> <Avatar size='small' color='teal' className='mr-3 shadow-md'>
<UserPlus size={16} /> <UserPlus size={16} />
</Avatar> </Avatar>
<div> <div>
<Typography.Text className="text-lg font-medium">{t('账户管理')}</Typography.Text> <Typography.Text className='text-lg font-medium'>
<div className="text-xs text-gray-600">{t('账户绑定、安全设置和身份验证')}</div> {t('账户管理')}
</Typography.Text>
<div className='text-xs text-gray-600'>
{t('账户绑定、安全设置和身份验证')}
</div>
</div> </div>
</div> </div>
<Tabs type="card" defaultActiveKey="binding"> <Tabs type='card' defaultActiveKey='binding'>
{/* 账户绑定 Tab */} {/* 账户绑定 Tab */}
<TabPane <TabPane
tab={ tab={
<div className="flex items-center"> <div className='flex items-center'>
<UserPlus size={16} className="mr-2" /> <UserPlus size={16} className='mr-2' />
{t('账户绑定')} {t('账户绑定')}
</div> </div>
} }
itemKey="binding" itemKey='binding'
> >
<div className="py-4"> <div className='py-4'>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4"> <div className='grid grid-cols-1 lg:grid-cols-2 gap-4'>
{/* 邮箱绑定 */} {/* 邮箱绑定 */}
<Card className="!rounded-xl"> <Card className='!rounded-xl'>
<div className="flex items-center justify-between gap-3"> <div className='flex items-center justify-between gap-3'>
<div className="flex items-center flex-1 min-w-0"> <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"> <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" /> <IconMail
size='default'
className='text-slate-600 dark:text-slate-300'
/>
</div> </div>
<div className="flex-1 min-w-0"> <div className='flex-1 min-w-0'>
<div className="font-medium text-gray-900">{t('邮箱')}</div> <div className='font-medium text-gray-900'>
<div className="text-sm text-gray-500 truncate"> {t('邮箱')}
{renderAccountInfo(userState.user?.email, t('邮箱地址'))} </div>
<div className='text-sm text-gray-500 truncate'>
{renderAccountInfo(
userState.user?.email,
t('邮箱地址'),
)}
</div> </div>
</div> </div>
</div> </div>
<div className="flex-shrink-0"> <div className='flex-shrink-0'>
<Button <Button
type="primary" type='primary'
theme="outline" theme='outline'
size="small" size='small'
onClick={() => setShowEmailBindModal(true)} onClick={() => setShowEmailBindModal(true)}
> >
{userState.user && userState.user.email !== '' {userState.user && userState.user.email !== ''
@@ -139,26 +151,31 @@ const AccountManagement = ({
</Card> </Card>
{/* 微信绑定 */} {/* 微信绑定 */}
<Card className="!rounded-xl"> <Card className='!rounded-xl'>
<div className="flex items-center justify-between gap-3"> <div className='flex items-center justify-between gap-3'>
<div className="flex items-center flex-1 min-w-0"> <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"> <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" /> <SiWechat
size={20}
className='text-slate-600 dark:text-slate-300'
/>
</div> </div>
<div className="flex-1 min-w-0"> <div className='flex-1 min-w-0'>
<div className="font-medium text-gray-900">{t('微信')}</div> <div className='font-medium text-gray-900'>
<div className="text-sm text-gray-500 truncate"> {t('微信')}
</div>
<div className='text-sm text-gray-500 truncate'>
{userState.user && userState.user.wechat_id !== '' {userState.user && userState.user.wechat_id !== ''
? t('已绑定') ? t('已绑定')
: t('未绑定')} : t('未绑定')}
</div> </div>
</div> </div>
</div> </div>
<div className="flex-shrink-0"> <div className='flex-shrink-0'>
<Button <Button
type="primary" type='primary'
theme="outline" theme='outline'
size="small" size='small'
disabled={!status.wechat_login} disabled={!status.wechat_login}
onClick={() => setShowWeChatBindModal(true)} onClick={() => setShowWeChatBindModal(true)}
> >
@@ -173,25 +190,35 @@ const AccountManagement = ({
</Card> </Card>
{/* GitHub绑定 */} {/* GitHub绑定 */}
<Card className="!rounded-xl"> <Card className='!rounded-xl'>
<div className="flex items-center justify-between gap-3"> <div className='flex items-center justify-between gap-3'>
<div className="flex items-center flex-1 min-w-0"> <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"> <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" /> <IconGithubLogo
size='default'
className='text-slate-600 dark:text-slate-300'
/>
</div> </div>
<div className="flex-1 min-w-0"> <div className='flex-1 min-w-0'>
<div className="font-medium text-gray-900">{t('GitHub')}</div> <div className='font-medium text-gray-900'>
<div className="text-sm text-gray-500 truncate"> {t('GitHub')}
{renderAccountInfo(userState.user?.github_id, t('GitHub ID'))} </div>
<div className='text-sm text-gray-500 truncate'>
{renderAccountInfo(
userState.user?.github_id,
t('GitHub ID'),
)}
</div> </div>
</div> </div>
</div> </div>
<div className="flex-shrink-0"> <div className='flex-shrink-0'>
<Button <Button
type="primary" type='primary'
theme="outline" theme='outline'
size="small" size='small'
onClick={() => onGitHubOAuthClicked(status.github_client_id)} onClick={() =>
onGitHubOAuthClicked(status.github_client_id)
}
disabled={ disabled={
(userState.user && userState.user.github_id !== '') || (userState.user && userState.user.github_id !== '') ||
!status.github_oauth !status.github_oauth
@@ -204,28 +231,38 @@ const AccountManagement = ({
</Card> </Card>
{/* OIDC绑定 */} {/* OIDC绑定 */}
<Card className="!rounded-xl"> <Card className='!rounded-xl'>
<div className="flex items-center justify-between gap-3"> <div className='flex items-center justify-between gap-3'>
<div className="flex items-center flex-1 min-w-0"> <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"> <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" /> <IconShield
size='default'
className='text-slate-600 dark:text-slate-300'
/>
</div> </div>
<div className="flex-1 min-w-0"> <div className='flex-1 min-w-0'>
<div className="font-medium text-gray-900">{t('OIDC')}</div> <div className='font-medium text-gray-900'>
<div className="text-sm text-gray-500 truncate"> {t('OIDC')}
{renderAccountInfo(userState.user?.oidc_id, t('OIDC ID'))} </div>
<div className='text-sm text-gray-500 truncate'>
{renderAccountInfo(
userState.user?.oidc_id,
t('OIDC ID'),
)}
</div> </div>
</div> </div>
</div> </div>
<div className="flex-shrink-0"> <div className='flex-shrink-0'>
<Button <Button
type="primary" type='primary'
theme="outline" theme='outline'
size="small" size='small'
onClick={() => onOIDCClicked( onClick={() =>
status.oidc_authorization_endpoint, onOIDCClicked(
status.oidc_client_id, status.oidc_authorization_endpoint,
)} status.oidc_client_id,
)
}
disabled={ disabled={
(userState.user && userState.user.oidc_id !== '') || (userState.user && userState.user.oidc_id !== '') ||
!status.oidc_enabled !status.oidc_enabled
@@ -238,27 +275,35 @@ const AccountManagement = ({
</Card> </Card>
{/* Telegram绑定 */} {/* Telegram绑定 */}
<Card className="!rounded-xl"> <Card className='!rounded-xl'>
<div className="flex items-center justify-between gap-3"> <div className='flex items-center justify-between gap-3'>
<div className="flex items-center flex-1 min-w-0"> <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"> <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" /> <SiTelegram
size={20}
className='text-slate-600 dark:text-slate-300'
/>
</div> </div>
<div className="flex-1 min-w-0"> <div className='flex-1 min-w-0'>
<div className="font-medium text-gray-900">{t('Telegram')}</div> <div className='font-medium text-gray-900'>
<div className="text-sm text-gray-500 truncate"> {t('Telegram')}
{renderAccountInfo(userState.user?.telegram_id, t('Telegram ID'))} </div>
<div className='text-sm text-gray-500 truncate'>
{renderAccountInfo(
userState.user?.telegram_id,
t('Telegram ID'),
)}
</div> </div>
</div> </div>
</div> </div>
<div className="flex-shrink-0"> <div className='flex-shrink-0'>
{status.telegram_oauth ? ( {status.telegram_oauth ? (
userState.user.telegram_id !== '' ? ( userState.user.telegram_id !== '' ? (
<Button disabled={true} size="small"> <Button disabled={true} size='small'>
{t('已绑定')} {t('已绑定')}
</Button> </Button>
) : ( ) : (
<div className="scale-75"> <div className='scale-75'>
<TelegramLoginButton <TelegramLoginButton
dataAuthUrl='/api/oauth/telegram/bind' dataAuthUrl='/api/oauth/telegram/bind'
botName={status.telegram_bot_name} botName={status.telegram_bot_name}
@@ -266,7 +311,7 @@ const AccountManagement = ({
</div> </div>
) )
) : ( ) : (
<Button disabled={true} size="small"> <Button disabled={true} size='small'>
{t('未启用')} {t('未启用')}
</Button> </Button>
)} )}
@@ -275,25 +320,35 @@ const AccountManagement = ({
</Card> </Card>
{/* LinuxDO绑定 */} {/* LinuxDO绑定 */}
<Card className="!rounded-xl"> <Card className='!rounded-xl'>
<div className="flex items-center justify-between gap-3"> <div className='flex items-center justify-between gap-3'>
<div className="flex items-center flex-1 min-w-0"> <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"> <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" /> <SiLinux
size={20}
className='text-slate-600 dark:text-slate-300'
/>
</div> </div>
<div className="flex-1 min-w-0"> <div className='flex-1 min-w-0'>
<div className="font-medium text-gray-900">{t('LinuxDO')}</div> <div className='font-medium text-gray-900'>
<div className="text-sm text-gray-500 truncate"> {t('LinuxDO')}
{renderAccountInfo(userState.user?.linux_do_id, t('LinuxDO ID'))} </div>
<div className='text-sm text-gray-500 truncate'>
{renderAccountInfo(
userState.user?.linux_do_id,
t('LinuxDO ID'),
)}
</div> </div>
</div> </div>
</div> </div>
<div className="flex-shrink-0"> <div className='flex-shrink-0'>
<Button <Button
type="primary" type='primary'
theme="outline" theme='outline'
size="small" size='small'
onClick={() => onLinuxDOOAuthClicked(status.linuxdo_client_id)} onClick={() =>
onLinuxDOOAuthClicked(status.linuxdo_client_id)
}
disabled={ disabled={
(userState.user && userState.user.linux_do_id !== '') || (userState.user && userState.user.linux_do_id !== '') ||
!status.linuxdo_oauth !status.linuxdo_oauth
@@ -311,37 +366,37 @@ const AccountManagement = ({
{/* 安全设置 Tab */} {/* 安全设置 Tab */}
<TabPane <TabPane
tab={ tab={
<div className="flex items-center"> <div className='flex items-center'>
<ShieldCheck size={16} className="mr-2" /> <ShieldCheck size={16} className='mr-2' />
{t('安全设置')} {t('安全设置')}
</div> </div>
} }
itemKey="security" itemKey='security'
> >
<div className="py-4"> <div className='py-4'>
<div className="space-y-6"> <div className='space-y-6'>
<Space vertical className='w-full'> <Space vertical className='w-full'>
{/* 系统访问令牌 */} {/* 系统访问令牌 */}
<Card className="!rounded-xl 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 flex-col sm:flex-row items-start sm:justify-between gap-4'>
<div className="flex items-start w-full sm:w-auto"> <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"> <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" /> <IconKey size='large' className='text-slate-600' />
</div> </div>
<div className="flex-1"> <div className='flex-1'>
<Typography.Title heading={6} className="mb-1"> <Typography.Title heading={6} className='mb-1'>
{t('系统访问令牌')} {t('系统访问令牌')}
</Typography.Title> </Typography.Title>
<Typography.Text type="tertiary" className="text-sm"> <Typography.Text type='tertiary' className='text-sm'>
{t('用于API调用的身份验证令牌,请妥善保管')} {t('用于API调用的身份验证令牌,请妥善保管')}
</Typography.Text> </Typography.Text>
{systemToken && ( {systemToken && (
<div className="mt-3"> <div className='mt-3'>
<Input <Input
readonly readonly
value={systemToken} value={systemToken}
onClick={handleSystemTokenClick} onClick={handleSystemTokenClick}
size="large" size='large'
prefix={<IconKey />} prefix={<IconKey />}
/> />
</div> </div>
@@ -349,10 +404,10 @@ const AccountManagement = ({
</div> </div>
</div> </div>
<Button <Button
type="primary" type='primary'
theme="solid" theme='solid'
onClick={generateAccessToken} 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 />} icon={<IconKey />}
> >
{systemToken ? t('重新生成') : t('生成令牌')} {systemToken ? t('重新生成') : t('生成令牌')}
@@ -361,26 +416,26 @@ const AccountManagement = ({
</Card> </Card>
{/* 密码管理 */} {/* 密码管理 */}
<Card className="!rounded-xl 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 flex-col sm:flex-row items-start sm:justify-between gap-4'>
<div className="flex items-start w-full sm:w-auto"> <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"> <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" /> <IconLock size='large' className='text-slate-600' />
</div> </div>
<div> <div>
<Typography.Title heading={6} className="mb-1"> <Typography.Title heading={6} className='mb-1'>
{t('密码管理')} {t('密码管理')}
</Typography.Title> </Typography.Title>
<Typography.Text type="tertiary" className="text-sm"> <Typography.Text type='tertiary' className='text-sm'>
{t('定期更改密码可以提高账户安全性')} {t('定期更改密码可以提高账户安全性')}
</Typography.Text> </Typography.Text>
</div> </div>
</div> </div>
<Button <Button
type="primary" type='primary'
theme="solid" theme='solid'
onClick={() => setShowChangePasswordModal(true)} 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 />} icon={<IconLock />}
> >
{t('修改密码')} {t('修改密码')}
@@ -392,26 +447,29 @@ const AccountManagement = ({
<TwoFASetting t={t} /> <TwoFASetting t={t} />
{/* 危险区域 */} {/* 危险区域 */}
<Card className="!rounded-xl 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 flex-col sm:flex-row items-start sm:justify-between gap-4'>
<div className="flex items-start w-full sm:w-auto"> <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"> <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" /> <IconDelete size='large' className='text-slate-600' />
</div> </div>
<div> <div>
<Typography.Title heading={6} className="mb-1 text-slate-700"> <Typography.Title
heading={6}
className='mb-1 text-slate-700'
>
{t('删除账户')} {t('删除账户')}
</Typography.Title> </Typography.Title>
<Typography.Text type="tertiary" className="text-sm"> <Typography.Text type='tertiary' className='text-sm'>
{t('此操作不可逆,所有数据将被永久删除')} {t('此操作不可逆,所有数据将被永久删除')}
</Typography.Text> </Typography.Text>
</div> </div>
</div> </div>
<Button <Button
type="danger" type='danger'
theme="solid" theme='solid'
onClick={() => setShowAccountDeleteModal(true)} 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 />} icon={<IconDelete />}
> >
{t('删除账户')} {t('删除账户')}
@@ -27,13 +27,13 @@ import {
Tabs, Tabs,
TabPane, TabPane,
Typography, Typography,
Avatar Avatar,
} from '@douyinfe/semi-ui'; } from '@douyinfe/semi-ui';
import { IllustrationNoContent, IllustrationNoContentDark } from '@douyinfe/semi-illustrations';
import { import {
IconChevronDown, IllustrationNoContent,
IconChevronUp IllustrationNoContentDark,
} from '@douyinfe/semi-icons'; } from '@douyinfe/semi-illustrations';
import { IconChevronDown, IconChevronUp } from '@douyinfe/semi-icons';
import { Settings } from 'lucide-react'; import { Settings } from 'lucide-react';
import { renderModelTag, getModelCategories } from '../../../../helpers'; import { renderModelTag, getModelCategories } from '../../../../helpers';
@@ -52,38 +52,48 @@ const ModelsList = ({ t, models, modelsLoading, copyText }) => {
}, [isModelsExpanded]); }, [isModelsExpanded]);
return ( return (
<div className="py-4"> <div className='py-4'>
{/* 卡片头部 */} {/* 卡片头部 */}
<div className="flex items-center mb-4"> <div className='flex items-center mb-4'>
<Avatar size="small" color="green" className="mr-3 shadow-md"> <Avatar size='small' color='green' className='mr-3 shadow-md'>
<Settings size={16} /> <Settings size={16} />
</Avatar> </Avatar>
<div> <div>
<Typography.Text className="text-lg font-medium">{t('可用模型')}</Typography.Text> <Typography.Text className='text-lg font-medium'>
<div className="text-xs text-gray-600">{t('查看当前可用的所有模型')}</div> {t('可用模型')}
</Typography.Text>
<div className='text-xs text-gray-600'>
{t('查看当前可用的所有模型')}
</div>
</div> </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 ? ( {modelsLoading ? (
// 骨架屏加载状态 - 模拟实际加载后的布局 // 骨架屏加载状态 - 模拟实际加载后的布局
<div className="space-y-4"> <div className='space-y-4'>
{/* 模拟分类标签 */} {/* 模拟分类标签 */}
<div className="mb-4" style={{ borderBottom: '1px solid var(--semi-color-border)' }}> <div
<div className="flex overflow-x-auto py-2 gap-2"> 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) => ( {Array.from({ length: 8 }).map((_, index) => (
<Skeleton.Button key={`cat-${index}`} style={{ <Skeleton.Button
width: index === 0 ? 130 : 100 + Math.random() * 50, key={`cat-${index}`}
height: 36, style={{
borderRadius: 8 width: index === 0 ? 130 : 100 + Math.random() * 50,
}} /> height: 36,
borderRadius: 8,
}}
/>
))} ))}
</div> </div>
</div> </div>
{/* 模拟模型标签列表 */} {/* 模拟模型标签列表 */}
<div className="flex flex-wrap gap-2"> <div className='flex flex-wrap gap-2'>
{Array.from({ length: 20 }).map((_, index) => ( {Array.from({ length: 20 }).map((_, index) => (
<Skeleton.Button <Skeleton.Button
key={`model-${index}`} key={`model-${index}`}
@@ -91,17 +101,23 @@ const ModelsList = ({ t, models, modelsLoading, copyText }) => {
width: 100 + Math.random() * 100, width: 100 + Math.random() * 100,
height: 32, height: 32,
borderRadius: 16, borderRadius: 16,
margin: '4px' margin: '4px',
}} }}
/> />
))} ))}
</div> </div>
</div> </div>
) : models.length === 0 ? ( ) : models.length === 0 ? (
<div className="py-8"> <div className='py-8'>
<Empty <Empty
image={<IllustrationNoContent style={{ width: 150, height: 150 }} />} image={
darkModeImage={<IllustrationNoContentDark style={{ width: 150, height: 150 }} />} <IllustrationNoContent style={{ width: 150, height: 150 }} />
}
darkModeImage={
<IllustrationNoContentDark
style={{ width: 150, height: 150 }}
/>
}
description={t('没有可用模型')} description={t('没有可用模型')}
style={{ padding: '24px 0' }} style={{ padding: '24px 0' }}
/> />
@@ -109,59 +125,81 @@ const ModelsList = ({ t, models, modelsLoading, copyText }) => {
) : ( ) : (
<> <>
{/* 模型分类标签页 */} {/* 模型分类标签页 */}
<div className="mb-4"> <div className='mb-4'>
<Tabs <Tabs
type="card" type='card'
activeKey={activeModelCategory} activeKey={activeModelCategory}
onChange={key => setActiveModelCategory(key)} onChange={(key) => setActiveModelCategory(key)}
className="mt-2" className='mt-2'
collapsible collapsible
> >
{Object.entries(getModelCategories(t)).map(([key, category]) => { {Object.entries(getModelCategories(t)).map(
// 计算该分类下的模型数量 ([key, category]) => {
const modelCount = key === 'all' // 计算该分类下的模型数量
? models.length const modelCount =
: models.filter(model => category.filter({ model_name: model })).length; 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 ( return (
<TabPane <TabPane
tab={ tab={
<span className="flex items-center gap-2"> <span className='flex items-center gap-2'>
{category.icon && <span className="w-4 h-4">{category.icon}</span>} {category.icon && (
{category.label} <span className='w-4 h-4'>{category.icon}</span>
<Tag )}
color={activeModelCategory === key ? 'red' : 'grey'} {category.label}
size='small' <Tag
shape='circle' color={
> activeModelCategory === key ? 'red' : 'grey'
{modelCount} }
</Tag> size='small'
</span> shape='circle'
} >
itemKey={key} {modelCount}
key={key} </Tag>
/> </span>
); }
})} itemKey={key}
key={key}
/>
);
},
)}
</Tabs> </Tabs>
</div> </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 categories = getModelCategories(t);
const filteredModels = activeModelCategory === 'all' const filteredModels =
? models activeModelCategory === 'all'
: models.filter(model => categories[activeModelCategory].filter({ model_name: model })); ? models
: models.filter((model) =>
categories[activeModelCategory].filter({
model_name: model,
}),
);
// 如果过滤后没有模型,显示空状态 // 如果过滤后没有模型,显示空状态
if (filteredModels.length === 0) { if (filteredModels.length === 0) {
return ( return (
<Empty <Empty
image={<IllustrationNoContent style={{ width: 120, height: 120 }} />} image={
darkModeImage={<IllustrationNoContentDark style={{ width: 120, height: 120 }} />} <IllustrationNoContent
style={{ width: 120, height: 120 }}
/>
}
darkModeImage={
<IllustrationNoContentDark
style={{ width: 120, height: 120 }}
/>
}
description={t('该分类下没有可用模型')} description={t('该分类下没有可用模型')}
style={{ padding: '16px 0' }} style={{ padding: '16px 0' }}
/> />
@@ -171,13 +209,13 @@ const ModelsList = ({ t, models, modelsLoading, copyText }) => {
if (filteredModels.length <= MODELS_DISPLAY_COUNT) { if (filteredModels.length <= MODELS_DISPLAY_COUNT) {
return ( return (
<Space wrap> <Space wrap>
{filteredModels.map((model) => ( {filteredModels.map((model) =>
renderModelTag(model, { renderModelTag(model, {
size: 'small', size: 'small',
shape: 'circle', shape: 'circle',
onClick: () => copyText(model), onClick: () => copyText(model),
}) }),
))} )}
</Space> </Space>
); );
} else { } else {
@@ -185,17 +223,17 @@ const ModelsList = ({ t, models, modelsLoading, copyText }) => {
<> <>
<Collapsible isOpen={isModelsExpanded}> <Collapsible isOpen={isModelsExpanded}>
<Space wrap> <Space wrap>
{filteredModels.map((model) => ( {filteredModels.map((model) =>
renderModelTag(model, { renderModelTag(model, {
size: 'small', size: 'small',
shape: 'circle', shape: 'circle',
onClick: () => copyText(model), onClick: () => copyText(model),
}) }),
))} )}
<Tag <Tag
color='grey' color='grey'
type='light' type='light'
className="cursor-pointer !rounded-lg" className='cursor-pointer !rounded-lg'
onClick={() => setIsModelsExpanded(false)} onClick={() => setIsModelsExpanded(false)}
icon={<IconChevronUp />} icon={<IconChevronUp />}
> >
@@ -207,21 +245,23 @@ const ModelsList = ({ t, models, modelsLoading, copyText }) => {
<Space wrap> <Space wrap>
{filteredModels {filteredModels
.slice(0, MODELS_DISPLAY_COUNT) .slice(0, MODELS_DISPLAY_COUNT)
.map((model) => ( .map((model) =>
renderModelTag(model, { renderModelTag(model, {
size: 'small', size: 'small',
shape: 'circle', shape: 'circle',
onClick: () => copyText(model), onClick: () => copyText(model),
}) }),
))} )}
<Tag <Tag
color='grey' color='grey'
type='light' type='light'
className="cursor-pointer !rounded-lg" className='cursor-pointer !rounded-lg'
onClick={() => setIsModelsExpanded(true)} onClick={() => setIsModelsExpanded(true)}
icon={<IconChevronDown />} icon={<IconChevronDown />}
> >
{t('更多')} {filteredModels.length - MODELS_DISPLAY_COUNT} {t('个模型')} {t('更多')}{' '}
{filteredModels.length - MODELS_DISPLAY_COUNT}{' '}
{t('个模型')}
</Tag> </Tag>
</Space> </Space>
)} )}
@@ -27,14 +27,9 @@ import {
Radio, Radio,
Toast, Toast,
Tabs, Tabs,
TabPane TabPane,
} from '@douyinfe/semi-ui'; } from '@douyinfe/semi-ui';
import { import { IconMail, IconKey, IconBell, IconLink } from '@douyinfe/semi-icons';
IconMail,
IconKey,
IconBell,
IconLink
} from '@douyinfe/semi-icons';
import { ShieldCheck, Bell, DollarSign } from 'lucide-react'; import { ShieldCheck, Bell, DollarSign } from 'lucide-react';
import { renderQuotaWithPrompt } from '../../../../helpers'; import { renderQuotaWithPrompt } from '../../../../helpers';
import CodeViewer from '../../../playground/CodeViewer'; import CodeViewer from '../../../playground/CodeViewer';
@@ -43,7 +38,7 @@ const NotificationSettings = ({
t, t,
notificationSettings, notificationSettings,
handleNotificationSettingChange, handleNotificationSettingChange,
saveNotificationSettings saveNotificationSettings,
}) => { }) => {
const formApiRef = useRef(null); const formApiRef = useRef(null);
@@ -62,7 +57,8 @@ const NotificationSettings = ({
// 表单提交 // 表单提交
const handleSubmit = () => { const handleSubmit = () => {
if (formApiRef.current) { if (formApiRef.current) {
formApiRef.current.validate() formApiRef.current
.validate()
.then(() => { .then(() => {
saveNotificationSettings(); saveNotificationSettings();
}) })
@@ -77,26 +73,27 @@ const NotificationSettings = ({
return ( return (
<Card <Card
className="!rounded-2xl shadow-sm border-0" className='!rounded-2xl shadow-sm border-0'
footer={ footer={
<div className="flex justify-end"> <div className='flex justify-end'>
<Button <Button type='primary' onClick={handleSubmit}>
type='primary'
onClick={handleSubmit}
>
{t('保存设置')} {t('保存设置')}
</Button> </Button>
</div> </div>
} }
> >
{/* 卡片头部 */} {/* 卡片头部 */}
<div className="flex items-center mb-4"> <div className='flex items-center mb-4'>
<Avatar size="small" color="blue" className="mr-3 shadow-md"> <Avatar size='small' color='blue' className='mr-3 shadow-md'>
<Bell size={16} /> <Bell size={16} />
</Avatar> </Avatar>
<div> <div>
<Typography.Text className="text-lg font-medium">{t('其他设置')}</Typography.Text> <Typography.Text className='text-lg font-medium'>
<div className="text-xs text-gray-600">{t('通知、价格和隐私相关设置')}</div> {t('其他设置')}
</Typography.Text>
<div className='text-xs text-gray-600'>
{t('通知、价格和隐私相关设置')}
</div>
</div> </div>
</div> </div>
@@ -106,18 +103,18 @@ const NotificationSettings = ({
onSubmit={handleSubmit} onSubmit={handleSubmit}
> >
{() => ( {() => (
<Tabs type="card" defaultActiveKey="notification"> <Tabs type='card' defaultActiveKey='notification'>
{/* 通知配置 Tab */} {/* 通知配置 Tab */}
<TabPane <TabPane
tab={ tab={
<div className="flex items-center"> <div className='flex items-center'>
<Bell size={16} className="mr-2" /> <Bell size={16} className='mr-2' />
{t('通知配置')} {t('通知配置')}
</div> </div>
} }
itemKey="notification" itemKey='notification'
> >
<div className="py-4"> <div className='py-4'>
<Form.RadioGroup <Form.RadioGroup
field='warningType' field='warningType'
label={t('通知方式')} label={t('通知方式')}
@@ -125,15 +122,18 @@ const NotificationSettings = ({
onChange={(value) => handleFormChange('warningType', value)} onChange={(value) => handleFormChange('warningType', value)}
rules={[{ required: true, message: t('请选择通知方式') }]} rules={[{ required: true, message: t('请选择通知方式') }]}
> >
<Radio value="email">{t('邮件通知')}</Radio> <Radio value='email'>{t('邮件通知')}</Radio>
<Radio value="webhook">{t('Webhook通知')}</Radio> <Radio value='webhook'>{t('Webhook通知')}</Radio>
</Form.RadioGroup> </Form.RadioGroup>
<Form.AutoComplete <Form.AutoComplete
field='warningThreshold' field='warningThreshold'
label={ label={
<span> <span>
{t('额度预警阈值')} {renderQuotaWithPrompt(notificationSettings.warningThreshold)} {t('额度预警阈值')}{' '}
{renderQuotaWithPrompt(
notificationSettings.warningThreshold,
)}
</span> </span>
} }
placeholder={t('请输入预警额度')} placeholder={t('请输入预警额度')}
@@ -145,7 +145,9 @@ const NotificationSettings = ({
]} ]}
onChange={(val) => handleFormChange('warningThreshold', val)} onChange={(val) => handleFormChange('warningThreshold', val)}
prefix={<IconBell />} prefix={<IconBell />}
extraText={t('当剩余额度低于此数值时,系统将通过选择的方式发送通知')} extraText={t(
'当剩余额度低于此数值时,系统将通过选择的方式发送通知',
)}
style={{ width: '100%', maxWidth: '300px' }} style={{ width: '100%', maxWidth: '300px' }}
rules={[ rules={[
{ required: true, message: t('请输入预警阈值') }, { required: true, message: t('请输入预警阈值') },
@@ -156,8 +158,8 @@ const NotificationSettings = ({
return Promise.reject(t('预警阈值必须为正数')); return Promise.reject(t('预警阈值必须为正数'));
} }
return Promise.resolve(); return Promise.resolve();
} },
} },
]} ]}
/> />
@@ -167,9 +169,13 @@ const NotificationSettings = ({
field='notificationEmail' field='notificationEmail'
label={t('通知邮箱')} label={t('通知邮箱')}
placeholder={t('留空则使用账号绑定的邮箱')} placeholder={t('留空则使用账号绑定的邮箱')}
onChange={(val) => handleFormChange('notificationEmail', val)} onChange={(val) =>
handleFormChange('notificationEmail', val)
}
prefix={<IconMail />} prefix={<IconMail />}
extraText={t('设置用于接收额度预警的邮箱地址,不填则使用账号绑定的邮箱')} extraText={t(
'设置用于接收额度预警的邮箱地址,不填则使用账号绑定的邮箱',
)}
showClear showClear
/> />
)} )}
@@ -180,20 +186,25 @@ const NotificationSettings = ({
<Form.Input <Form.Input
field='webhookUrl' field='webhookUrl'
label={t('Webhook地址')} label={t('Webhook地址')}
placeholder={t('请输入Webhook地址,例如: https://example.com/webhook')} placeholder={t(
'请输入Webhook地址,例如: https://example.com/webhook',
)}
onChange={(val) => handleFormChange('webhookUrl', val)} onChange={(val) => handleFormChange('webhookUrl', val)}
prefix={<IconLink />} prefix={<IconLink />}
extraText={t('只支持HTTPS,系统将以POST方式发送通知,请确保地址可以接收POST请求')} extraText={t(
'只支持HTTPS,系统将以POST方式发送通知,请确保地址可以接收POST请求',
)}
showClear showClear
rules={[ rules={[
{ {
required: notificationSettings.warningType === 'webhook', required:
message: t('请输入Webhook地址') notificationSettings.warningType === 'webhook',
message: t('请输入Webhook地址'),
}, },
{ {
pattern: /^https:\/\/.+/, pattern: /^https:\/\/.+/,
message: t('Webhook地址必须以https://开头') message: t('Webhook地址必须以https://开头'),
} },
]} ]}
/> />
@@ -203,7 +214,9 @@ const NotificationSettings = ({
placeholder={t('请输入密钥')} placeholder={t('请输入密钥')}
onChange={(val) => handleFormChange('webhookSecret', val)} onChange={(val) => handleFormChange('webhookSecret', val)}
prefix={<IconKey />} prefix={<IconKey />}
extraText={t('密钥将以Bearer方式添加到请求头中,用于验证webhook请求的合法性')} extraText={t(
'密钥将以Bearer方式添加到请求头中,用于验证webhook请求的合法性',
)}
showClear showClear
/> />
@@ -212,22 +225,36 @@ const NotificationSettings = ({
<div style={{ height: '200px', marginBottom: '12px' }}> <div style={{ height: '200px', marginBottom: '12px' }}>
<CodeViewer <CodeViewer
content={{ content={{
"type": "quota_exceed", type: 'quota_exceed',
"title": "额度预警通知", title: '额度预警通知',
"content": "您的额度即将用尽,当前剩余额度为 {{value}}", content:
"values": ["$0.99"], '您的额度即将用尽,当前剩余额度为 {{value}}',
"timestamp": 1739950503 values: ['$0.99'],
timestamp: 1739950503,
}} }}
title="webhook" title='webhook'
language="json" language='json'
/> />
</div> </div>
<div className="text-xs text-gray-500 leading-relaxed"> <div className='text-xs text-gray-500 leading-relaxed'>
<div><strong>type:</strong> {t('通知类型 (quota_exceed: 额度预警)')} </div> <div>
<div><strong>title:</strong> {t('通知标题')}</div> <strong>type:</strong>{' '}
<div><strong>content:</strong> {t('通知内容,支持 {{value}} 变量占位符')}</div> {t('通知类型 (quota_exceed: 额度预警)')}{' '}
<div><strong>values:</strong> {t('按顺序替换content中的变量占位符')}</div> </div>
<div><strong>timestamp:</strong> {t('Unix时间戳')}</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>
</div> </div>
</Form.Slot> </Form.Slot>
@@ -239,21 +266,25 @@ const NotificationSettings = ({
{/* 价格设置 Tab */} {/* 价格设置 Tab */}
<TabPane <TabPane
tab={ tab={
<div className="flex items-center"> <div className='flex items-center'>
<DollarSign size={16} className="mr-2" /> <DollarSign size={16} className='mr-2' />
{t('价格设置')} {t('价格设置')}
</div> </div>
} }
itemKey="pricing" itemKey='pricing'
> >
<div className="py-4"> <div className='py-4'>
<Form.Switch <Form.Switch
field='acceptUnsetModelRatioModel' field='acceptUnsetModelRatioModel'
label={t('接受未设置价格模型')} label={t('接受未设置价格模型')}
checkedText={t('开')} checkedText={t('开')}
uncheckedText={t('关')} uncheckedText={t('关')}
onChange={(value) => handleFormChange('acceptUnsetModelRatioModel', value)} onChange={(value) =>
extraText={t('当模型没有设置价格时仍接受调用,仅当您信任该网站时使用,可能会产生高额费用')} handleFormChange('acceptUnsetModelRatioModel', value)
}
extraText={t(
'当模型没有设置价格时仍接受调用,仅当您信任该网站时使用,可能会产生高额费用',
)}
/> />
</div> </div>
</TabPane> </TabPane>
@@ -261,21 +292,23 @@ const NotificationSettings = ({
{/* 隐私设置 Tab */} {/* 隐私设置 Tab */}
<TabPane <TabPane
tab={ tab={
<div className="flex items-center"> <div className='flex items-center'>
<ShieldCheck size={16} className="mr-2" /> <ShieldCheck size={16} className='mr-2' />
{t('隐私设置')} {t('隐私设置')}
</div> </div>
} }
itemKey="privacy" itemKey='privacy'
> >
<div className="py-4"> <div className='py-4'>
<Form.Switch <Form.Switch
field='recordIpLog' field='recordIpLog'
label={t('记录请求与错误日志IP')} label={t('记录请求与错误日志IP')}
checkedText={t('开')} checkedText={t('开')}
uncheckedText={t('关')} uncheckedText={t('关')}
onChange={(value) => handleFormChange('recordIpLog', value)} onChange={(value) => handleFormChange('recordIpLog', value)}
extraText={t('开启后,仅"消费"和"错误"日志将记录您的客户端IP地址')} extraText={t(
'开启后,仅"消费"和"错误"日志将记录您的客户端IP地址',
)}
/> />
</div> </div>
</TabPane> </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 For commercial licensing, please contact support@quantumnous.com
*/ */
import { API, showError, showSuccess, showWarning } from '../../../../helpers'; 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 { import {
IconShield, IconShield,
IconAlertTriangle, IconAlertTriangle,
IconRefresh, IconRefresh,
IconCopy IconCopy,
} from '@douyinfe/semi-icons'; } from '@douyinfe/semi-icons';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
@@ -35,7 +48,7 @@ const TwoFASetting = ({ t }) => {
const [status, setStatus] = useState({ const [status, setStatus] = useState({
enabled: false, enabled: false,
locked: false, locked: false,
backup_codes_remaining: 0 backup_codes_remaining: 0,
}); });
// 模态框状态 // 模态框状态
@@ -96,7 +109,7 @@ const TwoFASetting = ({ t }) => {
setLoading(true); setLoading(true);
try { try {
const res = await API.post('/api/user/2fa/enable', { const res = await API.post('/api/user/2fa/enable', {
code: verificationCode code: verificationCode,
}); });
if (res.data.success) { if (res.data.success) {
showSuccess(t('两步验证启用成功!')); showSuccess(t('两步验证启用成功!'));
@@ -130,7 +143,7 @@ const TwoFASetting = ({ t }) => {
setLoading(true); setLoading(true);
try { try {
const res = await API.post('/api/user/2fa/disable', { const res = await API.post('/api/user/2fa/disable', {
code: verificationCode code: verificationCode,
}); });
if (res.data.success) { if (res.data.success) {
showSuccess(t('两步验证已禁用')); showSuccess(t('两步验证已禁用'));
@@ -158,7 +171,7 @@ const TwoFASetting = ({ t }) => {
setLoading(true); setLoading(true);
try { try {
const res = await API.post('/api/user/2fa/backup_codes', { const res = await API.post('/api/user/2fa/backup_codes', {
code: verificationCode code: verificationCode,
}); });
if (res.data.success) { if (res.data.success) {
setBackupCodes(res.data.data.backup_codes); setBackupCodes(res.data.data.backup_codes);
@@ -177,11 +190,14 @@ const TwoFASetting = ({ t }) => {
// 通用复制函数 // 通用复制函数
const copyTextToClipboard = (text, successMessage = t('已复制到剪贴板')) => { const copyTextToClipboard = (text, successMessage = t('已复制到剪贴板')) => {
navigator.clipboard.writeText(text).then(() => { navigator.clipboard
showSuccess(successMessage); .writeText(text)
}).catch(() => { .then(() => {
showError(t('复制失败,请手动复制')); showSuccess(successMessage);
}); })
.catch(() => {
showError(t('复制失败,请手动复制'));
});
}; };
const copyBackupCodes = () => { const copyBackupCodes = () => {
@@ -192,28 +208,25 @@ const TwoFASetting = ({ t }) => {
// 备用码展示组件 // 备用码展示组件
const BackupCodesDisplay = ({ codes, title, onCopy }) => { const BackupCodesDisplay = ({ codes, title, onCopy }) => {
return ( return (
<Card <Card className='!rounded-xl' style={{ width: '100%' }}>
className="!rounded-xl" <div className='space-y-3'>
style={{ width: '100%' }} <div className='flex items-center justify-between'>
> <Text strong className='text-slate-700 dark:text-slate-200'>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Text strong className="text-slate-700 dark:text-slate-200">
{title} {title}
</Text> </Text>
</div> </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) => ( {codes.map((code, index) => (
<div <div key={index} className='rounded-lg p-3'>
key={index} <div className='flex items-center justify-between'>
className="rounded-lg p-3" <Text
> code
<div className="flex items-center justify-between"> className='text-sm font-mono text-slate-700 dark:text-slate-200'
<Text code className="text-sm font-mono text-slate-700 dark:text-slate-200"> >
{code} {code}
</Text> </Text>
<Text type="quaternary" className="text-xs"> <Text type='quaternary' className='text-xs'>
#{(index + 1).toString().padStart(2, '0')} #{(index + 1).toString().padStart(2, '0')}
</Text> </Text>
</div> </div>
@@ -223,11 +236,11 @@ const TwoFASetting = ({ t }) => {
<Divider margin={12} /> <Divider margin={12} />
<Button <Button
type="primary" type='primary'
theme="solid" theme='solid'
icon={<IconCopy />} icon={<IconCopy />}
onClick={onCopy} 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('复制所有代码')} {t('复制所有代码')}
</Button> </Button>
@@ -243,24 +256,24 @@ const TwoFASetting = ({ t }) => {
{currentStep > 0 && ( {currentStep > 0 && (
<Button <Button
onClick={() => setCurrentStep(currentStep - 1)} onClick={() => setCurrentStep(currentStep - 1)}
className="!rounded-lg" className='!rounded-lg'
> >
{t('上一步')} {t('上一步')}
</Button> </Button>
)} )}
{currentStep < 2 ? ( {currentStep < 2 ? (
<Button <Button
type="primary" type='primary'
theme="solid" theme='solid'
onClick={() => setCurrentStep(currentStep + 1)} 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('下一步')} {t('下一步')}
</Button> </Button>
) : ( ) : (
<Button <Button
type="primary" type='primary'
theme="solid" theme='solid'
loading={loading} loading={loading}
onClick={() => { onClick={() => {
if (!verificationCode) { if (!verificationCode) {
@@ -269,7 +282,7 @@ const TwoFASetting = ({ t }) => {
} }
handleEnable2FA(); handleEnable2FA();
}} }}
className="!rounded-lg !bg-slate-600 hover:!bg-slate-700" className='!rounded-lg !bg-slate-600 hover:!bg-slate-700'
> >
{t('完成设置并启用两步验证')} {t('完成设置并启用两步验证')}
</Button> </Button>
@@ -288,17 +301,17 @@ const TwoFASetting = ({ t }) => {
setVerificationCode(''); setVerificationCode('');
setConfirmDisable(false); setConfirmDisable(false);
}} }}
className="!rounded-lg" className='!rounded-lg'
> >
{t('取消')} {t('取消')}
</Button> </Button>
<Button <Button
type="danger" type='danger'
theme="solid" theme='solid'
loading={loading} loading={loading}
disabled={!confirmDisable || !verificationCode} disabled={!confirmDisable || !verificationCode}
onClick={handleDisable2FA} onClick={handleDisable2FA}
className="!rounded-lg !bg-slate-500 hover:!bg-slate-600" className='!rounded-lg !bg-slate-500 hover:!bg-slate-600'
> >
{t('确认禁用')} {t('确认禁用')}
</Button> </Button>
@@ -311,14 +324,14 @@ const TwoFASetting = ({ t }) => {
if (backupCodes.length > 0) { if (backupCodes.length > 0) {
return ( return (
<Button <Button
type="primary" type='primary'
theme="solid" theme='solid'
onClick={() => { onClick={() => {
setBackupModalVisible(false); setBackupModalVisible(false);
setVerificationCode(''); setVerificationCode('');
setBackupCodes([]); setBackupCodes([]);
}} }}
className="!rounded-lg !bg-slate-600 hover:!bg-slate-700" className='!rounded-lg !bg-slate-600 hover:!bg-slate-700'
> >
{t('完成')} {t('完成')}
</Button> </Button>
@@ -333,17 +346,17 @@ const TwoFASetting = ({ t }) => {
setVerificationCode(''); setVerificationCode('');
setBackupCodes([]); setBackupCodes([]);
}} }}
className="!rounded-lg" className='!rounded-lg'
> >
{t('取消')} {t('取消')}
</Button> </Button>
<Button <Button
type="primary" type='primary'
theme="solid" theme='solid'
loading={loading} loading={loading}
disabled={!verificationCode} disabled={!verificationCode}
onClick={handleRegenerateBackupCodes} onClick={handleRegenerateBackupCodes}
className="!rounded-lg !bg-slate-600 hover:!bg-slate-700" className='!rounded-lg !bg-slate-600 hover:!bg-slate-700'
> >
{t('生成新的备用码')} {t('生成新的备用码')}
</Button> </Button>
@@ -353,67 +366,82 @@ const TwoFASetting = ({ t }) => {
return ( return (
<> <>
<Card className="!rounded-xl 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 flex-col sm:flex-row items-start sm:justify-between gap-4'>
<div className="flex items-start w-full sm:w-auto"> <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"> <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" /> <IconShield
size='large'
className='text-slate-600 dark:text-slate-300'
/>
</div> </div>
<div className="flex-1"> <div className='flex-1'>
<div className="flex items-center gap-2 mb-1"> <div className='flex items-center gap-2 mb-1'>
<Typography.Title heading={6} className="mb-0"> <Typography.Title heading={6} className='mb-0'>
{t('两步验证设置')} {t('两步验证设置')}
</Typography.Title> </Typography.Title>
{status.enabled ? ( {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 && ( {status.locked && (
<Tag color="orange" shape="circle" size="small">{t('账户已锁定')}</Tag> <Tag color='orange' shape='circle' size='small'>
{t('账户已锁定')}
</Tag>
)} )}
</div> </div>
<Typography.Text type="tertiary" className="text-sm"> <Typography.Text type='tertiary' className='text-sm'>
{t('两步验证(2FA)为您的账户提供额外的安全保护。启用后,登录时需要输入密码和验证器应用生成的验证码。')} {t(
'两步验证(2FA)为您的账户提供额外的安全保护。启用后,登录时需要输入密码和验证器应用生成的验证码。',
)}
</Typography.Text> </Typography.Text>
{status.enabled && ( {status.enabled && (
<div className="mt-2"> <div className='mt-2'>
<Text size="small" type="secondary">{t('剩余备用码:')}{status.backup_codes_remaining || 0}{t('个')}</Text> <Text size='small' type='secondary'>
{t('剩余备用码:')}
{status.backup_codes_remaining || 0}
{t('个')}
</Text>
</div> </div>
)} )}
</div> </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 ? ( {!status.enabled ? (
<Button <Button
type="primary" type='primary'
theme="solid" theme='solid'
size="default" size='default'
onClick={handleSetup2FA} onClick={handleSetup2FA}
loading={loading} loading={loading}
className="!rounded-lg !bg-slate-600 hover:!bg-slate-700" className='!rounded-lg !bg-slate-600 hover:!bg-slate-700'
icon={<IconShield />} icon={<IconShield />}
> >
{t('启用验证')} {t('启用验证')}
</Button> </Button>
) : ( ) : (
<div className="flex flex-col space-y-2"> <div className='flex flex-col space-y-2'>
<Button <Button
type="danger" type='danger'
theme="solid" theme='solid'
size="default" size='default'
onClick={() => setDisableModalVisible(true)} 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 />} icon={<IconAlertTriangle />}
> >
{t('禁用两步验证')} {t('禁用两步验证')}
</Button> </Button>
<Button <Button
type="primary" type='primary'
theme="solid" theme='solid'
size="default" size='default'
onClick={() => setBackupModalVisible(true)} onClick={() => setBackupModalVisible(true)}
className="!rounded-lg" className='!rounded-lg'
icon={<IconRefresh />} icon={<IconRefresh />}
> >
{t('重新生成备用码')} {t('重新生成备用码')}
@@ -427,8 +455,8 @@ const TwoFASetting = ({ t }) => {
{/* 2FA设置模态框 */} {/* 2FA设置模态框 */}
<Modal <Modal
title={ title={
<div className="flex items-center"> <div className='flex items-center'>
<IconShield className="mr-2 text-slate-600" /> <IconShield className='mr-2 text-slate-600' />
{t('设置两步验证')} {t('设置两步验证')}
</div> </div>
} }
@@ -444,36 +472,50 @@ const TwoFASetting = ({ t }) => {
style={{ maxWidth: '90vw' }} style={{ maxWidth: '90vw' }}
> >
{setupData && ( {setupData && (
<div className="space-y-6"> <div className='space-y-6'>
{/* 步骤进度 */} {/* 步骤进度 */}
<Steps type="basic" size="small" current={currentStep}> <Steps type='basic' size='small' current={currentStep}>
<Steps.Step title={t('扫描二维码')} description={t('使用认证器应用扫描二维码')} /> <Steps.Step
<Steps.Step title={t('保存备用码')} description={t('保存备用码以备不时之需')} /> title={t('扫描二维码')}
<Steps.Step title={t('验证设置')} description={t('输入验证码完成设置')} /> description={t('使用认证器应用扫描二维码')}
/>
<Steps.Step
title={t('保存备用码')}
description={t('保存备用码以备不时之需')}
/>
<Steps.Step
title={t('验证设置')}
description={t('输入验证码完成设置')}
/>
</Steps> </Steps>
{/* 步骤内容 */} {/* 步骤内容 */}
<div className="rounded-xl"> <div className='rounded-xl'>
{currentStep === 0 && ( {currentStep === 0 && (
<div> <div>
<Paragraph className="text-gray-600 dark:text-gray-300 mb-4"> <Paragraph className='text-gray-600 dark:text-gray-300 mb-4'>
{t('使用认证器应用(如 Google Authenticator、Microsoft Authenticator)扫描下方二维码:')} {t(
'使用认证器应用(如 Google Authenticator、Microsoft Authenticator)扫描下方二维码:',
)}
</Paragraph> </Paragraph>
<div className="flex justify-center mb-4"> <div className='flex justify-center mb-4'>
<div className="bg-white p-4 rounded-lg shadow-sm"> <div className='bg-white p-4 rounded-lg shadow-sm'>
<QRCodeSVG value={setupData.qr_code_data} size={180} /> <QRCodeSVG value={setupData.qr_code_data} size={180} />
</div> </div>
</div> </div>
<div className="bg-blue-50 dark:bg-blue-900 rounded-lg p-3"> <div className='bg-blue-50 dark:bg-blue-900 rounded-lg p-3'>
<Text className="text-blue-800 dark:text-blue-200 text-sm"> <Text className='text-blue-800 dark:text-blue-200 text-sm'>
{t('或手动输入密钥:')}<Text code copyable className="ml-2">{setupData.secret}</Text> {t('或手动输入密钥:')}
<Text code copyable className='ml-2'>
{setupData.secret}
</Text>
</Text> </Text>
</div> </div>
</div> </div>
)} )}
{currentStep === 1 && ( {currentStep === 1 && (
<div className="space-y-4"> <div className='space-y-4'>
{/* 备用码展示 */} {/* 备用码展示 */}
<BackupCodesDisplay <BackupCodesDisplay
codes={setupData.backup_codes} codes={setupData.backup_codes}
@@ -491,9 +533,9 @@ const TwoFASetting = ({ t }) => {
placeholder={t('输入认证器应用显示的6位数字验证码')} placeholder={t('输入认证器应用显示的6位数字验证码')}
value={verificationCode} value={verificationCode}
onChange={setVerificationCode} onChange={setVerificationCode}
size="large" size='large'
maxLength={6} maxLength={6}
className="!rounded-lg" className='!rounded-lg'
/> />
)} )}
</div> </div>
@@ -504,8 +546,8 @@ const TwoFASetting = ({ t }) => {
{/* 禁用2FA模态框 */} {/* 禁用2FA模态框 */}
<Modal <Modal
title={ title={
<div className="flex items-center"> <div className='flex items-center'>
<IconAlertTriangle className="mr-2 text-red-500" /> <IconAlertTriangle className='mr-2 text-red-500' />
{t('禁用两步验证')} {t('禁用两步验证')}
</div> </div>
} }
@@ -519,36 +561,41 @@ const TwoFASetting = ({ t }) => {
width={550} width={550}
style={{ maxWidth: '90vw' }} style={{ maxWidth: '90vw' }}
> >
<div className="space-y-6"> <div className='space-y-6'>
{/* 警告提示 */} {/* 警告提示 */}
<div className="rounded-xl"> <div className='rounded-xl'>
<Banner <Banner
type="warning" type='warning'
description={t('警告:禁用两步验证将永久删除您的验证设置和所有备用码,此操作不可撤销!')} description={t(
className="!rounded-lg" '警告:禁用两步验证将永久删除您的验证设置和所有备用码,此操作不可撤销!',
)}
className='!rounded-lg'
/> />
</div> </div>
{/* 内容区域 */} {/* 内容区域 */}
<div className="space-y-4"> <div className='space-y-4'>
<div> <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('禁用后的影响:')} {t('禁用后的影响:')}
</Text> </Text>
<ul className="space-y-2 text-sm text-slate-600 dark:text-slate-300"> <ul className='space-y-2 text-sm text-slate-600 dark:text-slate-300'>
<li className="flex items-start gap-2"> <li className='flex items-start gap-2'>
<Badge dot type='warning' /> <Badge dot type='warning' />
{t('降低您账户的安全性')} {t('降低您账户的安全性')}
</li> </li>
<li className="flex items-start gap-2"> <li className='flex items-start gap-2'>
<Badge dot type='warning' /> <Badge dot type='warning' />
{t('需要重新完整设置才能再次启用')} {t('需要重新完整设置才能再次启用')}
</li> </li>
<li className="flex items-start gap-2"> <li className='flex items-start gap-2'>
<Badge dot type='danger' /> <Badge dot type='danger' />
{t('永久删除您的两步验证设置')} {t('永久删除您的两步验证设置')}
</li> </li>
<li className="flex items-start gap-2"> <li className='flex items-start gap-2'>
<Badge dot type='danger' /> <Badge dot type='danger' />
{t('永久删除所有备用码(包括未使用的)')} {t('永久删除所有备用码(包括未使用的)')}
</li> </li>
@@ -557,17 +604,20 @@ const TwoFASetting = ({ t }) => {
<Divider margin={16} /> <Divider margin={16} />
<div className="space-y-4"> <div className='space-y-4'>
<div> <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('验证身份')} {t('验证身份')}
</Text> </Text>
<Input <Input
placeholder={t('请输入认证器验证码或备用码')} placeholder={t('请输入认证器验证码或备用码')}
value={verificationCode} value={verificationCode}
onChange={setVerificationCode} onChange={setVerificationCode}
size="large" size='large'
className="!rounded-lg" className='!rounded-lg'
/> />
</div> </div>
@@ -575,9 +625,11 @@ const TwoFASetting = ({ t }) => {
<Checkbox <Checkbox
checked={confirmDisable} checked={confirmDisable}
onChange={(e) => setConfirmDisable(e.target.checked)} onChange={(e) => setConfirmDisable(e.target.checked)}
className="text-sm" className='text-sm'
> >
{t('我已了解禁用两步验证将永久删除所有相关设置和备用码,此操作不可撤销')} {t(
'我已了解禁用两步验证将永久删除所有相关设置和备用码,此操作不可撤销',
)}
</Checkbox> </Checkbox>
</div> </div>
</div> </div>
@@ -588,8 +640,8 @@ const TwoFASetting = ({ t }) => {
{/* 重新生成备用码模态框 */} {/* 重新生成备用码模态框 */}
<Modal <Modal
title={ title={
<div className="flex items-center"> <div className='flex items-center'>
<IconRefresh className="mr-2 text-slate-600" /> <IconRefresh className='mr-2 text-slate-600' />
{t('重新生成备用码')} {t('重新生成备用码')}
</div> </div>
} }
@@ -603,30 +655,35 @@ const TwoFASetting = ({ t }) => {
width={500} width={500}
style={{ maxWidth: '90vw' }} style={{ maxWidth: '90vw' }}
> >
<div className="space-y-6"> <div className='space-y-6'>
{backupCodes.length === 0 ? ( {backupCodes.length === 0 ? (
<> <>
{/* 警告提示 */} {/* 警告提示 */}
<div className="rounded-xl"> <div className='rounded-xl'>
<Banner <Banner
type="warning" type='warning'
description={t('重新生成备用码将使现有的备用码失效,请确保您已保存了当前的备用码。')} description={t(
className="!rounded-lg" '重新生成备用码将使现有的备用码失效,请确保您已保存了当前的备用码。',
)}
className='!rounded-lg'
/> />
</div> </div>
{/* 验证区域 */} {/* 验证区域 */}
<div className="space-y-4"> <div className='space-y-4'>
<div> <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('验证身份')} {t('验证身份')}
</Text> </Text>
<Input <Input
placeholder={t('请输入认证器验证码')} placeholder={t('请输入认证器验证码')}
value={verificationCode} value={verificationCode}
onChange={setVerificationCode} onChange={setVerificationCode}
size="large" size='large'
className="!rounded-lg" className='!rounded-lg'
/> />
</div> </div>
</div> </div>
@@ -635,13 +692,16 @@ const TwoFASetting = ({ t }) => {
<> <>
{/* 成功提示 */} {/* 成功提示 */}
<Space vertical style={{ width: '100%' }}> <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' /> <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('新的备用码已生成')} {t('新的备用码已生成')}
</Text> </Text>
</div> </div>
<Text className="text-slate-500 dark:text-slate-400 text-sm"> <Text className='text-slate-500 dark:text-slate-400 text-sm'>
{t('旧的备用码已失效,请保存新的备用码')} {t('旧的备用码已失效,请保存新的备用码')}
</Text> </Text>
@@ -18,12 +18,23 @@ For commercial licensing, please contact support@quantumnous.com
*/ */
import React from 'react'; import React from 'react';
import { Avatar, Card, Tag, Divider, Typography, Badge } from '@douyinfe/semi-ui'; import {
import { isRoot, isAdmin, renderQuota, stringToColor } from '../../../../helpers'; Avatar,
Card,
Tag,
Divider,
Typography,
Badge,
} from '@douyinfe/semi-ui';
import {
isRoot,
isAdmin,
renderQuota,
stringToColor,
} from '../../../../helpers';
import { Coins, BarChart2, Users } from 'lucide-react'; import { Coins, BarChart2, Users } from 'lucide-react';
const UserInfoHeader = ({ t, userState }) => { const UserInfoHeader = ({ t, userState }) => {
const getUsername = () => { const getUsername = () => {
if (userState.user) { if (userState.user) {
return userState.user.username; return userState.user.username;
@@ -42,31 +53,33 @@ const UserInfoHeader = ({ t, userState }) => {
return ( return (
<Card <Card
className="!rounded-2xl overflow-hidden" className='!rounded-2xl overflow-hidden'
cover={ cover={
<div <div
className="relative h-32" className='relative h-32'
style={{ style={{
'--palette-primary-darkerChannel': '0 75 80', '--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')`, backgroundImage: `linear-gradient(0deg, rgba(var(--palette-primary-darkerChannel) / 80%), rgba(var(--palette-primary-darkerChannel) / 80%)), url('/cover-4.webp')`,
backgroundSize: 'cover', backgroundSize: 'cover',
backgroundPosition: 'center', backgroundPosition: 'center',
backgroundRepeat: 'no-repeat' backgroundRepeat: 'no-repeat',
}} }}
> >
{/* 用户信息内容 */} {/* 用户信息内容 */}
<div className="relative z-10 h-full flex flex-col justify-end p-6"> <div className='relative z-10 h-full flex flex-col justify-end p-6'>
<div className="flex items-center"> <div className='flex items-center'>
<div className="flex items-stretch gap-3 sm:gap-4 flex-1 min-w-0"> <div className='flex items-stretch gap-3 sm:gap-4 flex-1 min-w-0'>
<Avatar <Avatar size='large' color={stringToColor(getUsername())}>
size='large'
color={stringToColor(getUsername())}
>
{getAvatarText()} {getAvatarText()}
</Avatar> </Avatar>
<div className="flex-1 min-w-0 flex flex-col justify-between"> <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
<div className="flex flex-wrap items-center gap-2"> className='text-3xl font-bold truncate'
style={{ color: 'white' }}
>
{getUsername()}
</div>
<div className='flex flex-wrap items-center gap-2'>
{isRoot() ? ( {isRoot() ? (
<Tag <Tag
size='large' size='large'
@@ -92,11 +105,7 @@ const UserInfoHeader = ({ t, userState }) => {
{t('普通用户')} {t('普通用户')}
</Tag> </Tag>
)} )}
<Tag <Tag size='large' shape='circle' style={{ color: 'white' }}>
size='large'
shape='circle'
style={{ color: 'white' }}
>
ID: {userState?.user?.id} ID: {userState?.user?.id}
</Tag> </Tag>
</div> </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'> <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)} {renderQuota(userState?.user?.quota)}
</div> </div>
</Badge> </Badge>
{/* 桌面版统计信息(Semi UI 卡片) */} {/* 桌面版统计信息(Semi UI 卡片) */}
<div className="hidden lg:block flex-shrink-0"> <div className='hidden lg:block flex-shrink-0'>
<Card size="small" className="!rounded-xl" bodyStyle={{ padding: '12px 16px' }}> <Card
<div className="flex items-center gap-4"> size='small'
<div className="flex items-center gap-2"> className='!rounded-xl'
bodyStyle={{ padding: '12px 16px' }}
>
<div className='flex items-center gap-4'>
<div className='flex items-center gap-2'>
<Coins size={16} /> <Coins size={16} />
<Typography.Text size="small" type="tertiary">{t('历史消耗')}</Typography.Text> <Typography.Text size='small' type='tertiary'>
<Typography.Text size="small" type="tertiary" strong>{renderQuota(userState?.user?.used_quota)}</Typography.Text> {t('历史消耗')}
</Typography.Text>
<Typography.Text size='small' type='tertiary' strong>
{renderQuota(userState?.user?.used_quota)}
</Typography.Text>
</div> </div>
<Divider layout="vertical" /> <Divider layout='vertical' />
<div className="flex items-center gap-2"> <div className='flex items-center gap-2'>
<BarChart2 size={16} /> <BarChart2 size={16} />
<Typography.Text size="small" type="tertiary">{t('请求次数')}</Typography.Text> <Typography.Text size='small' type='tertiary'>
<Typography.Text size="small" type="tertiary" strong>{userState.user?.request_count || 0}</Typography.Text> {t('请求次数')}
</Typography.Text>
<Typography.Text size='small' type='tertiary' strong>
{userState.user?.request_count || 0}
</Typography.Text>
</div> </div>
<Divider layout="vertical" /> <Divider layout='vertical' />
<div className="flex items-center gap-2"> <div className='flex items-center gap-2'>
<Users size={16} /> <Users size={16} />
<Typography.Text size="small" type="tertiary">{t('用户分组')}</Typography.Text> <Typography.Text size='small' type='tertiary'>
<Typography.Text size="small" type="tertiary" strong>{userState?.user?.group || t('默认')}</Typography.Text> {t('用户分组')}
</Typography.Text>
<Typography.Text size='small' type='tertiary' strong>
{userState?.user?.group || t('默认')}
</Typography.Text>
</div> </div>
</div> </div>
</Card> </Card>
@@ -143,31 +168,47 @@ const UserInfoHeader = ({ t, userState }) => {
</div> </div>
{/* 移动端和中等屏幕统计信息卡片 */} {/* 移动端和中等屏幕统计信息卡片 */}
<div className="lg:hidden mt-2"> <div className='lg:hidden mt-2'>
<Card size="small" className="!rounded-xl" bodyStyle={{ padding: '12px 16px' }} > <Card
<div className="space-y-3"> size='small'
<div className="flex items-center justify-between"> className='!rounded-xl'
<div className="flex items-center gap-2"> 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} /> <Coins size={16} />
<Typography.Text size="small" type="tertiary">{t('历史消耗')}</Typography.Text> <Typography.Text size='small' type='tertiary'>
{t('历史消耗')}
</Typography.Text>
</div> </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> </div>
<Divider margin='8px' /> <Divider margin='8px' />
<div className="flex items-center justify-between"> <div className='flex items-center justify-between'>
<div className="flex items-center gap-2"> <div className='flex items-center gap-2'>
<BarChart2 size={16} /> <BarChart2 size={16} />
<Typography.Text size="small" type="tertiary">{t('请求次数')}</Typography.Text> <Typography.Text size='small' type='tertiary'>
{t('请求次数')}
</Typography.Text>
</div> </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> </div>
<Divider margin='8px' /> <Divider margin='8px' />
<div className="flex items-center justify-between"> <div className='flex items-center justify-between'>
<div className="flex items-center gap-2"> <div className='flex items-center gap-2'>
<Users size={16} /> <Users size={16} />
<Typography.Text size="small" type="tertiary">{t('用户分组')}</Typography.Text> <Typography.Text size='small' type='tertiary'>
{t('用户分组')}
</Typography.Text>
</div> </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>
</div> </div>
</Card> </Card>
@@ -32,13 +32,13 @@ const AccountDeleteModal = ({
userState, userState,
turnstileEnabled, turnstileEnabled,
turnstileSiteKey, turnstileSiteKey,
setTurnstileToken setTurnstileToken,
}) => { }) => {
return ( return (
<Modal <Modal
title={ title={
<div className="flex items-center"> <div className='flex items-center'>
<IconDelete className="mr-2 text-red-500" /> <IconDelete className='mr-2 text-red-500' />
{t('删除账户确认')} {t('删除账户确认')}
</div> </div>
} }
@@ -47,35 +47,37 @@ const AccountDeleteModal = ({
onOk={deleteAccount} onOk={deleteAccount}
size={'small'} size={'small'}
centered={true} centered={true}
className="modern-modal" className='modern-modal'
> >
<div className="space-y-4 py-4"> <div className='space-y-4 py-4'>
<Banner <Banner
type='danger' type='danger'
description={t('您正在删除自己的帐户,将清空所有数据且不可恢复')} description={t('您正在删除自己的帐户,将清空所有数据且不可恢复')}
closeIcon={null} closeIcon={null}
className="!rounded-lg" className='!rounded-lg'
/> />
<div> <div>
<Typography.Text strong className="block mb-2 text-red-600"> <Typography.Text strong className='block mb-2 text-red-600'>
{t('请输入您的用户名以确认删除')} {t('请输入您的用户名以确认删除')}
</Typography.Text> </Typography.Text>
<Input <Input
placeholder={t('输入你的账户名{{username}}以确认删除', { username: ` ${userState?.user?.username} ` })} placeholder={t('输入你的账户名{{username}}以确认删除', {
username: ` ${userState?.user?.username} `,
})}
name='self_account_deletion_confirmation' name='self_account_deletion_confirmation'
value={inputs.self_account_deletion_confirmation} value={inputs.self_account_deletion_confirmation}
onChange={(value) => onChange={(value) =>
handleInputChange('self_account_deletion_confirmation', value) handleInputChange('self_account_deletion_confirmation', value)
} }
size="large" size='large'
className="!rounded-lg" className='!rounded-lg'
prefix={<IconUser />} prefix={<IconUser />}
/> />
</div> </div>
{turnstileEnabled && ( {turnstileEnabled && (
<div className="flex justify-center"> <div className='flex justify-center'>
<Turnstile <Turnstile
sitekey={turnstileSiteKey} sitekey={turnstileSiteKey}
onVerify={(token) => { onVerify={(token) => {
@@ -31,13 +31,13 @@ const ChangePasswordModal = ({
changePassword, changePassword,
turnstileEnabled, turnstileEnabled,
turnstileSiteKey, turnstileSiteKey,
setTurnstileToken setTurnstileToken,
}) => { }) => {
return ( return (
<Modal <Modal
title={ title={
<div className="flex items-center"> <div className='flex items-center'>
<IconLock className="mr-2 text-orange-500" /> <IconLock className='mr-2 text-orange-500' />
{t('修改密码')} {t('修改密码')}
</div> </div>
} }
@@ -46,43 +46,45 @@ const ChangePasswordModal = ({
onOk={changePassword} onOk={changePassword}
size={'small'} size={'small'}
centered={true} centered={true}
className="modern-modal" className='modern-modal'
> >
<div className="space-y-4 py-4"> <div className='space-y-4 py-4'>
<div> <div>
<Typography.Text strong className="block mb-2">{t('原密码')}</Typography.Text> <Typography.Text strong className='block mb-2'>
{t('原密码')}
</Typography.Text>
<Input <Input
name='original_password' name='original_password'
placeholder={t('请输入原密码')} placeholder={t('请输入原密码')}
type='password' type='password'
value={inputs.original_password} value={inputs.original_password}
onChange={(value) => onChange={(value) => handleInputChange('original_password', value)}
handleInputChange('original_password', value) size='large'
} className='!rounded-lg'
size="large"
className="!rounded-lg"
prefix={<IconLock />} prefix={<IconLock />}
/> />
</div> </div>
<div> <div>
<Typography.Text strong className="block mb-2">{t('新密码')}</Typography.Text> <Typography.Text strong className='block mb-2'>
{t('新密码')}
</Typography.Text>
<Input <Input
name='set_new_password' name='set_new_password'
placeholder={t('请输入新密码')} placeholder={t('请输入新密码')}
type='password' type='password'
value={inputs.set_new_password} value={inputs.set_new_password}
onChange={(value) => onChange={(value) => handleInputChange('set_new_password', value)}
handleInputChange('set_new_password', value) size='large'
} className='!rounded-lg'
size="large"
className="!rounded-lg"
prefix={<IconLock />} prefix={<IconLock />}
/> />
</div> </div>
<div> <div>
<Typography.Text strong className="block mb-2">{t('确认新密码')}</Typography.Text> <Typography.Text strong className='block mb-2'>
{t('确认新密码')}
</Typography.Text>
<Input <Input
name='set_new_password_confirmation' name='set_new_password_confirmation'
placeholder={t('请再次输入新密码')} placeholder={t('请再次输入新密码')}
@@ -91,14 +93,14 @@ const ChangePasswordModal = ({
onChange={(value) => onChange={(value) =>
handleInputChange('set_new_password_confirmation', value) handleInputChange('set_new_password_confirmation', value)
} }
size="large" size='large'
className="!rounded-lg" className='!rounded-lg'
prefix={<IconLock />} prefix={<IconLock />}
/> />
</div> </div>
{turnstileEnabled && ( {turnstileEnabled && (
<div className="flex justify-center"> <div className='flex justify-center'>
<Turnstile <Turnstile
sitekey={turnstileSiteKey} sitekey={turnstileSiteKey}
onVerify={(token) => { onVerify={(token) => {
@@ -35,13 +35,13 @@ const EmailBindModal = ({
countdown, countdown,
turnstileEnabled, turnstileEnabled,
turnstileSiteKey, turnstileSiteKey,
setTurnstileToken setTurnstileToken,
}) => { }) => {
return ( return (
<Modal <Modal
title={ title={
<div className="flex items-center"> <div className='flex items-center'>
<IconMail className="mr-2 text-blue-500" /> <IconMail className='mr-2 text-blue-500' />
{t('绑定邮箱地址')} {t('绑定邮箱地址')}
</div> </div>
} }
@@ -51,28 +51,30 @@ const EmailBindModal = ({
size={'small'} size={'small'}
centered={true} centered={true}
maskClosable={false} maskClosable={false}
className="modern-modal" className='modern-modal'
> >
<div className="space-y-4 py-4"> <div className='space-y-4 py-4'>
<div className="flex gap-3"> <div className='flex gap-3'>
<Input <Input
placeholder={t('输入邮箱地址')} placeholder={t('输入邮箱地址')}
onChange={(value) => handleInputChange('email', value)} onChange={(value) => handleInputChange('email', value)}
name='email' name='email'
type='email' type='email'
size="large" size='large'
className="!rounded-lg flex-1" className='!rounded-lg flex-1'
prefix={<IconMail />} prefix={<IconMail />}
/> />
<Button <Button
onClick={sendVerificationCode} onClick={sendVerificationCode}
disabled={disableButton || loading} disabled={disableButton || loading}
className="!rounded-lg" className='!rounded-lg'
type="primary" type='primary'
theme="outline" theme='outline'
size='large' size='large'
> >
{disableButton ? `${t('重新发送')} (${countdown})` : t('获取验证码')} {disableButton
? `${t('重新发送')} (${countdown})`
: t('获取验证码')}
</Button> </Button>
</div> </div>
@@ -83,13 +85,13 @@ const EmailBindModal = ({
onChange={(value) => onChange={(value) =>
handleInputChange('email_verification_code', value) handleInputChange('email_verification_code', value)
} }
size="large" size='large'
className="!rounded-lg" className='!rounded-lg'
prefix={<IconKey />} prefix={<IconKey />}
/> />
{turnstileEnabled && ( {turnstileEnabled && (
<div className="flex justify-center"> <div className='flex justify-center'>
<Turnstile <Turnstile
sitekey={turnstileSiteKey} sitekey={turnstileSiteKey}
onVerify={(token) => { onVerify={(token) => {
@@ -29,13 +29,13 @@ const WeChatBindModal = ({
inputs, inputs,
handleInputChange, handleInputChange,
bindWeChat, bindWeChat,
status status,
}) => { }) => {
return ( return (
<Modal <Modal
title={ title={
<div className="flex items-center"> <div className='flex items-center'>
<SiWechat className="mr-2 text-green-500" size={20} /> <SiWechat className='mr-2 text-green-500' size={20} />
{t('绑定微信账户')} {t('绑定微信账户')}
</div> </div>
} }
@@ -44,30 +44,30 @@ const WeChatBindModal = ({
footer={null} footer={null}
size={'small'} size={'small'}
centered={true} centered={true}
className="modern-modal" className='modern-modal'
> >
<div className="space-y-4 py-4 text-center"> <div className='space-y-4 py-4 text-center'>
<Image src={status.wechat_qrcode} className="mx-auto" /> <Image src={status.wechat_qrcode} className='mx-auto' />
<div className="text-gray-600"> <div className='text-gray-600'>
<p>{t('微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)')}</p> <p>
{t('微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)')}
</p>
</div> </div>
<Input <Input
placeholder={t('验证码')} placeholder={t('验证码')}
name='wechat_verification_code' name='wechat_verification_code'
value={inputs.wechat_verification_code} value={inputs.wechat_verification_code}
onChange={(v) => onChange={(v) => handleInputChange('wechat_verification_code', v)}
handleInputChange('wechat_verification_code', v) size='large'
} className='!rounded-lg'
size="large"
className="!rounded-lg"
prefix={<IconKey />} prefix={<IconKey />}
/> />
<Button <Button
type="primary" type='primary'
theme="solid" theme='solid'
size='large' size='large'
onClick={bindWeChat} 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} />} icon={<SiWechat size={16} />}
> >
{t('绑定')} {t('绑定')}
+24 -22
View File
@@ -130,7 +130,11 @@ const SetupWizard = () => {
return true; // 如果已经初始化,可以继续 return true; // 如果已经初始化,可以继续
} }
// 检查必填字段 // 检查必填字段
if (!formData.username || !formData.password || !formData.confirmPassword) { if (
!formData.username ||
!formData.password ||
!formData.confirmPassword
) {
showError(t('请填写完整的管理员账号信息')); showError(t('请填写完整的管理员账号信息'));
return false; return false;
} }
@@ -226,12 +230,7 @@ const SetupWizard = () => {
const getStepContent = (step) => { const getStepContent = (step) => {
switch (step) { switch (step) {
case 0: case 0:
return ( return <DatabaseStep setupStatus={setupStatus} t={t} />;
<DatabaseStep
setupStatus={setupStatus}
t={t}
/>
);
case 1: case 1:
return ( return (
<AdminStep <AdminStep
@@ -252,11 +251,7 @@ const SetupWizard = () => {
); );
case 3: case 3:
return ( return (
<CompleteStep <CompleteStep setupStatus={setupStatus} formData={formData} t={t} />
setupStatus={setupStatus}
formData={formData}
t={t}
/>
); );
default: default:
return null; return null;
@@ -275,21 +270,25 @@ const SetupWizard = () => {
return ( return (
<div className='min-h-screen flex items-center justify-center px-4'> <div className='min-h-screen flex items-center justify-center px-4'>
<div className="w-full max-w-4xl"> <div className='w-full max-w-4xl'>
<Card className="!rounded-2xl shadow-sm border-0"> <Card className='!rounded-2xl shadow-sm border-0'>
<div className="mb-4"> <div className='mb-4'>
<div className="text-xl font-semibold">{t('系统初始化')}</div> <div className='text-xl font-semibold'>{t('系统初始化')}</div>
<div className="text-xs text-gray-600"> <div className='text-xs text-gray-600'>
{t('欢迎使用,请完成以下设置以开始使用系统')} {t('欢迎使用,请完成以下设置以开始使用系统')}
</div> </div>
</div> </div>
<div className="px-2 py-2"> <div className='px-2 py-2'>
<Steps type="basic" current={currentStep}> <Steps type='basic' current={currentStep}>
{steps.map((item, index) => ( {steps.map((item, index) => (
<Steps.Step <Steps.Step
key={item.title} 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} description={item.description}
/> />
))} ))}
@@ -306,9 +305,12 @@ const SetupWizard = () => {
initValues={formData} initValues={formData}
> >
{/* 步骤内容:保持所有字段挂载,仅隐藏非当前步骤 */} {/* 步骤内容:保持所有字段挂载,仅隐藏非当前步骤 */}
<div className="steps-content"> <div className='steps-content'>
{[0, 1, 2, 3].map((idx) => ( {[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), { {React.cloneElement(getStepContent(idx), {
...stepNavigationProps, ...stepNavigationProps,
renderNavigationButtons: () => ( renderNavigationButtons: () => (
@@ -32,29 +32,22 @@ const StepNavigation = ({
next, next,
onSubmit, onSubmit,
loading, loading,
t t,
}) => { }) => {
return ( return (
<div className="flex justify-between items-center pt-4"> <div className='flex justify-between items-center pt-4'>
{/* 上一步按钮 */} {/* 上一步按钮 */}
{currentStep > 0 && ( {currentStep > 0 && (
<Button <Button onClick={prev} className='!rounded-lg'>
onClick={prev}
className="!rounded-lg"
>
{t('上一步')} {t('上一步')}
</Button> </Button>
)} )}
<div className="flex-1"></div> <div className='flex-1'></div>
{/* 下一步按钮 */} {/* 下一步按钮 */}
{currentStep < steps.length - 1 && ( {currentStep < steps.length - 1 && (
<Button <Button type='primary' onClick={next} className='!rounded-lg'>
type="primary"
onClick={next}
className="!rounded-lg"
>
{t('下一步')} {t('下一步')}
</Button> </Button>
)} )}
@@ -62,10 +55,10 @@ const StepNavigation = ({
{/* 完成按钮 */} {/* 完成按钮 */}
{currentStep === steps.length - 1 && ( {currentStep === steps.length - 1 && (
<Button <Button
type="primary" type='primary'
onClick={onSubmit} onClick={onSubmit}
loading={loading} loading={loading}
className="!rounded-lg" className='!rounded-lg'
icon={<IconCheckCircleStroked />} icon={<IconCheckCircleStroked />}
> >
{t('初始化系统')} {t('初始化系统')}
@@ -31,7 +31,7 @@ const AdminStep = ({
setFormData, setFormData,
formRef, formRef,
renderNavigationButtons, renderNavigationButtons,
t t,
}) => { }) => {
return ( return (
<> <>
@@ -40,11 +40,11 @@ const AdminStep = ({
type='info' type='info'
closeIcon={null} closeIcon={null}
description={ description={
<div className="flex items-center"> <div className='flex items-center'>
<span>{t('管理员账号已经初始化过,请继续设置其他参数')}</span> <span>{t('管理员账号已经初始化过,请继续设置其他参数')}</span>
</div> </div>
} }
className="!rounded-lg" className='!rounded-lg'
/> />
) : ( ) : (
<> <>
@@ -55,7 +55,7 @@ const AdminStep = ({
prefix={<IconUser />} prefix={<IconUser />}
showClear showClear
noLabel={false} noLabel={false}
validateStatus="default" validateStatus='default'
rules={[{ required: true, message: t('请输入管理员用户名') }]} rules={[{ required: true, message: t('请输入管理员用户名') }]}
initValue={formData.username || ''} initValue={formData.username || ''}
onChange={(value) => { onChange={(value) => {
@@ -70,11 +70,11 @@ const AdminStep = ({
prefix={<IconLock />} prefix={<IconLock />}
showClear showClear
noLabel={false} noLabel={false}
mode="password" mode='password'
validateStatus="default" validateStatus='default'
rules={[ rules={[
{ required: true, message: t('请输入管理员密码') }, { required: true, message: t('请输入管理员密码') },
{ min: 8, message: t('密码长度至少为8个字符') } { min: 8, message: t('密码长度至少为8个字符') },
]} ]}
initValue={formData.password || ''} initValue={formData.password || ''}
onChange={(value) => { onChange={(value) => {
@@ -89,8 +89,8 @@ const AdminStep = ({
prefix={<IconLock />} prefix={<IconLock />}
showClear showClear
noLabel={false} noLabel={false}
mode="password" mode='password'
validateStatus="default" validateStatus='default'
rules={[ rules={[
{ required: true, message: t('请确认管理员密码') }, { required: true, message: t('请确认管理员密码') },
{ {
@@ -102,8 +102,8 @@ const AdminStep = ({
} }
} }
return Promise.resolve(); return Promise.resolve();
} },
} },
]} ]}
initValue={formData.confirmPassword || ''} initValue={formData.confirmPassword || ''}
onChange={(value) => { onChange={(value) => {
@@ -31,29 +31,39 @@ const CompleteStep = ({
setupStatus, setupStatus,
formData, formData,
renderNavigationButtons, renderNavigationButtons,
t t,
}) => { }) => {
return ( return (
<div className="text-center"> <div className='text-center'>
<Avatar color="green" className="mx-auto mb-4 shadow-lg"> <Avatar color='green' className='mx-auto mb-4 shadow-lg'>
<CheckCircle size={24} /> <CheckCircle size={24} />
</Avatar> </Avatar>
<Title heading={3} className="mb-2">{t('准备完成初始化')}</Title> <Title heading={3} className='mb-2'>
<Text type="secondary" className="mb-6 block"> {t('准备完成初始化')}
</Title>
<Text type='secondary' className='mb-6 block'>
{t('请确认以下设置信息,点击"初始化系统"开始配置')} {t('请确认以下设置信息,点击"初始化系统"开始配置')}
</Text> </Text>
<Descriptions> <Descriptions>
<Descriptions.Item itemKey={t('数据库类型')}> <Descriptions.Item itemKey={t('数据库类型')}>
{setupStatus.database_type === 'sqlite' ? 'SQLite' : {setupStatus.database_type === 'sqlite'
setupStatus.database_type === 'mysql' ? 'MySQL' : 'PostgreSQL'} ? 'SQLite'
: setupStatus.database_type === 'mysql'
? 'MySQL'
: 'PostgreSQL'}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item itemKey={t('管理员账号')}> <Descriptions.Item itemKey={t('管理员账号')}>
{setupStatus.root_init ? t('已初始化') : (formData.username || t('未设置'))} {setupStatus.root_init
? t('已初始化')
: formData.username || t('未设置')}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item itemKey={t('使用模式')}> <Descriptions.Item itemKey={t('使用模式')}>
{formData.usageMode === 'external' ? t('对外运营模式') : {formData.usageMode === 'external'
formData.usageMode === 'self' ? t('自用模式') : t('演示站点模式')} ? t('对外运营模式')
: formData.usageMode === 'self'
? t('自用模式')
: t('演示站点模式')}
</Descriptions.Item> </Descriptions.Item>
</Descriptions> </Descriptions>
@@ -40,14 +40,16 @@ const DatabaseStep = ({ setupStatus, renderNavigationButtons, t }) => {
'您正在使用 SQLite 数据库。如果您在容器环境中运行,请确保已正确设置数据库文件的持久化映射,否则容器重启后所有数据将丢失!', '您正在使用 SQLite 数据库。如果您在容器环境中运行,请确保已正确设置数据库文件的持久化映射,否则容器重启后所有数据将丢失!',
)} )}
</p> </p>
<p className="mt-1"> <p className='mt-1'>
<strong>{t( <strong>
'建议在生产环境中使用 MySQL 或 PostgreSQL 数据库,或确保 SQLite 数据库文件已映射到宿主机的持久化存储。', {t(
)}</strong> '建议在生产环境中使用 MySQL 或 PostgreSQL 数据库,或确保 SQLite 数据库文件已映射到宿主机的持久化存储。',
)}
</strong>
</p> </p>
</div> </div>
} }
className="!rounded-lg" className='!rounded-lg'
fullMode={false} fullMode={false}
bordered bordered
/> />
@@ -68,7 +70,7 @@ const DatabaseStep = ({ setupStatus, renderNavigationButtons, t }) => {
</p> </p>
</div> </div>
} }
className="!rounded-lg" className='!rounded-lg'
fullMode={false} fullMode={false}
bordered bordered
/> />
@@ -89,7 +91,7 @@ const DatabaseStep = ({ setupStatus, renderNavigationButtons, t }) => {
</p> </p>
</div> </div>
} }
className="!rounded-lg" className='!rounded-lg'
fullMode={false} fullMode={false}
bordered bordered
/> />
@@ -28,7 +28,7 @@ const UsageModeStep = ({
formData, formData,
handleUsageModeChange, handleUsageModeChange,
renderNavigationButtons, renderNavigationButtons,
t t,
}) => { }) => {
return ( return (
<> <>
@@ -37,9 +37,9 @@ const UsageModeStep = ({
onChange={handleUsageModeChange} onChange={handleUsageModeChange}
type='card' type='card'
direction='horizontal' direction='horizontal'
className="mt-4" className='mt-4'
aria-label="使用模式选择" aria-label='使用模式选择'
name="usage-mode-selection" name='usage-mode-selection'
> >
<Radio <Radio
value='external' value='external'
@@ -24,7 +24,7 @@ import {
Modal, Modal,
Switch, Switch,
Typography, Typography,
Select Select,
} from '@douyinfe/semi-ui'; } from '@douyinfe/semi-ui';
import CompactModeToggle from '../../common/ui/CompactModeToggle'; import CompactModeToggle from '../../common/ui/CompactModeToggle';
@@ -52,19 +52,19 @@ const ChannelsActions = ({
activePage, activePage,
pageSize, pageSize,
setActivePage, setActivePage,
t t,
}) => { }) => {
return ( 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 <Button
size='small' size='small'
disabled={!enableBatchDelete} disabled={!enableBatchDelete}
type='danger' type='danger'
className="w-full md:w-auto" className='w-full md:w-auto'
onClick={() => { onClick={() => {
Modal.confirm({ Modal.confirm({
title: t('确定是否要删除所选通道?'), title: t('确定是否要删除所选通道?'),
@@ -81,7 +81,7 @@ const ChannelsActions = ({
disabled={!enableBatchDelete} disabled={!enableBatchDelete}
type='tertiary' type='tertiary'
onClick={() => setShowBatchSetTag(true)} onClick={() => setShowBatchSetTag(true)}
className="w-full md:w-auto" className='w-full md:w-auto'
> >
{t('批量设置标签')} {t('批量设置标签')}
</Button> </Button>
@@ -95,7 +95,7 @@ const ChannelsActions = ({
<Button <Button
size='small' size='small'
type='tertiary' type='tertiary'
className="w-full" className='w-full'
onClick={() => { onClick={() => {
Modal.confirm({ Modal.confirm({
title: t('确定?'), title: t('确定?'),
@@ -112,11 +112,13 @@ const ChannelsActions = ({
<Dropdown.Item> <Dropdown.Item>
<Button <Button
size='small' size='small'
className="w-full" className='w-full'
onClick={() => { onClick={() => {
Modal.confirm({ Modal.confirm({
title: t('确定是否要修复数据库一致性?'), title: t('确定是否要修复数据库一致性?'),
content: t('进行该操作时,可能导致渠道访问错误,请仅在数据库出现问题时使用'), content: t(
'进行该操作时,可能导致渠道访问错误,请仅在数据库出现问题时使用',
),
onOk: () => fixChannelsAbilities(), onOk: () => fixChannelsAbilities(),
size: 'sm', size: 'sm',
centered: true, centered: true,
@@ -130,7 +132,7 @@ const ChannelsActions = ({
<Button <Button
size='small' size='small'
type='secondary' type='secondary'
className="w-full" className='w-full'
onClick={() => { onClick={() => {
Modal.confirm({ Modal.confirm({
title: t('确定?'), title: t('确定?'),
@@ -148,7 +150,7 @@ const ChannelsActions = ({
<Button <Button
size='small' size='small'
type='danger' type='danger'
className="w-full" className='w-full'
onClick={() => { onClick={() => {
Modal.confirm({ Modal.confirm({
title: t('确定是否要删除禁用通道?'), title: t('确定是否要删除禁用通道?'),
@@ -165,7 +167,12 @@ const ChannelsActions = ({
</Dropdown.Menu> </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('批量操作')} {t('批量操作')}
</Button> </Button>
</Dropdown> </Dropdown>
@@ -178,9 +185,9 @@ const ChannelsActions = ({
</div> </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 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"> <div className='flex items-center justify-between w-full md:w-auto'>
<Typography.Text strong className="mr-2"> <Typography.Text strong className='mr-2'>
{t('使用ID排序')} {t('使用ID排序')}
</Typography.Text> </Typography.Text>
<Switch <Switch
@@ -189,18 +196,30 @@ const ChannelsActions = ({
onChange={(v) => { onChange={(v) => {
localStorage.setItem('id-sort', v + ''); localStorage.setItem('id-sort', v + '');
setIdSort(v); setIdSort(v);
const { searchKeyword, searchGroup, searchModel } = getFormValues(); const { searchKeyword, searchGroup, searchModel } =
if (searchKeyword === '' && searchGroup === '' && searchModel === '') { getFormValues();
if (
searchKeyword === '' &&
searchGroup === '' &&
searchModel === ''
) {
loadChannels(activePage, pageSize, v, enableTagMode); loadChannels(activePage, pageSize, v, enableTagMode);
} else { } else {
searchChannels(enableTagMode, activeTypeKey, statusFilter, activePage, pageSize, v); searchChannels(
enableTagMode,
activeTypeKey,
statusFilter,
activePage,
pageSize,
v,
);
} }
}} }}
/> />
</div> </div>
<div className="flex items-center justify-between w-full md:w-auto"> <div className='flex items-center justify-between w-full md:w-auto'>
<Typography.Text strong className="mr-2"> <Typography.Text strong className='mr-2'>
{t('开启批量操作')} {t('开启批量操作')}
</Typography.Text> </Typography.Text>
<Switch <Switch
@@ -213,8 +232,8 @@ const ChannelsActions = ({
/> />
</div> </div>
<div className="flex items-center justify-between w-full md:w-auto"> <div className='flex items-center justify-between w-full md:w-auto'>
<Typography.Text strong className="mr-2"> <Typography.Text strong className='mr-2'>
{t('标签聚合模式')} {t('标签聚合模式')}
</Typography.Text> </Typography.Text>
<Switch <Switch
@@ -229,8 +248,8 @@ const ChannelsActions = ({
/> />
</div> </div>
<div className="flex items-center justify-between w-full md:w-auto"> <div className='flex items-center justify-between w-full md:w-auto'>
<Typography.Text strong className="mr-2"> <Typography.Text strong className='mr-2'>
{t('状态筛选')} {t('状态筛选')}
</Typography.Text> </Typography.Text>
<Select <Select
@@ -240,12 +259,19 @@ const ChannelsActions = ({
localStorage.setItem('channel-status-filter', v); localStorage.setItem('channel-status-filter', v);
setStatusFilter(v); setStatusFilter(v);
setActivePage(1); 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='all'>{t('全部')}</Select.Option>
<Select.Option value="enabled">{t('已启用')}</Select.Option> <Select.Option value='enabled'>{t('已启用')}</Select.Option>
<Select.Option value="disabled">{t('已禁用')}</Select.Option> <Select.Option value='disabled'>{t('已禁用')}</Select.Option>
</Select> </Select>
</div> </div>
</div> </div>
@@ -27,14 +27,14 @@ import {
SplitButtonGroup, SplitButtonGroup,
Tag, Tag,
Tooltip, Tooltip,
Typography Typography,
} from '@douyinfe/semi-ui'; } from '@douyinfe/semi-ui';
import { import {
timestamp2string, timestamp2string,
renderGroup, renderGroup,
renderQuota, renderQuota,
getChannelIcon, getChannelIcon,
renderQuotaWithAmount renderQuotaWithAmount,
} from '../../../helpers'; } from '../../../helpers';
import { CHANNEL_OPTIONS } from '../../../constants'; import { CHANNEL_OPTIONS } from '../../../constants';
import { IconTreeTriangleDown, IconMore } from '@douyinfe/semi-icons'; import { IconTreeTriangleDown, IconMore } from '@douyinfe/semi-icons';
@@ -51,27 +51,22 @@ const renderType = (type, channelInfo = undefined, t) => {
let icon = getChannelIcon(type); let icon = getChannelIcon(type);
if (channelInfo?.is_multi_key) { if (channelInfo?.is_multi_key) {
icon = ( icon =
channelInfo?.multi_key_mode === 'random' ? ( channelInfo?.multi_key_mode === 'random' ? (
<div className="flex items-center gap-1"> <div className='flex items-center gap-1'>
<FaRandom className="text-blue-500" /> <FaRandom className='text-blue-500' />
{icon} {icon}
</div> </div>
) : ( ) : (
<div className="flex items-center gap-1"> <div className='flex items-center gap-1'>
<IconTreeTriangleDown className="text-blue-500" /> <IconTreeTriangleDown className='text-blue-500' />
{icon} {icon}
</div> </div>
) );
)
} }
return ( return (
<Tag <Tag color={type2label[type]?.color} shape='circle' prefixIcon={icon}>
color={type2label[type]?.color}
shape='circle'
prefixIcon={icon}
>
{type2label[type]?.label} {type2label[type]?.label}
</Tag> </Tag>
); );
@@ -79,11 +74,7 @@ const renderType = (type, channelInfo = undefined, t) => {
const renderTagType = (t) => { const renderTagType = (t) => {
return ( return (
<Tag <Tag color='light-blue' shape='circle' type='light'>
color='light-blue'
shape='circle'
type='light'
>
{t('标签聚合')} {t('标签聚合')}
</Tag> </Tag>
); );
@@ -95,7 +86,8 @@ const renderStatus = (status, channelInfo = undefined, t) => {
let keySize = channelInfo.multi_key_size; let keySize = channelInfo.multi_key_size;
let enabledKeySize = keySize; let enabledKeySize = keySize;
if (channelInfo.multi_key_status_list) { 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); return renderMultiKeyStatus(status, keySize, enabledKeySize, t);
} }
@@ -155,7 +147,7 @@ const renderMultiKeyStatus = (status, keySize, enabledKeySize, t) => {
</Tag> </Tag>
); );
} }
} };
const renderResponseTime = (responseTime, t) => { const renderResponseTime = (responseTime, t) => {
let time = responseTime / 1000; let time = responseTime / 1000;
@@ -212,7 +204,7 @@ export const getChannelsColumns = ({
activePage, activePage,
channels, channels,
setShowMultiKeyManageModal, setShowMultiKeyManageModal,
setCurrentMultiKeyChannel setCurrentMultiKeyChannel,
}) => { }) => {
return [ return [
{ {
@@ -276,7 +268,9 @@ export const getChannelsColumns = ({
return ( return (
<div> <div>
<Tooltip <Tooltip
content={t('原因:') + reason + t(',时间:') + timestamp2string(time)} content={
t('原因:') + reason + t(',时间:') + timestamp2string(time)
}
> >
{renderStatus(text, record.channel_info, t)} {renderStatus(text, record.channel_info, t)}
</Tooltip> </Tooltip>
@@ -291,9 +285,7 @@ export const getChannelsColumns = ({
key: COLUMN_KEYS.RESPONSE_TIME, key: COLUMN_KEYS.RESPONSE_TIME,
title: t('响应时间'), title: t('响应时间'),
dataIndex: 'response_time', dataIndex: 'response_time',
render: (text, record, index) => ( render: (text, record, index) => <div>{renderResponseTime(text, t)}</div>,
<div>{renderResponseTime(text, t)}</div>
),
}, },
{ {
key: COLUMN_KEYS.BALANCE, key: COLUMN_KEYS.BALANCE,
@@ -309,7 +301,9 @@ export const getChannelsColumns = ({
{renderQuota(record.used_quota)} {renderQuota(record.used_quota)}
</Tag> </Tag>
</Tooltip> </Tooltip>
<Tooltip content={t('剩余额度$') + record.balance + t(',点击更新')}> <Tooltip
content={t('剩余额度$') + record.balance + t(',点击更新')}
>
<Tag <Tag
color='white' color='white'
type='ghost' type='ghost'
@@ -351,7 +345,7 @@ export const getChannelsColumns = ({
innerButtons innerButtons
defaultValue={record.priority} defaultValue={record.priority}
min={-999} min={-999}
size="small" size='small'
/> />
</div> </div>
); );
@@ -364,7 +358,10 @@ export const getChannelsColumns = ({
onBlur={(e) => { onBlur={(e) => {
Modal.warning({ Modal.warning({
title: t('修改子渠道优先级'), title: t('修改子渠道优先级'),
content: t('确定要修改所有子渠道优先级为 ') + e.target.value + t(' 吗?'), content:
t('确定要修改所有子渠道优先级为 ') +
e.target.value +
t(' 吗?'),
onOk: () => { onOk: () => {
if (e.target.value === '') { if (e.target.value === '') {
return; return;
@@ -379,7 +376,7 @@ export const getChannelsColumns = ({
innerButtons innerButtons
defaultValue={record.priority} defaultValue={record.priority}
min={-999} min={-999}
size="small" size='small'
/> />
); );
} }
@@ -403,7 +400,7 @@ export const getChannelsColumns = ({
innerButtons innerButtons
defaultValue={record.weight} defaultValue={record.weight}
min={0} min={0}
size="small" size='small'
/> />
</div> </div>
); );
@@ -416,7 +413,10 @@ export const getChannelsColumns = ({
onBlur={(e) => { onBlur={(e) => {
Modal.warning({ Modal.warning({
title: t('修改子渠道权重'), title: t('修改子渠道权重'),
content: t('确定要修改所有子渠道权重为 ') + e.target.value + t(' 吗?'), content:
t('确定要修改所有子渠道权重为 ') +
e.target.value +
t(' 吗?'),
onOk: () => { onOk: () => {
if (e.target.value === '') { if (e.target.value === '') {
return; return;
@@ -431,7 +431,7 @@ export const getChannelsColumns = ({
innerButtons innerButtons
defaultValue={record.weight} defaultValue={record.weight}
min={-999} min={-999}
size="small" size='small'
/> />
); );
} }
@@ -484,18 +484,18 @@ export const getChannelsColumns = ({
return ( return (
<Space wrap> <Space wrap>
<SplitButtonGroup <SplitButtonGroup
className="overflow-hidden" className='overflow-hidden'
aria-label={t('测试单个渠道操作项目组')} aria-label={t('测试单个渠道操作项目组')}
> >
<Button <Button
size="small" size='small'
type='tertiary' type='tertiary'
onClick={() => testChannel(record, '')} onClick={() => testChannel(record, '')}
> >
{t('测试')} {t('测试')}
</Button> </Button>
<Button <Button
size="small" size='small'
type='tertiary' type='tertiary'
icon={<IconTreeTriangleDown />} icon={<IconTreeTriangleDown />}
onClick={() => { onClick={() => {
@@ -505,32 +505,28 @@ export const getChannelsColumns = ({
/> />
</SplitButtonGroup> </SplitButtonGroup>
{ {record.status === 1 ? (
record.status === 1 ? ( <Button
<Button type='danger'
type='danger' size='small'
size="small" onClick={() => manageChannel(record.id, 'disable', record)}
onClick={() => manageChannel(record.id, 'disable', record)} >
> {t('禁用')}
{t('禁用')} </Button>
</Button> ) : (
) : ( <Button
<Button size='small'
size="small" onClick={() => manageChannel(record.id, 'enable', record)}
onClick={() => manageChannel(record.id, 'enable', record)} >
> {t('启用')}
{t('启用')} </Button>
</Button> )}
)
}
{record.channel_info?.is_multi_key ? ( {record.channel_info?.is_multi_key ? (
<SplitButtonGroup <SplitButtonGroup aria-label={t('多密钥渠道操作项目组')}>
aria-label={t('多密钥渠道操作项目组')}
>
<Button <Button
type='tertiary' type='tertiary'
size="small" size='small'
onClick={() => { onClick={() => {
setEditingChannel(record); setEditingChannel(record);
setShowEdit(true); setShowEdit(true);
@@ -549,12 +545,12 @@ export const getChannelsColumns = ({
setCurrentMultiKeyChannel(record); setCurrentMultiKeyChannel(record);
setShowMultiKeyManageModal(true); setShowMultiKeyManageModal(true);
}, },
} },
]} ]}
> >
<Button <Button
type='tertiary' type='tertiary'
size="small" size='small'
icon={<IconTreeTriangleDown />} icon={<IconTreeTriangleDown />}
/> />
</Dropdown> </Dropdown>
@@ -562,7 +558,7 @@ export const getChannelsColumns = ({
) : ( ) : (
<Button <Button
type='tertiary' type='tertiary'
size="small" size='small'
onClick={() => { onClick={() => {
setEditingChannel(record); setEditingChannel(record);
setShowEdit(true); setShowEdit(true);
@@ -577,11 +573,7 @@ export const getChannelsColumns = ({
position='bottomRight' position='bottomRight'
menu={moreMenuItems} menu={moreMenuItems}
> >
<Button <Button icon={<IconMore />} type='tertiary' size='small' />
icon={<IconMore />}
type='tertiary'
size="small"
/>
</Dropdown> </Dropdown>
</Space> </Space>
); );
@@ -591,21 +583,21 @@ export const getChannelsColumns = ({
<Space wrap> <Space wrap>
<Button <Button
type='tertiary' type='tertiary'
size="small" size='small'
onClick={() => manageTag(record.key, 'enable')} onClick={() => manageTag(record.key, 'enable')}
> >
{t('启用全部')} {t('启用全部')}
</Button> </Button>
<Button <Button
type='tertiary' type='tertiary'
size="small" size='small'
onClick={() => manageTag(record.key, 'disable')} onClick={() => manageTag(record.key, 'disable')}
> >
{t('禁用全部')} {t('禁用全部')}
</Button> </Button>
<Button <Button
type='tertiary' type='tertiary'
size="small" size='small'
onClick={() => { onClick={() => {
setShowEditTag(true); setShowEditTag(true);
setEditingTag(record.key); setEditingTag(record.key);
@@ -34,16 +34,16 @@ const ChannelsFilters = ({
groupOptions, groupOptions,
loading, loading,
searching, searching,
t t,
}) => { }) => {
return ( return (
<div className="flex flex-col md:flex-row justify-between items-center gap-2 w-full"> <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 gap-2 w-full md:w-auto order-2 md:order-1'>
<Button <Button
size='small' size='small'
theme='light' theme='light'
type='primary' type='primary'
className="w-full md:w-auto" className='w-full md:w-auto'
onClick={() => { onClick={() => {
setEditingChannel({ setEditingChannel({
id: undefined, id: undefined,
@@ -57,7 +57,7 @@ const ChannelsFilters = ({
<Button <Button
size='small' size='small'
type='tertiary' type='tertiary'
className="w-full md:w-auto" className='w-full md:w-auto'
onClick={refresh} onClick={refresh}
> >
{t('刷新')} {t('刷新')}
@@ -67,54 +67,54 @@ const ChannelsFilters = ({
size='small' size='small'
type='tertiary' type='tertiary'
onClick={() => setShowColumnSelector(true)} onClick={() => setShowColumnSelector(true)}
className="w-full md:w-auto" className='w-full md:w-auto'
> >
{t('列设置')} {t('列设置')}
</Button> </Button>
</div> </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 <Form
initValues={formInitValues} initValues={formInitValues}
getFormApi={(api) => setFormApi(api)} getFormApi={(api) => setFormApi(api)}
onSubmit={() => searchChannels(enableTagMode)} onSubmit={() => searchChannels(enableTagMode)}
allowEmpty={true} allowEmpty={true}
autoComplete="off" autoComplete='off'
layout="horizontal" layout='horizontal'
trigger="change" trigger='change'
stopValidateWithError={false} 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 <Form.Input
size='small' size='small'
field="searchKeyword" field='searchKeyword'
prefix={<IconSearch />} prefix={<IconSearch />}
placeholder={t('渠道ID,名称,密钥,API地址')} placeholder={t('渠道ID,名称,密钥,API地址')}
showClear showClear
pure pure
/> />
</div> </div>
<div className="w-full md:w-48"> <div className='w-full md:w-48'>
<Form.Input <Form.Input
size='small' size='small'
field="searchModel" field='searchModel'
prefix={<IconSearch />} prefix={<IconSearch />}
placeholder={t('模型关键字')} placeholder={t('模型关键字')}
showClear showClear
pure pure
/> />
</div> </div>
<div className="w-full md:w-32"> <div className='w-full md:w-32'>
<Form.Select <Form.Select
size='small' size='small'
field="searchGroup" field='searchGroup'
placeholder={t('选择分组')} placeholder={t('选择分组')}
optionList={[ optionList={[
{ label: t('选择分组'), value: null }, { label: t('选择分组'), value: null },
...groupOptions, ...groupOptions,
]} ]}
className="w-full" className='w-full'
showClear showClear
pure pure
onChange={() => { onChange={() => {
@@ -127,10 +127,10 @@ const ChannelsFilters = ({
</div> </div>
<Button <Button
size='small' size='small'
type="tertiary" type='tertiary'
htmlType="submit" htmlType='submit'
loading={loading || searching} loading={loading || searching}
className="w-full md:w-auto" className='w-full md:w-auto'
> >
{t('查询')} {t('查询')}
</Button> </Button>
@@ -146,7 +146,7 @@ const ChannelsFilters = ({
}, 100); }, 100);
} }
}} }}
className="w-full md:w-auto" className='w-full md:w-auto'
> >
{t('重置')} {t('重置')}
</Button> </Button>
@@ -22,7 +22,7 @@ import { Empty } from '@douyinfe/semi-ui';
import CardTable from '../../common/ui/CardTable'; import CardTable from '../../common/ui/CardTable';
import { import {
IllustrationNoResult, IllustrationNoResult,
IllustrationNoResultDark IllustrationNoResultDark,
} from '@douyinfe/semi-illustrations'; } from '@douyinfe/semi-illustrations';
import { getChannelsColumns } from './ChannelsColumnDefs'; import { getChannelsColumns } from './ChannelsColumnDefs';
@@ -142,22 +142,24 @@ const ChannelsTable = (channelsData) => {
rowSelection={ rowSelection={
enableBatchDelete enableBatchDelete
? { ? {
onChange: (selectedRowKeys, selectedRows) => { onChange: (selectedRowKeys, selectedRows) => {
setSelectedChannels(selectedRows); setSelectedChannels(selectedRows);
}, },
} }
: null : null
} }
empty={ empty={
<Empty <Empty
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />} image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />} darkModeImage={
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
}
description={t('搜索无结果')} description={t('搜索无结果')}
style={{ padding: 30 }} style={{ padding: 30 }}
/> />
} }
className="rounded-xl overflow-hidden" className='rounded-xl overflow-hidden'
size="middle" size='middle'
loading={loading || searching} loading={loading || searching}
/> />
); );
@@ -33,7 +33,7 @@ const ChannelsTabs = ({
pageSize, pageSize,
idSort, idSort,
setActivePage, setActivePage,
t t,
}) => { }) => {
if (enableTagMode) return null; if (enableTagMode) return null;
@@ -46,24 +46,29 @@ const ChannelsTabs = ({
return ( return (
<Tabs <Tabs
activeKey={activeTypeKey} activeKey={activeTypeKey}
type="card" type='card'
collapsible collapsible
onChange={handleTabChange} onChange={handleTabChange}
className="mb-2" className='mb-2'
> >
<TabPane <TabPane
itemKey="all" itemKey='all'
tab={ tab={
<span className="flex items-center gap-2"> <span className='flex items-center gap-2'>
{t('全部')} {t('全部')}
<Tag color={activeTypeKey === 'all' ? 'red' : 'grey'} shape='circle'> <Tag
color={activeTypeKey === 'all' ? 'red' : 'grey'}
shape='circle'
>
{channelTypeCounts['all'] || 0} {channelTypeCounts['all'] || 0}
</Tag> </Tag>
</span> </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 key = String(option.value);
const count = channelTypeCounts[option.value] || 0; const count = channelTypeCounts[option.value] || 0;
return ( return (
@@ -71,10 +76,13 @@ const ChannelsTabs = ({
key={key} key={key}
itemKey={key} itemKey={key}
tab={ tab={
<span className="flex items-center gap-2"> <span className='flex items-center gap-2'>
{getChannelIcon(option.value)} {getChannelIcon(option.value)}
{option.label} {option.label}
<Tag color={activeTypeKey === key ? 'red' : 'grey'} shape='circle'> <Tag
color={activeTypeKey === key ? 'red' : 'grey'}
shape='circle'
>
{count} {count}
</Tag> </Tag>
</span> </span>
+1 -1
View File
@@ -64,7 +64,7 @@ const ChannelsPage = () => {
{/* Main Content */} {/* Main Content */}
<CardPro <CardPro
type="type3" type='type3'
tabsArea={<ChannelsTabs {...channelsData} />} tabsArea={<ChannelsTabs {...channelsData} />}
actionsArea={<ChannelsActions {...channelsData} />} actionsArea={<ChannelsActions {...channelsData} />}
searchArea={<ChannelsFilters {...channelsData} />} searchArea={<ChannelsFilters {...channelsData} />}
@@ -27,7 +27,7 @@ const BatchTagModal = ({
batchSetTagValue, batchSetTagValue,
setBatchSetTagValue, setBatchSetTagValue,
selectedChannels, selectedChannels,
t t,
}) => { }) => {
return ( return (
<Modal <Modal
@@ -37,10 +37,10 @@ const BatchTagModal = ({
onCancel={() => setShowBatchSetTag(false)} onCancel={() => setShowBatchSetTag(false)}
maskClosable={false} maskClosable={false}
centered={true} centered={true}
size="small" size='small'
className="!rounded-lg" className='!rounded-lg'
> >
<div className="mb-5"> <div className='mb-5'>
<Typography.Text>{t('请输入要设置的标签名称')}</Typography.Text> <Typography.Text>{t('请输入要设置的标签名称')}</Typography.Text>
</div> </div>
<Input <Input
@@ -48,9 +48,12 @@ const BatchTagModal = ({
value={batchSetTagValue} value={batchSetTagValue}
onChange={(v) => setBatchSetTagValue(v)} onChange={(v) => setBatchSetTagValue(v)}
/> />
<div className="mt-4"> <div className='mt-4'>
<Typography.Text type='secondary'> <Typography.Text type='secondary'>
{t('已选择 ${count} 个渠道').replace('${count}', selectedChannels.length)} {t('已选择 ${count} 个渠道').replace(
'${count}',
selectedChannels.length,
)}
</Typography.Text> </Typography.Text>
</div> </div>
</Modal> </Modal>
@@ -74,10 +74,8 @@ const ColumnSelectorModal = ({
visible={showColumnSelector} visible={showColumnSelector}
onCancel={() => setShowColumnSelector(false)} onCancel={() => setShowColumnSelector(false)}
footer={ footer={
<div className="flex justify-end"> <div className='flex justify-end'>
<Button onClick={() => initDefaultColumns()}> <Button onClick={() => initDefaultColumns()}>{t('重置')}</Button>
{t('重置')}
</Button>
<Button onClick={() => setShowColumnSelector(false)}> <Button onClick={() => setShowColumnSelector(false)}>
{t('取消')} {t('取消')}
</Button> </Button>
@@ -100,7 +98,7 @@ const ColumnSelectorModal = ({
</Checkbox> </Checkbox>
</div> </div>
<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)' }} style={{ border: '1px solid var(--semi-color-border)' }}
> >
{allColumns.map((column) => { {allColumns.map((column) => {
@@ -110,10 +108,7 @@ const ColumnSelectorModal = ({
} }
return ( return (
<div <div key={column.key} className='w-1/2 mb-4 pr-2'>
key={column.key}
className="w-1/2 mb-4 pr-2"
>
<Checkbox <Checkbox
checked={!!visibleColumns[column.key]} checked={!!visibleColumns[column.key]}
onChange={(e) => onChange={(e) =>
File diff suppressed because it is too large Load Diff
@@ -289,7 +289,7 @@ const EditTagModal = (props) => {
t('已新增 {{count}} 个模型:{{list}}', { t('已新增 {{count}} 个模型:{{list}}', {
count: addedModels.length, count: addedModels.length,
list: addedModels.join(', '), list: addedModels.join(', '),
}) }),
); );
} else { } else {
showInfo(t('未发现新增模型')); showInfo(t('未发现新增模型'));
@@ -301,8 +301,10 @@ const EditTagModal = (props) => {
placement='right' placement='right'
title={ title={
<Space> <Space>
<Tag color="blue" shape="circle">{t('编辑')}</Tag> <Tag color='blue' shape='circle'>
<Title heading={4} className="m-0"> {t('编辑')}
</Tag>
<Title heading={4} className='m-0'>
{t('编辑标签')} {t('编辑标签')}
</Title> </Title>
</Space> </Space>
@@ -312,10 +314,10 @@ const EditTagModal = (props) => {
width={600} width={600}
onCancel={handleClose} onCancel={handleClose}
footer={ footer={
<div className="flex justify-end bg-white"> <div className='flex justify-end bg-white'>
<Space> <Space>
<Button <Button
theme="solid" theme='solid'
onClick={() => formApiRef.current?.submitForm()} onClick={() => formApiRef.current?.submitForm()}
loading={loading} loading={loading}
icon={<IconSave />} icon={<IconSave />}
@@ -323,8 +325,8 @@ const EditTagModal = (props) => {
{t('保存')} {t('保存')}
</Button> </Button>
<Button <Button
theme="light" theme='light'
type="primary" type='primary'
onClick={handleClose} onClick={handleClose}
icon={<IconClose />} icon={<IconClose />}
> >
@@ -343,26 +345,28 @@ const EditTagModal = (props) => {
> >
{() => ( {() => (
<Spin spinning={loading}> <Spin spinning={loading}>
<div className="p-2"> <div className='p-2'>
<Card className="!rounded-2xl shadow-sm border-0 mb-6"> <Card className='!rounded-2xl shadow-sm border-0 mb-6'>
{/* Header: Tag Info */} {/* Header: Tag Info */}
<div className="flex items-center mb-2"> <div className='flex items-center mb-2'>
<Avatar size="small" color="blue" className="mr-2 shadow-md"> <Avatar size='small' color='blue' className='mr-2 shadow-md'>
<IconBookmark size={16} /> <IconBookmark size={16} />
</Avatar> </Avatar>
<div> <div>
<Text className="text-lg font-medium">{t('标签信息')}</Text> <Text className='text-lg font-medium'>{t('标签信息')}</Text>
<div className="text-xs text-gray-600">{t('标签的基本配置')}</div> <div className='text-xs text-gray-600'>
{t('标签的基本配置')}
</div>
</div> </div>
</div> </div>
<Banner <Banner
type="warning" type='warning'
description={t('所有编辑均为覆盖操作,留空则不更改')} description={t('所有编辑均为覆盖操作,留空则不更改')}
className="!rounded-lg mb-4" className='!rounded-lg mb-4'
/> />
<div className="space-y-4"> <div className='space-y-4'>
<Form.Input <Form.Input
field='new_tag' field='new_tag'
label={t('标签名称')} label={t('标签名称')}
@@ -372,23 +376,31 @@ const EditTagModal = (props) => {
</div> </div>
</Card> </Card>
<Card className="!rounded-2xl shadow-sm border-0 mb-6"> <Card className='!rounded-2xl shadow-sm border-0 mb-6'>
{/* Header: Model Config */} {/* Header: Model Config */}
<div className="flex items-center mb-2"> <div className='flex items-center mb-2'>
<Avatar size="small" color="purple" className="mr-2 shadow-md"> <Avatar
size='small'
color='purple'
className='mr-2 shadow-md'
>
<IconCode size={16} /> <IconCode size={16} />
</Avatar> </Avatar>
<div> <div>
<Text className="text-lg font-medium">{t('模型配置')}</Text> <Text className='text-lg font-medium'>{t('模型配置')}</Text>
<div className="text-xs text-gray-600">{t('模型选择和映射设置')}</div> <div className='text-xs text-gray-600'>
{t('模型选择和映射设置')}
</div>
</div> </div>
</div> </div>
<div className="space-y-4"> <div className='space-y-4'>
<Banner <Banner
type="info" type='info'
description={t('当前模型列表为该标签下所有渠道模型列表最长的一个,并非所有渠道的并集,请注意可能导致某些渠道模型丢失。')} description={t(
className="!rounded-lg mb-4" '当前模型列表为该标签下所有渠道模型列表最长的一个,并非所有渠道的并集,请注意可能导致某些渠道模型丢失。',
)}
className='!rounded-lg mb-4'
/> />
<Form.Select <Form.Select
field='models' field='models'
@@ -408,46 +420,87 @@ const EditTagModal = (props) => {
label={t('自定义模型名称')} label={t('自定义模型名称')}
placeholder={t('输入自定义模型名称')} placeholder={t('输入自定义模型名称')}
onChange={(value) => setCustomModel(value.trim())} 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 <Form.TextArea
field='model_mapping' field='model_mapping'
label={t('模型重定向')} label={t('模型重定向')}
placeholder={t('此项可选,用于修改请求体中的模型名称,为一个 JSON 字符串,键为请求中模型名称,值为要替换的模型名称,留空则不更改')} placeholder={t(
autosize '此项可选,用于修改请求体中的模型名称,为一个 JSON 字符串,键为请求中模型名称,值为要替换的模型名称,留空则不更改',
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>
)} )}
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> </div>
</Card> </Card>
<Card className="!rounded-2xl shadow-sm border-0"> <Card className='!rounded-2xl shadow-sm border-0'>
{/* Header: Group Settings */} {/* Header: Group Settings */}
<div className="flex items-center mb-2"> <div className='flex items-center mb-2'>
<Avatar size="small" color="green" className="mr-2 shadow-md"> <Avatar size='small' color='green' className='mr-2 shadow-md'>
<IconUser size={16} /> <IconUser size={16} />
</Avatar> </Avatar>
<div> <div>
<Text className="text-lg font-medium">{t('分组设置')}</Text> <Text className='text-lg font-medium'>{t('分组设置')}</Text>
<div className="text-xs text-gray-600">{t('用户分组配置')}</div> <div className='text-xs text-gray-600'>
{t('用户分组配置')}
</div>
</div> </div>
</div> </div>
<div className="space-y-4"> <div className='space-y-4'>
<Form.Select <Form.Select
field='groups' field='groups'
label={t('分组')} label={t('分组')}
placeholder={t('请选择可以使用该渠道的分组,留空则不更改')} placeholder={t('请选择可以使用该渠道的分组,留空则不更改')}
multiple multiple
allowAdditions allowAdditions
additionLabel={t('请在系统设置页面编辑分组倍率以添加新的分组:')} additionLabel={t(
'请在系统设置页面编辑分组倍率以添加新的分组:',
)}
optionList={groupOptions} optionList={groupOptions}
style={{ width: '100%' }} style={{ width: '100%' }}
onChange={(value) => handleInputChange('groups', value)} onChange={(value) => handleInputChange('groups', value)}
@@ -19,16 +19,31 @@ For commercial licensing, please contact support@quantumnous.com
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useIsMobile } from '../../../../hooks/common/useIsMobile'; 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 { import {
IllustrationNoResult, IllustrationNoResult,
IllustrationNoResultDark IllustrationNoResultDark,
} from '@douyinfe/semi-illustrations'; } from '@douyinfe/semi-illustrations';
import { IconSearch } from '@douyinfe/semi-icons'; import { IconSearch } from '@douyinfe/semi-icons';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { getModelCategories } from '../../../../helpers/render'; import { getModelCategories } from '../../../../helpers/render';
const ModelSelectModal = ({ visible, models = [], selected = [], onConfirm, onCancel }) => { const ModelSelectModal = ({
visible,
models = [],
selected = [],
onConfirm,
onCancel,
}) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [checkedList, setCheckedList] = useState(selected); const [checkedList, setCheckedList] = useState(selected);
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
@@ -36,11 +51,15 @@ const ModelSelectModal = ({ visible, models = [], selected = [], onConfirm, onCa
const isMobile = useIsMobile(); 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 newModels = filteredModels.filter((model) => !selected.includes(model));
const existingModels = filteredModels.filter(model => selected.includes(model)); const existingModels = filteredModels.filter((model) =>
selected.includes(model),
);
// 同步外部选中值 // 同步外部选中值
useEffect(() => { useEffect(() => {
@@ -68,7 +87,7 @@ const ModelSelectModal = ({ visible, models = [], selected = [], onConfirm, onCa
const categorizedModels = {}; const categorizedModels = {};
const uncategorizedModels = []; const uncategorizedModels = [];
models.forEach(model => { models.forEach((model) => {
let foundCategory = false; let foundCategory = false;
for (const [key, category] of Object.entries(categories)) { for (const [key, category] of Object.entries(categories)) {
if (key !== 'all' && category.filter({ model_name: model })) { if (key !== 'all' && category.filter({ model_name: model })) {
@@ -76,7 +95,7 @@ const ModelSelectModal = ({ visible, models = [], selected = [], onConfirm, onCa
categorizedModels[key] = { categorizedModels[key] = {
label: category.label, label: category.label,
icon: category.icon, icon: category.icon,
models: [] models: [],
}; };
} }
categorizedModels[key].models.push(model); categorizedModels[key].models.push(model);
@@ -94,7 +113,7 @@ const ModelSelectModal = ({ visible, models = [], selected = [], onConfirm, onCa
categorizedModels['other'] = { categorizedModels['other'] = {
label: t('其他'), label: t('其他'),
icon: null, icon: null,
models: uncategorizedModels models: uncategorizedModels,
}; };
} }
@@ -106,14 +125,22 @@ const ModelSelectModal = ({ visible, models = [], selected = [], onConfirm, onCa
// Tab列表配置 // Tab列表配置
const tabList = [ const tabList = [
...(newModels.length > 0 ? [{ ...(newModels.length > 0
tab: `${t('新获取的模型')} (${newModels.length})`, ? [
itemKey: 'new' {
}] : []), tab: `${t('新获取的模型')} (${newModels.length})`,
...(existingModels.length > 0 ? [{ itemKey: 'new',
tab: `${t('已有的模型')} (${existingModels.length})`, },
itemKey: 'existing' ]
}] : []) : []),
...(existingModels.length > 0
? [
{
tab: `${t('已有的模型')} (${existingModels.length})`,
itemKey: 'existing',
},
]
: []),
]; ];
// 处理分类全选/取消全选 // 处理分类全选/取消全选
@@ -122,14 +149,16 @@ const ModelSelectModal = ({ visible, models = [], selected = [], onConfirm, onCa
if (isChecked) { if (isChecked) {
// 全选:添加该分类下所有未选中的模型 // 全选:添加该分类下所有未选中的模型
categoryModels.forEach(model => { categoryModels.forEach((model) => {
if (!newCheckedList.includes(model)) { if (!newCheckedList.includes(model)) {
newCheckedList.push(model); newCheckedList.push(model);
} }
}); });
} else { } else {
// 取消全选:移除该分类下所有已选中的模型 // 取消全选:移除该分类下所有已选中的模型
newCheckedList = newCheckedList.filter(model => !categoryModels.includes(model)); newCheckedList = newCheckedList.filter(
(model) => !categoryModels.includes(model),
);
} }
setCheckedList(newCheckedList); setCheckedList(newCheckedList);
@@ -137,12 +166,17 @@ const ModelSelectModal = ({ visible, models = [], selected = [], onConfirm, onCa
// 检查分类是否全选 // 检查分类是否全选
const isCategoryAllSelected = (categoryModels) => { 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 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; return selectedCount > 0 && selectedCount < categoryModels.length;
}; };
@@ -151,10 +185,15 @@ const ModelSelectModal = ({ visible, models = [], selected = [], onConfirm, onCa
if (categoryEntries.length === 0) return null; if (categoryEntries.length === 0) return null;
// 生成所有面板的key,确保都展开 // 生成所有面板的key,确保都展开
const allActiveKeys = categoryEntries.map((_, index) => `${categoryKeyPrefix}_${index}`); const allActiveKeys = categoryEntries.map(
(_, index) => `${categoryKeyPrefix}_${index}`,
);
return ( return (
<Collapse key={`${categoryKeyPrefix}_${categoryEntries.length}`} defaultActiveKey={[]}> <Collapse
key={`${categoryKeyPrefix}_${categoryEntries.length}`}
defaultActiveKey={[]}
>
{categoryEntries.map(([key, categoryData], index) => ( {categoryEntries.map(([key, categoryData], index) => (
<Collapse.Panel <Collapse.Panel
key={`${categoryKeyPrefix}_${index}`} key={`${categoryKeyPrefix}_${index}`}
@@ -166,24 +205,29 @@ const ModelSelectModal = ({ visible, models = [], selected = [], onConfirm, onCa
indeterminate={isCategoryIndeterminate(categoryData.models)} indeterminate={isCategoryIndeterminate(categoryData.models)}
onChange={(e) => { onChange={(e) => {
e.stopPropagation(); // 防止触发面板折叠 e.stopPropagation(); // 防止触发面板折叠
handleCategorySelectAll(categoryData.models, e.target.checked); handleCategorySelectAll(
categoryData.models,
e.target.checked,
);
}} }}
onClick={(e) => e.stopPropagation()} // 防止点击checkbox时折叠面板 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} {categoryData.icon}
<Typography.Text type="secondary" size="small"> <Typography.Text type='secondary' size='small'>
{t('已选择 {{selected}} / {{total}}', { {t('已选择 {{selected}} / {{total}}', {
selected: categoryData.models.filter(model => checkedList.includes(model)).length, selected: categoryData.models.filter((model) =>
total: categoryData.models.length checkedList.includes(model),
).length,
total: categoryData.models.length,
})} })}
</Typography.Text> </Typography.Text>
</div> </div>
<div className="grid grid-cols-2 gap-x-4"> <div className='grid grid-cols-2 gap-x-4'>
{categoryData.models.map((model) => ( {categoryData.models.map((model) => (
<Checkbox key={model} value={model} className="my-1"> <Checkbox key={model} value={model} className='my-1'>
{model} {model}
</Checkbox> </Checkbox>
))} ))}
@@ -197,14 +241,14 @@ const ModelSelectModal = ({ visible, models = [], selected = [], onConfirm, onCa
return ( return (
<Modal <Modal
header={ header={
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-4 py-4"> <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"> <Typography.Title heading={5} className='m-0'>
{t('选择模型')} {t('选择模型')}
</Typography.Title> </Typography.Title>
<div className="flex-shrink-0"> <div className='flex-shrink-0'>
<Tabs <Tabs
type="slash" type='slash'
size="small" size='small'
tabList={tabList} tabList={tabList}
activeKey={activeTab} activeKey={activeTab}
onChange={(key) => setActiveTab(key)} onChange={(key) => setActiveTab(key)}
@@ -234,17 +278,22 @@ const ModelSelectModal = ({ visible, models = [], selected = [], onConfirm, onCa
<div style={{ maxHeight: 400, overflowY: 'auto', paddingRight: 8 }}> <div style={{ maxHeight: 400, overflowY: 'auto', paddingRight: 8 }}>
{filteredModels.length === 0 ? ( {filteredModels.length === 0 ? (
<Empty <Empty
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />} image={
darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />} <IllustrationNoResult style={{ width: 150, height: 150 }} />
}
darkModeImage={
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
}
description={t('暂无匹配模型')} description={t('暂无匹配模型')}
style={{ padding: 30 }} style={{ padding: 30 }}
/> />
) : ( ) : (
<Checkbox.Group value={checkedList} onChange={(vals) => setCheckedList(vals)}> <Checkbox.Group
value={checkedList}
onChange={(vals) => setCheckedList(vals)}
>
{activeTab === 'new' && newModels.length > 0 && ( {activeTab === 'new' && newModels.length > 0 && (
<div> <div>{renderModelsByCategory(newModelsByCategory, 'new')}</div>
{renderModelsByCategory(newModelsByCategory, 'new')}
</div>
)} )}
{activeTab === 'existing' && existingModels.length > 0 && ( {activeTab === 'existing' && existingModels.length > 0 && (
<div> <div>
@@ -256,20 +305,30 @@ const ModelSelectModal = ({ visible, models = [], selected = [], onConfirm, onCa
</div> </div>
</Spin> </Spin>
<Typography.Text type="secondary" size="small" className="block text-right mt-4"> <Typography.Text
<div className="flex items-center justify-end gap-2"> 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 currentModels =
const currentSelected = currentModels.filter(model => checkedList.includes(model)).length; activeTab === 'new' ? newModels : existingModels;
const isAllSelected = currentModels.length > 0 && currentSelected === currentModels.length; const currentSelected = currentModels.filter((model) =>
const isIndeterminate = currentSelected > 0 && currentSelected < currentModels.length; checkedList.includes(model),
).length;
const isAllSelected =
currentModels.length > 0 &&
currentSelected === currentModels.length;
const isIndeterminate =
currentSelected > 0 && currentSelected < currentModels.length;
return ( return (
<> <>
<span> <span>
{t('已选择 {{selected}} / {{total}}', { {t('已选择 {{selected}} / {{total}}', {
selected: currentSelected, selected: currentSelected,
total: currentModels.length total: currentModels.length,
})} })}
</span> </span>
<Checkbox <Checkbox
@@ -24,7 +24,7 @@ import {
Input, Input,
Table, Table,
Tag, Tag,
Typography Typography,
} from '@douyinfe/semi-ui'; } from '@douyinfe/semi-ui';
import { IconSearch } from '@douyinfe/semi-icons'; import { IconSearch } from '@douyinfe/semi-icons';
import { copy, showError, showInfo, showSuccess } from '../../../../helpers'; import { copy, showError, showInfo, showSuccess } from '../../../../helpers';
@@ -47,16 +47,16 @@ const ModelTestModal = ({
setModelTablePage, setModelTablePage,
allSelectingRef, allSelectingRef,
isMobile, isMobile,
t t,
}) => { }) => {
const hasChannel = Boolean(currentTestChannel); const hasChannel = Boolean(currentTestChannel);
const filteredModels = hasChannel const filteredModels = hasChannel
? currentTestChannel.models ? currentTestChannel.models
.split(',') .split(',')
.filter((model) => .filter((model) =>
model.toLowerCase().includes(modelSearchKeyword.toLowerCase()) model.toLowerCase().includes(modelSearchKeyword.toLowerCase()),
) )
: []; : [];
const handleCopySelected = () => { const handleCopySelected = () => {
@@ -66,7 +66,12 @@ const ModelTestModal = ({
} }
copy(selectedModelKeys.join(',')).then((ok) => { copy(selectedModelKeys.join(',')).then((ok) => {
if (ok) { if (ok) {
showSuccess(t('已复制 ${count} 个模型').replace('${count}', selectedModelKeys.length)); showSuccess(
t('已复制 ${count} 个模型').replace(
'${count}',
selectedModelKeys.length,
),
);
} else { } else {
showError(t('复制失败,请手动复制')); showError(t('复制失败,请手动复制'));
} }
@@ -93,16 +98,17 @@ const ModelTestModal = ({
title: t('模型名称'), title: t('模型名称'),
dataIndex: 'model', dataIndex: 'model',
render: (text) => ( render: (text) => (
<div className="flex items-center"> <div className='flex items-center'>
<Typography.Text strong>{text}</Typography.Text> <Typography.Text strong>{text}</Typography.Text>
</div> </div>
) ),
}, },
{ {
title: t('状态'), title: t('状态'),
dataIndex: 'status', dataIndex: 'status',
render: (text, record) => { render: (text, record) => {
const testResult = modelTestResults[`${currentTestChannel.id}-${record.model}`]; const testResult =
modelTestResults[`${currentTestChannel.id}-${record.model}`];
const isTesting = testingModels.has(record.model); const isTesting = testingModels.has(record.model);
if (isTesting) { if (isTesting) {
@@ -122,21 +128,21 @@ const ModelTestModal = ({
} }
return ( return (
<div className="flex items-center gap-2"> <div className='flex items-center gap-2'>
<Tag <Tag color={testResult.success ? 'green' : 'red'} shape='circle'>
color={testResult.success ? 'green' : 'red'}
shape='circle'
>
{testResult.success ? t('成功') : t('失败')} {testResult.success ? t('成功') : t('失败')}
</Tag> </Tag>
{testResult.success && ( {testResult.success && (
<Typography.Text type="tertiary"> <Typography.Text type='tertiary'>
{t('请求时长: ${time}s').replace('${time}', testResult.time.toFixed(2))} {t('请求时长: ${time}s').replace(
'${time}',
testResult.time.toFixed(2),
)}
</Typography.Text> </Typography.Text>
)} )}
</div> </div>
); );
} },
}, },
{ {
title: '', title: '',
@@ -153,8 +159,8 @@ const ModelTestModal = ({
{t('测试')} {t('测试')}
</Button> </Button>
); );
} },
} },
]; ];
const dataSource = (() => { const dataSource = (() => {
@@ -169,106 +175,107 @@ const ModelTestModal = ({
return ( return (
<Modal <Modal
title={hasChannel ? ( title={
<div className="flex flex-col gap-2 w-full"> hasChannel ? (
<div className="flex items-center gap-2"> <div className='flex flex-col gap-2 w-full'>
<Typography.Text strong className="!text-[var(--semi-color-text-0)] !text-base"> <div className='flex items-center gap-2'>
{currentTestChannel.name} {t('渠道的模型测试')} <Typography.Text
</Typography.Text> strong
<Typography.Text type="tertiary" size="small"> className='!text-[var(--semi-color-text-0)] !text-base'
{t('共')} {currentTestChannel.models.split(',').length} {t('个模型')} >
</Typography.Text> {currentTestChannel.name} {t('渠道的模型测试')}
</Typography.Text>
<Typography.Text type='tertiary' size='small'>
{t('共')} {currentTestChannel.models.split(',').length}{' '}
{t('个模型')}
</Typography.Text>
</div>
</div> </div>
</div> ) : null
) : null} }
visible={showModelTestModal} visible={showModelTestModal}
onCancel={handleCloseModal} onCancel={handleCloseModal}
footer={hasChannel ? ( footer={
<div className="flex justify-end"> hasChannel ? (
{isBatchTesting ? ( <div className='flex justify-end'>
<Button {isBatchTesting ? (
type='danger' <Button type='danger' onClick={handleCloseModal}>
onClick={handleCloseModal} {t('停止测试')}
> </Button>
{t('停止测试')} ) : (
</Button> <Button type='tertiary' 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
)} )}
</Button> <Button
</div> onClick={batchTestModels}
) : null} loading={isBatchTesting}
disabled={isBatchTesting}
>
{isBatchTesting
? t('测试中...')
: t('批量测试${count}个模型').replace(
'${count}',
filteredModels.length,
)}
</Button>
</div>
) : null
}
maskClosable={!isBatchTesting} maskClosable={!isBatchTesting}
className="!rounded-lg" className='!rounded-lg'
size={isMobile ? 'full-width' : 'large'} size={isMobile ? 'full-width' : 'large'}
> >
{hasChannel && (<div className="model-test-scroll"> {hasChannel && (
{/* 搜索与操作按钮 */} <div className='model-test-scroll'>
<div className="flex items-center justify-end gap-2 w-full mb-2"> {/* 搜索与操作按钮 */}
<Input <div className='flex items-center justify-end gap-2 w-full mb-2'>
placeholder={t('搜索模型...')} <Input
value={modelSearchKeyword} placeholder={t('搜索模型...')}
onChange={(v) => { value={modelSearchKeyword}
setModelSearchKeyword(v); onChange={(v) => {
setModelTablePage(1); 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> </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> </Modal>
); );
}; };
@@ -35,19 +35,22 @@ import {
Col, Col,
Badge, Badge,
Progress, Progress,
Card Card,
} from '@douyinfe/semi-ui'; } from '@douyinfe/semi-ui';
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations'; import {
import { API, showError, showSuccess, timestamp2string } from '../../../../helpers'; IllustrationNoResult,
IllustrationNoResultDark,
} from '@douyinfe/semi-illustrations';
import {
API,
showError,
showSuccess,
timestamp2string,
} from '../../../../helpers';
const { Text } = Typography; const { Text } = Typography;
const MultiKeyManageModal = ({ const MultiKeyManageModal = ({ visible, onCancel, channel, onRefresh }) => {
visible,
onCancel,
channel,
onRefresh
}) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [keyStatusList, setKeyStatusList] = useState([]); 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 const [statusFilter, setStatusFilter] = useState(null); // null=all, 1=enabled, 2=manual_disabled, 3=auto_disabled
// Load key status data // 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; if (!channel?.id) return;
setLoading(true); setLoading(true);
@@ -77,7 +84,7 @@ const MultiKeyManageModal = ({
channel_id: channel.id, channel_id: channel.id,
action: 'get_key_status', action: 'get_key_status',
page: page, page: page,
page_size: size page_size: size,
}; };
// Add status filter if specified // Add status filter if specified
@@ -113,13 +120,13 @@ const MultiKeyManageModal = ({
// Disable a specific key // Disable a specific key
const handleDisableKey = async (keyIndex) => { const handleDisableKey = async (keyIndex) => {
const operationId = `disable_${keyIndex}`; const operationId = `disable_${keyIndex}`;
setOperationLoading(prev => ({ ...prev, [operationId]: true })); setOperationLoading((prev) => ({ ...prev, [operationId]: true }));
try { try {
const res = await API.post('/api/channel/multi_key/manage', { const res = await API.post('/api/channel/multi_key/manage', {
channel_id: channel.id, channel_id: channel.id,
action: 'disable_key', action: 'disable_key',
key_index: keyIndex key_index: keyIndex,
}); });
if (res.data.success) { if (res.data.success) {
@@ -132,20 +139,20 @@ const MultiKeyManageModal = ({
} catch (error) { } catch (error) {
showError(t('禁用密钥失败')); showError(t('禁用密钥失败'));
} finally { } finally {
setOperationLoading(prev => ({ ...prev, [operationId]: false })); setOperationLoading((prev) => ({ ...prev, [operationId]: false }));
} }
}; };
// Enable a specific key // Enable a specific key
const handleEnableKey = async (keyIndex) => { const handleEnableKey = async (keyIndex) => {
const operationId = `enable_${keyIndex}`; const operationId = `enable_${keyIndex}`;
setOperationLoading(prev => ({ ...prev, [operationId]: true })); setOperationLoading((prev) => ({ ...prev, [operationId]: true }));
try { try {
const res = await API.post('/api/channel/multi_key/manage', { const res = await API.post('/api/channel/multi_key/manage', {
channel_id: channel.id, channel_id: channel.id,
action: 'enable_key', action: 'enable_key',
key_index: keyIndex key_index: keyIndex,
}); });
if (res.data.success) { if (res.data.success) {
@@ -158,18 +165,18 @@ const MultiKeyManageModal = ({
} catch (error) { } catch (error) {
showError(t('启用密钥失败')); showError(t('启用密钥失败'));
} finally { } finally {
setOperationLoading(prev => ({ ...prev, [operationId]: false })); setOperationLoading((prev) => ({ ...prev, [operationId]: false }));
} }
}; };
// Enable all disabled keys // Enable all disabled keys
const handleEnableAll = async () => { const handleEnableAll = async () => {
setOperationLoading(prev => ({ ...prev, enable_all: true })); setOperationLoading((prev) => ({ ...prev, enable_all: true }));
try { try {
const res = await API.post('/api/channel/multi_key/manage', { const res = await API.post('/api/channel/multi_key/manage', {
channel_id: channel.id, channel_id: channel.id,
action: 'enable_all_keys' action: 'enable_all_keys',
}); });
if (res.data.success) { if (res.data.success) {
@@ -184,18 +191,18 @@ const MultiKeyManageModal = ({
} catch (error) { } catch (error) {
showError(t('启用所有密钥失败')); showError(t('启用所有密钥失败'));
} finally { } finally {
setOperationLoading(prev => ({ ...prev, enable_all: false })); setOperationLoading((prev) => ({ ...prev, enable_all: false }));
} }
}; };
// Disable all enabled keys // Disable all enabled keys
const handleDisableAll = async () => { const handleDisableAll = async () => {
setOperationLoading(prev => ({ ...prev, disable_all: true })); setOperationLoading((prev) => ({ ...prev, disable_all: true }));
try { try {
const res = await API.post('/api/channel/multi_key/manage', { const res = await API.post('/api/channel/multi_key/manage', {
channel_id: channel.id, channel_id: channel.id,
action: 'disable_all_keys' action: 'disable_all_keys',
}); });
if (res.data.success) { if (res.data.success) {
@@ -210,18 +217,18 @@ const MultiKeyManageModal = ({
} catch (error) { } catch (error) {
showError(t('禁用所有密钥失败')); showError(t('禁用所有密钥失败'));
} finally { } finally {
setOperationLoading(prev => ({ ...prev, disable_all: false })); setOperationLoading((prev) => ({ ...prev, disable_all: false }));
} }
}; };
// Delete all disabled keys // Delete all disabled keys
const handleDeleteDisabledKeys = async () => { const handleDeleteDisabledKeys = async () => {
setOperationLoading(prev => ({ ...prev, delete_disabled: true })); setOperationLoading((prev) => ({ ...prev, delete_disabled: true }));
try { try {
const res = await API.post('/api/channel/multi_key/manage', { const res = await API.post('/api/channel/multi_key/manage', {
channel_id: channel.id, channel_id: channel.id,
action: 'delete_disabled_keys' action: 'delete_disabled_keys',
}); });
if (res.data.success) { if (res.data.success) {
@@ -236,7 +243,7 @@ const MultiKeyManageModal = ({
} catch (error) { } catch (error) {
showError(t('删除禁用密钥失败')); showError(t('删除禁用密钥失败'));
} finally { } finally {
setOperationLoading(prev => ({ ...prev, delete_disabled: false })); setOperationLoading((prev) => ({ ...prev, delete_disabled: false }));
} }
}; };
@@ -283,9 +290,12 @@ const MultiKeyManageModal = ({
}, [visible]); }, [visible]);
// Percentages for progress display // Percentages for progress display
const enabledPercent = total > 0 ? Math.round((enabledCount / total) * 100) : 0; const enabledPercent =
const manualDisabledPercent = total > 0 ? Math.round((manualDisabledCount / total) * 100) : 0; total > 0 ? Math.round((enabledCount / total) * 100) : 0;
const autoDisabledPercent = total > 0 ? Math.round((autoDisabledCount / 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) => { const renderStatusTag = (status) => {
switch (status) { switch (status) {
case 1: case 1:
return <Tag color='green' shape='circle' size='small'>{t('已启用')}</Tag>; return (
<Tag color='green' shape='circle' size='small'>
{t('已启用')}
</Tag>
);
case 2: case 2:
return <Tag color='red' shape='circle' size='small'>{t('已禁用')}</Tag>; return (
<Tag color='red' shape='circle' size='small'>
{t('已禁用')}
</Tag>
);
case 3: case 3:
return <Tag color='orange' shape='circle' size='small'>{t('自动禁用')}</Tag>; return (
<Tag color='orange' shape='circle' size='small'>
{t('自动禁用')}
</Tag>
);
default: 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 ( return (
<Tooltip content={timestamp2string(time)}> <Tooltip content={timestamp2string(time)}>
<Text style={{ fontSize: '12px' }}> <Text style={{ fontSize: '12px' }}>{timestamp2string(time)}</Text>
{timestamp2string(time)}
</Text>
</Tooltip> </Tooltip>
); );
}, },
@@ -393,14 +417,18 @@ const MultiKeyManageModal = ({
<Space> <Space>
<Text>{t('多密钥管理')}</Text> <Text>{t('多密钥管理')}</Text>
{channel?.name && ( {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'> <Tag size='small' shape='circle' color='white'>
{t('总密钥数')}: {total} {t('总密钥数')}: {total}
</Tag> </Tag>
{channel?.channel_info?.multi_key_mode && ( {channel?.channel_info?.multi_key_mode && (
<Tag size='small' shape='circle' color='white'> <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> </Tag>
)} )}
</Space> </Space>
@@ -410,60 +438,123 @@ const MultiKeyManageModal = ({
width={900} width={900}
footer={null} footer={null}
> >
<div className="flex flex-col mb-5"> <div className='flex flex-col mb-5'>
{/* Stats & Mode */} {/* Stats & Mode */}
<div <div
className="rounded-xl p-4 mb-3" className='rounded-xl p-4 mb-3'
style={{ style={{
background: 'var(--semi-color-bg-1)', 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}> <Col span={8}>
<div style={{ background: 'var(--semi-color-bg-0)', border: '1px solid var(--semi-color-border)', borderRadius: 12, padding: 12 }}> <div
<div className="flex items-center gap-2 mb-2"> 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' /> <Badge dot type='success' />
<Text type='tertiary'>{t('已启用')}</Text> <Text type='tertiary'>{t('已启用')}</Text>
</div> </div>
<div className="flex items-end gap-2 mb-2"> <div className='flex items-end gap-2 mb-2'>
<Text style={{ fontSize: 18, fontWeight: 700, color: '#22c55e' }}>{enabledCount}</Text> <Text
<Text style={{ fontSize: 18, color: 'var(--semi-color-text-2)' }}>/ {total}</Text> style={{ fontSize: 18, fontWeight: 700, color: '#22c55e' }}
>
{enabledCount}
</Text>
<Text
style={{ fontSize: 18, color: 'var(--semi-color-text-2)' }}
>
/ {total}
</Text>
</div> </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> </div>
</Col> </Col>
<Col span={8}> <Col span={8}>
<div style={{ background: 'var(--semi-color-bg-0)', border: '1px solid var(--semi-color-border)', borderRadius: 12, padding: 12 }}> <div
<div className="flex items-center gap-2 mb-2"> 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' /> <Badge dot type='danger' />
<Text type='tertiary'>{t('手动禁用')}</Text> <Text type='tertiary'>{t('手动禁用')}</Text>
</div> </div>
<div className="flex items-end gap-2 mb-2"> <div className='flex items-end gap-2 mb-2'>
<Text style={{ fontSize: 18, fontWeight: 700, color: '#ef4444' }}>{manualDisabledCount}</Text> <Text
<Text style={{ fontSize: 18, color: 'var(--semi-color-text-2)' }}>/ {total}</Text> style={{ fontSize: 18, fontWeight: 700, color: '#ef4444' }}
>
{manualDisabledCount}
</Text>
<Text
style={{ fontSize: 18, color: 'var(--semi-color-text-2)' }}
>
/ {total}
</Text>
</div> </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> </div>
</Col> </Col>
<Col span={8}> <Col span={8}>
<div style={{ background: 'var(--semi-color-bg-0)', border: '1px solid var(--semi-color-border)', borderRadius: 12, padding: 12 }}> <div
<div className="flex items-center gap-2 mb-2"> 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' /> <Badge dot type='warning' />
<Text type='tertiary'>{t('自动禁用')}</Text> <Text type='tertiary'>{t('自动禁用')}</Text>
</div> </div>
<div className="flex items-end gap-2 mb-2"> <div className='flex items-end gap-2 mb-2'>
<Text style={{ fontSize: 18, fontWeight: 700, color: '#f59e0b' }}>{autoDisabledCount}</Text> <Text
<Text style={{ fontSize: 18, color: 'var(--semi-color-text-2)' }}>/ {total}</Text> style={{ fontSize: 18, fontWeight: 700, color: '#f59e0b' }}
>
{autoDisabledCount}
</Text>
<Text
style={{ fontSize: 18, color: 'var(--semi-color-text-2)' }}
>
/ {total}
</Text>
</div> </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> </div>
</Col> </Col>
</Row> </Row>
</div> </div>
{/* Table */} {/* Table */}
<div className="flex-1 flex flex-col min-h-0"> <div className='flex-1 flex flex-col min-h-0'>
<Spin spinning={loading}> <Spin spinning={loading}>
<Card className='!rounded-xl'> <Card className='!rounded-xl'>
<Table <Table
@@ -478,15 +569,26 @@ const MultiKeyManageModal = ({
size='small' size='small'
placeholder={t('全部状态')} placeholder={t('全部状态')}
> >
<Select.Option value={null}>{t('全部状态')}</Select.Option> <Select.Option value={null}>
<Select.Option value={1}>{t('已启用')}</Select.Option> {t('全部状态')}
<Select.Option value={2}>{t('手动禁用')}</Select.Option> </Select.Option>
<Select.Option value={3}>{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> </Select>
</Col> </Col>
</Row> </Row>
</Col> </Col>
<Col span={10} style={{ display: 'flex', justifyContent: 'flex-end' }}> <Col
span={10}
style={{ display: 'flex', justifyContent: 'flex-end' }}
>
<Space> <Space>
<Button <Button
size='small' size='small'
@@ -496,7 +598,7 @@ const MultiKeyManageModal = ({
> >
{t('刷新')} {t('刷新')}
</Button> </Button>
{(manualDisabledCount + autoDisabledCount) > 0 && ( {manualDisabledCount + autoDisabledCount > 0 && (
<Popconfirm <Popconfirm
title={t('确定要启用所有密钥吗?')} title={t('确定要启用所有密钥吗?')}
onConfirm={handleEnableAll} onConfirm={handleEnableAll}
@@ -529,7 +631,9 @@ const MultiKeyManageModal = ({
)} )}
<Popconfirm <Popconfirm
title={t('确定要删除所有已自动禁用的密钥吗?')} title={t('确定要删除所有已自动禁用的密钥吗?')}
content={t('此操作不可撤销,将永久删除已自动禁用的密钥')} content={t(
'此操作不可撤销,将永久删除已自动禁用的密钥',
)}
onConfirm={handleDeleteDisabledKeys} onConfirm={handleDeleteDisabledKeys}
okType={'danger'} okType={'danger'}
position={'topRight'} position={'topRight'}
@@ -562,7 +666,7 @@ const MultiKeyManageModal = ({
onShowSizeChange: (current, size) => { onShowSizeChange: (current, size) => {
setCurrentPage(1); setCurrentPage(1);
handlePageSizeChange(size); handlePageSizeChange(size);
} },
}} }}
size='small' size='small'
bordered={false} bordered={false}
@@ -570,8 +674,16 @@ const MultiKeyManageModal = ({
scroll={{ x: 'max-content' }} scroll={{ x: 'max-content' }}
empty={ empty={
<Empty <Empty
image={<IllustrationNoResult style={{ width: 140, height: 140 }} />} image={
darkModeImage={<IllustrationNoResultDark style={{ width: 140, height: 140 }} />} <IllustrationNoResult
style={{ width: 140, height: 140 }}
/>
}
darkModeImage={
<IllustrationNoResultDark
style={{ width: 140, height: 140 }}
/>
}
title={t('暂无密钥数据')} title={t('暂无密钥数据')}
description={t('请检查渠道配置或刷新重试')} description={t('请检查渠道配置或刷新重试')}
style={{ padding: 30 }} style={{ padding: 30 }}
@@ -36,20 +36,22 @@ const MjLogsActions = ({
const showSkeleton = useMinimumLoadingTime(loading); const showSkeleton = useMinimumLoadingTime(loading);
const placeholder = ( const placeholder = (
<div className="flex items-center mb-2 md:mb-0"> <div className='flex items-center mb-2 md:mb-0'>
<IconEyeOpened className="mr-2" /> <IconEyeOpened className='mr-2' />
<Skeleton.Title style={{ width: 300, height: 21, borderRadius: 6 }} /> <Skeleton.Title style={{ width: 300, height: 21, borderRadius: 6 }} />
</div> </div>
); );
return ( return (
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full"> <div className='flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full'>
<Skeleton loading={showSkeleton} active placeholder={placeholder}> <Skeleton loading={showSkeleton} active placeholder={placeholder}>
<div className="flex items-center mb-2 md:mb-0"> <div className='flex items-center mb-2 md:mb-0'>
<IconEyeOpened className="mr-2" /> <IconEyeOpened className='mr-2' />
<Text> <Text>
{isAdminUser && showBanner {isAdminUser && showBanner
? t('当前未开启Midjourney回调,部分项目可能无法获得绘图结果,可在运营设置中开启。') ? t(
'当前未开启Midjourney回调,部分项目可能无法获得绘图结果,可在运营设置中开启。',
)
: t('Midjourney 任务记录')} : t('Midjourney 任务记录')}
</Text> </Text>
</div> </div>
@@ -18,12 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/ */
import React from 'react'; import React from 'react';
import { import { Button, Progress, Tag, Typography } from '@douyinfe/semi-ui';
Button,
Progress,
Tag,
Typography
} from '@douyinfe/semi-ui';
import { import {
Palette, Palette,
ZoomIn, ZoomIn,
@@ -49,7 +44,7 @@ import {
Loader, Loader,
AlertCircle, AlertCircle,
Hash, Hash,
Video Video,
} from 'lucide-react'; } from 'lucide-react';
const colors = [ const colors = [
@@ -153,7 +148,11 @@ function renderType(type, t) {
); );
case 'INPAINT': case 'INPAINT':
return ( return (
<Tag color='violet' shape='circle' prefixIcon={<PaintBucket size={14} />}> <Tag
color='violet'
shape='circle'
prefixIcon={<PaintBucket size={14} />}
>
{t('局部重绘-提交')} {t('局部重绘-提交')}
</Tag> </Tag>
); );
@@ -177,7 +176,11 @@ function renderType(type, t) {
); );
case 'SWAP_FACE': case 'SWAP_FACE':
return ( return (
<Tag color='light-green' shape='circle' prefixIcon={<UserCheck size={14} />}> <Tag
color='light-green'
shape='circle'
prefixIcon={<UserCheck size={14} />}
>
{t('换脸')} {t('换脸')}
</Tag> </Tag>
); );
@@ -194,7 +197,11 @@ function renderCode(code, t) {
switch (code) { switch (code) {
case 1: case 1:
return ( return (
<Tag color='green' shape='circle' prefixIcon={<CheckCircle size={14} />}> <Tag
color='green'
shape='circle'
prefixIcon={<CheckCircle size={14} />}
>
{t('已提交')} {t('已提交')}
</Tag> </Tag>
); );
@@ -229,7 +236,11 @@ function renderStatus(type, t) {
switch (type) { switch (type) {
case 'SUCCESS': case 'SUCCESS':
return ( return (
<Tag color='green' shape='circle' prefixIcon={<CheckCircle size={14} />}> <Tag
color='green'
shape='circle'
prefixIcon={<CheckCircle size={14} />}
>
{t('成功')} {t('成功')}
</Tag> </Tag>
); );
@@ -259,7 +270,11 @@ function renderStatus(type, t) {
); );
case 'MODAL': case 'MODAL':
return ( return (
<Tag color='yellow' shape='circle' prefixIcon={<AlertCircle size={14} />}> <Tag
color='yellow'
shape='circle'
prefixIcon={<AlertCircle size={14} />}
>
{t('窗口等待')} {t('窗口等待')}
</Tag> </Tag>
); );
@@ -415,7 +430,7 @@ export const getMjLogsColumns = ({
} }
return ( return (
<Button <Button
size="small" size='small'
onClick={() => { onClick={() => {
openImageModal(text); openImageModal(text);
}} }}
@@ -37,23 +37,23 @@ const MjLogsFilters = ({
getFormApi={(api) => setFormApi(api)} getFormApi={(api) => setFormApi(api)}
onSubmit={refresh} onSubmit={refresh}
allowEmpty={true} allowEmpty={true}
autoComplete="off" autoComplete='off'
layout="vertical" layout='vertical'
trigger="change" trigger='change'
stopValidateWithError={false} stopValidateWithError={false}
> >
<div className="flex flex-col gap-2"> <div className='flex flex-col gap-2'>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-2"> <div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-2'>
{/* 时间选择器 */} {/* 时间选择器 */}
<div className="col-span-1 lg:col-span-2"> <div className='col-span-1 lg:col-span-2'>
<Form.DatePicker <Form.DatePicker
field='dateRange' field='dateRange'
className="w-full" className='w-full'
type='dateTimeRange' type='dateTimeRange'
placeholder={[t('开始时间'), t('结束时间')]} placeholder={[t('开始时间'), t('结束时间')]}
showClear showClear
pure pure
size="small" size='small'
/> />
</div> </div>
@@ -64,7 +64,7 @@ const MjLogsFilters = ({
placeholder={t('任务 ID')} placeholder={t('任务 ID')}
showClear showClear
pure pure
size="small" size='small'
/> />
{/* 渠道 ID - 仅管理员可见 */} {/* 渠道 ID - 仅管理员可见 */}
@@ -75,20 +75,20 @@ const MjLogsFilters = ({
placeholder={t('渠道 ID')} placeholder={t('渠道 ID')}
showClear showClear
pure pure
size="small" size='small'
/> />
)} )}
</div> </div>
{/* 操作按钮区域 */} {/* 操作按钮区域 */}
<div className="flex justify-between items-center"> <div className='flex justify-between items-center'>
<div></div> <div></div>
<div className="flex gap-2"> <div className='flex gap-2'>
<Button <Button
type='tertiary' type='tertiary'
htmlType='submit' htmlType='submit'
loading={loading} loading={loading}
size="small" size='small'
> >
{t('查询')} {t('查询')}
</Button> </Button>
@@ -102,14 +102,14 @@ const MjLogsFilters = ({
}, 100); }, 100);
} }
}} }}
size="small" size='small'
> >
{t('重置')} {t('重置')}
</Button> </Button>
<Button <Button
type='tertiary' type='tertiary'
onClick={() => setShowColumnSelector(true)} onClick={() => setShowColumnSelector(true)}
size="small" size='small'
> >
{t('列设置')} {t('列设置')}
</Button> </Button>
@@ -55,14 +55,7 @@ const MjLogsTable = (mjLogsData) => {
openImageModal, openImageModal,
isAdminUser, isAdminUser,
}); });
}, [ }, [t, COLUMN_KEYS, copyText, openContentModal, openImageModal, isAdminUser]);
t,
COLUMN_KEYS,
copyText,
openContentModal,
openImageModal,
isAdminUser,
]);
// Filter columns based on visibility settings // Filter columns based on visibility settings
const getVisibleColumns = () => { const getVisibleColumns = () => {
@@ -86,13 +79,11 @@ const MjLogsTable = (mjLogsData) => {
rowKey='key' rowKey='key'
loading={loading} loading={loading}
scroll={compactMode ? undefined : { x: 'max-content' }} scroll={compactMode ? undefined : { x: 'max-content' }}
className="rounded-xl overflow-hidden" className='rounded-xl overflow-hidden'
size="middle" size='middle'
empty={ empty={
<Empty <Empty
image={ image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
<IllustrationNoResult style={{ width: 150, height: 150 }} />
}
darkModeImage={ darkModeImage={
<IllustrationNoResultDark style={{ width: 150, height: 150 }} /> <IllustrationNoResultDark style={{ width: 150, height: 150 }} />
} }
+1 -1
View File
@@ -41,7 +41,7 @@ const MjLogsPage = () => {
<Layout> <Layout>
<CardPro <CardPro
type="type2" type='type2'
statsArea={<MjLogsActions {...mjLogsData} />} statsArea={<MjLogsActions {...mjLogsData} />}
searchArea={<MjLogsFilters {...mjLogsData} />} searchArea={<MjLogsFilters {...mjLogsData} />}
paginationArea={createCardProPagination({ paginationArea={createCardProPagination({
@@ -51,10 +51,8 @@ const ColumnSelectorModal = ({
visible={showColumnSelector} visible={showColumnSelector}
onCancel={() => setShowColumnSelector(false)} onCancel={() => setShowColumnSelector(false)}
footer={ footer={
<div className="flex justify-end"> <div className='flex justify-end'>
<Button onClick={() => initDefaultColumns()}> <Button onClick={() => initDefaultColumns()}>{t('重置')}</Button>
{t('重置')}
</Button>
<Button onClick={() => setShowColumnSelector(false)}> <Button onClick={() => setShowColumnSelector(false)}>
{t('取消')} {t('取消')}
</Button> </Button>
@@ -77,7 +75,7 @@ const ColumnSelectorModal = ({
</Checkbox> </Checkbox>
</div> </div>
<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)' }} style={{ border: '1px solid var(--semi-color-border)' }}
> >
{allColumns.map((column) => { {allColumns.map((column) => {
@@ -91,7 +89,7 @@ const ColumnSelectorModal = ({
} }
return ( 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 <Checkbox
checked={!!visibleColumns[column.key]} checked={!!visibleColumns[column.key]}
onChange={(e) => onChange={(e) =>

Some files were not shown because too many files have changed in this diff Show More