🎨 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 105b86c660
commit 6a87808612
274 changed files with 11025 additions and 7659 deletions

View File

@@ -1,34 +1,42 @@
module.exports = {
root: true,
env: { browser: true, es2021: true, node: true },
parserOptions: { ecmaVersion: 2020, sourceType: 'module', ecmaFeatures: { jsx: true } },
parserOptions: {
ecmaVersion: 2020,
sourceType: 'module',
ecmaFeatures: { jsx: true },
},
plugins: ['header', 'react-hooks'],
overrides: [
{
files: ['**/*.{js,jsx}'],
rules: {
'header/header': [2, 'block', [
'',
'Copyright (C) 2025 QuantumNous',
'',
'This program is free software: you can redistribute it and/or modify',
'it under the terms of the GNU Affero General Public License as',
'published by the Free Software Foundation, either version 3 of the',
'License, or (at your option) any later version.',
'',
'This program is distributed in the hope that it will be useful,',
'but WITHOUT ANY WARRANTY; without even the implied warranty of',
'MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the',
'GNU Affero General Public License for more details.',
'',
'You should have received a copy of the GNU Affero General Public License',
'along with this program. If not, see <https://www.gnu.org/licenses/>.',
'',
'For commercial licensing, please contact support@quantumnous.com',
''
]],
'no-multiple-empty-lines': ['error', { max: 1 }]
}
}
]
};
'header/header': [
2,
'block',
[
'',
'Copyright (C) 2025 QuantumNous',
'',
'This program is free software: you can redistribute it and/or modify',
'it under the terms of the GNU Affero General Public License as',
'published by the Free Software Foundation, either version 3 of the',
'License, or (at your option) any later version.',
'',
'This program is distributed in the hope that it will be useful,',
'but WITHOUT ANY WARRANTY; without even the implied warranty of',
'MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the',
'GNU Affero General Public License for more details.',
'',
'You should have received a copy of the GNU Affero General Public License',
'along with this program. If not, see <https://www.gnu.org/licenses/>.',
'',
'For commercial licensing, please contact support@quantumnous.com',
'',
],
],
'no-multiple-empty-lines': ['error', { max: 1 }],
},
},
],
};

View File

@@ -1,19 +1,20 @@
<!doctype html>
<html lang="zh">
<head>
<meta charset="utf-8" />
<link rel="icon" href="/logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#ffffff" />
<meta
name="description"
content="OpenAI 接口聚合管理,支持多种渠道包括 Azure可用于二次分发管理 key仅单可执行文件已打包好 Docker 镜像,一键部署,开箱即用"
/>
<title>New API</title>
</head>
<head>
<meta charset="utf-8" />
<link rel="icon" href="/logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#ffffff" />
<meta name="description" content="OpenAI 接口聚合管理,支持多种渠道包括 Azure可用于二次分发管理 key仅单可执行文件已打包好 Docker 镜像,一键部署,开箱即用" />
<title>New API</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="/src/index.jsx"></script>
</body>
</html>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="/src/index.jsx"></script>
</body>
</html>

View File

@@ -22,4 +22,4 @@ export default {
tailwindcss: {},
autoprefixer: {},
},
}
};

View File

@@ -73,10 +73,7 @@ function App() {
</Suspense>
}
/>
<Route
path='/forbidden'
element={<Forbidden />}
/>
<Route path='/forbidden' element={<Forbidden />} />
<Route
path='/console/models'
element={

View File

@@ -31,17 +31,10 @@ import {
setUserData,
onGitHubOAuthClicked,
onOIDCClicked,
onLinuxDOOAuthClicked
onLinuxDOOAuthClicked,
} from '../../helpers';
import Turnstile from 'react-turnstile';
import {
Button,
Card,
Divider,
Form,
Icon,
Modal,
} from '@douyinfe/semi-ui';
import { Button, Card, Divider, Form, Icon, Modal } from '@douyinfe/semi-ui';
import Title from '@douyinfe/semi-ui/lib/es/typography/title';
import Text from '@douyinfe/semi-ui/lib/es/typography/text';
import TelegramLoginButton from 'react-telegram-login';
@@ -77,7 +70,8 @@ const LoginForm = () => {
const [emailLoginLoading, setEmailLoginLoading] = useState(false);
const [loginLoading, setLoginLoading] = useState(false);
const [resetPasswordLoading, setResetPasswordLoading] = useState(false);
const [otherLoginOptionsLoading, setOtherLoginOptionsLoading] = useState(false);
const [otherLoginOptionsLoading, setOtherLoginOptionsLoading] =
useState(false);
const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false);
const [showTwoFA, setShowTwoFA] = useState(false);
@@ -247,10 +241,7 @@ const LoginForm = () => {
const handleOIDCClick = () => {
setOidcLoading(true);
try {
onOIDCClicked(
status.oidc_authorization_endpoint,
status.oidc_client_id
);
onOIDCClicked(status.oidc_authorization_endpoint, status.oidc_client_id);
} finally {
// 由于重定向,这里不会执行到,但为了完整性添加
setTimeout(() => setOidcLoading(false), 3000);
@@ -306,73 +297,87 @@ const LoginForm = () => {
const renderOAuthOptions = () => {
return (
<div className="flex flex-col items-center">
<div className="w-full max-w-md">
<div className="flex items-center justify-center mb-6 gap-2">
<img src={logo} alt="Logo" className="h-10 rounded-full" />
<Title heading={3} className='!text-gray-800'>{systemName}</Title>
<div className='flex flex-col items-center'>
<div className='w-full max-w-md'>
<div className='flex items-center justify-center mb-6 gap-2'>
<img src={logo} alt='Logo' className='h-10 rounded-full' />
<Title heading={3} className='!text-gray-800'>
{systemName}
</Title>
</div>
<Card className="border-0 !rounded-2xl overflow-hidden">
<div className="flex justify-center pt-6 pb-2">
<Title heading={3} className="text-gray-800 dark:text-gray-200">{t('登 录')}</Title>
<Card className='border-0 !rounded-2xl overflow-hidden'>
<div className='flex justify-center pt-6 pb-2'>
<Title heading={3} className='text-gray-800 dark:text-gray-200'>
{t('登 录')}
</Title>
</div>
<div className="px-2 py-8">
<div className="space-y-3">
<div className='px-2 py-8'>
<div className='space-y-3'>
{status.wechat_login && (
<Button
theme='outline'
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors"
type="tertiary"
icon={<Icon svg={<WeChatIcon />} style={{ color: '#07C160' }} />}
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
type='tertiary'
icon={
<Icon svg={<WeChatIcon />} style={{ color: '#07C160' }} />
}
onClick={onWeChatLoginClicked}
loading={wechatLoading}
>
<span className="ml-3">{t('使用 微信 继续')}</span>
<span className='ml-3'>{t('使用 微信 继续')}</span>
</Button>
)}
{status.github_oauth && (
<Button
theme='outline'
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors"
type="tertiary"
icon={<IconGithubLogo size="large" />}
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
type='tertiary'
icon={<IconGithubLogo size='large' />}
onClick={handleGitHubClick}
loading={githubLoading}
>
<span className="ml-3">{t('使用 GitHub 继续')}</span>
<span className='ml-3'>{t('使用 GitHub 继续')}</span>
</Button>
)}
{status.oidc_enabled && (
<Button
theme='outline'
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors"
type="tertiary"
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
type='tertiary'
icon={<OIDCIcon style={{ color: '#1877F2' }} />}
onClick={handleOIDCClick}
loading={oidcLoading}
>
<span className="ml-3">{t('使用 OIDC 继续')}</span>
<span className='ml-3'>{t('使用 OIDC 继续')}</span>
</Button>
)}
{status.linuxdo_oauth && (
<Button
theme='outline'
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors"
type="tertiary"
icon={<LinuxDoIcon style={{ color: '#E95420', width: '20px', height: '20px' }} />}
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
type='tertiary'
icon={
<LinuxDoIcon
style={{
color: '#E95420',
width: '20px',
height: '20px',
}}
/>
}
onClick={handleLinuxDOClick}
loading={linuxdoLoading}
>
<span className="ml-3">{t('使用 LinuxDO 继续')}</span>
<span className='ml-3'>{t('使用 LinuxDO 继续')}</span>
</Button>
)}
{status.telegram_oauth && (
<div className="flex justify-center my-2">
<div className='flex justify-center my-2'>
<TelegramLoginButton
dataOnauth={onTelegramLoginClicked}
botName={status.telegram_bot_name}
@@ -385,24 +390,24 @@ const LoginForm = () => {
</Divider>
<Button
theme="solid"
type="primary"
className="w-full h-12 flex items-center justify-center bg-black text-white !rounded-full hover:bg-gray-800 transition-colors"
icon={<IconMail size="large" />}
theme='solid'
type='primary'
className='w-full h-12 flex items-center justify-center bg-black text-white !rounded-full hover:bg-gray-800 transition-colors'
icon={<IconMail size='large' />}
onClick={handleEmailLoginClick}
loading={emailLoginLoading}
>
<span className="ml-3">{t('使用 邮箱或用户名 登录')}</span>
<span className='ml-3'>{t('使用 邮箱或用户名 登录')}</span>
</Button>
</div>
{!status.self_use_mode_enabled && (
<div className="mt-6 text-center text-sm">
<div className='mt-6 text-center text-sm'>
<Text>
{t('没有账户?')}{' '}
<Link
to="/register"
className="text-blue-600 hover:text-blue-800 font-medium"
to='/register'
className='text-blue-600 hover:text-blue-800 font-medium'
>
{t('注册')}
</Link>
@@ -418,44 +423,46 @@ const LoginForm = () => {
const renderEmailLoginForm = () => {
return (
<div className="flex flex-col items-center">
<div className="w-full max-w-md">
<div className="flex items-center justify-center mb-6 gap-2">
<img src={logo} alt="Logo" className="h-10 rounded-full" />
<div className='flex flex-col items-center'>
<div className='w-full max-w-md'>
<div className='flex items-center justify-center mb-6 gap-2'>
<img src={logo} alt='Logo' className='h-10 rounded-full' />
<Title heading={3}>{systemName}</Title>
</div>
<Card className="border-0 !rounded-2xl overflow-hidden">
<div className="flex justify-center pt-6 pb-2">
<Title heading={3} className="text-gray-800 dark:text-gray-200">{t('登 录')}</Title>
<Card className='border-0 !rounded-2xl overflow-hidden'>
<div className='flex justify-center pt-6 pb-2'>
<Title heading={3} className='text-gray-800 dark:text-gray-200'>
{t('登 录')}
</Title>
</div>
<div className="px-2 py-8">
<Form className="space-y-3">
<div className='px-2 py-8'>
<Form className='space-y-3'>
<Form.Input
field="username"
field='username'
label={t('用户名或邮箱')}
placeholder={t('请输入您的用户名或邮箱地址')}
name="username"
name='username'
onChange={(value) => handleChange('username', value)}
prefix={<IconMail />}
/>
<Form.Input
field="password"
field='password'
label={t('密码')}
placeholder={t('请输入您的密码')}
name="password"
mode="password"
name='password'
mode='password'
onChange={(value) => handleChange('password', value)}
prefix={<IconLock />}
/>
<div className="space-y-2 pt-2">
<div className='space-y-2 pt-2'>
<Button
theme="solid"
className="w-full !rounded-full"
type="primary"
htmlType="submit"
theme='solid'
className='w-full !rounded-full'
type='primary'
htmlType='submit'
onClick={handleSubmit}
loading={loginLoading}
>
@@ -463,9 +470,9 @@ const LoginForm = () => {
</Button>
<Button
theme="borderless"
theme='borderless'
type='tertiary'
className="w-full !rounded-full"
className='w-full !rounded-full'
onClick={handleResetPasswordClick}
loading={resetPasswordLoading}
>
@@ -474,17 +481,21 @@ const LoginForm = () => {
</div>
</Form>
{(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth) && (
{(status.github_oauth ||
status.oidc_enabled ||
status.wechat_login ||
status.linuxdo_oauth ||
status.telegram_oauth) && (
<>
<Divider margin='12px' align='center'>
{t('或')}
</Divider>
<div className="mt-4 text-center">
<div className='mt-4 text-center'>
<Button
theme="outline"
type="tertiary"
className="w-full !rounded-full"
theme='outline'
type='tertiary'
className='w-full !rounded-full'
onClick={handleOtherLoginOptionsClick}
loading={otherLoginOptionsLoading}
>
@@ -495,12 +506,12 @@ const LoginForm = () => {
)}
{!status.self_use_mode_enabled && (
<div className="mt-6 text-center text-sm">
<div className='mt-6 text-center text-sm'>
<Text>
{t('没有账户?')}{' '}
<Link
to="/register"
className="text-blue-600 hover:text-blue-800 font-medium"
to='/register'
className='text-blue-600 hover:text-blue-800 font-medium'
>
{t('注册')}
</Link>
@@ -529,21 +540,25 @@ const LoginForm = () => {
loading: wechatCodeSubmitLoading,
}}
>
<div className="flex flex-col items-center">
<img src={status.wechat_qrcode} alt="微信二维码" className="mb-4" />
<div className='flex flex-col items-center'>
<img src={status.wechat_qrcode} alt='微信二维码' className='mb-4' />
</div>
<div className="text-center mb-4">
<p>{t('微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)')}</p>
<div className='text-center mb-4'>
<p>
{t('微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)')}
</p>
</div>
<Form>
<Form.Input
field="wechat_verification_code"
field='wechat_verification_code'
placeholder={t('验证码')}
label={t('验证码')}
value={inputs.wechat_verification_code}
onChange={(value) => handleChange('wechat_verification_code', value)}
onChange={(value) =>
handleChange('wechat_verification_code', value)
}
/>
</Form>
</Modal>
@@ -555,10 +570,18 @@ const LoginForm = () => {
return (
<Modal
title={
<div className="flex items-center">
<div className="w-8 h-8 rounded-full bg-green-100 dark:bg-green-900 flex items-center justify-center mr-3">
<svg className="w-4 h-4 text-green-600 dark:text-green-400" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M6 8a2 2 0 11-4 0 2 2 0 014 0zM8 7a1 1 0 100 2h8a1 1 0 100-2H8zM6 14a2 2 0 11-4 0 2 2 0 014 0zM8 13a1 1 0 100 2h8a1 1 0 100-2H8z" clipRule="evenodd" />
<div className='flex items-center'>
<div className='w-8 h-8 rounded-full bg-green-100 dark:bg-green-900 flex items-center justify-center mr-3'>
<svg
className='w-4 h-4 text-green-600 dark:text-green-400'
fill='currentColor'
viewBox='0 0 20 20'
>
<path
fillRule='evenodd'
d='M6 8a2 2 0 11-4 0 2 2 0 014 0zM8 7a1 1 0 100 2h8a1 1 0 100-2H8zM6 14a2 2 0 11-4 0 2 2 0 014 0zM8 13a1 1 0 100 2h8a1 1 0 100-2H8z'
clipRule='evenodd'
/>
</svg>
</div>
两步验证
@@ -580,19 +603,32 @@ const LoginForm = () => {
};
return (
<div className="relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div className='relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
{/* 背景模糊晕染球 */}
<div className="blur-ball blur-ball-indigo" style={{ top: '-80px', right: '-80px', transform: 'none' }} />
<div className="blur-ball blur-ball-teal" style={{ top: '50%', left: '-120px' }} />
<div className="w-full max-w-sm mt-[60px]">
{showEmailLogin || !(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth)
<div
className='blur-ball blur-ball-indigo'
style={{ top: '-80px', right: '-80px', transform: 'none' }}
/>
<div
className='blur-ball blur-ball-teal'
style={{ top: '50%', left: '-120px' }}
/>
<div className='w-full max-w-sm mt-[60px]'>
{showEmailLogin ||
!(
status.github_oauth ||
status.oidc_enabled ||
status.wechat_login ||
status.linuxdo_oauth ||
status.telegram_oauth
)
? renderEmailLoginForm()
: renderOAuthOptions()}
{renderWeChatLoginModal()}
{render2FAModal()}
{turnstileEnabled && (
<div className="flex justify-center mt-6">
<div className='flex justify-center mt-6'>
<Turnstile
sitekey={turnstileSiteKey}
onVerify={(token) => {

View File

@@ -20,7 +20,13 @@ For commercial licensing, please contact support@quantumnous.com
import React, { useContext, useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { API, showError, showSuccess, updateAPI, setUserData } from '../../helpers';
import {
API,
showError,
showSuccess,
updateAPI,
setUserData,
} from '../../helpers';
import { UserContext } from '../../context/User';
import Loading from '../common/ui/Loading';

View File

@@ -18,7 +18,14 @@ For commercial licensing, please contact support@quantumnous.com
*/
import React, { useEffect, useState } from 'react';
import { API, copy, showError, showNotice, getLogo, getSystemName } from '../../helpers';
import {
API,
copy,
showError,
showNotice,
getLogo,
getSystemName,
} from '../../helpers';
import { useSearchParams, Link } from 'react-router-dom';
import { Button, Card, Form, Typography, Banner } from '@douyinfe/semi-ui';
import { IconMail, IconLock, IconCopy } from '@douyinfe/semi-icons';
@@ -55,7 +62,7 @@ const PasswordResetConfirm = () => {
if (formApi) {
formApi.setValues({
email: email || '',
newPassword: newPassword || ''
newPassword: newPassword || '',
});
}
}, [searchParams, newPassword, formApi]);
@@ -97,40 +104,53 @@ const PasswordResetConfirm = () => {
}
return (
<div className="relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div className='relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
{/* 背景模糊晕染球 */}
<div className="blur-ball blur-ball-indigo" style={{ top: '-80px', right: '-80px', transform: 'none' }} />
<div className="blur-ball blur-ball-teal" style={{ top: '50%', left: '-120px' }} />
<div className="w-full max-w-sm mt-[60px]">
<div className="flex flex-col items-center">
<div className="w-full max-w-md">
<div className="flex items-center justify-center mb-6 gap-2">
<img src={logo} alt="Logo" className="h-10 rounded-full" />
<Title heading={3} className='!text-gray-800'>{systemName}</Title>
<div
className='blur-ball blur-ball-indigo'
style={{ top: '-80px', right: '-80px', transform: 'none' }}
/>
<div
className='blur-ball blur-ball-teal'
style={{ top: '50%', left: '-120px' }}
/>
<div className='w-full max-w-sm mt-[60px]'>
<div className='flex flex-col items-center'>
<div className='w-full max-w-md'>
<div className='flex items-center justify-center mb-6 gap-2'>
<img src={logo} alt='Logo' className='h-10 rounded-full' />
<Title heading={3} className='!text-gray-800'>
{systemName}
</Title>
</div>
<Card className="border-0 !rounded-2xl overflow-hidden">
<div className="flex justify-center pt-6 pb-2">
<Title heading={3} className="text-gray-800 dark:text-gray-200">{t('密码重置确认')}</Title>
<Card className='border-0 !rounded-2xl overflow-hidden'>
<div className='flex justify-center pt-6 pb-2'>
<Title heading={3} className='text-gray-800 dark:text-gray-200'>
{t('密码重置确认')}
</Title>
</div>
<div className="px-2 py-8">
<div className='px-2 py-8'>
{!isValidResetLink && (
<Banner
type="danger"
type='danger'
description={t('无效的重置链接,请重新发起密码重置请求')}
className="mb-4 !rounded-lg"
className='mb-4 !rounded-lg'
closeIcon={null}
/>
)}
<Form
getFormApi={(api) => setFormApi(api)}
initValues={{ email: email || '', newPassword: newPassword || '' }}
className="space-y-4"
initValues={{
email: email || '',
newPassword: newPassword || '',
}}
className='space-y-4'
>
<Form.Input
field="email"
field='email'
label={t('邮箱')}
name="email"
name='email'
disabled={true}
prefix={<IconMail />}
placeholder={email ? '' : t('等待获取邮箱信息...')}
@@ -138,19 +158,21 @@ const PasswordResetConfirm = () => {
{newPassword && (
<Form.Input
field="newPassword"
field='newPassword'
label={t('新密码')}
name="newPassword"
name='newPassword'
disabled={true}
prefix={<IconLock />}
suffix={
<Button
icon={<IconCopy />}
type="tertiary"
theme="borderless"
type='tertiary'
theme='borderless'
onClick={async () => {
await copy(newPassword);
showNotice(`${t('密码已复制到剪贴板:')} ${newPassword}`);
showNotice(
`${t('密码已复制到剪贴板:')} ${newPassword}`,
);
}}
>
{t('复制')}
@@ -159,23 +181,32 @@ const PasswordResetConfirm = () => {
/>
)}
<div className="space-y-2 pt-2">
<div className='space-y-2 pt-2'>
<Button
theme="solid"
className="w-full !rounded-full"
type="primary"
htmlType="submit"
theme='solid'
className='w-full !rounded-full'
type='primary'
htmlType='submit'
onClick={handleSubmit}
loading={loading}
disabled={disableButton || newPassword || !isValidResetLink}
disabled={
disableButton || newPassword || !isValidResetLink
}
>
{newPassword ? t('密码重置完成') : t('确认重置密码')}
</Button>
</div>
</Form>
<div className="mt-6 text-center text-sm">
<Text><Link to="/login" className="text-blue-600 hover:text-blue-800 font-medium">{t('返回登录')}</Link></Text>
<div className='mt-6 text-center text-sm'>
<Text>
<Link
to='/login'
className='text-blue-600 hover:text-blue-800 font-medium'
>
{t('返回登录')}
</Link>
</Text>
</div>
</div>
</Card>

View File

@@ -18,7 +18,14 @@ For commercial licensing, please contact support@quantumnous.com
*/
import React, { useEffect, useState } from 'react';
import { API, getLogo, showError, showInfo, showSuccess, getSystemName } from '../../helpers';
import {
API,
getLogo,
showError,
showInfo,
showSuccess,
getSystemName,
} from '../../helpers';
import Turnstile from 'react-turnstile';
import { Button, Card, Form, Typography } from '@douyinfe/semi-ui';
import { IconMail } from '@douyinfe/semi-icons';
@@ -97,57 +104,77 @@ const PasswordResetForm = () => {
}
return (
<div className="relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div className='relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
{/* 背景模糊晕染球 */}
<div className="blur-ball blur-ball-indigo" style={{ top: '-80px', right: '-80px', transform: 'none' }} />
<div className="blur-ball blur-ball-teal" style={{ top: '50%', left: '-120px' }} />
<div className="w-full max-w-sm mt-[60px]">
<div className="flex flex-col items-center">
<div className="w-full max-w-md">
<div className="flex items-center justify-center mb-6 gap-2">
<img src={logo} alt="Logo" className="h-10 rounded-full" />
<Title heading={3} className='!text-gray-800'>{systemName}</Title>
<div
className='blur-ball blur-ball-indigo'
style={{ top: '-80px', right: '-80px', transform: 'none' }}
/>
<div
className='blur-ball blur-ball-teal'
style={{ top: '50%', left: '-120px' }}
/>
<div className='w-full max-w-sm mt-[60px]'>
<div className='flex flex-col items-center'>
<div className='w-full max-w-md'>
<div className='flex items-center justify-center mb-6 gap-2'>
<img src={logo} alt='Logo' className='h-10 rounded-full' />
<Title heading={3} className='!text-gray-800'>
{systemName}
</Title>
</div>
<Card className="border-0 !rounded-2xl overflow-hidden">
<div className="flex justify-center pt-6 pb-2">
<Title heading={3} className="text-gray-800 dark:text-gray-200">{t('密码重置')}</Title>
<Card className='border-0 !rounded-2xl overflow-hidden'>
<div className='flex justify-center pt-6 pb-2'>
<Title heading={3} className='text-gray-800 dark:text-gray-200'>
{t('密码重置')}
</Title>
</div>
<div className="px-2 py-8">
<Form className="space-y-3">
<div className='px-2 py-8'>
<Form className='space-y-3'>
<Form.Input
field="email"
field='email'
label={t('邮箱')}
placeholder={t('请输入您的邮箱地址')}
name="email"
name='email'
value={email}
onChange={handleChange}
prefix={<IconMail />}
/>
<div className="space-y-2 pt-2">
<div className='space-y-2 pt-2'>
<Button
theme="solid"
className="w-full !rounded-full"
type="primary"
htmlType="submit"
theme='solid'
className='w-full !rounded-full'
type='primary'
htmlType='submit'
onClick={handleSubmit}
loading={loading}
disabled={disableButton}
>
{disableButton ? `${t('重试')} (${countdown})` : t('提交')}
{disableButton
? `${t('重试')} (${countdown})`
: t('提交')}
</Button>
</div>
</Form>
<div className="mt-6 text-center text-sm">
<Text>{t('想起来了?')} <Link to="/login" className="text-blue-600 hover:text-blue-800 font-medium">{t('登录')}</Link></Text>
<div className='mt-6 text-center text-sm'>
<Text>
{t('想起来了?')}{' '}
<Link
to='/login'
className='text-blue-600 hover:text-blue-800 font-medium'
>
{t('登录')}
</Link>
</Text>
</div>
</div>
</Card>
{turnstileEnabled && (
<div className="flex justify-center mt-6">
<div className='flex justify-center mt-6'>
<Turnstile
sitekey={turnstileSiteKey}
onVerify={(token) => {

View File

@@ -27,20 +27,19 @@ import {
showSuccess,
updateAPI,
getSystemName,
setUserData
setUserData,
} from '../../helpers';
import Turnstile from 'react-turnstile';
import {
Button,
Card,
Divider,
Form,
Icon,
Modal,
} from '@douyinfe/semi-ui';
import { Button, Card, Divider, Form, Icon, Modal } from '@douyinfe/semi-ui';
import Title from '@douyinfe/semi-ui/lib/es/typography/title';
import Text from '@douyinfe/semi-ui/lib/es/typography/text';
import { IconGithubLogo, IconMail, IconUser, IconLock, IconKey } from '@douyinfe/semi-icons';
import {
IconGithubLogo,
IconMail,
IconUser,
IconLock,
IconKey,
} from '@douyinfe/semi-icons';
import {
onGitHubOAuthClicked,
onLinuxDOOAuthClicked,
@@ -78,7 +77,8 @@ const RegisterForm = () => {
const [emailRegisterLoading, setEmailRegisterLoading] = useState(false);
const [registerLoading, setRegisterLoading] = useState(false);
const [verificationCodeLoading, setVerificationCodeLoading] = useState(false);
const [otherRegisterOptionsLoading, setOtherRegisterOptionsLoading] = useState(false);
const [otherRegisterOptionsLoading, setOtherRegisterOptionsLoading] =
useState(false);
const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false);
const [disableButton, setDisableButton] = useState(false);
const [countdown, setCountdown] = useState(30);
@@ -236,10 +236,7 @@ const RegisterForm = () => {
const handleOIDCClick = () => {
setOidcLoading(true);
try {
onOIDCClicked(
status.oidc_authorization_endpoint,
status.oidc_client_id
);
onOIDCClicked(status.oidc_authorization_endpoint, status.oidc_client_id);
} finally {
setTimeout(() => setOidcLoading(false), 3000);
}
@@ -303,73 +300,87 @@ const RegisterForm = () => {
const renderOAuthOptions = () => {
return (
<div className="flex flex-col items-center">
<div className="w-full max-w-md">
<div className="flex items-center justify-center mb-6 gap-2">
<img src={logo} alt="Logo" className="h-10 rounded-full" />
<Title heading={3} className='!text-gray-800'>{systemName}</Title>
<div className='flex flex-col items-center'>
<div className='w-full max-w-md'>
<div className='flex items-center justify-center mb-6 gap-2'>
<img src={logo} alt='Logo' className='h-10 rounded-full' />
<Title heading={3} className='!text-gray-800'>
{systemName}
</Title>
</div>
<Card className="border-0 !rounded-2xl overflow-hidden">
<div className="flex justify-center pt-6 pb-2">
<Title heading={3} className="text-gray-800 dark:text-gray-200">{t('注 册')}</Title>
<Card className='border-0 !rounded-2xl overflow-hidden'>
<div className='flex justify-center pt-6 pb-2'>
<Title heading={3} className='text-gray-800 dark:text-gray-200'>
{t('注 册')}
</Title>
</div>
<div className="px-2 py-8">
<div className="space-y-3">
<div className='px-2 py-8'>
<div className='space-y-3'>
{status.wechat_login && (
<Button
theme='outline'
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors"
type="tertiary"
icon={<Icon svg={<WeChatIcon />} style={{ color: '#07C160' }} />}
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
type='tertiary'
icon={
<Icon svg={<WeChatIcon />} style={{ color: '#07C160' }} />
}
onClick={onWeChatLoginClicked}
loading={wechatLoading}
>
<span className="ml-3">{t('使用 微信 继续')}</span>
<span className='ml-3'>{t('使用 微信 继续')}</span>
</Button>
)}
{status.github_oauth && (
<Button
theme='outline'
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors"
type="tertiary"
icon={<IconGithubLogo size="large" />}
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
type='tertiary'
icon={<IconGithubLogo size='large' />}
onClick={handleGitHubClick}
loading={githubLoading}
>
<span className="ml-3">{t('使用 GitHub 继续')}</span>
<span className='ml-3'>{t('使用 GitHub 继续')}</span>
</Button>
)}
{status.oidc_enabled && (
<Button
theme='outline'
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors"
type="tertiary"
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
type='tertiary'
icon={<OIDCIcon style={{ color: '#1877F2' }} />}
onClick={handleOIDCClick}
loading={oidcLoading}
>
<span className="ml-3">{t('使用 OIDC 继续')}</span>
<span className='ml-3'>{t('使用 OIDC 继续')}</span>
</Button>
)}
{status.linuxdo_oauth && (
<Button
theme='outline'
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors"
type="tertiary"
icon={<LinuxDoIcon style={{ color: '#E95420', width: '20px', height: '20px' }} />}
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
type='tertiary'
icon={
<LinuxDoIcon
style={{
color: '#E95420',
width: '20px',
height: '20px',
}}
/>
}
onClick={handleLinuxDOClick}
loading={linuxdoLoading}
>
<span className="ml-3">{t('使用 LinuxDO 继续')}</span>
<span className='ml-3'>{t('使用 LinuxDO 继续')}</span>
</Button>
)}
{status.telegram_oauth && (
<div className="flex justify-center my-2">
<div className='flex justify-center my-2'>
<TelegramLoginButton
dataOnauth={onTelegramLoginClicked}
botName={status.telegram_bot_name}
@@ -382,19 +393,27 @@ const RegisterForm = () => {
</Divider>
<Button
theme="solid"
type="primary"
className="w-full h-12 flex items-center justify-center bg-black text-white !rounded-full hover:bg-gray-800 transition-colors"
icon={<IconMail size="large" />}
theme='solid'
type='primary'
className='w-full h-12 flex items-center justify-center bg-black text-white !rounded-full hover:bg-gray-800 transition-colors'
icon={<IconMail size='large' />}
onClick={handleEmailRegisterClick}
loading={emailRegisterLoading}
>
<span className="ml-3">{t('使用 用户名 注册')}</span>
<span className='ml-3'>{t('使用 用户名 注册')}</span>
</Button>
</div>
<div className="mt-6 text-center text-sm">
<Text>{t('已有账户?')} <Link to="/login" className="text-blue-600 hover:text-blue-800 font-medium">{t('登录')}</Link></Text>
<div className='mt-6 text-center text-sm'>
<Text>
{t('已有账户?')}{' '}
<Link
to='/login'
className='text-blue-600 hover:text-blue-800 font-medium'
>
{t('登录')}
</Link>
</Text>
</div>
</div>
</Card>
@@ -405,44 +424,48 @@ const RegisterForm = () => {
const renderEmailRegisterForm = () => {
return (
<div className="flex flex-col items-center">
<div className="w-full max-w-md">
<div className="flex items-center justify-center mb-6 gap-2">
<img src={logo} alt="Logo" className="h-10 rounded-full" />
<Title heading={3} className='!text-gray-800'>{systemName}</Title>
<div className='flex flex-col items-center'>
<div className='w-full max-w-md'>
<div className='flex items-center justify-center mb-6 gap-2'>
<img src={logo} alt='Logo' className='h-10 rounded-full' />
<Title heading={3} className='!text-gray-800'>
{systemName}
</Title>
</div>
<Card className="border-0 !rounded-2xl overflow-hidden">
<div className="flex justify-center pt-6 pb-2">
<Title heading={3} className="text-gray-800 dark:text-gray-200">{t('注 册')}</Title>
<Card className='border-0 !rounded-2xl overflow-hidden'>
<div className='flex justify-center pt-6 pb-2'>
<Title heading={3} className='text-gray-800 dark:text-gray-200'>
{t('注 册')}
</Title>
</div>
<div className="px-2 py-8">
<Form className="space-y-3">
<div className='px-2 py-8'>
<Form className='space-y-3'>
<Form.Input
field="username"
field='username'
label={t('用户名')}
placeholder={t('请输入用户名')}
name="username"
name='username'
onChange={(value) => handleChange('username', value)}
prefix={<IconUser />}
/>
<Form.Input
field="password"
field='password'
label={t('密码')}
placeholder={t('输入密码,最短 8 位,最长 20 位')}
name="password"
mode="password"
name='password'
mode='password'
onChange={(value) => handleChange('password', value)}
prefix={<IconLock />}
/>
<Form.Input
field="password2"
field='password2'
label={t('确认密码')}
placeholder={t('确认密码')}
name="password2"
mode="password"
name='password2'
mode='password'
onChange={(value) => handleChange('password2', value)}
prefix={<IconLock />}
/>
@@ -450,11 +473,11 @@ const RegisterForm = () => {
{showEmailVerification && (
<>
<Form.Input
field="email"
field='email'
label={t('邮箱')}
placeholder={t('输入邮箱地址')}
name="email"
type="email"
name='email'
type='email'
onChange={(value) => handleChange('email', value)}
prefix={<IconMail />}
suffix={
@@ -463,27 +486,31 @@ const RegisterForm = () => {
loading={verificationCodeLoading}
disabled={disableButton || verificationCodeLoading}
>
{disableButton ? `${t('重新发送')} (${countdown})` : t('获取验证码')}
{disableButton
? `${t('重新发送')} (${countdown})`
: t('获取验证码')}
</Button>
}
/>
<Form.Input
field="verification_code"
field='verification_code'
label={t('验证码')}
placeholder={t('输入验证码')}
name="verification_code"
onChange={(value) => handleChange('verification_code', value)}
name='verification_code'
onChange={(value) =>
handleChange('verification_code', value)
}
prefix={<IconKey />}
/>
</>
)}
<div className="space-y-2 pt-2">
<div className='space-y-2 pt-2'>
<Button
theme="solid"
className="w-full !rounded-full"
type="primary"
htmlType="submit"
theme='solid'
className='w-full !rounded-full'
type='primary'
htmlType='submit'
onClick={handleSubmit}
loading={registerLoading}
>
@@ -492,17 +519,21 @@ const RegisterForm = () => {
</div>
</Form>
{(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth) && (
{(status.github_oauth ||
status.oidc_enabled ||
status.wechat_login ||
status.linuxdo_oauth ||
status.telegram_oauth) && (
<>
<Divider margin='12px' align='center'>
{t('或')}
</Divider>
<div className="mt-4 text-center">
<div className='mt-4 text-center'>
<Button
theme="outline"
type="tertiary"
className="w-full !rounded-full"
theme='outline'
type='tertiary'
className='w-full !rounded-full'
onClick={handleOtherRegisterOptionsClick}
loading={otherRegisterOptionsLoading}
>
@@ -512,8 +543,16 @@ const RegisterForm = () => {
</>
)}
<div className="mt-6 text-center text-sm">
<Text>{t('已有账户?')} <Link to="/login" className="text-blue-600 hover:text-blue-800 font-medium">{t('登录')}</Link></Text>
<div className='mt-6 text-center text-sm'>
<Text>
{t('已有账户?')}{' '}
<Link
to='/login'
className='text-blue-600 hover:text-blue-800 font-medium'
>
{t('登录')}
</Link>
</Text>
</div>
</div>
</Card>
@@ -536,21 +575,25 @@ const RegisterForm = () => {
loading: wechatCodeSubmitLoading,
}}
>
<div className="flex flex-col items-center">
<img src={status.wechat_qrcode} alt="微信二维码" className="mb-4" />
<div className='flex flex-col items-center'>
<img src={status.wechat_qrcode} alt='微信二维码' className='mb-4' />
</div>
<div className="text-center mb-4">
<p>{t('微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)')}</p>
<div className='text-center mb-4'>
<p>
{t('微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)')}
</p>
</div>
<Form>
<Form.Input
field="wechat_verification_code"
field='wechat_verification_code'
placeholder={t('验证码')}
label={t('验证码')}
value={inputs.wechat_verification_code}
onChange={(value) => handleChange('wechat_verification_code', value)}
onChange={(value) =>
handleChange('wechat_verification_code', value)
}
/>
</Form>
</Modal>
@@ -558,18 +601,31 @@ const RegisterForm = () => {
};
return (
<div className="relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div className='relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
{/* 背景模糊晕染球 */}
<div className="blur-ball blur-ball-indigo" style={{ top: '-80px', right: '-80px', transform: 'none' }} />
<div className="blur-ball blur-ball-teal" style={{ top: '50%', left: '-120px' }} />
<div className="w-full max-w-sm mt-[60px]">
{showEmailRegister || !(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth)
<div
className='blur-ball blur-ball-indigo'
style={{ top: '-80px', right: '-80px', transform: 'none' }}
/>
<div
className='blur-ball blur-ball-teal'
style={{ top: '50%', left: '-120px' }}
/>
<div className='w-full max-w-sm mt-[60px]'>
{showEmailRegister ||
!(
status.github_oauth ||
status.oidc_enabled ||
status.wechat_login ||
status.linuxdo_oauth ||
status.telegram_oauth
)
? renderEmailRegisterForm()
: renderOAuthOptions()}
{renderWeChatLoginModal()}
{turnstileEnabled && (
<div className="flex justify-center mt-6">
<div className='flex justify-center mt-6'>
<Turnstile
sitekey={turnstileSiteKey}
onVerify={(token) => {

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
*/
import { API, showError, showSuccess } from '../../helpers';
import { Button, Card, Divider, Form, Input, Typography } from '@douyinfe/semi-ui';
import {
Button,
Card,
Divider,
Form,
Input,
Typography,
} from '@douyinfe/semi-ui';
import React, { useState } from 'react';
const { Title, Text, Paragraph } = Typography;
@@ -44,7 +51,7 @@ const TwoFAVerification = ({ onSuccess, onBack, isModal = false }) => {
setLoading(true);
try {
const res = await API.post('/api/user/login/2fa', {
code: verificationCode
code: verificationCode,
});
if (res.data.success) {
@@ -72,30 +79,30 @@ const TwoFAVerification = ({ onSuccess, onBack, isModal = false }) => {
if (isModal) {
return (
<div className="space-y-4">
<Paragraph className="text-gray-600 dark:text-gray-300">
<div className='space-y-4'>
<Paragraph className='text-gray-600 dark:text-gray-300'>
请输入认证器应用显示的验证码完成登录
</Paragraph>
<Form onSubmit={handleSubmit}>
<Form.Input
field="code"
label={useBackupCode ? "备用码" : "验证码"}
placeholder={useBackupCode ? "请输入8位备用码" : "请输入6位验证码"}
field='code'
label={useBackupCode ? '备用码' : '验证码'}
placeholder={useBackupCode ? '请输入8位备用码' : '请输入6位验证码'}
value={verificationCode}
onChange={setVerificationCode}
onKeyPress={handleKeyPress}
size="large"
size='large'
style={{ marginBottom: 16 }}
autoFocus
/>
<Button
htmlType="submit"
type="primary"
htmlType='submit'
type='primary'
loading={loading}
block
size="large"
size='large'
style={{ marginBottom: 16 }}
>
验证并登录
@@ -106,8 +113,8 @@ const TwoFAVerification = ({ onSuccess, onBack, isModal = false }) => {
<div style={{ textAlign: 'center' }}>
<Button
theme="borderless"
type="tertiary"
theme='borderless'
type='tertiary'
onClick={() => {
setUseBackupCode(!useBackupCode);
setVerificationCode('');
@@ -119,8 +126,8 @@ const TwoFAVerification = ({ onSuccess, onBack, isModal = false }) => {
{onBack && (
<Button
theme="borderless"
type="tertiary"
theme='borderless'
type='tertiary'
onClick={onBack}
style={{ color: '#1890ff', padding: 0 }}
>
@@ -129,15 +136,14 @@ const TwoFAVerification = ({ onSuccess, onBack, isModal = false }) => {
)}
</div>
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-3">
<Text size="small" type="secondary">
<div className='bg-gray-50 dark:bg-gray-800 rounded-lg p-3'>
<Text size='small' type='secondary'>
<strong>提示</strong>
<br />
验证码每30秒更新一次
<br />
如果无法获取验证码请使用备用码
<br />
每个备用码只能使用一次
<br /> 每个备用码只能使用一次
</Text>
</div>
</div>
@@ -145,39 +151,41 @@ const TwoFAVerification = ({ onSuccess, onBack, isModal = false }) => {
}
return (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '60vh'
}}>
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '60vh',
}}
>
<Card style={{ width: 400, padding: 24 }}>
<div style={{ textAlign: 'center', marginBottom: 24 }}>
<Title heading={3}>两步验证</Title>
<Paragraph type="secondary">
<Paragraph type='secondary'>
请输入认证器应用显示的验证码完成登录
</Paragraph>
</div>
<Form onSubmit={handleSubmit}>
<Form.Input
field="code"
label={useBackupCode ? "备用码" : "验证码"}
placeholder={useBackupCode ? "请输入8位备用码" : "请输入6位验证码"}
field='code'
label={useBackupCode ? '备用码' : '验证码'}
placeholder={useBackupCode ? '请输入8位备用码' : '请输入6位验证码'}
value={verificationCode}
onChange={setVerificationCode}
onKeyPress={handleKeyPress}
size="large"
size='large'
style={{ marginBottom: 16 }}
autoFocus
/>
<Button
htmlType="submit"
type="primary"
htmlType='submit'
type='primary'
loading={loading}
block
size="large"
size='large'
style={{ marginBottom: 16 }}
>
验证并登录
@@ -188,8 +196,8 @@ const TwoFAVerification = ({ onSuccess, onBack, isModal = false }) => {
<div style={{ textAlign: 'center' }}>
<Button
theme="borderless"
type="tertiary"
theme='borderless'
type='tertiary'
onClick={() => {
setUseBackupCode(!useBackupCode);
setVerificationCode('');
@@ -201,8 +209,8 @@ const TwoFAVerification = ({ onSuccess, onBack, isModal = false }) => {
{onBack && (
<Button
theme="borderless"
type="tertiary"
theme='borderless'
type='tertiary'
onClick={onBack}
style={{ color: '#1890ff', padding: 0 }}
>
@@ -211,15 +219,21 @@ const TwoFAVerification = ({ onSuccess, onBack, isModal = false }) => {
)}
</div>
<div style={{ marginTop: 24, padding: 16, background: '#f6f8fa', borderRadius: 6 }}>
<Text size="small" type="secondary">
<div
style={{
marginTop: 24,
padding: 16,
background: '#f6f8fa',
borderRadius: 6,
}}
>
<Text size='small' type='secondary'>
<strong>提示</strong>
<br />
验证码每30秒更新一次
<br />
如果无法获取验证码请使用备用码
<br />
每个备用码只能使用一次
<br /> 每个备用码只能使用一次
</Text>
</div>
</Card>
@@ -227,4 +241,4 @@ const TwoFAVerification = ({ onSuccess, onBack, isModal = false }) => {
);
};
export default TwoFAVerification;
export default TwoFAVerification;

View File

@@ -160,7 +160,7 @@ export function PreCode(props) {
}}
>
<div
className="copy-code-button"
className='copy-code-button'
style={{
position: 'absolute',
top: '8px',
@@ -174,14 +174,15 @@ export function PreCode(props) {
>
<Tooltip content={t('复制代码')}>
<Button
size="small"
theme="borderless"
size='small'
theme='borderless'
icon={<IconCopy />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (ref.current) {
const code = ref.current.querySelector('code')?.innerText ?? '';
const code =
ref.current.querySelector('code')?.innerText ?? '';
copy(code).then((success) => {
if (success) {
Toast.success(t('代码已复制到剪贴板'));
@@ -217,7 +218,13 @@ export function PreCode(props) {
backgroundColor: 'var(--semi-color-bg-1)',
}}
>
<div style={{ marginBottom: '8px', fontSize: '12px', color: 'var(--semi-color-text-2)' }}>
<div
style={{
marginBottom: '8px',
fontSize: '12px',
color: 'var(--semi-color-text-2)',
}}
>
HTML预览:
</div>
<div dangerouslySetInnerHTML={{ __html: htmlCode }} />
@@ -258,7 +265,7 @@ function CustomCode(props) {
justifyContent: 'center',
}}
>
<Button size="small" onClick={toggleCollapsed} theme="solid">
<Button size='small' onClick={toggleCollapsed} theme='solid'>
{t('显示更多')}
</Button>
</div>
@@ -367,7 +374,16 @@ function _MarkdownContent(props) {
components={{
pre: PreCode,
code: CustomCode,
p: (pProps) => <p {...pProps} dir="auto" style={{ lineHeight: '1.6', color: isUserMessage ? 'white' : 'inherit' }} />,
p: (pProps) => (
<p
{...pProps}
dir='auto'
style={{
lineHeight: '1.6',
color: isUserMessage ? 'white' : 'inherit',
}}
/>
),
a: (aProps) => {
const href = aProps.href || '';
if (/\.(aac|mp3|opus|wav)$/.test(href)) {
@@ -379,13 +395,16 @@ function _MarkdownContent(props) {
}
if (/\.(3gp|3g2|webm|ogv|mpeg|mp4|avi)$/.test(href)) {
return (
<video controls style={{ width: '100%', maxWidth: '100%', margin: '12px 0' }}>
<video
controls
style={{ width: '100%', maxWidth: '100%', margin: '12px 0' }}
>
<source src={href} />
</video>
);
}
const isInternal = /^\/#/i.test(href);
const target = isInternal ? '_self' : aProps.target ?? '_blank';
const target = isInternal ? '_self' : (aProps.target ?? '_blank');
return (
<a
{...aProps}
@@ -403,20 +422,84 @@ function _MarkdownContent(props) {
/>
);
},
h1: (props) => <h1 {...props} style={{ fontSize: '24px', fontWeight: 'bold', margin: '20px 0 12px 0', color: isUserMessage ? 'white' : 'var(--semi-color-text-0)' }} />,
h2: (props) => <h2 {...props} style={{ fontSize: '20px', fontWeight: 'bold', margin: '18px 0 10px 0', color: isUserMessage ? 'white' : 'var(--semi-color-text-0)' }} />,
h3: (props) => <h3 {...props} style={{ fontSize: '18px', fontWeight: 'bold', margin: '16px 0 8px 0', color: isUserMessage ? 'white' : 'var(--semi-color-text-0)' }} />,
h4: (props) => <h4 {...props} style={{ fontSize: '16px', fontWeight: 'bold', margin: '14px 0 6px 0', color: isUserMessage ? 'white' : 'var(--semi-color-text-0)' }} />,
h5: (props) => <h5 {...props} style={{ fontSize: '14px', fontWeight: 'bold', margin: '12px 0 4px 0', color: isUserMessage ? 'white' : 'var(--semi-color-text-0)' }} />,
h6: (props) => <h6 {...props} style={{ fontSize: '13px', fontWeight: 'bold', margin: '10px 0 4px 0', color: isUserMessage ? 'white' : 'var(--semi-color-text-0)' }} />,
h1: (props) => (
<h1
{...props}
style={{
fontSize: '24px',
fontWeight: 'bold',
margin: '20px 0 12px 0',
color: isUserMessage ? 'white' : 'var(--semi-color-text-0)',
}}
/>
),
h2: (props) => (
<h2
{...props}
style={{
fontSize: '20px',
fontWeight: 'bold',
margin: '18px 0 10px 0',
color: isUserMessage ? 'white' : 'var(--semi-color-text-0)',
}}
/>
),
h3: (props) => (
<h3
{...props}
style={{
fontSize: '18px',
fontWeight: 'bold',
margin: '16px 0 8px 0',
color: isUserMessage ? 'white' : 'var(--semi-color-text-0)',
}}
/>
),
h4: (props) => (
<h4
{...props}
style={{
fontSize: '16px',
fontWeight: 'bold',
margin: '14px 0 6px 0',
color: isUserMessage ? 'white' : 'var(--semi-color-text-0)',
}}
/>
),
h5: (props) => (
<h5
{...props}
style={{
fontSize: '14px',
fontWeight: 'bold',
margin: '12px 0 4px 0',
color: isUserMessage ? 'white' : 'var(--semi-color-text-0)',
}}
/>
),
h6: (props) => (
<h6
{...props}
style={{
fontSize: '13px',
fontWeight: 'bold',
margin: '10px 0 4px 0',
color: isUserMessage ? 'white' : 'var(--semi-color-text-0)',
}}
/>
),
blockquote: (props) => (
<blockquote
{...props}
style={{
borderLeft: isUserMessage ? '4px solid rgba(255, 255, 255, 0.5)' : '4px solid var(--semi-color-primary)',
borderLeft: isUserMessage
? '4px solid rgba(255, 255, 255, 0.5)'
: '4px solid var(--semi-color-primary)',
paddingLeft: '16px',
margin: '12px 0',
backgroundColor: isUserMessage ? 'rgba(255, 255, 255, 0.1)' : 'var(--semi-color-fill-0)',
backgroundColor: isUserMessage
? 'rgba(255, 255, 255, 0.1)'
: 'var(--semi-color-fill-0)',
padding: '8px 16px',
borderRadius: '0 4px 4px 0',
fontStyle: 'italic',
@@ -424,9 +507,36 @@ function _MarkdownContent(props) {
}}
/>
),
ul: (props) => <ul {...props} style={{ margin: '8px 0', paddingLeft: '20px', color: isUserMessage ? 'white' : 'inherit' }} />,
ol: (props) => <ol {...props} style={{ margin: '8px 0', paddingLeft: '20px', color: isUserMessage ? 'white' : 'inherit' }} />,
li: (props) => <li {...props} style={{ margin: '4px 0', lineHeight: '1.6', color: isUserMessage ? 'white' : 'inherit' }} />,
ul: (props) => (
<ul
{...props}
style={{
margin: '8px 0',
paddingLeft: '20px',
color: isUserMessage ? 'white' : 'inherit',
}}
/>
),
ol: (props) => (
<ol
{...props}
style={{
margin: '8px 0',
paddingLeft: '20px',
color: isUserMessage ? 'white' : 'inherit',
}}
/>
),
li: (props) => (
<li
{...props}
style={{
margin: '4px 0',
lineHeight: '1.6',
color: isUserMessage ? 'white' : 'inherit',
}}
/>
),
table: (props) => (
<div style={{ overflow: 'auto', margin: '12px 0' }}>
<table
@@ -434,7 +544,9 @@ function _MarkdownContent(props) {
style={{
width: '100%',
borderCollapse: 'collapse',
border: isUserMessage ? '1px solid rgba(255, 255, 255, 0.3)' : '1px solid var(--semi-color-border)',
border: isUserMessage
? '1px solid rgba(255, 255, 255, 0.3)'
: '1px solid var(--semi-color-border)',
borderRadius: '6px',
overflow: 'hidden',
}}
@@ -446,8 +558,12 @@ function _MarkdownContent(props) {
{...props}
style={{
padding: '8px 12px',
backgroundColor: isUserMessage ? 'rgba(255, 255, 255, 0.2)' : 'var(--semi-color-fill-1)',
border: isUserMessage ? '1px solid rgba(255, 255, 255, 0.3)' : '1px solid var(--semi-color-border)',
backgroundColor: isUserMessage
? 'rgba(255, 255, 255, 0.2)'
: 'var(--semi-color-fill-1)',
border: isUserMessage
? '1px solid rgba(255, 255, 255, 0.3)'
: '1px solid var(--semi-color-border)',
fontWeight: 'bold',
textAlign: 'left',
color: isUserMessage ? 'white' : 'inherit',
@@ -459,7 +575,9 @@ function _MarkdownContent(props) {
{...props}
style={{
padding: '8px 12px',
border: isUserMessage ? '1px solid rgba(255, 255, 255, 0.3)' : '1px solid var(--semi-color-border)',
border: isUserMessage
? '1px solid rgba(255, 255, 255, 0.3)'
: '1px solid var(--semi-color-border)',
color: isUserMessage ? 'white' : 'inherit',
}}
/>
@@ -496,25 +614,29 @@ export function MarkdownRenderer(props) {
color: 'var(--semi-color-text-0)',
...style,
}}
dir="auto"
dir='auto'
{...otherProps}
>
{loading ? (
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '16px',
color: 'var(--semi-color-text-2)',
}}>
<div style={{
width: '16px',
height: '16px',
border: '2px solid var(--semi-color-border)',
borderTop: '2px solid var(--semi-color-primary)',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
}} />
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '16px',
color: 'var(--semi-color-text-2)',
}}
>
<div
style={{
width: '16px',
height: '16px',
border: '2px solid var(--semi-color-border)',
borderTop: '2px solid var(--semi-color-primary)',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
}}
/>
正在渲染...
</div>
) : (
@@ -529,4 +651,4 @@ export function MarkdownRenderer(props) {
);
}
export default MarkdownRenderer;
export default MarkdownRenderer;

View File

@@ -59,12 +59,12 @@
}
.user-message a {
color: #87CEEB !important;
color: #87ceeb !important;
/* 浅蓝色链接 */
}
.user-message a:hover {
color: #B0E0E6 !important;
color: #b0e0e6 !important;
/* hover时更浅的蓝色 */
}
@@ -298,7 +298,12 @@ pre:hover .copy-code-button {
.markdown-body hr {
border: none;
height: 1px;
background: linear-gradient(to right, transparent, var(--semi-color-border), transparent);
background: linear-gradient(
to right,
transparent,
var(--semi-color-border),
transparent
);
margin: 24px 0;
}
@@ -332,7 +337,7 @@ pre:hover .copy-code-button {
}
/* 任务列表样式 */
.markdown-body input[type="checkbox"] {
.markdown-body input[type='checkbox'] {
margin-right: 8px;
transform: scale(1.1);
}
@@ -441,4 +446,4 @@ pre:hover .copy-code-button {
.animate-fade-in {
animation: fade-in 0.6s cubic-bezier(0.22, 1, 0.36, 1) both;
will-change: opacity, transform;
}
}

View File

@@ -43,7 +43,7 @@ const TwoFactorAuthModal = ({
onCancel,
title,
description,
placeholder
placeholder,
}) => {
const { t } = useTranslation();
@@ -56,10 +56,18 @@ const TwoFactorAuthModal = ({
return (
<Modal
title={
<div className="flex items-center">
<div className="w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900 flex items-center justify-center mr-3">
<svg className="w-4 h-4 text-blue-600 dark:text-blue-400" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clipRule="evenodd" />
<div className='flex items-center'>
<div className='w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900 flex items-center justify-center mr-3'>
<svg
className='w-4 h-4 text-blue-600 dark:text-blue-400'
fill='currentColor'
viewBox='0 0 20 20'
>
<path
fillRule='evenodd'
d='M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z'
clipRule='evenodd'
/>
</svg>
</div>
{title || t('安全验证')}
@@ -69,11 +77,9 @@ const TwoFactorAuthModal = ({
onCancel={onCancel}
footer={
<>
<Button onClick={onCancel}>
{t('取消')}
</Button>
<Button onClick={onCancel}>{t('取消')}</Button>
<Button
type="primary"
type='primary'
loading={loading}
disabled={!code || loading}
onClick={onVerify}
@@ -85,18 +91,29 @@ const TwoFactorAuthModal = ({
width={500}
style={{ maxWidth: '90vw' }}
>
<div className="space-y-6">
<div className='space-y-6'>
{/* 安全提示 */}
<div className="bg-blue-50 dark:bg-blue-900 rounded-lg p-4">
<div className="flex items-start">
<svg className="w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 mr-3 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
<div className='bg-blue-50 dark:bg-blue-900 rounded-lg p-4'>
<div className='flex items-start'>
<svg
className='w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 mr-3 flex-shrink-0'
fill='currentColor'
viewBox='0 0 20 20'
>
<path
fillRule='evenodd'
d='M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z'
clipRule='evenodd'
/>
</svg>
<div>
<Typography.Text strong className="text-blue-800 dark:text-blue-200">
<Typography.Text
strong
className='text-blue-800 dark:text-blue-200'
>
{t('安全验证')}
</Typography.Text>
<Typography.Text className="block text-blue-700 dark:text-blue-300 text-sm mt-1">
<Typography.Text className='block text-blue-700 dark:text-blue-300 text-sm mt-1'>
{description || t('为了保护账户安全,请验证您的两步验证码。')}
</Typography.Text>
</div>
@@ -105,19 +122,19 @@ const TwoFactorAuthModal = ({
{/* 验证码输入 */}
<div>
<Typography.Text strong className="block mb-2">
<Typography.Text strong className='block mb-2'>
{t('验证身份')}
</Typography.Text>
<Input
placeholder={placeholder || t('请输入认证器验证码或备用码')}
value={code}
onChange={onCodeChange}
size="large"
size='large'
maxLength={8}
onKeyDown={handleKeyDown}
autoFocus
/>
<Typography.Text type="tertiary" size="small" className="mt-2 block">
<Typography.Text type='tertiary' size='small' className='mt-2 block'>
{t('支持6位TOTP验证码或8位备用码')}
</Typography.Text>
</div>
@@ -126,4 +143,4 @@ const TwoFactorAuthModal = ({
);
};
export default TwoFactorAuthModal;
export default TwoFactorAuthModal;

View File

@@ -27,15 +27,15 @@ const { Text } = Typography;
/**
* CardPro 高级卡片组件
*
*
* 布局分为6个区域
* 1. 统计信息区域 (statsArea)
* 2. 描述信息区域 (descriptionArea)
* 2. 描述信息区域 (descriptionArea)
* 3. 类型切换/标签区域 (tabsArea)
* 4. 操作按钮区域 (actionsArea)
* 5. 搜索表单区域 (searchArea)
* 6. 分页区域 (paginationArea) - 固定在卡片底部
*
*
* 支持三种布局类型:
* - type1: 操作型 (如TokensTable) - 描述信息 + 操作按钮 + 搜索表单
* - type2: 查询型 (如LogsTable) - 统计信息 + 搜索表单
@@ -71,47 +71,38 @@ const CardPro = ({
const hasMobileHideableContent = actionsArea || searchArea;
const renderHeader = () => {
const hasContent = statsArea || descriptionArea || tabsArea || actionsArea || searchArea;
const hasContent =
statsArea || descriptionArea || tabsArea || actionsArea || searchArea;
if (!hasContent) return null;
return (
<div className="flex flex-col w-full">
<div className='flex flex-col w-full'>
{/* 统计信息区域 - 用于type2 */}
{type === 'type2' && statsArea && (
<>
{statsArea}
</>
)}
{type === 'type2' && statsArea && <>{statsArea}</>}
{/* 描述信息区域 - 用于type1和type3 */}
{(type === 'type1' || type === 'type3') && descriptionArea && (
<>
{descriptionArea}
</>
<>{descriptionArea}</>
)}
{/* 第一个分隔线 - 在描述信息或统计信息后面 */}
{((type === 'type1' || type === 'type3') && descriptionArea) ||
(type === 'type2' && statsArea) ? (
<Divider margin="12px" />
(type === 'type2' && statsArea) ? (
<Divider margin='12px' />
) : null}
{/* 类型切换/标签区域 - 主要用于type3 */}
{type === 'type3' && tabsArea && (
<>
{tabsArea}
</>
)}
{type === 'type3' && tabsArea && <>{tabsArea}</>}
{/* 移动端操作切换按钮 */}
{isMobile && hasMobileHideableContent && (
<>
<div className="w-full mb-2">
<div className='w-full mb-2'>
<Button
onClick={toggleMobileActions}
icon={showMobileActions ? <IconEyeClosed /> : <IconEyeOpened />}
type="tertiary"
size="small"
type='tertiary'
size='small'
theme='outline'
block
>
@@ -126,32 +117,24 @@ const CardPro = ({
className={`flex flex-col gap-2 ${isMobile && !showMobileActions ? 'hidden' : ''}`}
>
{/* 操作按钮区域 - 用于type1和type3 */}
{(type === 'type1' || type === 'type3') && actionsArea && (
Array.isArray(actionsArea) ? (
{(type === 'type1' || type === 'type3') &&
actionsArea &&
(Array.isArray(actionsArea) ? (
actionsArea.map((area, idx) => (
<React.Fragment key={idx}>
{idx !== 0 && <Divider />}
<div className="w-full">
{area}
</div>
<div className='w-full'>{area}</div>
</React.Fragment>
))
) : (
<div className="w-full">
{actionsArea}
</div>
)
)}
<div className='w-full'>{actionsArea}</div>
))}
{/* 当同时存在操作区和搜索区时,插入分隔线 */}
{(actionsArea && searchArea) && <Divider />}
{actionsArea && searchArea && <Divider />}
{/* 搜索表单区域 - 所有类型都可能有 */}
{searchArea && (
<div className="w-full">
{searchArea}
</div>
)}
{searchArea && <div className='w-full'>{searchArea}</div>}
</div>
</div>
);
@@ -214,4 +197,4 @@ CardPro.propTypes = {
t: PropTypes.func,
};
export default CardPro;
export default CardPro;

View File

@@ -19,7 +19,15 @@ For commercial licensing, please contact support@quantumnous.com
import React, { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Table, Card, Skeleton, Pagination, Empty, Button, Collapsible } from '@douyinfe/semi-ui';
import {
Table,
Card,
Skeleton,
Pagination,
Empty,
Button,
Collapsible,
} from '@douyinfe/semi-ui';
import { IconChevronDown, IconChevronUp } from '@douyinfe/semi-icons';
import PropTypes from 'prop-types';
import { useIsMobile } from '../../../hooks/common/useIsMobile';
@@ -27,7 +35,7 @@ import { useMinimumLoadingTime } from '../../../hooks/common/useMinimumLoadingTi
/**
* CardTable 响应式表格组件
*
*
* 在桌面端渲染 Semi-UI 的 Table 组件,在移动端则将每一行数据渲染成 Card 形式。
* 该组件与 Table 组件的大部分 API 保持一致,只需将原 Table 换成 CardTable 即可。
*/
@@ -75,18 +83,22 @@ const CardTable = ({
const renderSkeletonCard = (key) => {
const placeholder = (
<div className="p-2">
<div className='p-2'>
{visibleCols.map((col, idx) => {
if (!col.title) {
return (
<div key={idx} className="mt-2 flex justify-end">
<div key={idx} className='mt-2 flex justify-end'>
<Skeleton.Title active style={{ width: 100, height: 24 }} />
</div>
);
}
return (
<div key={idx} className="flex justify-between items-center py-1 border-b last:border-b-0 border-dashed" style={{ borderColor: 'var(--semi-color-border)' }}>
<div
key={idx}
className='flex justify-between items-center py-1 border-b last:border-b-0 border-dashed'
style={{ borderColor: 'var(--semi-color-border)' }}
>
<Skeleton.Title active style={{ width: 80, height: 14 }} />
<Skeleton.Title
active
@@ -103,14 +115,14 @@ const CardTable = ({
);
return (
<Card key={key} className="!rounded-2xl shadow-sm">
<Card key={key} className='!rounded-2xl shadow-sm'>
<Skeleton loading={true} active placeholder={placeholder}></Skeleton>
</Card>
);
};
return (
<div className="flex flex-col gap-2">
<div className='flex flex-col gap-2'>
{[1, 2, 3].map((i) => renderSkeletonCard(i))}
</div>
);
@@ -127,9 +139,12 @@ const CardTable = ({
(!tableProps.rowExpandable || tableProps.rowExpandable(record));
return (
<Card key={rowKeyVal} className="!rounded-2xl shadow-sm">
<Card key={rowKeyVal} className='!rounded-2xl shadow-sm'>
{columns.map((col, colIdx) => {
if (tableProps?.visibleColumns && !tableProps.visibleColumns[col.key]) {
if (
tableProps?.visibleColumns &&
!tableProps.visibleColumns[col.key]
) {
return null;
}
@@ -140,7 +155,7 @@ const CardTable = ({
if (!title) {
return (
<div key={col.key || colIdx} className="mt-2 flex justify-end">
<div key={col.key || colIdx} className='mt-2 flex justify-end'>
{cellContent}
</div>
);
@@ -149,14 +164,16 @@ const CardTable = ({
return (
<div
key={col.key || colIdx}
className="flex justify-between items-start py-1 border-b last:border-b-0 border-dashed"
className='flex justify-between items-start py-1 border-b last:border-b-0 border-dashed'
style={{ borderColor: 'var(--semi-color-border)' }}
>
<span className="font-medium text-gray-600 mr-2 whitespace-nowrap select-none">
<span className='font-medium text-gray-600 mr-2 whitespace-nowrap select-none'>
{title}
</span>
<div className="flex-1 break-all flex justify-end items-center gap-1">
{cellContent !== undefined && cellContent !== null ? cellContent : '-'}
<div className='flex-1 break-all flex justify-end items-center gap-1'>
{cellContent !== undefined && cellContent !== null
? cellContent
: '-'}
</div>
</div>
);
@@ -177,7 +194,7 @@ const CardTable = ({
{showDetails ? t('收起') : t('详情')}
</Button>
<Collapsible isOpen={showDetails} keepDOM>
<div className="pt-2">
<div className='pt-2'>
{tableProps.expandedRowRender(record, index)}
</div>
</Collapsible>
@@ -190,19 +207,23 @@ const CardTable = ({
if (isEmpty) {
if (tableProps.empty) return tableProps.empty;
return (
<div className="flex justify-center p-4">
<Empty description="No Data" />
<div className='flex justify-center p-4'>
<Empty description='No Data' />
</div>
);
}
return (
<div className="flex flex-col gap-2">
<div className='flex flex-col gap-2'>
{dataSource.map((record, index) => (
<MobileRowCard key={getRowKey(record, index)} record={record} index={index} />
<MobileRowCard
key={getRowKey(record, index)}
record={record}
index={index}
/>
))}
{!hidePagination && tableProps.pagination && dataSource.length > 0 && (
<div className="mt-2 flex justify-center">
<div className='mt-2 flex justify-center'>
<Pagination {...tableProps.pagination} />
</div>
)}
@@ -218,4 +239,4 @@ CardTable.propTypes = {
hidePagination: PropTypes.bool,
};
export default CardTable;
export default CardTable;

View File

@@ -30,9 +30,9 @@ import { copy, showSuccess } from '../../../helpers';
*/
const parseChannelKeys = (keyData, t) => {
if (!keyData) return [];
const trimmed = keyData.trim();
// 检查是否是JSON数组格式如Vertex AI
if (trimmed.startsWith('[')) {
try {
@@ -40,9 +40,10 @@ const parseChannelKeys = (keyData, t) => {
if (Array.isArray(parsed)) {
return parsed.map((item, index) => ({
id: index,
content: typeof item === 'string' ? item : JSON.stringify(item, null, 2),
content:
typeof item === 'string' ? item : JSON.stringify(item, null, 2),
type: typeof item === 'string' ? 'text' : 'json',
label: `${t('密钥')} ${index + 1}`
label: `${t('密钥')} ${index + 1}`,
}));
}
} catch (e) {
@@ -50,25 +51,27 @@ const parseChannelKeys = (keyData, t) => {
console.warn('Failed to parse JSON keys:', e);
}
}
// 检查是否是多行密钥(按换行符分割)
const lines = trimmed.split('\n').filter(line => line.trim());
const lines = trimmed.split('\n').filter((line) => line.trim());
if (lines.length > 1) {
return lines.map((line, index) => ({
id: index,
content: line.trim(),
type: 'text',
label: `${t('密钥')} ${index + 1}`
label: `${t('密钥')} ${index + 1}`,
}));
}
// 单个密钥
return [{
id: 0,
content: trimmed,
type: trimmed.startsWith('{') ? 'json' : 'text',
label: t('密钥')
}];
return [
{
id: 0,
content: trimmed,
type: trimmed.startsWith('{') ? 'json' : 'text',
label: t('密钥'),
},
];
};
/**
@@ -85,7 +88,7 @@ const ChannelKeyDisplay = ({
showSuccessIcon = true,
successText,
showWarning = true,
warningText
warningText,
}) => {
const { t } = useTranslation();
@@ -103,34 +106,42 @@ const ChannelKeyDisplay = ({
};
return (
<div className="space-y-4">
<div className='space-y-4'>
{/* 成功状态 */}
{showSuccessIcon && (
<div className="flex items-center gap-2">
<svg className="w-5 h-5 text-green-600" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
<div className='flex items-center gap-2'>
<svg
className='w-5 h-5 text-green-600'
fill='currentColor'
viewBox='0 0 20 20'
>
<path
fillRule='evenodd'
d='M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z'
clipRule='evenodd'
/>
</svg>
<Typography.Text strong className="text-green-700">
<Typography.Text strong className='text-green-700'>
{successText || t('验证成功')}
</Typography.Text>
</div>
)}
{/* 密钥内容 */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className='space-y-3'>
<div className='flex items-center justify-between'>
<Typography.Text strong>
{isMultipleKeys ? t('渠道密钥列表') : t('渠道密钥')}
</Typography.Text>
{isMultipleKeys && (
<div className="flex items-center gap-2">
<Typography.Text type="tertiary" size="small">
<div className='flex items-center gap-2'>
<Typography.Text type='tertiary' size='small'>
{t('共 {{count}} 个密钥', { count: parsedKeys.length })}
</Typography.Text>
<Button
size="small"
type="primary"
theme="outline"
size='small'
type='primary'
theme='outline'
onClick={handleCopyAll}
>
{t('复制全部')}
@@ -138,27 +149,40 @@ const ChannelKeyDisplay = ({
</div>
)}
</div>
<div className="space-y-3 max-h-80 overflow-auto">
<div className='space-y-3 max-h-80 overflow-auto'>
{parsedKeys.map((keyItem) => (
<Card key={keyItem.id} className="!rounded-lg !border !border-gray-200 dark:!border-gray-700">
<div className="space-y-2">
<div className="flex items-center justify-between">
<Typography.Text strong size="small" className="text-gray-700 dark:text-gray-300">
<Card
key={keyItem.id}
className='!rounded-lg !border !border-gray-200 dark:!border-gray-700'
>
<div className='space-y-2'>
<div className='flex items-center justify-between'>
<Typography.Text
strong
size='small'
className='text-gray-700 dark:text-gray-300'
>
{keyItem.label}
</Typography.Text>
<div className="flex items-center gap-2">
<div className='flex items-center gap-2'>
{keyItem.type === 'json' && (
<Tag size="small" color="blue">{t('JSON')}</Tag>
<Tag size='small' color='blue'>
{t('JSON')}
</Tag>
)}
<Button
size="small"
type="primary"
theme="outline"
size='small'
type='primary'
theme='outline'
icon={
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path d="M8 3a1 1 0 011-1h2a1 1 0 110 2H9a1 1 0 01-1-1z" />
<path d="M6 3a2 2 0 00-2 2v11a2 2 0 002 2h8a2 2 0 002-2V5a2 2 0 00-2-2 3 3 0 01-3 3H9a3 3 0 01-3-3z" />
<svg
className='w-3 h-3'
fill='currentColor'
viewBox='0 0 20 20'
>
<path d='M8 3a1 1 0 011-1h2a1 1 0 110 2H9a1 1 0 01-1-1z' />
<path d='M6 3a2 2 0 00-2 2v11a2 2 0 002 2h8a2 2 0 002-2V5a2 2 0 00-2-2 3 3 0 01-3 3H9a3 3 0 01-3-3z' />
</svg>
}
onClick={() => handleCopyKey(keyItem.content)}
@@ -167,18 +191,22 @@ const ChannelKeyDisplay = ({
</Button>
</div>
</div>
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-3 max-h-40 overflow-auto">
<div className='bg-gray-50 dark:bg-gray-800 rounded-lg p-3 max-h-40 overflow-auto'>
<Typography.Text
code
className="text-xs font-mono break-all whitespace-pre-wrap text-gray-800 dark:text-gray-200"
className='text-xs font-mono break-all whitespace-pre-wrap text-gray-800 dark:text-gray-200'
>
{keyItem.content}
</Typography.Text>
</div>
{keyItem.type === 'json' && (
<Typography.Text type="tertiary" size="small" className="block">
<Typography.Text
type='tertiary'
size='small'
className='block'
>
{t('JSON格式密钥请确保格式正确')}
</Typography.Text>
)}
@@ -186,14 +214,28 @@ const ChannelKeyDisplay = ({
</Card>
))}
</div>
{isMultipleKeys && (
<div className="bg-blue-50 dark:bg-blue-900 rounded-lg p-3">
<Typography.Text type="tertiary" size="small" className="text-blue-700 dark:text-blue-300">
<svg className="w-4 h-4 inline mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
<div className='bg-blue-50 dark:bg-blue-900 rounded-lg p-3'>
<Typography.Text
type='tertiary'
size='small'
className='text-blue-700 dark:text-blue-300'
>
<svg
className='w-4 h-4 inline mr-1'
fill='currentColor'
viewBox='0 0 20 20'
>
<path
fillRule='evenodd'
d='M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z'
clipRule='evenodd'
/>
</svg>
{t('检测到多个密钥,您可以单独复制每个密钥,或点击复制全部获取完整内容。')}
{t(
'检测到多个密钥,您可以单独复制每个密钥,或点击复制全部获取完整内容。',
)}
</Typography.Text>
</div>
)}
@@ -201,17 +243,31 @@ const ChannelKeyDisplay = ({
{/* 安全警告 */}
{showWarning && (
<div className="bg-yellow-50 dark:bg-yellow-900 rounded-lg p-4">
<div className="flex items-start">
<svg className="w-5 h-5 text-yellow-600 dark:text-yellow-400 mt-0.5 mr-3 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
<div className='bg-yellow-50 dark:bg-yellow-900 rounded-lg p-4'>
<div className='flex items-start'>
<svg
className='w-5 h-5 text-yellow-600 dark:text-yellow-400 mt-0.5 mr-3 flex-shrink-0'
fill='currentColor'
viewBox='0 0 20 20'
>
<path
fillRule='evenodd'
d='M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z'
clipRule='evenodd'
/>
</svg>
<div>
<Typography.Text strong className="text-yellow-800 dark:text-yellow-200">
<Typography.Text
strong
className='text-yellow-800 dark:text-yellow-200'
>
{t('安全提醒')}
</Typography.Text>
<Typography.Text className="block text-yellow-700 dark:text-yellow-300 text-sm mt-1">
{warningText || t('请妥善保管密钥信息,不要泄露给他人。如有安全疑虑,请及时更换密钥。')}
<Typography.Text className='block text-yellow-700 dark:text-yellow-300 text-sm mt-1'>
{warningText ||
t(
'请妥善保管密钥信息,不要泄露给他人。如有安全疑虑,请及时更换密钥。',
)}
</Typography.Text>
</div>
</div>
@@ -221,4 +277,4 @@ const ChannelKeyDisplay = ({
);
};
export default ChannelKeyDisplay;
export default ChannelKeyDisplay;

View File

@@ -65,4 +65,4 @@ CompactModeToggle.propTypes = {
className: PropTypes.string,
};
export default CompactModeToggle;
export default CompactModeToggle;

View File

@@ -36,11 +36,7 @@ import {
Divider,
Tooltip,
} from '@douyinfe/semi-ui';
import {
IconPlus,
IconDelete,
IconAlertTriangle,
} from '@douyinfe/semi-icons';
import { IconPlus, IconDelete, IconAlertTriangle } from '@douyinfe/semi-icons';
const { Text } = Typography;
@@ -88,7 +84,7 @@ const JSONEditor = ({
// 将键值对数组转换为对象(重复键时后面的会覆盖前面的)
const keyValueArrayToObject = useCallback((arr) => {
const result = {};
arr.forEach(item => {
arr.forEach((item) => {
if (item.key) {
result[item.key] = item.value;
}
@@ -115,7 +111,8 @@ const JSONEditor = ({
// 手动模式下的本地文本缓冲
const [manualText, setManualText] = useState(() => {
if (typeof value === 'string') return value;
if (value && typeof value === 'object') return JSON.stringify(value, null, 2);
if (value && typeof value === 'object')
return JSON.stringify(value, null, 2);
return '';
});
@@ -140,7 +137,7 @@ const JSONEditor = ({
const keyCount = {};
const duplicates = new Set();
keyValuePairs.forEach(pair => {
keyValuePairs.forEach((pair) => {
if (pair.key) {
keyCount[pair.key] = (keyCount[pair.key] || 0) + 1;
if (keyCount[pair.key] > 1) {
@@ -178,51 +175,65 @@ const JSONEditor = ({
useEffect(() => {
if (editMode !== 'manual') {
if (typeof value === 'string') setManualText(value);
else if (value && typeof value === 'object') setManualText(JSON.stringify(value, null, 2));
else if (value && typeof value === 'object')
setManualText(JSON.stringify(value, null, 2));
else setManualText('');
}
}, [value, editMode]);
// 处理可视化编辑的数据变化
const handleVisualChange = useCallback((newPairs) => {
setKeyValuePairs(newPairs);
const jsonObject = keyValueArrayToObject(newPairs);
const jsonString = Object.keys(jsonObject).length === 0 ? '' : JSON.stringify(jsonObject, null, 2);
const handleVisualChange = useCallback(
(newPairs) => {
setKeyValuePairs(newPairs);
const jsonObject = keyValueArrayToObject(newPairs);
const jsonString =
Object.keys(jsonObject).length === 0
? ''
: JSON.stringify(jsonObject, null, 2);
setJsonError('');
setJsonError('');
// 通过formApi设置值
if (formApi && field) {
formApi.setValue(field, jsonString);
}
// 通过formApi设置值
if (formApi && field) {
formApi.setValue(field, jsonString);
}
onChange?.(jsonString);
}, [onChange, formApi, field, keyValueArrayToObject]);
onChange?.(jsonString);
},
[onChange, formApi, field, keyValueArrayToObject],
);
// 处理手动编辑的数据变化
const handleManualChange = useCallback((newValue) => {
setManualText(newValue);
if (newValue && newValue.trim()) {
try {
const parsed = JSON.parse(newValue);
setKeyValuePairs(objectToKeyValueArray(parsed, keyValuePairs));
const handleManualChange = useCallback(
(newValue) => {
setManualText(newValue);
if (newValue && newValue.trim()) {
try {
const parsed = JSON.parse(newValue);
setKeyValuePairs(objectToKeyValueArray(parsed, keyValuePairs));
setJsonError('');
onChange?.(newValue);
} catch (error) {
setJsonError(error.message);
}
} else {
setKeyValuePairs([]);
setJsonError('');
onChange?.(newValue);
} catch (error) {
setJsonError(error.message);
onChange?.('');
}
} else {
setKeyValuePairs([]);
setJsonError('');
onChange?.('');
}
}, [onChange, objectToKeyValueArray, keyValuePairs]);
},
[onChange, objectToKeyValueArray, keyValuePairs],
);
// 切换编辑模式
const toggleEditMode = useCallback(() => {
if (editMode === 'visual') {
const jsonObject = keyValueArrayToObject(keyValuePairs);
setManualText(Object.keys(jsonObject).length === 0 ? '' : JSON.stringify(jsonObject, null, 2));
setManualText(
Object.keys(jsonObject).length === 0
? ''
: JSON.stringify(jsonObject, null, 2),
);
setEditMode('manual');
} else {
try {
@@ -242,12 +253,19 @@ const JSONEditor = ({
return;
}
}
}, [editMode, value, manualText, keyValuePairs, keyValueArrayToObject, objectToKeyValueArray]);
}, [
editMode,
value,
manualText,
keyValuePairs,
keyValueArrayToObject,
objectToKeyValueArray,
]);
// 添加键值对
const addKeyValue = useCallback(() => {
const newPairs = [...keyValuePairs];
const existingKeys = newPairs.map(p => p.key);
const existingKeys = newPairs.map((p) => p.key);
let counter = 1;
let newKey = `field_${counter}`;
while (existingKeys.includes(newKey)) {
@@ -257,32 +275,41 @@ const JSONEditor = ({
newPairs.push({
id: generateUniqueId(),
key: newKey,
value: ''
value: '',
});
handleVisualChange(newPairs);
}, [keyValuePairs, handleVisualChange]);
// 删除键值对
const removeKeyValue = useCallback((id) => {
const newPairs = keyValuePairs.filter(pair => pair.id !== id);
handleVisualChange(newPairs);
}, [keyValuePairs, handleVisualChange]);
const removeKeyValue = useCallback(
(id) => {
const newPairs = keyValuePairs.filter((pair) => pair.id !== id);
handleVisualChange(newPairs);
},
[keyValuePairs, handleVisualChange],
);
// 更新键名
const updateKey = useCallback((id, newKey) => {
const newPairs = keyValuePairs.map(pair =>
pair.id === id ? { ...pair, key: newKey } : pair
);
handleVisualChange(newPairs);
}, [keyValuePairs, handleVisualChange]);
const updateKey = useCallback(
(id, newKey) => {
const newPairs = keyValuePairs.map((pair) =>
pair.id === id ? { ...pair, key: newKey } : pair,
);
handleVisualChange(newPairs);
},
[keyValuePairs, handleVisualChange],
);
// 更新值
const updateValue = useCallback((id, newValue) => {
const newPairs = keyValuePairs.map(pair =>
pair.id === id ? { ...pair, value: newValue } : pair
);
handleVisualChange(newPairs);
}, [keyValuePairs, handleVisualChange]);
const updateValue = useCallback(
(id, newValue) => {
const newPairs = keyValuePairs.map((pair) =>
pair.id === id ? { ...pair, value: newValue } : pair,
);
handleVisualChange(newPairs);
},
[keyValuePairs, handleVisualChange],
);
// 填入模板
const fillTemplate = useCallback(() => {
@@ -298,7 +325,14 @@ const JSONEditor = ({
onChange?.(templateString);
setJsonError('');
}
}, [template, onChange, formApi, field, objectToKeyValueArray, keyValuePairs]);
}, [
template,
onChange,
formApi,
field,
objectToKeyValueArray,
keyValuePairs,
]);
// 渲染值输入控件(支持嵌套)
const renderValueInput = (pairId, value) => {
@@ -306,12 +340,12 @@ const JSONEditor = ({
if (valueType === 'boolean') {
return (
<div className="flex items-center">
<div className='flex items-center'>
<Switch
checked={value}
onChange={(newValue) => updateValue(pairId, newValue)}
/>
<Text type="tertiary" className="ml-2">
<Text type='tertiary' className='ml-2'>
{value ? t('true') : t('false')}
</Text>
</div>
@@ -373,29 +407,29 @@ const JSONEditor = ({
// 渲染键值对编辑器
const renderKeyValueEditor = () => {
return (
<div className="space-y-1">
<div className='space-y-1'>
{/* 重复键警告 */}
{duplicateKeys.size > 0 && (
<Banner
type="warning"
type='warning'
icon={<IconAlertTriangle />}
description={
<div>
<Text strong>{t('存在重复的键名:')}</Text>
<Text>{Array.from(duplicateKeys).join(', ')}</Text>
<br />
<Text type="tertiary" size="small">
<Text type='tertiary' size='small'>
{t('注意JSON中重复的键只会保留最后一个同名键的值')}
</Text>
</div>
}
className="mb-3"
className='mb-3'
/>
)}
{keyValuePairs.length === 0 && (
<div className="text-center py-6 px-4">
<Text type="tertiary" className="text-gray-500 text-sm">
<div className='text-center py-6 px-4'>
<Text type='tertiary' className='text-gray-500 text-sm'>
{t('暂无数据,点击下方按钮添加键值对')}
</Text>
</div>
@@ -403,13 +437,14 @@ const JSONEditor = ({
{keyValuePairs.map((pair, index) => {
const isDuplicate = duplicateKeys.has(pair.key);
const isLastDuplicate = isDuplicate &&
keyValuePairs.slice(index + 1).every(p => p.key !== pair.key);
const isLastDuplicate =
isDuplicate &&
keyValuePairs.slice(index + 1).every((p) => p.key !== pair.key);
return (
<Row key={pair.id} gutter={8} align="middle">
<Row key={pair.id} gutter={8} align='middle'>
<Col span={6}>
<div className="relative">
<div className='relative'>
<Input
placeholder={t('键名')}
value={pair.key}
@@ -425,24 +460,22 @@ const JSONEditor = ({
}
>
<IconAlertTriangle
className="absolute right-2 top-1/2 transform -translate-y-1/2"
className='absolute right-2 top-1/2 transform -translate-y-1/2'
style={{
color: isLastDuplicate ? '#ff7d00' : '#faad14',
fontSize: '14px'
fontSize: '14px',
}}
/>
</Tooltip>
)}
</div>
</Col>
<Col span={16}>
{renderValueInput(pair.id, pair.value)}
</Col>
<Col span={16}>{renderValueInput(pair.id, pair.value)}</Col>
<Col span={2}>
<Button
icon={<IconDelete />}
type="danger"
theme="borderless"
type='danger'
theme='borderless'
onClick={() => removeKeyValue(pair.id)}
style={{ width: '100%' }}
/>
@@ -451,11 +484,11 @@ const JSONEditor = ({
);
})}
<div className="mt-2 flex justify-center">
<div className='mt-2 flex justify-center'>
<Button
icon={<IconPlus />}
type="primary"
theme="outline"
type='primary'
theme='outline'
onClick={addKeyValue}
>
{t('添加键值对')}
@@ -467,27 +500,27 @@ const JSONEditor = ({
// 渲染区域编辑器(特殊格式)- 也需要改造以支持重复键
const renderRegionEditor = () => {
const defaultPair = keyValuePairs.find(pair => pair.key === 'default');
const modelPairs = keyValuePairs.filter(pair => pair.key !== 'default');
const defaultPair = keyValuePairs.find((pair) => pair.key === 'default');
const modelPairs = keyValuePairs.filter((pair) => pair.key !== 'default');
return (
<div className="space-y-2">
<div className='space-y-2'>
{/* 重复键警告 */}
{duplicateKeys.size > 0 && (
<Banner
type="warning"
type='warning'
icon={<IconAlertTriangle />}
description={
<div>
<Text strong>{t('存在重复的键名:')}</Text>
<Text>{Array.from(duplicateKeys).join(', ')}</Text>
<br />
<Text type="tertiary" size="small">
<Text type='tertiary' size='small'>
{t('注意JSON中重复的键只会保留最后一个同名键的值')}
</Text>
</div>
}
className="mb-3"
className='mb-3'
/>
)}
@@ -500,11 +533,14 @@ const JSONEditor = ({
if (defaultPair) {
updateValue(defaultPair.id, value);
} else {
const newPairs = [...keyValuePairs, {
id: generateUniqueId(),
key: 'default',
value: value
}];
const newPairs = [
...keyValuePairs,
{
id: generateUniqueId(),
key: 'default',
value: value,
},
];
handleVisualChange(newPairs);
}
}}
@@ -517,9 +553,9 @@ const JSONEditor = ({
{modelPairs.map((pair) => {
const isDuplicate = duplicateKeys.has(pair.key);
return (
<Row key={pair.id} gutter={8} align="middle" className="mb-2">
<Row key={pair.id} gutter={8} align='middle' className='mb-2'>
<Col span={10}>
<div className="relative">
<div className='relative'>
<Input
placeholder={t('模型名称')}
value={pair.key}
@@ -529,7 +565,7 @@ const JSONEditor = ({
{isDuplicate && (
<Tooltip content={t('重复的键名')}>
<IconAlertTriangle
className="absolute right-2 top-1/2 transform -translate-y-1/2"
className='absolute right-2 top-1/2 transform -translate-y-1/2'
style={{ color: '#faad14', fontSize: '14px' }}
/>
</Tooltip>
@@ -546,8 +582,8 @@ const JSONEditor = ({
<Col span={2}>
<Button
icon={<IconDelete />}
type="danger"
theme="borderless"
type='danger'
theme='borderless'
onClick={() => removeKeyValue(pair.id)}
style={{ width: '100%' }}
/>
@@ -556,12 +592,12 @@ const JSONEditor = ({
);
})}
<div className="mt-2 flex justify-center">
<div className='mt-2 flex justify-center'>
<Button
icon={<IconPlus />}
onClick={addKeyValue}
type="primary"
theme="outline"
type='primary'
theme='outline'
>
{t('添加模型区域')}
</Button>
@@ -590,9 +626,9 @@ const JSONEditor = ({
<Form.Slot label={label}>
<Card
header={
<div className="flex justify-between items-center">
<div className='flex justify-between items-center'>
<Tabs
type="slash"
type='slash'
activeKey={editMode}
onChange={(key) => {
if (key === 'manual' && editMode === 'visual') {
@@ -602,16 +638,12 @@ const JSONEditor = ({
}
}}
>
<TabPane tab={t('可视化')} itemKey="visual" />
<TabPane tab={t('手动编辑')} itemKey="manual" />
<TabPane tab={t('可视化')} itemKey='visual' />
<TabPane tab={t('手动编辑')} itemKey='manual' />
</Tabs>
{template && templateLabel && (
<Button
type="tertiary"
onClick={fillTemplate}
size="small"
>
<Button type='tertiary' onClick={fillTemplate} size='small'>
{templateLabel}
</Button>
)}
@@ -619,14 +651,14 @@ const JSONEditor = ({
}
headerStyle={{ padding: '12px 16px' }}
bodyStyle={{ padding: '16px' }}
className="!rounded-2xl"
className='!rounded-2xl'
>
{/* JSON错误提示 */}
{hasJsonError && (
<Banner
type="danger"
type='danger'
description={`JSON 格式错误: ${jsonError}`}
className="mb-3"
className='mb-3'
/>
)}
@@ -668,17 +700,15 @@ const JSONEditor = ({
{/* 额外文本显示在卡片底部 */}
{extraText && (
<Divider margin='12px' align='center'>
<Text type="tertiary" size="small">{extraText}</Text>
<Text type='tertiary' size='small'>
{extraText}
</Text>
</Divider>
)}
{extraFooter && (
<div className="mt-1">
{extraFooter}
</div>
)}
{extraFooter && <div className='mt-1'>{extraFooter}</div>}
</Card>
</Form.Slot>
);
};
export default JSONEditor;
export default JSONEditor;

View File

@@ -21,13 +21,9 @@ import React from 'react';
import { Spin } from '@douyinfe/semi-ui';
const Loading = ({ size = 'small' }) => {
return (
<div className="fixed inset-0 w-screen h-screen flex items-center justify-center">
<Spin
size={size}
spinning={true}
/>
<div className='fixed inset-0 w-screen h-screen flex items-center justify-center'>
<Spin size={size} spinning={true} />
</div>
);
};

View File

@@ -57,4 +57,4 @@ export const renderDescription = (text, maxWidth = 200) => {
{text || '-'}
</Text>
);
};
};

View File

@@ -24,197 +24,219 @@ import React, {
useCallback,
useMemo,
useImperativeHandle,
forwardRef
forwardRef,
} from 'react';
/**
* ScrollableContainer 可滚动容器组件
*
*
* 提供自动检测滚动状态和显示渐变指示器的功能
* 当内容超出容器高度且未滚动到底部时,会显示底部渐变指示器
*
*
*/
const ScrollableContainer = forwardRef(({
children,
maxHeight = '24rem',
className = '',
contentClassName = '',
fadeIndicatorClassName = '',
checkInterval = 100,
scrollThreshold = 5,
debounceDelay = 16, // ~60fps
onScroll,
onScrollStateChange,
...props
}, ref) => {
const scrollRef = useRef(null);
const containerRef = useRef(null);
const debounceTimerRef = useRef(null);
const resizeObserverRef = useRef(null);
const onScrollStateChangeRef = useRef(onScrollStateChange);
const onScrollRef = useRef(onScroll);
const [showScrollHint, setShowScrollHint] = useState(false);
useEffect(() => {
onScrollStateChangeRef.current = onScrollStateChange;
}, [onScrollStateChange]);
useEffect(() => {
onScrollRef.current = onScroll;
}, [onScroll]);
const debounce = useCallback((func, delay) => {
return (...args) => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
debounceTimerRef.current = setTimeout(() => func(...args), delay);
};
}, []);
const checkScrollable = useCallback(() => {
if (!scrollRef.current) return;
const element = scrollRef.current;
const isScrollable = element.scrollHeight > element.clientHeight;
const isAtBottom = element.scrollTop + element.clientHeight >= element.scrollHeight - scrollThreshold;
const shouldShowHint = isScrollable && !isAtBottom;
setShowScrollHint(shouldShowHint);
if (onScrollStateChangeRef.current) {
onScrollStateChangeRef.current({
isScrollable,
isAtBottom,
showScrollHint: shouldShowHint,
scrollTop: element.scrollTop,
scrollHeight: element.scrollHeight,
clientHeight: element.clientHeight
});
}
}, [scrollThreshold]);
const debouncedCheckScrollable = useMemo(() =>
debounce(checkScrollable, debounceDelay),
[debounce, checkScrollable, debounceDelay]
);
const handleScroll = useCallback((e) => {
debouncedCheckScrollable();
if (onScrollRef.current) {
onScrollRef.current(e);
}
}, [debouncedCheckScrollable]);
useImperativeHandle(ref, () => ({
checkScrollable: () => {
checkScrollable();
const ScrollableContainer = forwardRef(
(
{
children,
maxHeight = '24rem',
className = '',
contentClassName = '',
fadeIndicatorClassName = '',
checkInterval = 100,
scrollThreshold = 5,
debounceDelay = 16, // ~60fps
onScroll,
onScrollStateChange,
...props
},
scrollToTop: () => {
if (scrollRef.current) {
scrollRef.current.scrollTop = 0;
}
},
scrollToBottom: () => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
},
getScrollInfo: () => {
if (!scrollRef.current) return null;
const element = scrollRef.current;
return {
scrollTop: element.scrollTop,
scrollHeight: element.scrollHeight,
clientHeight: element.clientHeight,
isScrollable: element.scrollHeight > element.clientHeight,
isAtBottom: element.scrollTop + element.clientHeight >= element.scrollHeight - scrollThreshold
ref,
) => {
const scrollRef = useRef(null);
const containerRef = useRef(null);
const debounceTimerRef = useRef(null);
const resizeObserverRef = useRef(null);
const onScrollStateChangeRef = useRef(onScrollStateChange);
const onScrollRef = useRef(onScroll);
const [showScrollHint, setShowScrollHint] = useState(false);
useEffect(() => {
onScrollStateChangeRef.current = onScrollStateChange;
}, [onScrollStateChange]);
useEffect(() => {
onScrollRef.current = onScroll;
}, [onScroll]);
const debounce = useCallback((func, delay) => {
return (...args) => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
debounceTimerRef.current = setTimeout(() => func(...args), delay);
};
}
}), [checkScrollable, scrollThreshold]);
}, []);
useEffect(() => {
const timer = setTimeout(() => {
checkScrollable();
}, checkInterval);
return () => clearTimeout(timer);
}, [checkScrollable, checkInterval]);
const checkScrollable = useCallback(() => {
if (!scrollRef.current) return;
useEffect(() => {
if (!scrollRef.current) return;
const element = scrollRef.current;
const isScrollable = element.scrollHeight > element.clientHeight;
const isAtBottom =
element.scrollTop + element.clientHeight >=
element.scrollHeight - scrollThreshold;
const shouldShowHint = isScrollable && !isAtBottom;
if (typeof ResizeObserver === 'undefined') {
if (typeof MutationObserver !== 'undefined') {
const observer = new MutationObserver(() => {
debouncedCheckScrollable();
setShowScrollHint(shouldShowHint);
if (onScrollStateChangeRef.current) {
onScrollStateChangeRef.current({
isScrollable,
isAtBottom,
showScrollHint: shouldShowHint,
scrollTop: element.scrollTop,
scrollHeight: element.scrollHeight,
clientHeight: element.clientHeight,
});
observer.observe(scrollRef.current, {
childList: true,
subtree: true,
attributes: true,
characterData: true
});
return () => observer.disconnect();
}
return;
}
}, [scrollThreshold]);
resizeObserverRef.current = new ResizeObserver((entries) => {
for (const entry of entries) {
const debouncedCheckScrollable = useMemo(
() => debounce(checkScrollable, debounceDelay),
[debounce, checkScrollable, debounceDelay],
);
const handleScroll = useCallback(
(e) => {
debouncedCheckScrollable();
if (onScrollRef.current) {
onScrollRef.current(e);
}
},
[debouncedCheckScrollable],
);
useImperativeHandle(
ref,
() => ({
checkScrollable: () => {
checkScrollable();
},
scrollToTop: () => {
if (scrollRef.current) {
scrollRef.current.scrollTop = 0;
}
},
scrollToBottom: () => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
},
getScrollInfo: () => {
if (!scrollRef.current) return null;
const element = scrollRef.current;
return {
scrollTop: element.scrollTop,
scrollHeight: element.scrollHeight,
clientHeight: element.clientHeight,
isScrollable: element.scrollHeight > element.clientHeight,
isAtBottom:
element.scrollTop + element.clientHeight >=
element.scrollHeight - scrollThreshold,
};
},
}),
[checkScrollable, scrollThreshold],
);
useEffect(() => {
const timer = setTimeout(() => {
checkScrollable();
}, checkInterval);
return () => clearTimeout(timer);
}, [checkScrollable, checkInterval]);
useEffect(() => {
if (!scrollRef.current) return;
if (typeof ResizeObserver === 'undefined') {
if (typeof MutationObserver !== 'undefined') {
const observer = new MutationObserver(() => {
debouncedCheckScrollable();
});
observer.observe(scrollRef.current, {
childList: true,
subtree: true,
attributes: true,
characterData: true,
});
return () => observer.disconnect();
}
return;
}
});
resizeObserverRef.current.observe(scrollRef.current);
resizeObserverRef.current = new ResizeObserver((entries) => {
for (const entry of entries) {
debouncedCheckScrollable();
}
});
return () => {
if (resizeObserverRef.current) {
resizeObserverRef.current.disconnect();
}
};
}, [debouncedCheckScrollable]);
resizeObserverRef.current.observe(scrollRef.current);
useEffect(() => {
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
};
}, []);
return () => {
if (resizeObserverRef.current) {
resizeObserverRef.current.disconnect();
}
};
}, [debouncedCheckScrollable]);
const containerStyle = useMemo(() => ({
maxHeight
}), [maxHeight]);
useEffect(() => {
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
};
}, []);
const fadeIndicatorStyle = useMemo(() => ({
opacity: showScrollHint ? 1 : 0
}), [showScrollHint]);
const containerStyle = useMemo(
() => ({
maxHeight,
}),
[maxHeight],
);
return (
<div
ref={containerRef}
className={`card-content-container ${className}`}
{...props}
>
const fadeIndicatorStyle = useMemo(
() => ({
opacity: showScrollHint ? 1 : 0,
}),
[showScrollHint],
);
return (
<div
ref={scrollRef}
className={`overflow-y-auto card-content-scroll ${contentClassName}`}
style={containerStyle}
onScroll={handleScroll}
ref={containerRef}
className={`card-content-container ${className}`}
{...props}
>
{children}
<div
ref={scrollRef}
className={`overflow-y-auto card-content-scroll ${contentClassName}`}
style={containerStyle}
onScroll={handleScroll}
>
{children}
</div>
<div
className={`card-content-fade-indicator ${fadeIndicatorClassName}`}
style={fadeIndicatorStyle}
/>
</div>
<div
className={`card-content-fade-indicator ${fadeIndicatorClassName}`}
style={fadeIndicatorStyle}
/>
</div>
);
});
);
},
);
ScrollableContainer.displayName = 'ScrollableContainer';
export default ScrollableContainer;
export default ScrollableContainer;

View File

@@ -20,7 +20,17 @@ For commercial licensing, please contact support@quantumnous.com
import React, { useState, useRef, useEffect } from 'react';
import { useMinimumLoadingTime } from '../../../hooks/common/useMinimumLoadingTime';
import { useContainerWidth } from '../../../hooks/common/useContainerWidth';
import { Divider, Button, Tag, Row, Col, Collapsible, Checkbox, Skeleton, Tooltip } from '@douyinfe/semi-ui';
import {
Divider,
Button,
Tag,
Row,
Col,
Collapsible,
Checkbox,
Skeleton,
Tooltip,
} from '@douyinfe/semi-ui';
import { IconChevronDown, IconChevronUp } from '@douyinfe/semi-icons';
/**
@@ -47,7 +57,7 @@ const SelectableButtonGroup = ({
collapsible = true,
collapseHeight = 200,
withCheckbox = false,
loading = false
loading = false,
}) => {
const [isOpen, setIsOpen] = useState(false);
const [skeletonCount] = useState(12);
@@ -64,15 +74,13 @@ const SelectableButtonGroup = ({
}, [text, containerWidth]);
const textElement = (
<span ref={textRef} className="sbg-ellipsis">
<span ref={textRef} className='sbg-ellipsis'>
{text}
</span>
);
return isOverflowing ? (
<Tooltip content={text}>
{textElement}
</Tooltip>
<Tooltip content={text}>{textElement}</Tooltip>
) : (
textElement
);
@@ -80,10 +88,10 @@ const SelectableButtonGroup = ({
// 基于容器宽度计算响应式列数和标签显示策略
const getResponsiveConfig = () => {
if (containerWidth <= 280) return { columns: 1, showTags: true }; // 极窄1列+标签
if (containerWidth <= 380) return { columns: 2, showTags: true }; // 窄屏2列+标签
if (containerWidth <= 460) return { columns: 3, showTags: false }; // 中等3列不加标签
return { columns: 3, showTags: true }; // 最宽3列+标签
if (containerWidth <= 280) return { columns: 1, showTags: true }; // 极窄1列+标签
if (containerWidth <= 380) return { columns: 2, showTags: true }; // 窄屏2列+标签
if (containerWidth <= 460) return { columns: 3, showTags: false }; // 中等3列不加标签
return { columns: 3, showTags: true }; // 最宽3列+标签
};
const { columns: perRow, showTags: shouldShowTags } = getResponsiveConfig();
@@ -102,9 +110,9 @@ const SelectableButtonGroup = ({
const maskStyle = isOpen
? {}
: {
WebkitMaskImage:
'linear-gradient(to bottom, black 0%, rgba(0, 0, 0, 1) 60%, rgba(0, 0, 0, 0.2) 80%, transparent 100%)',
};
WebkitMaskImage:
'linear-gradient(to bottom, black 0%, rgba(0, 0, 0, 1) 60%, rgba(0, 0, 0, 0.2) 80%, transparent 100%)',
};
const toggle = () => {
setIsOpen(!isOpen);
@@ -127,25 +135,23 @@ const SelectableButtonGroup = ({
};
const renderSkeletonButtons = () => {
const placeholder = (
<Row gutter={gutterSize} style={{ lineHeight: '32px', ...style }}>
{Array.from({ length: skeletonCount }).map((_, index) => (
<Col
span={getColSpan()}
key={index}
>
<div style={{
width: '100%',
height: '32px',
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-start',
border: '1px solid var(--semi-color-border)',
borderRadius: 'var(--semi-border-radius-medium)',
padding: '0 12px',
gap: '6px'
}}>
<Col span={getColSpan()} key={index}>
<div
style={{
width: '100%',
height: '32px',
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-start',
border: '1px solid var(--semi-color-border)',
borderRadius: 'var(--semi-border-radius-medium)',
padding: '0 12px',
gap: '6px',
}}
>
{withCheckbox && (
<Skeleton.Title active style={{ width: 14, height: 14 }} />
)}
@@ -153,7 +159,7 @@ const SelectableButtonGroup = ({
active
style={{
width: `${60 + (index % 3) * 20}px`,
height: 14
height: 14,
}}
/>
</div>
@@ -167,26 +173,29 @@ const SelectableButtonGroup = ({
);
};
const contentElement = showSkeleton ? renderSkeletonButtons() : (
const contentElement = showSkeleton ? (
renderSkeletonButtons()
) : (
<Row gutter={gutterSize} style={{ lineHeight: '32px', ...style }}>
{items.map((item) => {
const isDisabled = item.disabled || (typeof item.tagCount === 'number' && item.tagCount === 0);
const isDisabled =
item.disabled ||
(typeof item.tagCount === 'number' && item.tagCount === 0);
const isActive = Array.isArray(activeValue)
? activeValue.includes(item.value)
: activeValue === item.value;
if (withCheckbox) {
return (
<Col
span={getColSpan()}
key={item.value}
>
<Col span={getColSpan()} key={item.value}>
<Button
onClick={() => { /* disabled */ }}
onClick={() => {
/* disabled */
}}
theme={isActive ? 'light' : 'outline'}
type={isActive ? 'primary' : 'tertiary'}
disabled={isDisabled}
className="sbg-button"
className='sbg-button'
icon={
<Checkbox
checked={isActive}
@@ -197,11 +206,18 @@ const SelectableButtonGroup = ({
}
style={{ width: '100%', cursor: 'default' }}
>
<div className="sbg-content">
{item.icon && (<span className="sbg-icon">{item.icon}</span>)}
<div className='sbg-content'>
{item.icon && <span className='sbg-icon'>{item.icon}</span>}
<ConditionalTooltipText text={item.label} />
{item.tagCount !== undefined && shouldShowTags && (
<Tag className="sbg-tag" color='white' shape="circle" size="small">{item.tagCount}</Tag>
<Tag
className='sbg-tag'
color='white'
shape='circle'
size='small'
>
{item.tagCount}
</Tag>
)}
</div>
</Button>
@@ -210,23 +226,27 @@ const SelectableButtonGroup = ({
}
return (
<Col
span={getColSpan()}
key={item.value}
>
<Col span={getColSpan()} key={item.value}>
<Button
onClick={() => onChange(item.value)}
theme={isActive ? 'light' : 'outline'}
type={isActive ? 'primary' : 'tertiary'}
disabled={isDisabled}
className="sbg-button"
className='sbg-button'
style={{ width: '100%' }}
>
<div className="sbg-content">
{item.icon && (<span className="sbg-icon">{item.icon}</span>)}
<div className='sbg-content'>
{item.icon && <span className='sbg-icon'>{item.icon}</span>}
<ConditionalTooltipText text={item.label} />
{item.tagCount !== undefined && shouldShowTags && (
<Tag className="sbg-tag" color='white' shape="circle" size="small">{item.tagCount}</Tag>
<Tag
className='sbg-tag'
color='white'
shape='circle'
size='small'
>
{item.tagCount}
</Tag>
)}
</div>
</Button>
@@ -237,9 +257,12 @@ const SelectableButtonGroup = ({
);
return (
<div className={`mb-8 ${containerWidth <= 400 ? 'sbg-compact' : ''}`} ref={containerRef}>
<div
className={`mb-8 ${containerWidth <= 400 ? 'sbg-compact' : ''}`}
ref={containerRef}
>
{title && (
<Divider margin="12px" align="left">
<Divider margin='12px' align='left'>
{showSkeleton ? (
<Skeleton.Title active style={{ width: 80, height: 14 }} />
) : (
@@ -249,23 +272,30 @@ const SelectableButtonGroup = ({
)}
{needCollapse && !showSkeleton ? (
<div style={{ position: 'relative' }}>
<Collapsible isOpen={isOpen} collapseHeight={collapseHeight} style={{ ...maskStyle }}>
<Collapsible
isOpen={isOpen}
collapseHeight={collapseHeight}
style={{ ...maskStyle }}
>
{contentElement}
</Collapsible>
{isOpen ? null : (
<div onClick={toggle} style={{ ...linkStyle }}>
<IconChevronDown size="small" />
<IconChevronDown size='small' />
<span>{t('展开更多')}</span>
</div>
)}
{isOpen && (
<div onClick={toggle} style={{
...linkStyle,
position: 'static',
marginTop: 8,
bottom: 'auto'
}}>
<IconChevronUp size="small" />
<div
onClick={toggle}
style={{
...linkStyle,
position: 'static',
marginTop: 8,
bottom: 'auto',
}}
>
<IconChevronUp size='small' />
<span>{t('收起')}</span>
</div>
)}
@@ -277,4 +307,4 @@ const SelectableButtonGroup = ({
);
};
export default SelectableButtonGroup;
export default SelectableButtonGroup;

View File

@@ -21,7 +21,10 @@ import React from 'react';
import { Card, Tag, Timeline, Empty } from '@douyinfe/semi-ui';
import { Bell } from 'lucide-react';
import { marked } from 'marked';
import { IllustrationConstruction, IllustrationConstructionDark } from '@douyinfe/semi-illustrations';
import {
IllustrationConstruction,
IllustrationConstructionDark,
} from '@douyinfe/semi-illustrations';
import ScrollableContainer from '../common/ui/ScrollableContainer';
const AnnouncementsPanel = ({
@@ -29,36 +32,43 @@ const AnnouncementsPanel = ({
announcementLegendData,
CARD_PROPS,
ILLUSTRATION_SIZE,
t
t,
}) => {
return (
<Card
{...CARD_PROPS}
className="shadow-sm !rounded-2xl lg:col-span-2"
className='shadow-sm !rounded-2xl lg:col-span-2'
title={
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-2 w-full">
<div className="flex items-center gap-2">
<div className='flex flex-col lg:flex-row lg:items-center lg:justify-between gap-2 w-full'>
<div className='flex items-center gap-2'>
<Bell size={16} />
{t('系统公告')}
<Tag color="white" shape="circle">
<Tag color='white' shape='circle'>
{t('显示最新20条')}
</Tag>
</div>
{/* 图例 */}
<div className="flex flex-wrap gap-3 text-xs">
<div className='flex flex-wrap gap-3 text-xs'>
{announcementLegendData.map((legend, index) => (
<div key={index} className="flex items-center gap-1">
<div key={index} className='flex items-center gap-1'>
<div
className="w-2 h-2 rounded-full"
className='w-2 h-2 rounded-full'
style={{
backgroundColor: legend.color === 'grey' ? '#8b9aa7' :
legend.color === 'blue' ? '#3b82f6' :
legend.color === 'green' ? '#10b981' :
legend.color === 'orange' ? '#f59e0b' :
legend.color === 'red' ? '#ef4444' : '#8b9aa7'
backgroundColor:
legend.color === 'grey'
? '#8b9aa7'
: legend.color === 'blue'
? '#3b82f6'
: legend.color === 'green'
? '#10b981'
: legend.color === 'orange'
? '#f59e0b'
: legend.color === 'red'
? '#ef4444'
: '#8b9aa7',
}}
/>
<span className="text-gray-600">{legend.label}</span>
<span className='text-gray-600'>{legend.label}</span>
</div>
))}
</div>
@@ -66,9 +76,9 @@ const AnnouncementsPanel = ({
}
bodyStyle={{ padding: 0 }}
>
<ScrollableContainer maxHeight="24rem">
<ScrollableContainer maxHeight='24rem'>
{announcementData.length > 0 ? (
<Timeline mode="left">
<Timeline mode='left'>
{announcementData.map((item, idx) => {
const htmlExtra = item.extra ? marked.parse(item.extra) : '';
return (
@@ -76,16 +86,20 @@ const AnnouncementsPanel = ({
key={idx}
type={item.type || 'default'}
time={`${item.relative ? item.relative + ' ' : ''}${item.time}`}
extra={item.extra ? (
<div
className="text-xs text-gray-500"
dangerouslySetInnerHTML={{ __html: htmlExtra }}
/>
) : null}
extra={
item.extra ? (
<div
className='text-xs text-gray-500'
dangerouslySetInnerHTML={{ __html: htmlExtra }}
/>
) : null
}
>
<div>
<div
dangerouslySetInnerHTML={{ __html: marked.parse(item.content || '') }}
dangerouslySetInnerHTML={{
__html: marked.parse(item.content || ''),
}}
/>
</div>
</Timeline.Item>
@@ -93,10 +107,12 @@ const AnnouncementsPanel = ({
})}
</Timeline>
) : (
<div className="flex justify-center items-center py-8">
<div className='flex justify-center items-center py-8'>
<Empty
image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
darkModeImage={<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />}
darkModeImage={
<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />
}
title={t('暂无系统公告')}
description={t('请联系管理员在系统设置中配置公告信息')}
/>
@@ -107,4 +123,4 @@ const AnnouncementsPanel = ({
);
};
export default AnnouncementsPanel;
export default AnnouncementsPanel;

View File

@@ -20,7 +20,10 @@ For commercial licensing, please contact support@quantumnous.com
import React from 'react';
import { Card, Avatar, Tag, Divider, Empty } from '@douyinfe/semi-ui';
import { Server, Gauge, ExternalLink } from 'lucide-react';
import { IllustrationConstruction, IllustrationConstructionDark } from '@douyinfe/semi-illustrations';
import {
IllustrationConstruction,
IllustrationConstructionDark,
} from '@douyinfe/semi-illustrations';
import ScrollableContainer from '../common/ui/ScrollableContainer';
const ApiInfoPanel = ({
@@ -30,12 +33,12 @@ const ApiInfoPanel = ({
CARD_PROPS,
FLEX_CENTER_GAP2,
ILLUSTRATION_SIZE,
t
t,
}) => {
return (
<Card
{...CARD_PROPS}
className="bg-gray-50 border-0 !rounded-2xl"
className='bg-gray-50 border-0 !rounded-2xl'
title={
<div className={FLEX_CENTER_GAP2}>
<Server size={16} />
@@ -44,66 +47,65 @@ const ApiInfoPanel = ({
}
bodyStyle={{ padding: 0 }}
>
<ScrollableContainer maxHeight="24rem">
<ScrollableContainer maxHeight='24rem'>
{apiInfoData.length > 0 ? (
apiInfoData.map((api) => (
<React.Fragment key={api.id}>
<div className="flex p-2 hover:bg-white rounded-lg transition-colors cursor-pointer">
<div className="flex-shrink-0 mr-3">
<Avatar
size="extra-small"
color={api.color}
>
<div className='flex p-2 hover:bg-white rounded-lg transition-colors cursor-pointer'>
<div className='flex-shrink-0 mr-3'>
<Avatar size='extra-small' color={api.color}>
{api.route.substring(0, 2)}
</Avatar>
</div>
<div className="flex-1">
<div className="flex flex-wrap items-center justify-between mb-1 w-full gap-2">
<span className="text-sm font-medium text-gray-900 !font-bold break-all">
<div className='flex-1'>
<div className='flex flex-wrap items-center justify-between mb-1 w-full gap-2'>
<span className='text-sm font-medium text-gray-900 !font-bold break-all'>
{api.route}
</span>
<div className="flex items-center gap-1 mt-1 lg:mt-0">
<div className='flex items-center gap-1 mt-1 lg:mt-0'>
<Tag
prefixIcon={<Gauge size={12} />}
size="small"
color="white"
size='small'
color='white'
shape='circle'
onClick={() => handleSpeedTest(api.url)}
className="cursor-pointer hover:opacity-80 text-xs"
className='cursor-pointer hover:opacity-80 text-xs'
>
{t('测速')}
</Tag>
<Tag
prefixIcon={<ExternalLink size={12} />}
size="small"
color="white"
size='small'
color='white'
shape='circle'
onClick={() => window.open(api.url, '_blank', 'noopener,noreferrer')}
className="cursor-pointer hover:opacity-80 text-xs"
onClick={() =>
window.open(api.url, '_blank', 'noopener,noreferrer')
}
className='cursor-pointer hover:opacity-80 text-xs'
>
{t('跳转')}
</Tag>
</div>
</div>
<div
className="!text-semi-color-primary break-all cursor-pointer hover:underline mb-1"
className='!text-semi-color-primary break-all cursor-pointer hover:underline mb-1'
onClick={() => handleCopyUrl(api.url)}
>
{api.url}
</div>
<div className="text-gray-500">
{api.description}
</div>
<div className='text-gray-500'>{api.description}</div>
</div>
</div>
<Divider />
</React.Fragment>
))
) : (
<div className="flex justify-center items-center py-8">
<div className='flex justify-center items-center py-8'>
<Empty
image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
darkModeImage={<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />}
darkModeImage={
<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />
}
title={t('暂无API信息')}
description={t('请联系管理员在系统设置中配置API信息')}
/>
@@ -114,4 +116,4 @@ const ApiInfoPanel = ({
);
};
export default ApiInfoPanel;
export default ApiInfoPanel;

View File

@@ -23,7 +23,7 @@ import { PieChart } from 'lucide-react';
import {
IconHistogram,
IconPulse,
IconPieChart2Stroked
IconPieChart2Stroked,
} from '@douyinfe/semi-icons';
import { VChart } from '@visactor/react-vchart';
@@ -38,80 +38,80 @@ const ChartsPanel = ({
CHART_CONFIG,
FLEX_CENTER_GAP2,
hasApiInfoPanel,
t
t,
}) => {
return (
<Card
{...CARD_PROPS}
className={`!rounded-2xl ${hasApiInfoPanel ? 'lg:col-span-3' : ''}`}
title={
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between w-full gap-3">
<div className='flex flex-col lg:flex-row lg:items-center lg:justify-between w-full gap-3'>
<div className={FLEX_CENTER_GAP2}>
<PieChart size={16} />
{t('模型数据分析')}
</div>
<Tabs
type="button"
type='button'
activeKey={activeChartTab}
onChange={setActiveChartTab}
>
<TabPane tab={
<span>
<IconHistogram />
{t('消耗分布')}
</span>
} itemKey="1" />
<TabPane tab={
<span>
<IconPulse />
{t('消耗趋势')}
</span>
} itemKey="2" />
<TabPane tab={
<span>
<IconPieChart2Stroked />
{t('调用次数分布')}
</span>
} itemKey="3" />
<TabPane tab={
<span>
<IconHistogram />
{t('调用次数排行')}
</span>
} itemKey="4" />
<TabPane
tab={
<span>
<IconHistogram />
{t('消耗分布')}
</span>
}
itemKey='1'
/>
<TabPane
tab={
<span>
<IconPulse />
{t('消耗趋势')}
</span>
}
itemKey='2'
/>
<TabPane
tab={
<span>
<IconPieChart2Stroked />
{t('调用次数分布')}
</span>
}
itemKey='3'
/>
<TabPane
tab={
<span>
<IconHistogram />
{t('调用次数排行')}
</span>
}
itemKey='4'
/>
</Tabs>
</div>
}
bodyStyle={{ padding: 0 }}
>
<div className="h-96 p-2">
<div className='h-96 p-2'>
{activeChartTab === '1' && (
<VChart
spec={spec_line}
option={CHART_CONFIG}
/>
<VChart spec={spec_line} option={CHART_CONFIG} />
)}
{activeChartTab === '2' && (
<VChart
spec={spec_model_line}
option={CHART_CONFIG}
/>
<VChart spec={spec_model_line} option={CHART_CONFIG} />
)}
{activeChartTab === '3' && (
<VChart
spec={spec_pie}
option={CHART_CONFIG}
/>
<VChart spec={spec_pie} option={CHART_CONFIG} />
)}
{activeChartTab === '4' && (
<VChart
spec={spec_rank_bar}
option={CHART_CONFIG}
/>
<VChart spec={spec_rank_bar} option={CHART_CONFIG} />
)}
</div>
</Card>
);
};
export default ChartsPanel;
export default ChartsPanel;

View File

@@ -27,19 +27,19 @@ const DashboardHeader = ({
showSearchModal,
refresh,
loading,
t
t,
}) => {
const ICON_BUTTON_CLASS = "text-white hover:bg-opacity-80 !rounded-full";
const ICON_BUTTON_CLASS = 'text-white hover:bg-opacity-80 !rounded-full';
return (
<div className="flex items-center justify-between mb-4">
<div className='flex items-center justify-between mb-4'>
<h2
className="text-2xl font-semibold text-gray-800 transition-opacity duration-1000 ease-in-out"
className='text-2xl font-semibold text-gray-800 transition-opacity duration-1000 ease-in-out'
style={{ opacity: greetingVisible ? 1 : 0 }}
>
{getGreeting}
</h2>
<div className="flex gap-3">
<div className='flex gap-3'>
<Button
type='tertiary'
icon={<Search size={16} />}
@@ -58,4 +58,4 @@ const DashboardHeader = ({
);
};
export default DashboardHeader;
export default DashboardHeader;

View File

@@ -22,7 +22,10 @@ import { Card, Collapse, Empty } from '@douyinfe/semi-ui';
import { HelpCircle } from 'lucide-react';
import { IconPlus, IconMinus } from '@douyinfe/semi-icons';
import { marked } from 'marked';
import { IllustrationConstruction, IllustrationConstructionDark } from '@douyinfe/semi-illustrations';
import {
IllustrationConstruction,
IllustrationConstructionDark,
} from '@douyinfe/semi-illustrations';
import ScrollableContainer from '../common/ui/ScrollableContainer';
const FaqPanel = ({
@@ -30,12 +33,12 @@ const FaqPanel = ({
CARD_PROPS,
FLEX_CENTER_GAP2,
ILLUSTRATION_SIZE,
t
t,
}) => {
return (
<Card
{...CARD_PROPS}
className="shadow-sm !rounded-2xl lg:col-span-1"
className='shadow-sm !rounded-2xl lg:col-span-1'
title={
<div className={FLEX_CENTER_GAP2}>
<HelpCircle size={16} />
@@ -44,7 +47,7 @@ const FaqPanel = ({
}
bodyStyle={{ padding: 0 }}
>
<ScrollableContainer maxHeight="24rem">
<ScrollableContainer maxHeight='24rem'>
{faqData.length > 0 ? (
<Collapse
accordion
@@ -58,16 +61,20 @@ const FaqPanel = ({
itemKey={index.toString()}
>
<div
dangerouslySetInnerHTML={{ __html: marked.parse(item.answer || '') }}
dangerouslySetInnerHTML={{
__html: marked.parse(item.answer || ''),
}}
/>
</Collapse.Panel>
))}
</Collapse>
) : (
<div className="flex justify-center items-center py-8">
<div className='flex justify-center items-center py-8'>
<Empty
image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
darkModeImage={<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />}
darkModeImage={
<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />
}
title={t('暂无常见问答')}
description={t('请联系管理员在系统设置中配置常见问答')}
/>
@@ -78,4 +85,4 @@ const FaqPanel = ({
);
};
export default FaqPanel;
export default FaqPanel;

View File

@@ -28,13 +28,13 @@ const StatsCards = ({
loading,
getTrendSpec,
CARD_PROPS,
CHART_CONFIG
CHART_CONFIG,
}) => {
const navigate = useNavigate();
const { t } = useTranslation();
return (
<div className="mb-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className='mb-4'>
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4'>
{groupedStatsData.map((group, idx) => (
<Card
key={idx}
@@ -42,24 +42,24 @@ const StatsCards = ({
className={`${group.color} border-0 !rounded-2xl w-full`}
title={group.title}
>
<div className="space-y-4">
<div className='space-y-4'>
{group.items.map((item, itemIdx) => (
<div
key={itemIdx}
className="flex items-center justify-between cursor-pointer"
className='flex items-center justify-between cursor-pointer'
onClick={item.onClick}
>
<div className="flex items-center">
<div className='flex items-center'>
<Avatar
className="mr-3"
size="small"
className='mr-3'
size='small'
color={item.avatarColor}
>
{item.icon}
</Avatar>
<div>
<div className="text-xs text-gray-500">{item.title}</div>
<div className="text-lg font-semibold">
<div className='text-xs text-gray-500'>{item.title}</div>
<div className='text-lg font-semibold'>
<Skeleton
loading={loading}
active
@@ -67,7 +67,11 @@ const StatsCards = ({
<Skeleton.Paragraph
active
rows={1}
style={{ width: '65px', height: '24px', marginTop: '4px' }}
style={{
width: '65px',
height: '24px',
marginTop: '4px',
}}
/>
}
>
@@ -78,9 +82,9 @@ const StatsCards = ({
</div>
{item.title === t('当前余额') ? (
<Tag
color="white"
color='white'
shape='circle'
size="large"
size='large'
onClick={(e) => {
e.stopPropagation();
navigate('/console/topup');
@@ -88,13 +92,16 @@ const StatsCards = ({
>
{t('充值')}
</Tag>
) : (loading || (item.trendData && item.trendData.length > 0)) && (
<div className="w-24 h-10">
<VChart
spec={getTrendSpec(item.trendData, item.trendColor)}
option={CHART_CONFIG}
/>
</div>
) : (
(loading ||
(item.trendData && item.trendData.length > 0)) && (
<div className='w-24 h-10'>
<VChart
spec={getTrendSpec(item.trendData, item.trendColor)}
option={CHART_CONFIG}
/>
</div>
)
)}
</div>
))}
@@ -106,4 +113,4 @@ const StatsCards = ({
);
};
export default StatsCards;
export default StatsCards;

View File

@@ -18,9 +18,20 @@ For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react';
import { Card, Button, Spin, Tabs, TabPane, Tag, Empty } from '@douyinfe/semi-ui';
import {
Card,
Button,
Spin,
Tabs,
TabPane,
Tag,
Empty,
} from '@douyinfe/semi-ui';
import { Gauge, RefreshCw } from 'lucide-react';
import { IllustrationConstruction, IllustrationConstructionDark } from '@douyinfe/semi-illustrations';
import {
IllustrationConstruction,
IllustrationConstructionDark,
} from '@douyinfe/semi-illustrations';
import ScrollableContainer from '../common/ui/ScrollableContainer';
const UptimePanel = ({
@@ -33,15 +44,15 @@ const UptimePanel = ({
renderMonitorList,
CARD_PROPS,
ILLUSTRATION_SIZE,
t
t,
}) => {
return (
<Card
{...CARD_PROPS}
className="shadow-sm !rounded-2xl lg:col-span-1"
className='shadow-sm !rounded-2xl lg:col-span-1'
title={
<div className="flex items-center justify-between w-full gap-2">
<div className="flex items-center gap-2">
<div className='flex items-center justify-between w-full gap-2'>
<div className='flex items-center gap-2'>
<Gauge size={16} />
{t('服务可用性')}
</div>
@@ -49,39 +60,43 @@ const UptimePanel = ({
icon={<RefreshCw size={14} />}
onClick={loadUptimeData}
loading={uptimeLoading}
size="small"
theme="borderless"
size='small'
theme='borderless'
type='tertiary'
className="text-gray-500 hover:text-blue-500 hover:bg-blue-50 !rounded-full"
className='text-gray-500 hover:text-blue-500 hover:bg-blue-50 !rounded-full'
/>
</div>
}
bodyStyle={{ padding: 0 }}
>
{/* 内容区域 */}
<div className="relative">
<div className='relative'>
<Spin spinning={uptimeLoading}>
{uptimeData.length > 0 ? (
uptimeData.length === 1 ? (
<ScrollableContainer maxHeight="24rem">
<ScrollableContainer maxHeight='24rem'>
{renderMonitorList(uptimeData[0].monitors)}
</ScrollableContainer>
) : (
<Tabs
type="card"
type='card'
collapsible
activeKey={activeUptimeTab}
onChange={setActiveUptimeTab}
size="small"
size='small'
>
{uptimeData.map((group, groupIdx) => (
<TabPane
tab={
<span className="flex items-center gap-2">
<span className='flex items-center gap-2'>
<Gauge size={14} />
{group.categoryName}
<Tag
color={activeUptimeTab === group.categoryName ? 'red' : 'grey'}
color={
activeUptimeTab === group.categoryName
? 'red'
: 'grey'
}
size='small'
shape='circle'
>
@@ -92,7 +107,7 @@ const UptimePanel = ({
itemKey={group.categoryName}
key={groupIdx}
>
<ScrollableContainer maxHeight="21.5rem">
<ScrollableContainer maxHeight='21.5rem'>
{renderMonitorList(group.monitors)}
</ScrollableContainer>
</TabPane>
@@ -100,10 +115,12 @@ const UptimePanel = ({
</Tabs>
)
) : (
<div className="flex justify-center items-center py-8">
<div className='flex justify-center items-center py-8'>
<Empty
image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
darkModeImage={<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />}
darkModeImage={
<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />
}
title={t('暂无监控数据')}
description={t('请联系管理员在系统设置中配置Uptime')}
/>
@@ -114,15 +131,15 @@ const UptimePanel = ({
{/* 图例 */}
{uptimeData.length > 0 && (
<div className="p-3 bg-gray-50 rounded-b-2xl">
<div className="flex flex-wrap gap-3 text-xs justify-center">
<div className='p-3 bg-gray-50 rounded-b-2xl'>
<div className='flex flex-wrap gap-3 text-xs justify-center'>
{uptimeLegendData.map((legend, index) => (
<div key={index} className="flex items-center gap-1">
<div key={index} className='flex items-center gap-1'>
<div
className="w-2 h-2 rounded-full"
className='w-2 h-2 rounded-full'
style={{ backgroundColor: legend.color }}
/>
<span className="text-gray-600">{legend.label}</span>
<span className='text-gray-600'>{legend.label}</span>
</div>
))}
</div>
@@ -132,4 +149,4 @@ const UptimePanel = ({
);
};
export default UptimePanel;
export default UptimePanel;

View File

@@ -41,7 +41,7 @@ import {
FLEX_CENTER_GAP2,
ILLUSTRATION_SIZE,
ANNOUNCEMENT_LEGEND_DATA,
UPTIME_STATUS_MAP
UPTIME_STATUS_MAP,
} from '../../constants/dashboard.constants';
import {
getTrendSpec,
@@ -49,7 +49,7 @@ import {
handleSpeedTest,
getUptimeStatusColor,
getUptimeStatusText,
renderMonitorList
renderMonitorList,
} from '../../helpers/dashboard';
const Dashboard = () => {
@@ -70,7 +70,7 @@ const Dashboard = () => {
dashboardData.setPieData,
dashboardData.setLineData,
dashboardData.setModelColors,
dashboardData.t
dashboardData.t,
);
// ========== 统计数据 ==========
@@ -82,12 +82,12 @@ const Dashboard = () => {
dashboardData.trendData,
dashboardData.performanceMetrics,
dashboardData.navigate,
dashboardData.t
dashboardData.t,
);
// ========== 数据处理 ==========
const initChart = async () => {
await dashboardData.loadQuotaData().then(data => {
await dashboardData.loadQuotaData().then((data) => {
if (data && data.length > 0) {
dashboardCharts.updateChartData(data);
}
@@ -108,25 +108,30 @@ const Dashboard = () => {
// ========== 数据准备 ==========
const apiInfoData = statusState?.status?.api_info || [];
const announcementData = (statusState?.status?.announcements || []).map(item => {
const pubDate = item?.publishDate ? new Date(item.publishDate) : null;
const absoluteTime = pubDate && !isNaN(pubDate.getTime())
? `${pubDate.getFullYear()}-${String(pubDate.getMonth() + 1).padStart(2, '0')}-${String(pubDate.getDate()).padStart(2, '0')} ${String(pubDate.getHours()).padStart(2, '0')}:${String(pubDate.getMinutes()).padStart(2, '0')}`
: (item?.publishDate || '');
const relativeTime = getRelativeTime(item.publishDate);
return ({
...item,
time: absoluteTime,
relative: relativeTime
});
});
const announcementData = (statusState?.status?.announcements || []).map(
(item) => {
const pubDate = item?.publishDate ? new Date(item.publishDate) : null;
const absoluteTime =
pubDate && !isNaN(pubDate.getTime())
? `${pubDate.getFullYear()}-${String(pubDate.getMonth() + 1).padStart(2, '0')}-${String(pubDate.getDate()).padStart(2, '0')} ${String(pubDate.getHours()).padStart(2, '0')}:${String(pubDate.getMinutes()).padStart(2, '0')}`
: item?.publishDate || '';
const relativeTime = getRelativeTime(item.publishDate);
return {
...item,
time: absoluteTime,
relative: relativeTime,
};
},
);
const faqData = statusState?.status?.faq || [];
const uptimeLegendData = Object.entries(UPTIME_STATUS_MAP).map(([status, info]) => ({
status: Number(status),
color: info.color,
label: dashboardData.t(info.label)
}));
const uptimeLegendData = Object.entries(UPTIME_STATUS_MAP).map(
([status, info]) => ({
status: Number(status),
color: info.color,
label: dashboardData.t(info.label),
}),
);
// ========== Effects ==========
useEffect(() => {
@@ -134,7 +139,7 @@ const Dashboard = () => {
}, []);
return (
<div className="h-full">
<div className='h-full'>
<DashboardHeader
getGreeting={dashboardData.getGreeting}
greetingVisible={dashboardData.greetingVisible}
@@ -166,8 +171,10 @@ const Dashboard = () => {
/>
{/* API信息和图表面板 */}
<div className="mb-4">
<div className={`grid grid-cols-1 gap-4 ${dashboardData.hasApiInfoPanel ? 'lg:grid-cols-4' : ''}`}>
<div className='mb-4'>
<div
className={`grid grid-cols-1 gap-4 ${dashboardData.hasApiInfoPanel ? 'lg:grid-cols-4' : ''}`}
>
<ChartsPanel
activeChartTab={dashboardData.activeChartTab}
setActiveChartTab={dashboardData.setActiveChartTab}
@@ -198,16 +205,18 @@ const Dashboard = () => {
{/* 系统公告和常见问答卡片 */}
{dashboardData.hasInfoPanels && (
<div className="mb-4">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4">
<div className='mb-4'>
<div className='grid grid-cols-1 lg:grid-cols-4 gap-4'>
{/* 公告卡片 */}
{dashboardData.announcementsEnabled && (
<AnnouncementsPanel
announcementData={announcementData}
announcementLegendData={ANNOUNCEMENT_LEGEND_DATA.map(item => ({
...item,
label: dashboardData.t(item.label)
}))}
announcementLegendData={ANNOUNCEMENT_LEGEND_DATA.map(
(item) => ({
...item,
label: dashboardData.t(item.label),
}),
)}
CARD_PROPS={CARD_PROPS}
ILLUSTRATION_SIZE={ILLUSTRATION_SIZE}
t={dashboardData.t}
@@ -234,12 +243,19 @@ const Dashboard = () => {
setActiveUptimeTab={dashboardData.setActiveUptimeTab}
loadUptimeData={dashboardData.loadUptimeData}
uptimeLegendData={uptimeLegendData}
renderMonitorList={(monitors) => renderMonitorList(
monitors,
(status) => getUptimeStatusColor(status, UPTIME_STATUS_MAP),
(status) => getUptimeStatusText(status, UPTIME_STATUS_MAP, dashboardData.t),
dashboardData.t
)}
renderMonitorList={(monitors) =>
renderMonitorList(
monitors,
(status) => getUptimeStatusColor(status, UPTIME_STATUS_MAP),
(status) =>
getUptimeStatusText(
status,
UPTIME_STATUS_MAP,
dashboardData.t,
),
dashboardData.t,
)
}
CARD_PROPS={CARD_PROPS}
ILLUSTRATION_SIZE={ILLUSTRATION_SIZE}
t={dashboardData.t}
@@ -252,4 +268,4 @@ const Dashboard = () => {
);
};
export default Dashboard;
export default Dashboard;

View File

@@ -30,12 +30,12 @@ const SearchModal = ({
dataExportDefaultTime,
timeOptions,
handleInputChange,
t
t,
}) => {
const formRef = useRef();
const FORM_FIELD_PROPS = {
className: "w-full mb-2 !rounded-lg",
className: 'w-full mb-2 !rounded-lg',
};
const createFormField = (Component, props) => (
@@ -54,7 +54,7 @@ const SearchModal = ({
size={isMobile ? 'full-width' : 'small'}
centered
>
<Form ref={formRef} layout='vertical' className="w-full">
<Form ref={formRef} layout='vertical' className='w-full'>
{createFormField(Form.DatePicker, {
field: 'start_timestamp',
label: t('起始时间'),
@@ -62,7 +62,7 @@ const SearchModal = ({
value: start_timestamp,
type: 'dateTime',
name: 'start_timestamp',
onChange: (value) => handleInputChange(value, 'start_timestamp')
onChange: (value) => handleInputChange(value, 'start_timestamp'),
})}
{createFormField(Form.DatePicker, {
@@ -72,7 +72,7 @@ const SearchModal = ({
value: end_timestamp,
type: 'dateTime',
name: 'end_timestamp',
onChange: (value) => handleInputChange(value, 'end_timestamp')
onChange: (value) => handleInputChange(value, 'end_timestamp'),
})}
{createFormField(Form.Select, {
@@ -82,20 +82,22 @@ const SearchModal = ({
placeholder: t('时间粒度'),
name: 'data_export_default_time',
optionList: timeOptions,
onChange: (value) => handleInputChange(value, 'data_export_default_time')
onChange: (value) =>
handleInputChange(value, 'data_export_default_time'),
})}
{isAdminUser && createFormField(Form.Input, {
field: 'username',
label: t('用户名称'),
value: username,
placeholder: t('可选值'),
name: 'username',
onChange: (value) => handleInputChange(value, 'username')
})}
{isAdminUser &&
createFormField(Form.Input, {
field: 'username',
label: t('用户名称'),
value: username,
placeholder: t('可选值'),
name: 'username',
onChange: (value) => handleInputChange(value, 'username'),
})}
</Form>
</Modal>
);
};
export default SearchModal;
export default SearchModal;

View File

@@ -40,85 +40,191 @@ const FooterBar = () => {
const currentYear = new Date().getFullYear();
const customFooter = useMemo(() => (
<footer className="relative h-auto py-16 px-6 md:px-24 w-full flex flex-col items-center justify-between overflow-hidden">
<div className="absolute hidden md:block top-[204px] left-[-100px] w-[151px] h-[151px] rounded-full bg-[#FFD166]"></div>
<div className="absolute md:hidden bottom-[20px] left-[-50px] w-[80px] h-[80px] rounded-full bg-[#FFD166] opacity-60"></div>
const customFooter = useMemo(
() => (
<footer className='relative h-auto py-16 px-6 md:px-24 w-full flex flex-col items-center justify-between overflow-hidden'>
<div className='absolute hidden md:block top-[204px] left-[-100px] w-[151px] h-[151px] rounded-full bg-[#FFD166]'></div>
<div className='absolute md:hidden bottom-[20px] left-[-50px] w-[80px] h-[80px] rounded-full bg-[#FFD166] opacity-60'></div>
{isDemoSiteMode && (
<div className="flex flex-col md:flex-row justify-between w-full max-w-[1110px] mb-10 gap-8">
<div className="flex-shrink-0">
<img
src={logo}
alt={systemName}
className="w-16 h-16 rounded-full bg-gray-800 p-1.5 object-contain"
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-8 w-full">
<div className="text-left">
<p className="!text-semi-color-text-0 font-semibold mb-5">{t('关于我们')}</p>
<div className="flex flex-col gap-4">
<a href="https://docs.newapi.pro/wiki/project-introduction/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">{t('关于项目')}</a>
<a href="https://docs.newapi.pro/support/community-interaction/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">{t('联系我们')}</a>
<a href="https://docs.newapi.pro/wiki/features-introduction/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">{t('功能特性')}</a>
</div>
{isDemoSiteMode && (
<div className='flex flex-col md:flex-row justify-between w-full max-w-[1110px] mb-10 gap-8'>
<div className='flex-shrink-0'>
<img
src={logo}
alt={systemName}
className='w-16 h-16 rounded-full bg-gray-800 p-1.5 object-contain'
/>
</div>
<div className="text-left">
<p className="!text-semi-color-text-0 font-semibold mb-5">{t('文档')}</p>
<div className="flex flex-col gap-4">
<a href="https://docs.newapi.pro/getting-started/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">{t('快速开始')}</a>
<a href="https://docs.newapi.pro/installation/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">{t('安装指南')}</a>
<a href="https://docs.newapi.pro/api/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">{t('API 文档')}</a>
<div className='grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-8 w-full'>
<div className='text-left'>
<p className='!text-semi-color-text-0 font-semibold mb-5'>
{t('关于我们')}
</p>
<div className='flex flex-col gap-4'>
<a
href='https://docs.newapi.pro/wiki/project-introduction/'
target='_blank'
rel='noopener noreferrer'
className='!text-semi-color-text-1'
>
{t('关于项目')}
</a>
<a
href='https://docs.newapi.pro/support/community-interaction/'
target='_blank'
rel='noopener noreferrer'
className='!text-semi-color-text-1'
>
{t('联系我们')}
</a>
<a
href='https://docs.newapi.pro/wiki/features-introduction/'
target='_blank'
rel='noopener noreferrer'
className='!text-semi-color-text-1'
>
{t('功能特性')}
</a>
</div>
</div>
</div>
<div className="text-left">
<p className="!text-semi-color-text-0 font-semibold mb-5">{t('相关项目')}</p>
<div className="flex flex-col gap-4">
<a href="https://github.com/songquanpeng/one-api" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">One API</a>
<a href="https://github.com/novicezk/midjourney-proxy" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">Midjourney-Proxy</a>
<a href="https://github.com/Deeptrain-Community/chatnio" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">chatnio</a>
<a href="https://github.com/Calcium-Ion/neko-api-key-tool" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">neko-api-key-tool</a>
<div className='text-left'>
<p className='!text-semi-color-text-0 font-semibold mb-5'>
{t('文档')}
</p>
<div className='flex flex-col gap-4'>
<a
href='https://docs.newapi.pro/getting-started/'
target='_blank'
rel='noopener noreferrer'
className='!text-semi-color-text-1'
>
{t('快速开始')}
</a>
<a
href='https://docs.newapi.pro/installation/'
target='_blank'
rel='noopener noreferrer'
className='!text-semi-color-text-1'
>
{t('安装指南')}
</a>
<a
href='https://docs.newapi.pro/api/'
target='_blank'
rel='noopener noreferrer'
className='!text-semi-color-text-1'
>
{t('API 文档')}
</a>
</div>
</div>
</div>
<div className="text-left">
<p className="!text-semi-color-text-0 font-semibold mb-5">{t('基于New API的项目')}</p>
<div className="flex flex-col gap-4">
<a href="https://github.com/Calcium-Ion/new-api-horizon" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">new-api-horizon</a>
{/* <a href="https://github.com/VoAPI/VoAPI" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">VoAPI</a> */}
<div className='text-left'>
<p className='!text-semi-color-text-0 font-semibold mb-5'>
{t('相关项目')}
</p>
<div className='flex flex-col gap-4'>
<a
href='https://github.com/songquanpeng/one-api'
target='_blank'
rel='noopener noreferrer'
className='!text-semi-color-text-1'
>
One API
</a>
<a
href='https://github.com/novicezk/midjourney-proxy'
target='_blank'
rel='noopener noreferrer'
className='!text-semi-color-text-1'
>
Midjourney-Proxy
</a>
<a
href='https://github.com/Deeptrain-Community/chatnio'
target='_blank'
rel='noopener noreferrer'
className='!text-semi-color-text-1'
>
chatnio
</a>
<a
href='https://github.com/Calcium-Ion/neko-api-key-tool'
target='_blank'
rel='noopener noreferrer'
className='!text-semi-color-text-1'
>
neko-api-key-tool
</a>
</div>
</div>
<div className='text-left'>
<p className='!text-semi-color-text-0 font-semibold mb-5'>
{t('基于New API的项目')}
</p>
<div className='flex flex-col gap-4'>
<a
href='https://github.com/Calcium-Ion/new-api-horizon'
target='_blank'
rel='noopener noreferrer'
className='!text-semi-color-text-1'
>
new-api-horizon
</a>
{/* <a href="https://github.com/VoAPI/VoAPI" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">VoAPI</a> */}
</div>
</div>
</div>
</div>
</div>
)}
)}
<div className="flex flex-col md:flex-row items-center justify-between w-full max-w-[1110px] gap-6">
<div className="flex flex-wrap items-center gap-2">
<Typography.Text className="text-sm !text-semi-color-text-1">© {currentYear} {systemName}. {t('版权所有')}</Typography.Text>
</div>
<div className='flex flex-col md:flex-row items-center justify-between w-full max-w-[1110px] gap-6'>
<div className='flex flex-wrap items-center gap-2'>
<Typography.Text className='text-sm !text-semi-color-text-1'>
© {currentYear} {systemName}. {t('版权所有')}
</Typography.Text>
</div>
<div className="text-sm">
<span className="!text-semi-color-text-1">{t('设计与开发由')} </span>
<a href="https://github.com/QuantumNous/new-api" target="_blank" rel="noopener noreferrer" className="!text-semi-color-primary font-medium">New API</a>
<span className="!text-semi-color-text-1"> & </span>
<a href="https://github.com/songquanpeng/one-api" target="_blank" rel="noopener noreferrer" className="!text-semi-color-primary font-medium">One API</a>
<div className='text-sm'>
<span className='!text-semi-color-text-1'>
{t('设计与开发由')}{' '}
</span>
<a
href='https://github.com/QuantumNous/new-api'
target='_blank'
rel='noopener noreferrer'
className='!text-semi-color-primary font-medium'
>
New API
</a>
<span className='!text-semi-color-text-1'> & </span>
<a
href='https://github.com/songquanpeng/one-api'
target='_blank'
rel='noopener noreferrer'
className='!text-semi-color-primary font-medium'
>
One API
</a>
</div>
</div>
</div>
</footer>
), [logo, systemName, t, currentYear, isDemoSiteMode]);
</footer>
),
[logo, systemName, t, currentYear, isDemoSiteMode],
);
useEffect(() => {
loadFooter();
}, []);
return (
<div className="w-full">
<div className='w-full'>
{footer ? (
<div
className="custom-footer"
className='custom-footer'
dangerouslySetInnerHTML={{ __html: footer }}
></div>
) : (

View File

@@ -41,7 +41,7 @@ const ActionButtons = ({
t,
}) => {
return (
<div className="flex items-center gap-2 md:gap-3">
<div className='flex items-center gap-2 md:gap-3'>
<NewYearButton isNewYear={isNewYear} />
<NotificationButton
@@ -50,11 +50,7 @@ const ActionButtons = ({
t={t}
/>
<ThemeToggle
theme={theme}
onThemeToggle={onThemeToggle}
t={t}
/>
<ThemeToggle theme={theme} onThemeToggle={onThemeToggle} t={t} />
<LanguageSelector
currentLang={currentLang}

View File

@@ -38,35 +38,35 @@ const HeaderLogo = ({
}
return (
<Link to="/" className="group flex items-center gap-2">
<div className="relative w-8 h-8 md:w-8 md:h-8">
<SkeletonWrapper
loading={isLoading || !logoLoaded}
type="image"
/>
<Link to='/' className='group flex items-center gap-2'>
<div className='relative w-8 h-8 md:w-8 md:h-8'>
<SkeletonWrapper loading={isLoading || !logoLoaded} type='image' />
<img
src={logo}
alt="logo"
className={`absolute inset-0 w-full h-full transition-all duration-200 group-hover:scale-110 rounded-full ${(!isLoading && logoLoaded) ? 'opacity-100' : 'opacity-0'}`}
alt='logo'
className={`absolute inset-0 w-full h-full transition-all duration-200 group-hover:scale-110 rounded-full ${!isLoading && logoLoaded ? 'opacity-100' : 'opacity-0'}`}
/>
</div>
<div className="hidden md:flex items-center gap-2">
<div className="flex items-center gap-2">
<div className='hidden md:flex items-center gap-2'>
<div className='flex items-center gap-2'>
<SkeletonWrapper
loading={isLoading}
type="title"
type='title'
width={120}
height={24}
>
<Typography.Title heading={4} className="!text-lg !font-semibold !mb-0">
<Typography.Title
heading={4}
className='!text-lg !font-semibold !mb-0'
>
{systemName}
</Typography.Title>
</SkeletonWrapper>
{(isSelfUseMode || isDemoSiteMode) && !isLoading && (
<Tag
color={isSelfUseMode ? 'purple' : 'blue'}
className="text-xs px-1.5 py-0.5 rounded whitespace-nowrap shadow-sm"
size="small"
className='text-xs px-1.5 py-0.5 rounded whitespace-nowrap shadow-sm'
size='small'
shape='circle'
>
{isSelfUseMode ? t('自用模式') : t('演示站点')}

View File

@@ -25,21 +25,21 @@ import { CN, GB } from 'country-flag-icons/react/3x2';
const LanguageSelector = ({ currentLang, onLanguageChange, t }) => {
return (
<Dropdown
position="bottomRight"
position='bottomRight'
render={
<Dropdown.Menu className="!bg-semi-color-bg-overlay !border-semi-color-border !shadow-lg !rounded-lg dark:!bg-gray-700 dark:!border-gray-600">
<Dropdown.Menu className='!bg-semi-color-bg-overlay !border-semi-color-border !shadow-lg !rounded-lg dark:!bg-gray-700 dark:!border-gray-600'>
<Dropdown.Item
onClick={() => onLanguageChange('zh')}
className={`!flex !items-center !gap-2 !px-3 !py-1.5 !text-sm !text-semi-color-text-0 dark:!text-gray-200 ${currentLang === 'zh' ? '!bg-semi-color-primary-light-default dark:!bg-blue-600 !font-semibold' : 'hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-600'}`}
>
<CN title="中文" className="!w-5 !h-auto" />
<CN title='中文' className='!w-5 !h-auto' />
<span>中文</span>
</Dropdown.Item>
<Dropdown.Item
onClick={() => onLanguageChange('en')}
className={`!flex !items-center !gap-2 !px-3 !py-1.5 !text-sm !text-semi-color-text-0 dark:!text-gray-200 ${currentLang === 'en' ? '!bg-semi-color-primary-light-default dark:!bg-blue-600 !font-semibold' : 'hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-600'}`}
>
<GB title="English" className="!w-5 !h-auto" />
<GB title='English' className='!w-5 !h-auto' />
<span>English</span>
</Dropdown.Item>
</Dropdown.Menu>
@@ -48,9 +48,9 @@ const LanguageSelector = ({ currentLang, onLanguageChange, t }) => {
<Button
icon={<Languages size={18} />}
aria-label={t('切换语言')}
theme="borderless"
type="tertiary"
className="!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 !rounded-full !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2"
theme='borderless'
type='tertiary'
className='!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 !rounded-full !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2'
/>
</Dropdown>
);

View File

@@ -36,13 +36,19 @@ const MobileMenuButton = ({
return (
<Button
icon={
(isMobile ? drawerOpen : collapsed) ? <IconClose className="text-lg" /> : <IconMenu className="text-lg" />
(isMobile ? drawerOpen : collapsed) ? (
<IconClose className='text-lg' />
) : (
<IconMenu className='text-lg' />
)
}
aria-label={
(isMobile ? drawerOpen : collapsed) ? t('关闭侧边栏') : t('打开侧边栏')
}
aria-label={(isMobile ? drawerOpen : collapsed) ? t('关闭侧边栏') : t('打开侧边栏')}
onClick={onToggle}
theme="borderless"
type="tertiary"
className="!p-2 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700"
theme='borderless'
type='tertiary'
className='!p-2 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700'
/>
);
};

View File

@@ -21,21 +21,16 @@ import React from 'react';
import { Link } from 'react-router-dom';
import SkeletonWrapper from './SkeletonWrapper';
const Navigation = ({
mainNavLinks,
isMobile,
isLoading,
userState
}) => {
const Navigation = ({ mainNavLinks, isMobile, isLoading, userState }) => {
const renderNavLinks = () => {
const baseClasses = 'flex-shrink-0 flex items-center gap-1 font-semibold rounded-md transition-all duration-200 ease-in-out';
const baseClasses =
'flex-shrink-0 flex items-center gap-1 font-semibold rounded-md transition-all duration-200 ease-in-out';
const hoverClasses = 'hover:text-semi-color-primary';
const spacingClasses = isMobile ? 'p-1' : 'p-2';
const commonLinkClasses = `${baseClasses} ${spacingClasses} ${hoverClasses}`;
return mainNavLinks.map((link) => {
const linkContent = <span>{link.text}</span>;
if (link.isExternal) {
@@ -58,11 +53,7 @@ const Navigation = ({
}
return (
<Link
key={link.itemKey}
to={targetPath}
className={commonLinkClasses}
>
<Link key={link.itemKey} to={targetPath} className={commonLinkClasses}>
{linkContent}
</Link>
);
@@ -70,10 +61,10 @@ const Navigation = ({
};
return (
<nav className="flex flex-1 items-center gap-1 lg:gap-2 mx-2 md:mx-4 overflow-x-auto whitespace-nowrap scrollbar-hide">
<nav className='flex flex-1 items-center gap-1 lg:gap-2 mx-2 md:mx-4 overflow-x-auto whitespace-nowrap scrollbar-hide'>
<SkeletonWrapper
loading={isLoading}
type="navigation"
type='navigation'
count={4}
width={60}
height={16}

View File

@@ -36,21 +36,24 @@ const NewYearButton = ({ isNewYear }) => {
return (
<Dropdown
position="bottomRight"
position='bottomRight'
render={
<Dropdown.Menu className="!bg-semi-color-bg-overlay !border-semi-color-border !shadow-lg !rounded-lg dark:!bg-gray-700 dark:!border-gray-600">
<Dropdown.Item onClick={handleNewYearClick} className="!text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-gray-600">
<Dropdown.Menu className='!bg-semi-color-bg-overlay !border-semi-color-border !shadow-lg !rounded-lg dark:!bg-gray-700 dark:!border-gray-600'>
<Dropdown.Item
onClick={handleNewYearClick}
className='!text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-gray-600'
>
Happy New Year!!! 🎉
</Dropdown.Item>
</Dropdown.Menu>
}
>
<Button
theme="borderless"
type="tertiary"
icon={<span className="text-xl">🎉</span>}
aria-label="New Year"
className="!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 rounded-full"
theme='borderless'
type='tertiary'
icon={<span className='text-xl'>🎉</span>}
aria-label='New Year'
className='!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 rounded-full'
/>
</Dropdown>
);

View File

@@ -26,14 +26,15 @@ const NotificationButton = ({ unreadCount, onNoticeOpen, t }) => {
icon: <Bell size={18} />,
'aria-label': t('系统公告'),
onClick: onNoticeOpen,
theme: "borderless",
type: "tertiary",
className: "!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 !rounded-full !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2",
theme: 'borderless',
type: 'tertiary',
className:
'!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 !rounded-full !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2',
};
if (unreadCount > 0) {
return (
<Badge count={unreadCount} type="danger" overflowCount={99}>
<Badge count={unreadCount} type='danger' overflowCount={99}>
<Button {...buttonProps} />
</Badge>
);

View File

@@ -62,13 +62,17 @@ const SkeletonWrapper = ({
// 用户区域骨架屏 (头像 + 文本)
const renderUserAreaSkeleton = () => {
return (
<div className={`flex items-center p-1 rounded-full bg-semi-color-fill-0 dark:bg-semi-color-fill-1 ${className}`}>
<div
className={`flex items-center p-1 rounded-full bg-semi-color-fill-0 dark:bg-semi-color-fill-1 ${className}`}
>
<Skeleton
loading={true}
active
placeholder={<Skeleton.Avatar active size="extra-small" className="shadow-sm" />}
placeholder={
<Skeleton.Avatar active size='extra-small' className='shadow-sm' />
}
/>
<div className="ml-1.5 mr-1">
<div className='ml-1.5 mr-1'>
<Skeleton
loading={true}
active
@@ -107,12 +111,7 @@ const SkeletonWrapper = ({
<Skeleton
loading={true}
active
placeholder={
<Skeleton.Title
active
style={{ width, height: 24 }}
/>
}
placeholder={<Skeleton.Title active style={{ width, height: 24 }} />}
/>
);
};
@@ -124,12 +123,7 @@ const SkeletonWrapper = ({
<Skeleton
loading={true}
active
placeholder={
<Skeleton.Title
active
style={{ width, height }}
/>
}
placeholder={<Skeleton.Title active style={{ width, height }} />}
/>
</div>
);

View File

@@ -25,29 +25,32 @@ import { useActualTheme } from '../../../context/Theme';
const ThemeToggle = ({ theme, onThemeToggle, t }) => {
const actualTheme = useActualTheme();
const themeOptions = useMemo(() => ([
{
key: 'light',
icon: <Sun size={18} />,
buttonIcon: <Sun size={18} />,
label: t('浅色模式'),
description: t('始终使用浅色主题')
},
{
key: 'dark',
icon: <Moon size={18} />,
buttonIcon: <Moon size={18} />,
label: t('深色模式'),
description: t('始终使用深色主题')
},
{
key: 'auto',
icon: <Monitor size={18} />,
buttonIcon: <Monitor size={18} />,
label: t('自动模式'),
description: t('跟随系统主题设置')
}
]), [t]);
const themeOptions = useMemo(
() => [
{
key: 'light',
icon: <Sun size={18} />,
buttonIcon: <Sun size={18} />,
label: t('浅色模式'),
description: t('始终使用浅色主题'),
},
{
key: 'dark',
icon: <Moon size={18} />,
buttonIcon: <Moon size={18} />,
label: t('深色模式'),
description: t('始终使用深色主题'),
},
{
key: 'auto',
icon: <Monitor size={18} />,
buttonIcon: <Monitor size={18} />,
label: t('自动模式'),
description: t('跟随系统主题设置'),
},
],
[t],
);
const getItemClassName = (isSelected) =>
isSelected
@@ -55,13 +58,13 @@ const ThemeToggle = ({ theme, onThemeToggle, t }) => {
: 'hover:!bg-semi-color-fill-1';
const currentButtonIcon = useMemo(() => {
const currentOption = themeOptions.find(option => option.key === theme);
const currentOption = themeOptions.find((option) => option.key === theme);
return currentOption?.buttonIcon || themeOptions[2].buttonIcon;
}, [theme, themeOptions]);
return (
<Dropdown
position="bottomRight"
position='bottomRight'
render={
<Dropdown.Menu>
{themeOptions.map((option) => (
@@ -71,9 +74,9 @@ const ThemeToggle = ({ theme, onThemeToggle, t }) => {
onClick={() => onThemeToggle(option.key)}
className={getItemClassName(theme === option.key)}
>
<div className="flex flex-col">
<div className='flex flex-col'>
<span>{option.label}</span>
<span className="text-xs text-semi-color-text-2">
<span className='text-xs text-semi-color-text-2'>
{option.description}
</span>
</div>
@@ -83,8 +86,9 @@ const ThemeToggle = ({ theme, onThemeToggle, t }) => {
{theme === 'auto' && (
<>
<Dropdown.Divider />
<div className="px-3 py-2 text-xs text-semi-color-text-2">
{t('当前跟随系统')}{actualTheme === 'dark' ? t('深色') : t('浅色')}
<div className='px-3 py-2 text-xs text-semi-color-text-2'>
{t('当前跟随系统')}
{actualTheme === 'dark' ? t('深色') : t('浅色')}
</div>
</>
)}
@@ -94,9 +98,9 @@ const ThemeToggle = ({ theme, onThemeToggle, t }) => {
<Button
icon={currentButtonIcon}
aria-label={t('切换主题')}
theme="borderless"
type="tertiary"
className="!p-1.5 !text-current focus:!bg-semi-color-fill-1 !rounded-full !bg-semi-color-fill-0 hover:!bg-semi-color-fill-1"
theme='borderless'
type='tertiary'
className='!p-1.5 !text-current focus:!bg-semi-color-fill-1 !rounded-full !bg-semi-color-fill-0 hover:!bg-semi-color-fill-1'
/>
</Dropdown>
);

View File

@@ -19,12 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
import React from 'react';
import { Link } from 'react-router-dom';
import {
Avatar,
Button,
Dropdown,
Typography,
} from '@douyinfe/semi-ui';
import { Avatar, Button, Dropdown, Typography } from '@douyinfe/semi-ui';
import { ChevronDown } from 'lucide-react';
import {
IconExit,
@@ -48,7 +43,7 @@ const UserArea = ({
return (
<SkeletonWrapper
loading={true}
type="userArea"
type='userArea'
width={50}
isMobile={isMobile}
/>
@@ -58,17 +53,20 @@ const UserArea = ({
if (userState.user) {
return (
<Dropdown
position="bottomRight"
position='bottomRight'
render={
<Dropdown.Menu className="!bg-semi-color-bg-overlay !border-semi-color-border !shadow-lg !rounded-lg dark:!bg-gray-700 dark:!border-gray-600">
<Dropdown.Menu className='!bg-semi-color-bg-overlay !border-semi-color-border !shadow-lg !rounded-lg dark:!bg-gray-700 dark:!border-gray-600'>
<Dropdown.Item
onClick={() => {
navigate('/console/personal');
}}
className="!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white"
className='!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white'
>
<div className="flex items-center gap-2">
<IconUserSetting size="small" className="text-gray-500 dark:text-gray-400" />
<div className='flex items-center gap-2'>
<IconUserSetting
size='small'
className='text-gray-500 dark:text-gray-400'
/>
<span>{t('个人设置')}</span>
</div>
</Dropdown.Item>
@@ -76,10 +74,13 @@ const UserArea = ({
onClick={() => {
navigate('/console/token');
}}
className="!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white"
className='!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white'
>
<div className="flex items-center gap-2">
<IconKey size="small" className="text-gray-500 dark:text-gray-400" />
<div className='flex items-center gap-2'>
<IconKey
size='small'
className='text-gray-500 dark:text-gray-400'
/>
<span>{t('令牌管理')}</span>
</div>
</Dropdown.Item>
@@ -87,16 +88,25 @@ const UserArea = ({
onClick={() => {
navigate('/console/topup');
}}
className="!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white"
className='!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white'
>
<div className="flex items-center gap-2">
<IconCreditCard size="small" className="text-gray-500 dark:text-gray-400" />
<div className='flex items-center gap-2'>
<IconCreditCard
size='small'
className='text-gray-500 dark:text-gray-400'
/>
<span>{t('钱包管理')}</span>
</div>
</Dropdown.Item>
<Dropdown.Item onClick={logout} className="!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-red-500 dark:hover:!text-white">
<div className="flex items-center gap-2">
<IconExit size="small" className="text-gray-500 dark:text-gray-400" />
<Dropdown.Item
onClick={logout}
className='!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-red-500 dark:hover:!text-white'
>
<div className='flex items-center gap-2'>
<IconExit
size='small'
className='text-gray-500 dark:text-gray-400'
/>
<span>{t('退出')}</span>
</div>
</Dropdown.Item>
@@ -104,74 +114,76 @@ const UserArea = ({
}
>
<Button
theme="borderless"
type="tertiary"
className="flex items-center gap-1.5 !p-1 !rounded-full hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-700 !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2"
theme='borderless'
type='tertiary'
className='flex items-center gap-1.5 !p-1 !rounded-full hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-700 !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2'
>
<Avatar
size="extra-small"
size='extra-small'
color={stringToColor(userState.user.username)}
className="mr-1"
className='mr-1'
>
{userState.user.username[0].toUpperCase()}
</Avatar>
<span className="hidden md:inline">
<Typography.Text className="!text-xs !font-medium !text-semi-color-text-1 dark:!text-gray-300 mr-1">
<span className='hidden md:inline'>
<Typography.Text className='!text-xs !font-medium !text-semi-color-text-1 dark:!text-gray-300 mr-1'>
{userState.user.username}
</Typography.Text>
</span>
<ChevronDown size={14} className="text-xs text-semi-color-text-2 dark:text-gray-400" />
<ChevronDown
size={14}
className='text-xs text-semi-color-text-2 dark:text-gray-400'
/>
</Button>
</Dropdown>
);
} else {
const showRegisterButton = !isSelfUseMode;
const commonSizingAndLayoutClass = "flex items-center justify-center !py-[10px] !px-1.5";
const commonSizingAndLayoutClass =
'flex items-center justify-center !py-[10px] !px-1.5';
const loginButtonSpecificStyling = "!bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-700 transition-colors";
const loginButtonSpecificStyling =
'!bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-700 transition-colors';
let loginButtonClasses = `${commonSizingAndLayoutClass} ${loginButtonSpecificStyling}`;
let registerButtonClasses = `${commonSizingAndLayoutClass}`;
const loginButtonTextSpanClass = "!text-xs !text-semi-color-text-1 dark:!text-gray-300 !p-1.5";
const registerButtonTextSpanClass = "!text-xs !text-white !p-1.5";
const loginButtonTextSpanClass =
'!text-xs !text-semi-color-text-1 dark:!text-gray-300 !p-1.5';
const registerButtonTextSpanClass = '!text-xs !text-white !p-1.5';
if (showRegisterButton) {
if (isMobile) {
loginButtonClasses += " !rounded-full";
loginButtonClasses += ' !rounded-full';
} else {
loginButtonClasses += " !rounded-l-full !rounded-r-none";
loginButtonClasses += ' !rounded-l-full !rounded-r-none';
}
registerButtonClasses += " !rounded-r-full !rounded-l-none";
registerButtonClasses += ' !rounded-r-full !rounded-l-none';
} else {
loginButtonClasses += " !rounded-full";
loginButtonClasses += ' !rounded-full';
}
return (
<div className="flex items-center">
<Link to="/login" className="flex">
<div className='flex items-center'>
<Link to='/login' className='flex'>
<Button
theme="borderless"
type="tertiary"
theme='borderless'
type='tertiary'
className={loginButtonClasses}
>
<span className={loginButtonTextSpanClass}>
{t('登录')}
</span>
<span className={loginButtonTextSpanClass}>{t('登录')}</span>
</Button>
</Link>
{showRegisterButton && (
<div className="hidden md:block">
<Link to="/register" className="flex -ml-px">
<div className='hidden md:block'>
<Link to='/register' className='flex -ml-px'>
<Button
theme="solid"
type="primary"
theme='solid'
type='primary'
className={registerButtonClasses}
>
<span className={registerButtonTextSpanClass}>
{t('注册')}
</span>
<span className={registerButtonTextSpanClass}>{t('注册')}</span>
</Button>
</Link>
</div>

View File

@@ -63,7 +63,7 @@ const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
const { mainNavLinks } = useNavigation(t, docsLink);
return (
<header className="text-semi-color-text-0 sticky top-0 z-50 transition-colors duration-300 bg-white/75 dark:bg-zinc-900/75 backdrop-blur-lg">
<header className='text-semi-color-text-0 sticky top-0 z-50 transition-colors duration-300 bg-white/75 dark:bg-zinc-900/75 backdrop-blur-lg'>
<NoticeModal
visible={noticeVisible}
onClose={handleNoticeClose}
@@ -72,9 +72,9 @@ const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
unreadKeys={getUnreadKeys()}
/>
<div className="w-full px-2">
<div className="flex items-center justify-between h-16">
<div className="flex items-center">
<div className='w-full px-2'>
<div className='flex items-center justify-between h-16'>
<div className='flex items-center'>
<MobileMenuButton
isConsoleRoute={isConsoleRoute}
isMobile={isMobile}

View File

@@ -18,15 +18,31 @@ For commercial licensing, please contact support@quantumnous.com
*/
import React, { useEffect, useState, useContext, useMemo } from 'react';
import { Button, Modal, Empty, Tabs, TabPane, Timeline } from '@douyinfe/semi-ui';
import {
Button,
Modal,
Empty,
Tabs,
TabPane,
Timeline,
} from '@douyinfe/semi-ui';
import { useTranslation } from 'react-i18next';
import { API, showError, getRelativeTime } from '../../helpers';
import { marked } from 'marked';
import { IllustrationNoContent, IllustrationNoContentDark } from '@douyinfe/semi-illustrations';
import {
IllustrationNoContent,
IllustrationNoContentDark,
} from '@douyinfe/semi-illustrations';
import { StatusContext } from '../../context/Status';
import { Bell, Megaphone } from 'lucide-react';
const NoticeModal = ({ visible, onClose, isMobile, defaultTab = 'inApp', unreadKeys = [] }) => {
const NoticeModal = ({
visible,
onClose,
isMobile,
defaultTab = 'inApp',
unreadKeys = [],
}) => {
const { t } = useTranslation();
const [noticeContent, setNoticeContent] = useState('');
const [loading, setLoading] = useState(false);
@@ -38,23 +54,25 @@ const NoticeModal = ({ visible, onClose, isMobile, defaultTab = 'inApp', unreadK
const unreadSet = useMemo(() => new Set(unreadKeys), [unreadKeys]);
const getKeyForItem = (item) => `${item?.publishDate || ''}-${(item?.content || '').slice(0, 30)}`;
const getKeyForItem = (item) =>
`${item?.publishDate || ''}-${(item?.content || '').slice(0, 30)}`;
const processedAnnouncements = useMemo(() => {
return (announcements || []).slice(0, 20).map(item => {
return (announcements || []).slice(0, 20).map((item) => {
const pubDate = item?.publishDate ? new Date(item.publishDate) : null;
const absoluteTime = pubDate && !isNaN(pubDate.getTime())
? `${pubDate.getFullYear()}-${String(pubDate.getMonth() + 1).padStart(2, '0')}-${String(pubDate.getDate()).padStart(2, '0')} ${String(pubDate.getHours()).padStart(2, '0')}:${String(pubDate.getMinutes()).padStart(2, '0')}`
: (item?.publishDate || '');
return ({
const absoluteTime =
pubDate && !isNaN(pubDate.getTime())
? `${pubDate.getFullYear()}-${String(pubDate.getMonth() + 1).padStart(2, '0')}-${String(pubDate.getDate()).padStart(2, '0')} ${String(pubDate.getHours()).padStart(2, '0')}:${String(pubDate.getMinutes()).padStart(2, '0')}`
: item?.publishDate || '';
return {
key: getKeyForItem(item),
type: item.type || 'default',
time: absoluteTime,
content: item.content,
extra: item.extra,
relative: getRelativeTime(item.publishDate),
isUnread: unreadSet.has(getKeyForItem(item))
});
isUnread: unreadSet.has(getKeyForItem(item)),
};
});
}, [announcements, unreadSet]);
@@ -100,15 +118,23 @@ const NoticeModal = ({ visible, onClose, isMobile, defaultTab = 'inApp', unreadK
const renderMarkdownNotice = () => {
if (loading) {
return <div className="py-12"><Empty description={t('加载中...')} /></div>;
return (
<div className='py-12'>
<Empty description={t('加载中...')} />
</div>
);
}
if (!noticeContent) {
return (
<div className="py-12">
<div className='py-12'>
<Empty
image={<IllustrationNoContent style={{ width: 150, height: 150 }} />}
darkModeImage={<IllustrationNoContentDark style={{ width: 150, height: 150 }} />}
image={
<IllustrationNoContent style={{ width: 150, height: 150 }} />
}
darkModeImage={
<IllustrationNoContentDark style={{ width: 150, height: 150 }} />
}
description={t('暂无公告')}
/>
</div>
@@ -118,7 +144,7 @@ const NoticeModal = ({ visible, onClose, isMobile, defaultTab = 'inApp', unreadK
return (
<div
dangerouslySetInnerHTML={{ __html: noticeContent }}
className="notice-content-scroll max-h-[55vh] overflow-y-auto pr-2"
className='notice-content-scroll max-h-[55vh] overflow-y-auto pr-2'
/>
);
};
@@ -126,10 +152,14 @@ const NoticeModal = ({ visible, onClose, isMobile, defaultTab = 'inApp', unreadK
const renderAnnouncementTimeline = () => {
if (processedAnnouncements.length === 0) {
return (
<div className="py-12">
<div className='py-12'>
<Empty
image={<IllustrationNoContent style={{ width: 150, height: 150 }} />}
darkModeImage={<IllustrationNoContentDark style={{ width: 150, height: 150 }} />}
image={
<IllustrationNoContent style={{ width: 150, height: 150 }} />
}
darkModeImage={
<IllustrationNoContentDark style={{ width: 150, height: 150 }} />
}
description={t('暂无系统公告')}
/>
</div>
@@ -137,8 +167,8 @@ const NoticeModal = ({ visible, onClose, isMobile, defaultTab = 'inApp', unreadK
}
return (
<div className="max-h-[55vh] overflow-y-auto pr-2 card-content-scroll">
<Timeline mode="left">
<div className='max-h-[55vh] overflow-y-auto pr-2 card-content-scroll'>
<Timeline mode='left'>
{processedAnnouncements.map((item, idx) => {
const htmlContent = marked.parse(item.content || '');
const htmlExtra = item.extra ? marked.parse(item.extra) : '';
@@ -147,12 +177,14 @@ const NoticeModal = ({ visible, onClose, isMobile, defaultTab = 'inApp', unreadK
key={idx}
type={item.type}
time={`${item.relative ? item.relative + ' ' : ''}${item.time}`}
extra={item.extra ? (
<div
className="text-xs text-gray-500"
dangerouslySetInnerHTML={{ __html: htmlExtra }}
/>
) : null}
extra={
item.extra ? (
<div
className='text-xs text-gray-500'
dangerouslySetInnerHTML={{ __html: htmlExtra }}
/>
) : null
}
className={item.isUnread ? '' : ''}
>
<div>
@@ -179,26 +211,40 @@ const NoticeModal = ({ visible, onClose, isMobile, defaultTab = 'inApp', unreadK
return (
<Modal
title={
<div className="flex items-center justify-between w-full">
<div className='flex items-center justify-between w-full'>
<span>{t('系统公告')}</span>
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
type='button'
>
<TabPane tab={<span className="flex items-center gap-1"><Bell size={14} /> {t('通知')}</span>} itemKey='inApp' />
<TabPane tab={<span className="flex items-center gap-1"><Megaphone size={14} /> {t('系统公告')}</span>} itemKey='system' />
<Tabs activeKey={activeTab} onChange={setActiveTab} type='button'>
<TabPane
tab={
<span className='flex items-center gap-1'>
<Bell size={14} /> {t('通知')}
</span>
}
itemKey='inApp'
/>
<TabPane
tab={
<span className='flex items-center gap-1'>
<Megaphone size={14} /> {t('系统公告')}
</span>
}
itemKey='system'
/>
</Tabs>
</div>
}
visible={visible}
onCancel={onClose}
footer={(
<div className="flex justify-end">
<Button type='secondary' onClick={handleCloseTodayNotice}>{t('今日关闭')}</Button>
<Button type="primary" onClick={onClose}>{t('关闭公告')}</Button>
footer={
<div className='flex justify-end'>
<Button type='secondary' onClick={handleCloseTodayNotice}>
{t('今日关闭')}
</Button>
<Button type='primary' onClick={onClose}>
{t('关闭公告')}
</Button>
</div>
)}
}
size={isMobile ? 'full-width' : 'large'}
>
{renderBody()}
@@ -206,4 +252,4 @@ const NoticeModal = ({ visible, onClose, isMobile, defaultTab = 'inApp', unreadK
);
};
export default NoticeModal;
export default NoticeModal;

View File

@@ -27,7 +27,13 @@ import React, { useContext, useEffect, useState } from 'react';
import { useIsMobile } from '../../hooks/common/useIsMobile';
import { useSidebarCollapsed } from '../../hooks/common/useSidebarCollapsed';
import { useTranslation } from 'react-i18next';
import { API, getLogo, getSystemName, showError, setStatusData } from '../../helpers';
import {
API,
getLogo,
getSystemName,
showError,
setStatusData,
} from '../../helpers';
import { UserContext } from '../../context/User';
import { StatusContext } from '../../context/Status';
import { useLocation } from 'react-router-dom';
@@ -42,9 +48,12 @@ const PageLayout = () => {
const { i18n } = useTranslation();
const location = useLocation();
const shouldHideFooter = location.pathname.startsWith('/console') || location.pathname === '/pricing';
const shouldHideFooter =
location.pathname.startsWith('/console') ||
location.pathname === '/pricing';
const shouldInnerPadding = location.pathname.includes('/console') &&
const shouldInnerPadding =
location.pathname.includes('/console') &&
!location.pathname.startsWith('/console/chat') &&
location.pathname !== '/console/playground';
@@ -120,7 +129,10 @@ const PageLayout = () => {
zIndex: 100,
}}
>
<HeaderBar onMobileMenuToggle={() => setDrawerOpen(prev => !prev)} drawerOpen={drawerOpen} />
<HeaderBar
onMobileMenuToggle={() => setDrawerOpen((prev) => !prev)}
drawerOpen={drawerOpen}
/>
</Header>
<Layout
style={{
@@ -142,12 +154,20 @@ const PageLayout = () => {
width: 'var(--sidebar-current-width)',
}}
>
<SiderBar onNavigate={() => { if (isMobile) setDrawerOpen(false); }} />
<SiderBar
onNavigate={() => {
if (isMobile) setDrawerOpen(false);
}}
/>
</Sider>
)}
<Layout
style={{
marginLeft: isMobile ? '0' : showSider ? 'var(--sidebar-current-width)' : '0',
marginLeft: isMobile
? '0'
: showSider
? 'var(--sidebar-current-width)'
: '0',
flex: '1 1 auto',
display: 'flex',
flexDirection: 'column',

View File

@@ -26,7 +26,10 @@ const SetupCheck = ({ children }) => {
const location = useLocation();
useEffect(() => {
if (statusState?.status?.setup === false && location.pathname !== '/setup') {
if (
statusState?.status?.setup === false &&
location.pathname !== '/setup'
) {
window.location.href = '/setup';
}
}, [statusState?.status?.setup, location.pathname]);
@@ -34,4 +37,4 @@ const SetupCheck = ({ children }) => {
return children;
};
export default SetupCheck;
export default SetupCheck;

View File

@@ -23,17 +23,9 @@ import { useTranslation } from 'react-i18next';
import { getLucideIcon } from '../../helpers/render';
import { ChevronLeft } from 'lucide-react';
import { useSidebarCollapsed } from '../../hooks/common/useSidebarCollapsed';
import {
isAdmin,
isRoot,
showError
} from '../../helpers';
import { isAdmin, isRoot, showError } from '../../helpers';
import {
Nav,
Divider,
Button,
} from '@douyinfe/semi-ui';
import { Nav, Divider, Button } from '@douyinfe/semi-ui';
const routerMap = {
home: '/',
@@ -54,7 +46,7 @@ const routerMap = {
personal: '/console/personal',
};
const SiderBar = ({ onNavigate = () => { } }) => {
const SiderBar = ({ onNavigate = () => {} }) => {
const { t } = useTranslation();
const [collapsed, toggleCollapsed] = useSidebarCollapsed();
@@ -275,14 +267,17 @@ const SiderBar = ({ onNavigate = () => { } }) => {
key={item.itemKey}
itemKey={item.itemKey}
text={
<div className="flex items-center">
<span className="truncate font-medium text-sm" style={{ color: textColor }}>
<div className='flex items-center'>
<span
className='truncate font-medium text-sm'
style={{ color: textColor }}
>
{item.text}
</span>
</div>
}
icon={
<div className="sidebar-icon-container flex-shrink-0">
<div className='sidebar-icon-container flex-shrink-0'>
{getLucideIcon(item.itemKey, isSelected)}
</div>
}
@@ -302,14 +297,17 @@ const SiderBar = ({ onNavigate = () => { } }) => {
key={item.itemKey}
itemKey={item.itemKey}
text={
<div className="flex items-center">
<span className="truncate font-medium text-sm" style={{ color: textColor }}>
<div className='flex items-center'>
<span
className='truncate font-medium text-sm'
style={{ color: textColor }}
>
{item.text}
</span>
</div>
}
icon={
<div className="sidebar-icon-container flex-shrink-0">
<div className='sidebar-icon-container flex-shrink-0'>
{getLucideIcon(item.itemKey, isSelected)}
</div>
}
@@ -323,7 +321,10 @@ const SiderBar = ({ onNavigate = () => { } }) => {
key={subItem.itemKey}
itemKey={subItem.itemKey}
text={
<span className="truncate font-medium text-sm" style={{ color: subTextColor }}>
<span
className='truncate font-medium text-sm'
style={{ color: subTextColor }}
>
{subItem.text}
</span>
}
@@ -339,18 +340,18 @@ const SiderBar = ({ onNavigate = () => { } }) => {
return (
<div
className="sidebar-container"
className='sidebar-container'
style={{ width: 'var(--sidebar-current-width)' }}
>
<Nav
className="sidebar-nav"
className='sidebar-nav'
defaultIsCollapsed={collapsed}
isCollapsed={collapsed}
onCollapseChange={toggleCollapsed}
selectedKeys={selectedKeys}
itemStyle="sidebar-nav-item"
hoverStyle="sidebar-nav-item:hover"
selectedStyle="sidebar-nav-item-selected"
itemStyle='sidebar-nav-item'
hoverStyle='sidebar-nav-item:hover'
selectedStyle='sidebar-nav-item-selected'
renderWrapper={({ itemElement, props }) => {
const to = routerMapState[props.itemKey] || routerMap[props.itemKey];
@@ -381,27 +382,25 @@ const SiderBar = ({ onNavigate = () => { } }) => {
}}
>
{/* 聊天区域 */}
<div className="sidebar-section">
{!collapsed && (
<div className="sidebar-group-label">{t('聊天')}</div>
)}
<div className='sidebar-section'>
{!collapsed && <div className='sidebar-group-label'>{t('聊天')}</div>}
{chatMenuItems.map((item) => renderSubItem(item))}
</div>
{/* 控制台区域 */}
<Divider className="sidebar-divider" />
<Divider className='sidebar-divider' />
<div>
{!collapsed && (
<div className="sidebar-group-label">{t('控制台')}</div>
<div className='sidebar-group-label'>{t('控制台')}</div>
)}
{workspaceItems.map((item) => renderNavItem(item))}
</div>
{/* 个人中心区域 */}
<Divider className="sidebar-divider" />
<Divider className='sidebar-divider' />
<div>
{!collapsed && (
<div className="sidebar-group-label">{t('个人中心')}</div>
<div className='sidebar-group-label'>{t('个人中心')}</div>
)}
{financeItems.map((item) => renderNavItem(item))}
</div>
@@ -409,10 +408,10 @@ const SiderBar = ({ onNavigate = () => { } }) => {
{/* 管理员区域 - 只在管理员时显示 */}
{isAdmin() && (
<>
<Divider className="sidebar-divider" />
<Divider className='sidebar-divider' />
<div>
{!collapsed && (
<div className="sidebar-group-label">{t('管理员')}</div>
<div className='sidebar-group-label'>{t('管理员')}</div>
)}
{adminItems.map((item) => renderNavItem(item))}
</div>
@@ -421,22 +420,28 @@ const SiderBar = ({ onNavigate = () => { } }) => {
</Nav>
{/* 底部折叠按钮 */}
<div className="sidebar-collapse-button">
<div className='sidebar-collapse-button'>
<Button
theme="outline"
type="tertiary"
size="small"
theme='outline'
type='tertiary'
size='small'
icon={
<ChevronLeft
size={16}
strokeWidth={2.5}
color="var(--semi-color-text-2)"
style={{ transform: collapsed ? 'rotate(180deg)' : 'rotate(0deg)' }}
color='var(--semi-color-text-2)'
style={{
transform: collapsed ? 'rotate(180deg)' : 'rotate(0deg)',
}}
/>
}
onClick={toggleCollapsed}
icononly={collapsed}
style={collapsed ? { padding: '4px', width: '100%' } : { padding: '4px 12px', width: '100%' }}
style={
collapsed
? { padding: '4px', width: '100%' }
: { padding: '4px 12px', width: '100%' }
}
>
{!collapsed ? t('收起侧边栏') : null}
</Button>

View File

@@ -18,17 +18,8 @@ For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react';
import {
Card,
Chat,
Typography,
Button,
} from '@douyinfe/semi-ui';
import {
MessageSquare,
Eye,
EyeOff,
} from 'lucide-react';
import { Card, Chat, Typography, Button } from '@douyinfe/semi-ui';
import { MessageSquare, Eye, EyeOff } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import CustomInputRender from './CustomInputRender';
@@ -57,37 +48,43 @@ const ChatArea = ({
return (
<Card
className="h-full"
className='h-full'
bordered={false}
bodyStyle={{ padding: 0, height: 'calc(100vh - 66px)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}
bodyStyle={{
padding: 0,
height: 'calc(100vh - 66px)',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
{/* 聊天头部 */}
{styleState.isMobile ? (
<div className="pt-4"></div>
<div className='pt-4'></div>
) : (
<div className="px-6 py-4 bg-gradient-to-r from-purple-500 to-blue-500 rounded-t-2xl">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-white/20 backdrop-blur flex items-center justify-center">
<MessageSquare size={20} className="text-white" />
<div className='px-6 py-4 bg-gradient-to-r from-purple-500 to-blue-500 rounded-t-2xl'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-3'>
<div className='w-10 h-10 rounded-full bg-white/20 backdrop-blur flex items-center justify-center'>
<MessageSquare size={20} className='text-white' />
</div>
<div>
<Typography.Title heading={5} className="!text-white mb-0">
<Typography.Title heading={5} className='!text-white mb-0'>
{t('AI 对话')}
</Typography.Title>
<Typography.Text className="!text-white/80 text-sm hidden sm:inline">
<Typography.Text className='!text-white/80 text-sm hidden sm:inline'>
{inputs.model || t('选择模型开始对话')}
</Typography.Text>
</div>
</div>
<div className="flex items-center gap-2">
<div className='flex items-center gap-2'>
<Button
icon={showDebugPanel ? <EyeOff size={14} /> : <Eye size={14} />}
onClick={onToggleDebugPanel}
theme="borderless"
type="primary"
size="small"
className="!rounded-lg !text-white/80 hover:!text-white hover:!bg-white/10"
theme='borderless'
type='primary'
size='small'
className='!rounded-lg !text-white/80 hover:!text-white hover:!bg-white/10'
>
{showDebugPanel ? t('隐藏调试') : t('显示调试')}
</Button>
@@ -97,7 +94,7 @@ const ChatArea = ({
)}
{/* 聊天内容区域 */}
<div className="flex-1 overflow-hidden">
<div className='flex-1 overflow-hidden'>
<Chat
ref={chatRef}
chatBoxRenderConfig={{
@@ -110,7 +107,7 @@ const ChatArea = ({
style={{
height: '100%',
maxWidth: '100%',
overflow: 'hidden'
overflow: 'hidden',
}}
chats={message}
onMessageSend={onMessageSend}
@@ -121,7 +118,7 @@ const ChatArea = ({
showStopGenerate
onStopGenerator={onStopGenerator}
onClear={onClearMessages}
className="h-full"
className='h-full'
placeholder={t('请输入您的问题...')}
/>
</div>
@@ -129,4 +126,4 @@ const ChatArea = ({
);
};
export default ChatArea;
export default ChatArea;

View File

@@ -102,15 +102,17 @@ const highlightJson = (str) => {
color = '#569cd6';
}
return `<span style="color: ${color}">${match}</span>`;
}
},
);
};
const isJsonLike = (content, language) => {
if (language === 'json') return true;
const trimmed = content.trim();
return (trimmed.startsWith('{') && trimmed.endsWith('}')) ||
(trimmed.startsWith('[') && trimmed.endsWith(']'));
return (
(trimmed.startsWith('{') && trimmed.endsWith('}')) ||
(trimmed.startsWith('[') && trimmed.endsWith(']'))
);
};
const formatContent = (content) => {
@@ -148,7 +150,10 @@ const CodeViewer = ({ content, title, language = 'json' }) => {
const contentMetrics = useMemo(() => {
const length = formattedContent.length;
const isLarge = length > PERFORMANCE_CONFIG.MAX_DISPLAY_LENGTH;
const isVeryLarge = length > PERFORMANCE_CONFIG.MAX_DISPLAY_LENGTH * PERFORMANCE_CONFIG.VERY_LARGE_MULTIPLIER;
const isVeryLarge =
length >
PERFORMANCE_CONFIG.MAX_DISPLAY_LENGTH *
PERFORMANCE_CONFIG.VERY_LARGE_MULTIPLIER;
return { length, isLarge, isVeryLarge };
}, [formattedContent.length]);
@@ -156,8 +161,10 @@ const CodeViewer = ({ content, title, language = 'json' }) => {
if (!contentMetrics.isLarge || isExpanded) {
return formattedContent;
}
return formattedContent.substring(0, PERFORMANCE_CONFIG.PREVIEW_LENGTH) +
'\n\n// ... 内容被截断以提升性能 ...';
return (
formattedContent.substring(0, PERFORMANCE_CONFIG.PREVIEW_LENGTH) +
'\n\n// ... 内容被截断以提升性能 ...'
);
}, [formattedContent, contentMetrics.isLarge, isExpanded]);
const highlightedContent = useMemo(() => {
@@ -174,9 +181,10 @@ const CodeViewer = ({ content, title, language = 'json' }) => {
const handleCopy = useCallback(async () => {
try {
const textToCopy = typeof content === 'object' && content !== null
? JSON.stringify(content, null, 2)
: content;
const textToCopy =
typeof content === 'object' && content !== null
? JSON.stringify(content, null, 2)
: content;
const success = await copy(textToCopy);
setCopied(true);
@@ -205,11 +213,12 @@ const CodeViewer = ({ content, title, language = 'json' }) => {
}, [isExpanded, contentMetrics.isVeryLarge]);
if (!content) {
const placeholderText = {
preview: t('正在构造请求体预览...'),
request: t('暂无请求数据'),
response: t('暂无响应数据')
}[title] || t('暂无数据');
const placeholderText =
{
preview: t('正在构造请求体预览...'),
request: t('暂无请求数据'),
response: t('暂无响应数据'),
}[title] || t('暂无数据');
return (
<div style={codeThemeStyles.noContent}>
@@ -222,7 +231,7 @@ const CodeViewer = ({ content, title, language = 'json' }) => {
const contentPadding = contentMetrics.isLarge ? '52px' : '16px';
return (
<div style={codeThemeStyles.container} className="h-full">
<div style={codeThemeStyles.container} className='h-full'>
{/* 性能警告 */}
{contentMetrics.isLarge && (
<div style={codeThemeStyles.performanceWarning}>
@@ -250,8 +259,8 @@ const CodeViewer = ({ content, title, language = 'json' }) => {
<Button
icon={<Copy size={14} />}
onClick={handleCopy}
size="small"
theme="borderless"
size='small'
theme='borderless'
style={{
backgroundColor: 'transparent',
border: 'none',
@@ -268,25 +277,29 @@ const CodeViewer = ({ content, title, language = 'json' }) => {
...codeThemeStyles.content,
paddingTop: contentPadding,
}}
className="model-settings-scroll"
className='model-settings-scroll'
>
{isProcessing ? (
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '200px',
color: '#888'
}}>
<div style={{
width: '20px',
height: '20px',
border: '2px solid #444',
borderTop: '2px solid #888',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
marginRight: '8px'
}} />
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '200px',
color: '#888',
}}
>
<div
style={{
width: '20px',
height: '20px',
border: '2px solid #444',
borderTop: '2px solid #888',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
marginRight: '8px',
}}
/>
{t('正在处理大内容...')}
</div>
) : (
@@ -296,18 +309,22 @@ const CodeViewer = ({ content, title, language = 'json' }) => {
{/* 展开/收起按钮 */}
{contentMetrics.isLarge && !isProcessing && (
<div style={{
...codeThemeStyles.actionButton,
bottom: '12px',
left: '50%',
transform: 'translateX(-50%)',
}}>
<div
style={{
...codeThemeStyles.actionButton,
bottom: '12px',
left: '50%',
transform: 'translateX(-50%)',
}}
>
<Tooltip content={isExpanded ? t('收起内容') : t('显示完整内容')}>
<Button
icon={isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
icon={
isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />
}
onClick={handleToggleExpand}
size="small"
theme="borderless"
size='small'
theme='borderless'
style={{
backgroundColor: 'transparent',
border: 'none',
@@ -317,8 +334,16 @@ const CodeViewer = ({ content, title, language = 'json' }) => {
>
{isExpanded ? t('收起') : t('展开')}
{!isExpanded && (
<span style={{ fontSize: '11px', opacity: 0.7, marginLeft: '4px' }}>
(+{Math.round((contentMetrics.length - PERFORMANCE_CONFIG.PREVIEW_LENGTH) / 1000)}K)
<span
style={{ fontSize: '11px', opacity: 0.7, marginLeft: '4px' }}
>
(+
{Math.round(
(contentMetrics.length -
PERFORMANCE_CONFIG.PREVIEW_LENGTH) /
1000,
)}
K)
</span>
)}
</Button>
@@ -329,4 +354,4 @@ const CodeViewer = ({ content, title, language = 'json' }) => {
);
};
export default CodeViewer;
export default CodeViewer;

View File

@@ -18,21 +18,16 @@ For commercial licensing, please contact support@quantumnous.com
*/
import React, { useRef } from 'react';
import {
Button,
Typography,
Toast,
Modal,
Dropdown,
} from '@douyinfe/semi-ui';
import {
Download,
Upload,
RotateCcw,
Settings2,
} from 'lucide-react';
import { Button, Typography, Toast, Modal, Dropdown } from '@douyinfe/semi-ui';
import { Download, Upload, RotateCcw, Settings2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { exportConfig, importConfig, clearConfig, hasStoredConfig, getConfigTimestamp } from './configStorage';
import {
exportConfig,
importConfig,
clearConfig,
hasStoredConfig,
getConfigTimestamp,
} from './configStorage';
const ConfigManager = ({
currentConfig,
@@ -51,7 +46,10 @@ const ConfigManager = ({
...currentConfig,
timestamp: new Date().toISOString(),
};
localStorage.setItem('playground_config', JSON.stringify(configWithTimestamp));
localStorage.setItem(
'playground_config',
JSON.stringify(configWithTimestamp),
);
exportConfig(currentConfig, messages);
Toast.success({
@@ -104,7 +102,9 @@ const ConfigManager = ({
const handleReset = () => {
Modal.confirm({
title: t('重置配置'),
content: t('将清除所有保存的配置并恢复默认设置,此操作不可撤销。是否继续?'),
content: t(
'将清除所有保存的配置并恢复默认设置,此操作不可撤销。是否继续?',
),
okText: t('确定重置'),
cancelText: t('取消'),
okButtonProps: {
@@ -114,7 +114,9 @@ const ConfigManager = ({
// 询问是否同时重置消息
Modal.confirm({
title: t('重置选项'),
content: t('是否同时重置对话消息?选择"是"将清空所有对话记录并恢复默认示例;选择"否"将保留当前对话记录。'),
content: t(
'是否同时重置对话消息?选择"是"将清空所有对话记录并恢复默认示例;选择"否"将保留当前对话记录。',
),
okText: t('同时重置消息'),
cancelText: t('仅重置配置'),
okButtonProps: {
@@ -159,7 +161,7 @@ const ConfigManager = ({
name: 'export',
onClick: handleExport,
children: (
<div className="flex items-center gap-2">
<div className='flex items-center gap-2'>
<Download size={14} />
{t('导出配置')}
</div>
@@ -170,7 +172,7 @@ const ConfigManager = ({
name: 'import',
onClick: handleImportClick,
children: (
<div className="flex items-center gap-2">
<div className='flex items-center gap-2'>
<Upload size={14} />
{t('导入配置')}
</div>
@@ -184,7 +186,7 @@ const ConfigManager = ({
name: 'reset',
onClick: handleReset,
children: (
<div className="flex items-center gap-2 text-red-600">
<div className='flex items-center gap-2 text-red-600'>
<RotateCcw size={14} />
{t('重置配置')}
</div>
@@ -197,24 +199,24 @@ const ConfigManager = ({
return (
<>
<Dropdown
trigger="click"
position="bottomLeft"
trigger='click'
position='bottomLeft'
showTick
menu={dropdownItems}
>
<Button
icon={<Settings2 size={14} />}
theme="borderless"
type="tertiary"
size="small"
className="!rounded-lg !text-gray-600 hover:!text-blue-600 hover:!bg-blue-50"
theme='borderless'
type='tertiary'
size='small'
className='!rounded-lg !text-gray-600 hover:!text-blue-600 hover:!bg-blue-50'
/>
</Dropdown>
<input
ref={fileInputRef}
type="file"
accept=".json"
type='file'
accept='.json'
onChange={handleFileChange}
style={{ display: 'none' }}
/>
@@ -224,42 +226,42 @@ const ConfigManager = ({
// 桌面端显示紧凑的按钮组
return (
<div className="space-y-3">
<div className='space-y-3'>
{/* 配置状态信息和重置按钮 */}
<div className="flex items-center justify-between">
<Typography.Text className="text-xs text-gray-500">
<div className='flex items-center justify-between'>
<Typography.Text className='text-xs text-gray-500'>
{getConfigStatus()}
</Typography.Text>
<Button
icon={<RotateCcw size={12} />}
size="small"
theme="borderless"
type="danger"
size='small'
theme='borderless'
type='danger'
onClick={handleReset}
className="!rounded-full !text-xs !px-2"
className='!rounded-full !text-xs !px-2'
/>
</div>
{/* 导出和导入按钮 */}
<div className="flex gap-2">
<div className='flex gap-2'>
<Button
icon={<Download size={12} />}
size="small"
theme="solid"
type="primary"
size='small'
theme='solid'
type='primary'
onClick={handleExport}
className="!rounded-lg flex-1 !text-xs !h-7"
className='!rounded-lg flex-1 !text-xs !h-7'
>
{t('导出')}
</Button>
<Button
icon={<Upload size={12} />}
size="small"
theme="outline"
type="primary"
size='small'
theme='outline'
type='primary'
onClick={handleImportClick}
className="!rounded-lg flex-1 !text-xs !h-7"
className='!rounded-lg flex-1 !text-xs !h-7'
>
{t('导入')}
</Button>
@@ -267,8 +269,8 @@ const ConfigManager = ({
<input
ref={fileInputRef}
type="file"
accept=".json"
type='file'
accept='.json'
onChange={handleFileChange}
style={{ display: 'none' }}
/>
@@ -276,4 +278,4 @@ const ConfigManager = ({
);
};
export default ConfigManager;
export default ConfigManager;

View File

@@ -21,23 +21,24 @@ import React from 'react';
const CustomInputRender = (props) => {
const { detailProps } = props;
const { clearContextNode, uploadNode, inputNode, sendNode, onClick } = detailProps;
const { clearContextNode, uploadNode, inputNode, sendNode, onClick } =
detailProps;
// 清空按钮
const styledClearNode = clearContextNode
? React.cloneElement(clearContextNode, {
className: `!rounded-full !bg-gray-100 hover:!bg-red-500 hover:!text-white flex-shrink-0 transition-all ${clearContextNode.props.className || ''}`,
style: {
...clearContextNode.props.style,
width: '32px',
height: '32px',
minWidth: '32px',
padding: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}
})
className: `!rounded-full !bg-gray-100 hover:!bg-red-500 hover:!text-white flex-shrink-0 transition-all ${clearContextNode.props.className || ''}`,
style: {
...clearContextNode.props.style,
width: '32px',
height: '32px',
minWidth: '32px',
padding: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
})
: null;
// 发送按钮
@@ -52,21 +53,19 @@ const CustomInputRender = (props) => {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}
},
});
return (
<div className="p-2 sm:p-4">
<div className='p-2 sm:p-4'>
<div
className="flex items-center gap-2 sm:gap-3 p-2 bg-gray-50 rounded-xl sm:rounded-2xl shadow-sm hover:shadow-md transition-shadow"
className='flex items-center gap-2 sm:gap-3 p-2 bg-gray-50 rounded-xl sm:rounded-2xl shadow-sm hover:shadow-md transition-shadow'
style={{ border: '1px solid var(--semi-color-border)' }}
onClick={onClick}
>
{/* 清空对话按钮 - 左边 */}
{styledClearNode}
<div className="flex-1">
{inputNode}
</div>
<div className='flex-1'>{inputNode}</div>
{/* 发送按钮 - 右边 */}
{styledSendNode}
</div>
@@ -74,4 +73,4 @@ const CustomInputRender = (props) => {
);
};
export default CustomInputRender;
export default CustomInputRender;

View File

@@ -25,13 +25,7 @@ import {
Switch,
Banner,
} from '@douyinfe/semi-ui';
import {
Code,
Edit,
Check,
X,
AlertTriangle,
} from 'lucide-react';
import { Code, Edit, Check, X, AlertTriangle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
const CustomRequestEditor = ({
@@ -48,12 +42,22 @@ const CustomRequestEditor = ({
// 当切换到自定义模式时用默认payload初始化
useEffect(() => {
if (customRequestMode && (!customRequestBody || customRequestBody.trim() === '')) {
const defaultJson = defaultPayload ? JSON.stringify(defaultPayload, null, 2) : '';
if (
customRequestMode &&
(!customRequestBody || customRequestBody.trim() === '')
) {
const defaultJson = defaultPayload
? JSON.stringify(defaultPayload, null, 2)
: '';
setLocalValue(defaultJson);
onCustomRequestBodyChange(defaultJson);
}
}, [customRequestMode, defaultPayload, customRequestBody, onCustomRequestBodyChange]);
}, [
customRequestMode,
defaultPayload,
customRequestBody,
onCustomRequestBodyChange,
]);
// 同步外部传入的customRequestBody到本地状态
useEffect(() => {
@@ -113,21 +117,21 @@ const CustomRequestEditor = ({
};
return (
<div className="space-y-4">
<div className='space-y-4'>
{/* 自定义模式开关 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Code size={16} className="text-gray-500" />
<Typography.Text strong className="text-sm">
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<Code size={16} className='text-gray-500' />
<Typography.Text strong className='text-sm'>
自定义请求体模式
</Typography.Text>
</div>
<Switch
checked={customRequestMode}
onChange={handleModeToggle}
checkedText="开"
uncheckedText="关"
size="small"
checkedText='开'
uncheckedText='关'
size='small'
/>
</div>
@@ -135,43 +139,43 @@ const CustomRequestEditor = ({
<>
{/* 提示信息 */}
<Banner
type="warning"
description="启用此模式后将使用您自定义的请求体发送API请求模型配置面板的参数设置将被忽略。"
type='warning'
description='启用此模式后将使用您自定义的请求体发送API请求模型配置面板的参数设置将被忽略。'
icon={<AlertTriangle size={16} />}
className="!rounded-lg"
className='!rounded-lg'
closeIcon={null}
/>
{/* JSON编辑器 */}
<div>
<div className="flex items-center justify-between mb-2">
<Typography.Text strong className="text-sm">
<div className='flex items-center justify-between mb-2'>
<Typography.Text strong className='text-sm'>
请求体 JSON
</Typography.Text>
<div className="flex items-center gap-2">
<div className='flex items-center gap-2'>
{isValid ? (
<div className="flex items-center gap-1 text-green-600">
<div className='flex items-center gap-1 text-green-600'>
<Check size={14} />
<Typography.Text className="text-xs">
<Typography.Text className='text-xs'>
格式正确
</Typography.Text>
</div>
) : (
<div className="flex items-center gap-1 text-red-600">
<div className='flex items-center gap-1 text-red-600'>
<X size={14} />
<Typography.Text className="text-xs">
<Typography.Text className='text-xs'>
格式错误
</Typography.Text>
</div>
)}
<Button
theme="borderless"
type="tertiary"
size="small"
theme='borderless'
type='tertiary'
size='small'
icon={<Edit size={14} />}
onClick={formatJson}
disabled={!isValid}
className="!rounded-lg"
className='!rounded-lg'
>
格式化
</Button>
@@ -191,12 +195,12 @@ const CustomRequestEditor = ({
/>
{!isValid && errorMessage && (
<Typography.Text type="danger" className="text-xs mt-1 block">
<Typography.Text type='danger' className='text-xs mt-1 block'>
{errorMessage}
</Typography.Text>
)}
<Typography.Text className="text-xs text-gray-500 mt-2 block">
<Typography.Text className='text-xs text-gray-500 mt-2 block'>
请输入有效的JSON格式的请求体您可以参考预览面板中的默认请求体格式
</Typography.Text>
</div>
@@ -206,4 +210,4 @@ const CustomRequestEditor = ({
);
};
export default CustomRequestEditor;
export default CustomRequestEditor;

View File

@@ -26,14 +26,7 @@ import {
Button,
Dropdown,
} from '@douyinfe/semi-ui';
import {
Code,
Zap,
Clock,
X,
Eye,
Send,
} from 'lucide-react';
import { Code, Zap, Clock, X, Eye, Send } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import CodeViewer from './CodeViewer';
@@ -76,7 +69,7 @@ const DebugPanel = ({
<Dropdown
render={
<Dropdown.Menu>
{items.map(item => {
{items.map((item) => {
return (
<Dropdown.Item
key={item.itemKey}
@@ -104,21 +97,21 @@ const DebugPanel = ({
return (
<Card
className="h-full flex flex-col"
className='h-full flex flex-col'
bordered={false}
bodyStyle={{
padding: styleState.isMobile ? '16px' : '24px',
height: '100%',
display: 'flex',
flexDirection: 'column'
flexDirection: 'column',
}}
>
<div className="flex items-center justify-between mb-6 flex-shrink-0">
<div className="flex items-center">
<div className="w-10 h-10 rounded-full bg-gradient-to-r from-green-500 to-blue-500 flex items-center justify-center mr-3">
<Code size={20} className="text-white" />
<div className='flex items-center justify-between mb-6 flex-shrink-0'>
<div className='flex items-center'>
<div className='w-10 h-10 rounded-full bg-gradient-to-r from-green-500 to-blue-500 flex items-center justify-center mr-3'>
<Code size={20} className='text-white' />
</div>
<Typography.Title heading={5} className="mb-0">
<Typography.Title heading={5} className='mb-0'>
{t('调试信息')}
</Typography.Title>
</div>
@@ -127,75 +120,84 @@ const DebugPanel = ({
<Button
icon={<X size={16} />}
onClick={onCloseDebugPanel}
theme="borderless"
type="tertiary"
size="small"
className="!rounded-lg"
theme='borderless'
type='tertiary'
size='small'
className='!rounded-lg'
/>
)}
</div>
<div className="flex-1 overflow-hidden debug-panel">
<div className='flex-1 overflow-hidden debug-panel'>
<Tabs
renderArrow={renderArrow}
type="card"
type='card'
collapsible
className="h-full"
className='h-full'
style={{ height: '100%', display: 'flex', flexDirection: 'column' }}
activeKey={activeKey}
onChange={handleTabChange}
>
<TabPane tab={
<div className="flex items-center gap-2">
<Eye size={16} />
{t('预览请求体')}
{customRequestMode && (
<span className="px-1.5 py-0.5 text-xs bg-orange-100 text-orange-600 rounded-full">
自定义
</span>
)}
</div>
} itemKey="preview">
<TabPane
tab={
<div className='flex items-center gap-2'>
<Eye size={16} />
{t('预览请求体')}
{customRequestMode && (
<span className='px-1.5 py-0.5 text-xs bg-orange-100 text-orange-600 rounded-full'>
自定义
</span>
)}
</div>
}
itemKey='preview'
>
<CodeViewer
content={debugData.previewRequest}
title="preview"
language="json"
title='preview'
language='json'
/>
</TabPane>
<TabPane tab={
<div className="flex items-center gap-2">
<Send size={16} />
{t('实际请求体')}
</div>
} itemKey="request">
<TabPane
tab={
<div className='flex items-center gap-2'>
<Send size={16} />
{t('实际请求体')}
</div>
}
itemKey='request'
>
<CodeViewer
content={debugData.request}
title="request"
language="json"
title='request'
language='json'
/>
</TabPane>
<TabPane tab={
<div className="flex items-center gap-2">
<Zap size={16} />
{t('响应')}
</div>
} itemKey="response">
<TabPane
tab={
<div className='flex items-center gap-2'>
<Zap size={16} />
{t('响应')}
</div>
}
itemKey='response'
>
<CodeViewer
content={debugData.response}
title="response"
language="json"
title='response'
language='json'
/>
</TabPane>
</Tabs>
</div>
<div className="flex items-center justify-between mt-4 pt-4 flex-shrink-0">
<div className='flex items-center justify-between mt-4 pt-4 flex-shrink-0'>
{(debugData.timestamp || debugData.previewTimestamp) && (
<div className="flex items-center gap-2">
<Clock size={14} className="text-gray-500" />
<Typography.Text className="text-xs text-gray-500">
<div className='flex items-center gap-2'>
<Clock size={14} className='text-gray-500' />
<Typography.Text className='text-xs text-gray-500'>
{activeKey === 'preview' && debugData.previewTimestamp
? `${t('预览更新')}: ${new Date(debugData.previewTimestamp).toLocaleString()}`
: debugData.timestamp
@@ -209,4 +211,4 @@ const DebugPanel = ({
);
};
export default DebugPanel;
export default DebugPanel;

View File

@@ -19,11 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
import React from 'react';
import { Button } from '@douyinfe/semi-ui';
import {
Settings,
Eye,
EyeOff,
} from 'lucide-react';
import { Settings, Eye, EyeOff } from 'lucide-react';
const FloatingButtons = ({
styleState,
@@ -55,7 +51,7 @@ const FloatingButtons = ({
onClick={onToggleSettings}
theme='solid'
type='primary'
className="lg:hidden"
className='lg:hidden'
/>
)}
@@ -64,8 +60,8 @@ const FloatingButtons = ({
<Button
icon={showDebugPanel ? <EyeOff size={18} /> : <Eye size={18} />}
onClick={onToggleDebugPanel}
theme="solid"
type={showDebugPanel ? "danger" : "primary"}
theme='solid'
type={showDebugPanel ? 'danger' : 'primary'}
style={{
position: 'fixed',
right: 16,
@@ -80,11 +76,11 @@ const FloatingButtons = ({
? 'linear-gradient(to right, #e11d48, #be123c)'
: 'linear-gradient(to right, #4f46e5, #6366f1)',
}}
className="lg:hidden"
className='lg:hidden'
/>
)}
</>
);
};
export default FloatingButtons;
export default FloatingButtons;

View File

@@ -18,21 +18,17 @@ For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react';
import {
Input,
Typography,
Button,
Switch,
} from '@douyinfe/semi-ui';
import { Input, Typography, Button, Switch } from '@douyinfe/semi-ui';
import { IconFile } from '@douyinfe/semi-icons';
import {
FileText,
Plus,
X,
Image,
} from 'lucide-react';
import { FileText, Plus, X, Image } from 'lucide-react';
const ImageUrlInput = ({ imageUrls, imageEnabled, onImageUrlsChange, onImageEnabledChange, disabled = false }) => {
const ImageUrlInput = ({
imageUrls,
imageEnabled,
onImageUrlsChange,
onImageEnabledChange,
disabled = false,
}) => {
const handleAddImageUrl = () => {
const newUrls = [...imageUrls, ''];
onImageUrlsChange(newUrls);
@@ -51,75 +47,87 @@ const ImageUrlInput = ({ imageUrls, imageEnabled, onImageUrlsChange, onImageEnab
return (
<div className={disabled ? 'opacity-50' : ''}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Image size={16} className={imageEnabled && !disabled ? "text-blue-500" : "text-gray-400"} />
<Typography.Text strong className="text-sm">
<div className='flex items-center justify-between mb-2'>
<div className='flex items-center gap-2'>
<Image
size={16}
className={
imageEnabled && !disabled ? 'text-blue-500' : 'text-gray-400'
}
/>
<Typography.Text strong className='text-sm'>
图片地址
</Typography.Text>
{disabled && (
<Typography.Text className="text-xs text-orange-600">
<Typography.Text className='text-xs text-orange-600'>
(已在自定义模式中忽略)
</Typography.Text>
)}
</div>
<div className="flex items-center gap-2">
<div className='flex items-center gap-2'>
<Switch
checked={imageEnabled}
onChange={onImageEnabledChange}
checkedText="启用"
uncheckedText="停用"
size="small"
className="flex-shrink-0"
checkedText='启用'
uncheckedText='停用'
size='small'
className='flex-shrink-0'
disabled={disabled}
/>
<Button
icon={<Plus size={14} />}
size="small"
theme="solid"
type="primary"
size='small'
theme='solid'
type='primary'
onClick={handleAddImageUrl}
className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
className='!rounded-full !w-4 !h-4 !p-0 !min-w-0'
disabled={!imageEnabled || disabled}
/>
</div>
</div>
{!imageEnabled ? (
<Typography.Text className="text-xs text-gray-500 mb-2 block">
{disabled ? '图片功能在自定义请求体模式下不可用' : '启用后可添加图片URL进行多模态对话'}
<Typography.Text className='text-xs text-gray-500 mb-2 block'>
{disabled
? '图片功能在自定义请求体模式下不可用'
: '启用后可添加图片URL进行多模态对话'}
</Typography.Text>
) : imageUrls.length === 0 ? (
<Typography.Text className="text-xs text-gray-500 mb-2 block">
{disabled ? '图片功能在自定义请求体模式下不可用' : '点击 + 按钮添加图片URL进行多模态对话'}
<Typography.Text className='text-xs text-gray-500 mb-2 block'>
{disabled
? '图片功能在自定义请求体模式下不可用'
: '点击 + 按钮添加图片URL进行多模态对话'}
</Typography.Text>
) : (
<Typography.Text className="text-xs text-gray-500 mb-2 block">
已添加 {imageUrls.length} 张图片{disabled ? ' (自定义模式下不可用)' : ''}
<Typography.Text className='text-xs text-gray-500 mb-2 block'>
已添加 {imageUrls.length} 张图片
{disabled ? ' (自定义模式下不可用)' : ''}
</Typography.Text>
)}
<div className={`space-y-2 max-h-32 overflow-y-auto image-list-scroll ${!imageEnabled || disabled ? 'opacity-50' : ''}`}>
<div
className={`space-y-2 max-h-32 overflow-y-auto image-list-scroll ${!imageEnabled || disabled ? 'opacity-50' : ''}`}
>
{imageUrls.map((url, index) => (
<div key={index} className="flex items-center gap-2">
<div className="flex-1">
<div key={index} className='flex items-center gap-2'>
<div className='flex-1'>
<Input
placeholder={`https://example.com/image${index + 1}.jpg`}
value={url}
onChange={(value) => handleUpdateImageUrl(index, value)}
className="!rounded-lg"
size="small"
className='!rounded-lg'
size='small'
prefix={<IconFile size='small' />}
disabled={!imageEnabled || disabled}
/>
</div>
<Button
icon={<X size={12} />}
size="small"
theme="borderless"
type="danger"
size='small'
theme='borderless'
type='danger'
onClick={() => handleRemoveImageUrl(index)}
className="!rounded-full !w-6 !h-6 !p-0 !min-w-0 !text-red-500 hover:!bg-red-50 flex-shrink-0"
className='!rounded-full !w-6 !h-6 !p-0 !min-w-0 !text-red-500 hover:!bg-red-50 flex-shrink-0'
disabled={!imageEnabled || disabled}
/>
</div>
@@ -129,4 +137,4 @@ const ImageUrlInput = ({ imageUrls, imageEnabled, onImageUrlsChange, onImageEnab
);
};
export default ImageUrlInput;
export default ImageUrlInput;

View File

@@ -18,17 +18,8 @@ For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react';
import {
Button,
Tooltip,
} from '@douyinfe/semi-ui';
import {
RefreshCw,
Copy,
Trash2,
UserCheck,
Edit,
} from 'lucide-react';
import { Button, Tooltip } from '@douyinfe/semi-ui';
import { RefreshCw, Copy, Trash2, UserCheck, Edit } from 'lucide-react';
import { useTranslation } from 'react-i18next';
const MessageActions = ({
@@ -40,23 +31,32 @@ const MessageActions = ({
onRoleToggle,
onMessageEdit,
isAnyMessageGenerating = false,
isEditing = false
isEditing = false,
}) => {
const { t } = useTranslation();
const isLoading = message.status === 'loading' || message.status === 'incomplete';
const isLoading =
message.status === 'loading' || message.status === 'incomplete';
const shouldDisableActions = isAnyMessageGenerating || isEditing;
const canToggleRole = message.role === 'assistant' || message.role === 'system';
const canEdit = !isLoading && message.content && typeof onMessageEdit === 'function' && !isEditing;
const canToggleRole =
message.role === 'assistant' || message.role === 'system';
const canEdit =
!isLoading &&
message.content &&
typeof onMessageEdit === 'function' &&
!isEditing;
return (
<div className="flex items-center gap-0.5">
<div className='flex items-center gap-0.5'>
{!isLoading && (
<Tooltip content={shouldDisableActions ? t('操作暂时被禁用') : t('重试')} position="top">
<Tooltip
content={shouldDisableActions ? t('操作暂时被禁用') : t('重试')}
position='top'
>
<Button
theme="borderless"
type="tertiary"
size="small"
theme='borderless'
type='tertiary'
size='small'
icon={<RefreshCw size={styleState.isMobile ? 12 : 14} />}
onClick={() => !shouldDisableActions && onMessageReset(message)}
disabled={shouldDisableActions}
@@ -67,11 +67,11 @@ const MessageActions = ({
)}
{message.content && (
<Tooltip content={t('复制')} position="top">
<Tooltip content={t('复制')} position='top'>
<Button
theme="borderless"
type="tertiary"
size="small"
theme='borderless'
type='tertiary'
size='small'
icon={<Copy size={styleState.isMobile ? 12 : 14} />}
onClick={() => onMessageCopy(message)}
className={`!rounded-full !text-gray-400 hover:!text-green-600 hover:!bg-green-50 ${styleState.isMobile ? '!w-6 !h-6' : '!w-7 !h-7'} !p-0 transition-all`}
@@ -81,11 +81,14 @@ const MessageActions = ({
)}
{canEdit && (
<Tooltip content={shouldDisableActions ? t('操作暂时被禁用') : t('编辑')} position="top">
<Tooltip
content={shouldDisableActions ? t('操作暂时被禁用') : t('编辑')}
position='top'
>
<Button
theme="borderless"
type="tertiary"
size="small"
theme='borderless'
type='tertiary'
size='small'
icon={<Edit size={styleState.isMobile ? 12 : 14} />}
onClick={() => !shouldDisableActions && onMessageEdit(message)}
disabled={shouldDisableActions}
@@ -104,27 +107,36 @@ const MessageActions = ({
? t('切换为System角色')
: t('切换为Assistant角色')
}
position="top"
position='top'
>
<Button
theme="borderless"
type="tertiary"
size="small"
theme='borderless'
type='tertiary'
size='small'
icon={<UserCheck size={styleState.isMobile ? 12 : 14} />}
onClick={() => !shouldDisableActions && onRoleToggle && onRoleToggle(message)}
onClick={() =>
!shouldDisableActions && onRoleToggle && onRoleToggle(message)
}
disabled={shouldDisableActions}
className={`!rounded-full ${shouldDisableActions ? '!text-gray-300 !cursor-not-allowed' : message.role === 'system' ? '!text-purple-500 hover:!text-purple-700 hover:!bg-purple-50' : '!text-gray-400 hover:!text-purple-600 hover:!bg-purple-50'} ${styleState.isMobile ? '!w-6 !h-6' : '!w-7 !h-7'} !p-0 transition-all`}
aria-label={message.role === 'assistant' ? t('切换为System角色') : t('切换为Assistant角色')}
aria-label={
message.role === 'assistant'
? t('切换为System角色')
: t('切换为Assistant角色')
}
/>
</Tooltip>
)}
{!isLoading && (
<Tooltip content={shouldDisableActions ? t('操作暂时被禁用') : t('删除')} position="top">
<Tooltip
content={shouldDisableActions ? t('操作暂时被禁用') : t('删除')}
position='top'
>
<Button
theme="borderless"
type="tertiary"
size="small"
theme='borderless'
type='tertiary'
size='small'
icon={<Trash2 size={styleState.isMobile ? 12 : 14} />}
onClick={() => !shouldDisableActions && onMessageDelete(message)}
disabled={shouldDisableActions}
@@ -137,4 +149,4 @@ const MessageActions = ({
);
};
export default MessageActions;
export default MessageActions;

View File

@@ -18,18 +18,10 @@ For commercial licensing, please contact support@quantumnous.com
*/
import React, { useRef, useEffect } from 'react';
import {
Typography,
TextArea,
Button,
} from '@douyinfe/semi-ui';
import { Typography, TextArea, Button } from '@douyinfe/semi-ui';
import MarkdownRenderer from '../common/markdown/MarkdownRenderer';
import ThinkingContent from './ThinkingContent';
import {
Loader2,
Check,
X,
} from 'lucide-react';
import { Loader2, Check, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
const MessageContent = ({
@@ -41,13 +33,14 @@ const MessageContent = ({
onEditSave,
onEditCancel,
editValue,
onEditValueChange
onEditValueChange,
}) => {
const { t } = useTranslation();
const previousContentLengthRef = useRef(0);
const lastContentRef = useRef('');
const isThinkingStatus = message.status === 'loading' || message.status === 'incomplete';
const isThinkingStatus =
message.status === 'loading' || message.status === 'incomplete';
useEffect(() => {
if (!isThinkingStatus) {
@@ -60,10 +53,11 @@ const MessageContent = ({
let errorText;
if (Array.isArray(message.content)) {
const textContent = message.content.find(item => item.type === 'text');
errorText = textContent && textContent.text && typeof textContent.text === 'string'
? textContent.text
: t('请求发生错误');
const textContent = message.content.find((item) => item.type === 'text');
errorText =
textContent && textContent.text && typeof textContent.text === 'string'
? textContent.text
: t('请求发生错误');
} else if (typeof message.content === 'string') {
errorText = message.content;
} else {
@@ -72,21 +66,21 @@ const MessageContent = ({
return (
<div className={`${className}`}>
<Typography.Text className="text-white">
{errorText}
</Typography.Text>
<Typography.Text className='text-white'>{errorText}</Typography.Text>
</div>
);
}
let currentExtractedThinkingContent = null;
let currentDisplayableFinalContent = "";
let currentDisplayableFinalContent = '';
let thinkingSource = null;
const getTextContent = (content) => {
if (Array.isArray(content)) {
const textItem = content.find(item => item.type === 'text');
return textItem && textItem.text && typeof textItem.text === 'string' ? textItem.text : '';
const textItem = content.find((item) => item.type === 'text');
return textItem && textItem.text && typeof textItem.text === 'string'
? textItem.text
: '';
} else if (typeof content === 'string') {
return content;
}
@@ -97,7 +91,7 @@ const MessageContent = ({
if (message.role === 'assistant') {
let baseContentForDisplay = getTextContent(message.content);
let combinedThinkingContent = "";
let combinedThinkingContent = '';
if (message.reasoningContent) {
combinedThinkingContent = message.reasoningContent;
@@ -112,7 +106,9 @@ const MessageContent = ({
let lastIndex = 0;
while ((match = thinkTagRegex.exec(baseContentForDisplay)) !== null) {
replyParts.push(baseContentForDisplay.substring(lastIndex, match.index));
replyParts.push(
baseContentForDisplay.substring(lastIndex, match.index),
);
thoughtsFromPairedTags.push(match[1]);
lastIndex = match.index + match[0].length;
}
@@ -125,7 +121,9 @@ const MessageContent = ({
} else {
combinedThinkingContent = pairedThoughtsStr;
}
thinkingSource = thinkingSource ? thinkingSource + ' & <think> tags' : '<think> tags';
thinkingSource = thinkingSource
? thinkingSource + ' & <think> tags'
: '<think> tags';
}
baseContentForDisplay = replyParts.join('');
@@ -134,37 +132,55 @@ const MessageContent = ({
if (isThinkingStatus) {
const lastOpenThinkIndex = baseContentForDisplay.lastIndexOf('<think>');
if (lastOpenThinkIndex !== -1) {
const fragmentAfterLastOpen = baseContentForDisplay.substring(lastOpenThinkIndex);
const fragmentAfterLastOpen =
baseContentForDisplay.substring(lastOpenThinkIndex);
if (!fragmentAfterLastOpen.includes('</think>')) {
const unclosedThought = fragmentAfterLastOpen.substring('<think>'.length).trim();
const unclosedThought = fragmentAfterLastOpen
.substring('<think>'.length)
.trim();
if (unclosedThought) {
if (combinedThinkingContent) {
combinedThinkingContent += '\n\n---\n\n' + unclosedThought;
} else {
combinedThinkingContent = unclosedThought;
}
thinkingSource = thinkingSource ? thinkingSource + ' + streaming <think>' : 'streaming <think>';
thinkingSource = thinkingSource
? thinkingSource + ' + streaming <think>'
: 'streaming <think>';
}
baseContentForDisplay = baseContentForDisplay.substring(0, lastOpenThinkIndex);
baseContentForDisplay = baseContentForDisplay.substring(
0,
lastOpenThinkIndex,
);
}
}
}
currentExtractedThinkingContent = combinedThinkingContent || null;
currentDisplayableFinalContent = baseContentForDisplay.replace(/<\/?think>/g, '').trim();
currentDisplayableFinalContent = baseContentForDisplay
.replace(/<\/?think>/g, '')
.trim();
}
const finalExtractedThinkingContent = currentExtractedThinkingContent;
const finalDisplayableFinalContent = currentDisplayableFinalContent;
if (message.role === 'assistant' &&
if (
message.role === 'assistant' &&
isThinkingStatus &&
!finalExtractedThinkingContent &&
(!finalDisplayableFinalContent || finalDisplayableFinalContent.trim() === '')) {
(!finalDisplayableFinalContent ||
finalDisplayableFinalContent.trim() === '')
) {
return (
<div className={`${className} flex items-center gap-2 sm:gap-4 bg-gradient-to-r from-purple-50 to-indigo-50`}>
<div className="w-5 h-5 rounded-full bg-gradient-to-br from-purple-500 to-indigo-600 flex items-center justify-center shadow-lg">
<Loader2 className="animate-spin text-white" size={styleState.isMobile ? 16 : 20} />
<div
className={`${className} flex items-center gap-2 sm:gap-4 bg-gradient-to-r from-purple-50 to-indigo-50`}
>
<div className='w-5 h-5 rounded-full bg-gradient-to-br from-purple-500 to-indigo-600 flex items-center justify-center shadow-lg'>
<Loader2
className='animate-spin text-white'
size={styleState.isMobile ? 16 : 20}
/>
</div>
</div>
);
@@ -173,12 +189,17 @@ const MessageContent = ({
return (
<div className={className}>
{message.role === 'system' && (
<div className="mb-2 sm:mb-4">
<div className="flex items-center gap-2 p-2 sm:p-3 bg-gradient-to-r from-amber-50 to-orange-50 rounded-lg" style={{ border: '1px solid var(--semi-color-border)' }}>
<div className="w-4 h-4 sm:w-5 sm:h-5 rounded-full bg-gradient-to-br from-amber-500 to-orange-600 flex items-center justify-center shadow-sm">
<Typography.Text className="text-white text-xs font-bold">S</Typography.Text>
<div className='mb-2 sm:mb-4'>
<div
className='flex items-center gap-2 p-2 sm:p-3 bg-gradient-to-r from-amber-50 to-orange-50 rounded-lg'
style={{ border: '1px solid var(--semi-color-border)' }}
>
<div className='w-4 h-4 sm:w-5 sm:h-5 rounded-full bg-gradient-to-br from-amber-500 to-orange-600 flex items-center justify-center shadow-sm'>
<Typography.Text className='text-white text-xs font-bold'>
S
</Typography.Text>
</div>
<Typography.Text className="text-amber-700 text-xs sm:text-sm font-medium">
<Typography.Text className='text-amber-700 text-xs sm:text-sm font-medium'>
{t('系统消息')}
</Typography.Text>
</div>
@@ -196,7 +217,7 @@ const MessageContent = ({
)}
{isEditing ? (
<div className="space-y-3">
<div className='space-y-3'>
<TextArea
value={editValue}
onChange={(value) => onEditValueChange(value)}
@@ -207,27 +228,27 @@ const MessageContent = ({
fontSize: styleState.isMobile ? '14px' : '15px',
lineHeight: '1.6',
}}
className="!border-blue-200 focus:!border-blue-400 !bg-blue-50/50"
className='!border-blue-200 focus:!border-blue-400 !bg-blue-50/50'
/>
<div className="flex items-center gap-2 w-full">
<div className='flex items-center gap-2 w-full'>
<Button
size="small"
type="danger"
theme="light"
size='small'
type='danger'
theme='light'
icon={<X size={14} />}
onClick={onEditCancel}
className="flex-1"
className='flex-1'
>
{t('取消')}
</Button>
<Button
size="small"
type="warning"
theme="solid"
size='small'
type='warning'
theme='solid'
icon={<Check size={14} />}
onClick={onEditSave}
disabled={!editValue || editValue.trim() === ''}
className="flex-1"
className='flex-1'
>
{t('保存')}
</Button>
@@ -236,19 +257,23 @@ const MessageContent = ({
) : (
(() => {
if (Array.isArray(message.content)) {
const textContent = message.content.find(item => item.type === 'text');
const imageContents = message.content.filter(item => item.type === 'image_url');
const textContent = message.content.find(
(item) => item.type === 'text',
);
const imageContents = message.content.filter(
(item) => item.type === 'image_url',
);
return (
<div>
{imageContents.length > 0 && (
<div className="mb-3 space-y-2">
<div className='mb-3 space-y-2'>
{imageContents.map((imgItem, index) => (
<div key={index} className="max-w-sm">
<div key={index} className='max-w-sm'>
<img
src={imgItem.image_url.url}
alt={`用户上传的图片 ${index + 1}`}
className="rounded-lg max-w-full h-auto shadow-sm border"
className='rounded-lg max-w-full h-auto shadow-sm border'
style={{ maxHeight: '300px' }}
onError={(e) => {
e.target.style.display = 'none';
@@ -256,7 +281,7 @@ const MessageContent = ({
}}
/>
<div
className="text-red-500 text-sm p-2 bg-red-50 rounded-lg border border-red-200"
className='text-red-500 text-sm p-2 bg-red-50 rounded-lg border border-red-200'
style={{ display: 'none' }}
>
图片加载失败: {imgItem.image_url.url}
@@ -266,28 +291,42 @@ const MessageContent = ({
</div>
)}
{textContent && textContent.text && typeof textContent.text === 'string' && textContent.text.trim() !== '' && (
<div className={`prose prose-xs sm:prose-sm prose-gray max-w-none overflow-x-auto text-xs sm:text-sm ${message.role === 'user' ? 'user-message' : ''}`}>
<MarkdownRenderer
content={textContent.text}
className={message.role === 'user' ? 'user-message' : ''}
animated={false}
previousContentLength={0}
/>
</div>
)}
{textContent &&
textContent.text &&
typeof textContent.text === 'string' &&
textContent.text.trim() !== '' && (
<div
className={`prose prose-xs sm:prose-sm prose-gray max-w-none overflow-x-auto text-xs sm:text-sm ${message.role === 'user' ? 'user-message' : ''}`}
>
<MarkdownRenderer
content={textContent.text}
className={
message.role === 'user' ? 'user-message' : ''
}
animated={false}
previousContentLength={0}
/>
</div>
)}
</div>
);
}
if (typeof message.content === 'string') {
if (message.role === 'assistant') {
if (finalDisplayableFinalContent && finalDisplayableFinalContent.trim() !== '') {
if (
finalDisplayableFinalContent &&
finalDisplayableFinalContent.trim() !== ''
) {
// 获取上一次的内容长度
let prevLength = 0;
if (isThinkingStatus && lastContentRef.current) {
// 只有当前内容包含上一次内容时,才使用上一次的长度
if (finalDisplayableFinalContent.startsWith(lastContentRef.current)) {
if (
finalDisplayableFinalContent.startsWith(
lastContentRef.current,
)
) {
prevLength = lastContentRef.current.length;
}
}
@@ -298,10 +337,10 @@ const MessageContent = ({
}
return (
<div className="prose prose-xs sm:prose-sm prose-gray max-w-none overflow-x-auto text-xs sm:text-sm">
<div className='prose prose-xs sm:prose-sm prose-gray max-w-none overflow-x-auto text-xs sm:text-sm'>
<MarkdownRenderer
content={finalDisplayableFinalContent}
className=""
className=''
animated={isThinkingStatus}
previousContentLength={prevLength}
/>
@@ -310,7 +349,9 @@ const MessageContent = ({
}
} else {
return (
<div className={`prose prose-xs sm:prose-sm prose-gray max-w-none overflow-x-auto text-xs sm:text-sm ${message.role === 'user' ? 'user-message' : ''}`}>
<div
className={`prose prose-xs sm:prose-sm prose-gray max-w-none overflow-x-auto text-xs sm:text-sm ${message.role === 'user' ? 'user-message' : ''}`}
>
<MarkdownRenderer
content={message.content}
className={message.role === 'user' ? 'user-message' : ''}
@@ -329,4 +370,4 @@ const MessageContent = ({
);
};
export default MessageContent;
export default MessageContent;

View File

@@ -24,56 +24,74 @@ import SettingsPanel from './SettingsPanel';
import DebugPanel from './DebugPanel';
// 优化的消息内容组件
export const OptimizedMessageContent = React.memo(MessageContent, (prevProps, nextProps) => {
// 只有这些属性变化时才重新渲染
return (
prevProps.message.id === nextProps.message.id &&
prevProps.message.content === nextProps.message.content &&
prevProps.message.status === nextProps.message.status &&
prevProps.message.role === nextProps.message.role &&
prevProps.message.reasoningContent === nextProps.message.reasoningContent &&
prevProps.message.isReasoningExpanded === nextProps.message.isReasoningExpanded &&
prevProps.isEditing === nextProps.isEditing &&
prevProps.editValue === nextProps.editValue &&
prevProps.styleState.isMobile === nextProps.styleState.isMobile
);
});
export const OptimizedMessageContent = React.memo(
MessageContent,
(prevProps, nextProps) => {
// 只有这些属性变化时才重新渲染
return (
prevProps.message.id === nextProps.message.id &&
prevProps.message.content === nextProps.message.content &&
prevProps.message.status === nextProps.message.status &&
prevProps.message.role === nextProps.message.role &&
prevProps.message.reasoningContent ===
nextProps.message.reasoningContent &&
prevProps.message.isReasoningExpanded ===
nextProps.message.isReasoningExpanded &&
prevProps.isEditing === nextProps.isEditing &&
prevProps.editValue === nextProps.editValue &&
prevProps.styleState.isMobile === nextProps.styleState.isMobile
);
},
);
// 优化的消息操作组件
export const OptimizedMessageActions = React.memo(MessageActions, (prevProps, nextProps) => {
return (
prevProps.message.id === nextProps.message.id &&
prevProps.message.role === nextProps.message.role &&
prevProps.isAnyMessageGenerating === nextProps.isAnyMessageGenerating &&
prevProps.isEditing === nextProps.isEditing &&
prevProps.onMessageReset === nextProps.onMessageReset
);
});
export const OptimizedMessageActions = React.memo(
MessageActions,
(prevProps, nextProps) => {
return (
prevProps.message.id === nextProps.message.id &&
prevProps.message.role === nextProps.message.role &&
prevProps.isAnyMessageGenerating === nextProps.isAnyMessageGenerating &&
prevProps.isEditing === nextProps.isEditing &&
prevProps.onMessageReset === nextProps.onMessageReset
);
},
);
// 优化的设置面板组件
export const OptimizedSettingsPanel = React.memo(SettingsPanel, (prevProps, nextProps) => {
return (
JSON.stringify(prevProps.inputs) === JSON.stringify(nextProps.inputs) &&
JSON.stringify(prevProps.parameterEnabled) === JSON.stringify(nextProps.parameterEnabled) &&
JSON.stringify(prevProps.models) === JSON.stringify(nextProps.models) &&
JSON.stringify(prevProps.groups) === JSON.stringify(nextProps.groups) &&
prevProps.customRequestMode === nextProps.customRequestMode &&
prevProps.customRequestBody === nextProps.customRequestBody &&
prevProps.showDebugPanel === nextProps.showDebugPanel &&
prevProps.showSettings === nextProps.showSettings &&
JSON.stringify(prevProps.previewPayload) === JSON.stringify(nextProps.previewPayload) &&
JSON.stringify(prevProps.messages) === JSON.stringify(nextProps.messages)
);
});
export const OptimizedSettingsPanel = React.memo(
SettingsPanel,
(prevProps, nextProps) => {
return (
JSON.stringify(prevProps.inputs) === JSON.stringify(nextProps.inputs) &&
JSON.stringify(prevProps.parameterEnabled) ===
JSON.stringify(nextProps.parameterEnabled) &&
JSON.stringify(prevProps.models) === JSON.stringify(nextProps.models) &&
JSON.stringify(prevProps.groups) === JSON.stringify(nextProps.groups) &&
prevProps.customRequestMode === nextProps.customRequestMode &&
prevProps.customRequestBody === nextProps.customRequestBody &&
prevProps.showDebugPanel === nextProps.showDebugPanel &&
prevProps.showSettings === nextProps.showSettings &&
JSON.stringify(prevProps.previewPayload) ===
JSON.stringify(nextProps.previewPayload) &&
JSON.stringify(prevProps.messages) === JSON.stringify(nextProps.messages)
);
},
);
// 优化的调试面板组件
export const OptimizedDebugPanel = React.memo(DebugPanel, (prevProps, nextProps) => {
return (
prevProps.show === nextProps.show &&
prevProps.activeTab === nextProps.activeTab &&
JSON.stringify(prevProps.debugData) === JSON.stringify(nextProps.debugData) &&
JSON.stringify(prevProps.previewPayload) === JSON.stringify(nextProps.previewPayload) &&
prevProps.customRequestMode === nextProps.customRequestMode &&
prevProps.showDebugPanel === nextProps.showDebugPanel
);
});
export const OptimizedDebugPanel = React.memo(
DebugPanel,
(prevProps, nextProps) => {
return (
prevProps.show === nextProps.show &&
prevProps.activeTab === nextProps.activeTab &&
JSON.stringify(prevProps.debugData) ===
JSON.stringify(nextProps.debugData) &&
JSON.stringify(prevProps.previewPayload) ===
JSON.stringify(nextProps.previewPayload) &&
prevProps.customRequestMode === nextProps.customRequestMode &&
prevProps.showDebugPanel === nextProps.showDebugPanel
);
},
);

View File

@@ -18,13 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react';
import {
Input,
Slider,
Typography,
Button,
Tag,
} from '@douyinfe/semi-ui';
import { Input, Slider, Typography, Button, Tag } from '@douyinfe/semi-ui';
import {
Hash,
Thermometer,
@@ -46,28 +40,36 @@ const ParameterControl = ({
return (
<>
{/* Temperature */}
<div className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.temperature || disabled ? 'opacity-50' : ''}`}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Thermometer size={16} className="text-gray-500" />
<Typography.Text strong className="text-sm">
<div
className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.temperature || disabled ? 'opacity-50' : ''}`}
>
<div className='flex items-center justify-between mb-2'>
<div className='flex items-center gap-2'>
<Thermometer size={16} className='text-gray-500' />
<Typography.Text strong className='text-sm'>
Temperature
</Typography.Text>
<Tag size="small" shape='circle'>
<Tag size='small' shape='circle'>
{inputs.temperature}
</Tag>
</div>
<Button
theme={parameterEnabled.temperature ? 'solid' : 'borderless'}
type={parameterEnabled.temperature ? 'primary' : 'tertiary'}
size="small"
icon={parameterEnabled.temperature ? <Check size={10} /> : <X size={10} />}
size='small'
icon={
parameterEnabled.temperature ? (
<Check size={10} />
) : (
<X size={10} />
)
}
onClick={() => onParameterToggle('temperature')}
className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
className='!rounded-full !w-4 !h-4 !p-0 !min-w-0'
disabled={disabled}
/>
</div>
<Typography.Text className="text-xs text-gray-500 mb-2">
<Typography.Text className='text-xs text-gray-500 mb-2'>
控制输出的随机性和创造性
</Typography.Text>
<Slider
@@ -76,34 +78,38 @@ const ParameterControl = ({
max={1}
value={inputs.temperature}
onChange={(value) => onInputChange('temperature', value)}
className="mt-2"
className='mt-2'
disabled={!parameterEnabled.temperature || disabled}
/>
</div>
{/* Top P */}
<div className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.top_p || disabled ? 'opacity-50' : ''}`}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Target size={16} className="text-gray-500" />
<Typography.Text strong className="text-sm">
<div
className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.top_p || disabled ? 'opacity-50' : ''}`}
>
<div className='flex items-center justify-between mb-2'>
<div className='flex items-center gap-2'>
<Target size={16} className='text-gray-500' />
<Typography.Text strong className='text-sm'>
Top P
</Typography.Text>
<Tag size="small" shape='circle'>
<Tag size='small' shape='circle'>
{inputs.top_p}
</Tag>
</div>
<Button
theme={parameterEnabled.top_p ? 'solid' : 'borderless'}
type={parameterEnabled.top_p ? 'primary' : 'tertiary'}
size="small"
icon={parameterEnabled.top_p ? <Check size={10} /> : <X size={10} />}
size='small'
icon={
parameterEnabled.top_p ? <Check size={10} /> : <X size={10} />
}
onClick={() => onParameterToggle('top_p')}
className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
className='!rounded-full !w-4 !h-4 !p-0 !min-w-0'
disabled={disabled}
/>
</div>
<Typography.Text className="text-xs text-gray-500 mb-2">
<Typography.Text className='text-xs text-gray-500 mb-2'>
核采样控制词汇选择的多样性
</Typography.Text>
<Slider
@@ -112,34 +118,42 @@ const ParameterControl = ({
max={1}
value={inputs.top_p}
onChange={(value) => onInputChange('top_p', value)}
className="mt-2"
className='mt-2'
disabled={!parameterEnabled.top_p || disabled}
/>
</div>
{/* Frequency Penalty */}
<div className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.frequency_penalty || disabled ? 'opacity-50' : ''}`}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Repeat size={16} className="text-gray-500" />
<Typography.Text strong className="text-sm">
<div
className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.frequency_penalty || disabled ? 'opacity-50' : ''}`}
>
<div className='flex items-center justify-between mb-2'>
<div className='flex items-center gap-2'>
<Repeat size={16} className='text-gray-500' />
<Typography.Text strong className='text-sm'>
Frequency Penalty
</Typography.Text>
<Tag size="small" shape='circle'>
<Tag size='small' shape='circle'>
{inputs.frequency_penalty}
</Tag>
</div>
<Button
theme={parameterEnabled.frequency_penalty ? 'solid' : 'borderless'}
type={parameterEnabled.frequency_penalty ? 'primary' : 'tertiary'}
size="small"
icon={parameterEnabled.frequency_penalty ? <Check size={10} /> : <X size={10} />}
size='small'
icon={
parameterEnabled.frequency_penalty ? (
<Check size={10} />
) : (
<X size={10} />
)
}
onClick={() => onParameterToggle('frequency_penalty')}
className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
className='!rounded-full !w-4 !h-4 !p-0 !min-w-0'
disabled={disabled}
/>
</div>
<Typography.Text className="text-xs text-gray-500 mb-2">
<Typography.Text className='text-xs text-gray-500 mb-2'>
频率惩罚减少重复词汇的出现
</Typography.Text>
<Slider
@@ -148,34 +162,42 @@ const ParameterControl = ({
max={2}
value={inputs.frequency_penalty}
onChange={(value) => onInputChange('frequency_penalty', value)}
className="mt-2"
className='mt-2'
disabled={!parameterEnabled.frequency_penalty || disabled}
/>
</div>
{/* Presence Penalty */}
<div className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.presence_penalty || disabled ? 'opacity-50' : ''}`}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Ban size={16} className="text-gray-500" />
<Typography.Text strong className="text-sm">
<div
className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.presence_penalty || disabled ? 'opacity-50' : ''}`}
>
<div className='flex items-center justify-between mb-2'>
<div className='flex items-center gap-2'>
<Ban size={16} className='text-gray-500' />
<Typography.Text strong className='text-sm'>
Presence Penalty
</Typography.Text>
<Tag size="small" shape='circle'>
<Tag size='small' shape='circle'>
{inputs.presence_penalty}
</Tag>
</div>
<Button
theme={parameterEnabled.presence_penalty ? 'solid' : 'borderless'}
type={parameterEnabled.presence_penalty ? 'primary' : 'tertiary'}
size="small"
icon={parameterEnabled.presence_penalty ? <Check size={10} /> : <X size={10} />}
size='small'
icon={
parameterEnabled.presence_penalty ? (
<Check size={10} />
) : (
<X size={10} />
)
}
onClick={() => onParameterToggle('presence_penalty')}
className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
className='!rounded-full !w-4 !h-4 !p-0 !min-w-0'
disabled={disabled}
/>
</div>
<Typography.Text className="text-xs text-gray-500 mb-2">
<Typography.Text className='text-xs text-gray-500 mb-2'>
存在惩罚鼓励讨论新话题
</Typography.Text>
<Slider
@@ -184,27 +206,35 @@ const ParameterControl = ({
max={2}
value={inputs.presence_penalty}
onChange={(value) => onInputChange('presence_penalty', value)}
className="mt-2"
className='mt-2'
disabled={!parameterEnabled.presence_penalty || disabled}
/>
</div>
{/* MaxTokens */}
<div className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.max_tokens || disabled ? 'opacity-50' : ''}`}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Hash size={16} className="text-gray-500" />
<Typography.Text strong className="text-sm">
<div
className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.max_tokens || disabled ? 'opacity-50' : ''}`}
>
<div className='flex items-center justify-between mb-2'>
<div className='flex items-center gap-2'>
<Hash size={16} className='text-gray-500' />
<Typography.Text strong className='text-sm'>
Max Tokens
</Typography.Text>
</div>
<Button
theme={parameterEnabled.max_tokens ? 'solid' : 'borderless'}
type={parameterEnabled.max_tokens ? 'primary' : 'tertiary'}
size="small"
icon={parameterEnabled.max_tokens ? <Check size={10} /> : <X size={10} />}
size='small'
icon={
parameterEnabled.max_tokens ? (
<Check size={10} />
) : (
<X size={10} />
)
}
onClick={() => onParameterToggle('max_tokens')}
className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
className='!rounded-full !w-4 !h-4 !p-0 !min-w-0'
disabled={disabled}
/>
</div>
@@ -216,30 +246,32 @@ const ParameterControl = ({
defaultValue={0}
value={inputs.max_tokens}
onChange={(value) => onInputChange('max_tokens', value)}
className="!rounded-lg"
className='!rounded-lg'
disabled={!parameterEnabled.max_tokens || disabled}
/>
</div>
{/* Seed */}
<div className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.seed || disabled ? 'opacity-50' : ''}`}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Shuffle size={16} className="text-gray-500" />
<Typography.Text strong className="text-sm">
<div
className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.seed || disabled ? 'opacity-50' : ''}`}
>
<div className='flex items-center justify-between mb-2'>
<div className='flex items-center gap-2'>
<Shuffle size={16} className='text-gray-500' />
<Typography.Text strong className='text-sm'>
Seed
</Typography.Text>
<Typography.Text className="text-xs text-gray-400">
<Typography.Text className='text-xs text-gray-400'>
(可选用于复现结果)
</Typography.Text>
</div>
<Button
theme={parameterEnabled.seed ? 'solid' : 'borderless'}
type={parameterEnabled.seed ? 'primary' : 'tertiary'}
size="small"
size='small'
icon={parameterEnabled.seed ? <Check size={10} /> : <X size={10} />}
onClick={() => onParameterToggle('seed')}
className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
className='!rounded-full !w-4 !h-4 !p-0 !min-w-0'
disabled={disabled}
/>
</div>
@@ -248,8 +280,10 @@ const ParameterControl = ({
name='seed'
autoComplete='new-password'
value={inputs.seed || ''}
onChange={(value) => onInputChange('seed', value === '' ? null : value)}
className="!rounded-lg"
onChange={(value) =>
onInputChange('seed', value === '' ? null : value)
}
className='!rounded-lg'
disabled={!parameterEnabled.seed || disabled}
/>
</div>
@@ -257,4 +291,4 @@ const ParameterControl = ({
);
};
export default ParameterControl;
export default ParameterControl;

View File

@@ -18,20 +18,8 @@ For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react';
import {
Card,
Select,
Typography,
Button,
Switch,
} from '@douyinfe/semi-ui';
import {
Sparkles,
Users,
ToggleLeft,
X,
Settings,
} from 'lucide-react';
import { Card, Select, Typography, Button, Switch } from '@douyinfe/semi-ui';
import { Sparkles, Users, ToggleLeft, X, Settings } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { renderGroupOption, selectFilter } from '../../helpers';
import ParameterControl from './ParameterControl';
@@ -70,22 +58,22 @@ const SettingsPanel = ({
return (
<Card
className="h-full flex flex-col"
className='h-full flex flex-col'
bordered={false}
bodyStyle={{
padding: styleState.isMobile ? '16px' : '24px',
height: '100%',
display: 'flex',
flexDirection: 'column'
flexDirection: 'column',
}}
>
{/* 标题区域 - 与调试面板保持一致 */}
<div className="flex items-center justify-between mb-6 flex-shrink-0">
<div className="flex items-center">
<div className="w-10 h-10 rounded-full bg-gradient-to-r from-purple-500 to-pink-500 flex items-center justify-center mr-3">
<Settings size={20} className="text-white" />
<div className='flex items-center justify-between mb-6 flex-shrink-0'>
<div className='flex items-center'>
<div className='w-10 h-10 rounded-full bg-gradient-to-r from-purple-500 to-pink-500 flex items-center justify-center mr-3'>
<Settings size={20} className='text-white' />
</div>
<Typography.Title heading={5} className="mb-0">
<Typography.Title heading={5} className='mb-0'>
{t('模型配置')}
</Typography.Title>
</div>
@@ -94,17 +82,17 @@ const SettingsPanel = ({
<Button
icon={<X size={16} />}
onClick={onCloseSettings}
theme="borderless"
type="tertiary"
size="small"
className="!rounded-lg"
theme='borderless'
type='tertiary'
size='small'
className='!rounded-lg'
/>
)}
</div>
{/* 移动端配置管理 */}
{styleState.isMobile && (
<div className="mb-4 flex-shrink-0">
<div className='mb-4 flex-shrink-0'>
<ConfigManager
currentConfig={currentConfig}
onConfigImport={onConfigImport}
@@ -115,7 +103,7 @@ const SettingsPanel = ({
</div>
)}
<div className="space-y-6 overflow-y-auto flex-1 pr-2 model-settings-scroll">
<div className='space-y-6 overflow-y-auto flex-1 pr-2 model-settings-scroll'>
{/* 自定义请求体编辑器 */}
<CustomRequestEditor
customRequestMode={customRequestMode}
@@ -127,13 +115,13 @@ const SettingsPanel = ({
{/* 分组选择 */}
<div className={customRequestMode ? 'opacity-50' : ''}>
<div className="flex items-center gap-2 mb-2">
<Users size={16} className="text-gray-500" />
<Typography.Text strong className="text-sm">
<div className='flex items-center gap-2 mb-2'>
<Users size={16} className='text-gray-500' />
<Typography.Text strong className='text-sm'>
{t('分组')}
</Typography.Text>
{customRequestMode && (
<Typography.Text className="text-xs text-orange-600">
<Typography.Text className='text-xs text-orange-600'>
(已在自定义模式中忽略)
</Typography.Text>
)}
@@ -152,20 +140,20 @@ const SettingsPanel = ({
renderOptionItem={renderGroupOption}
style={{ width: '100%' }}
dropdownStyle={{ width: '100%', maxWidth: '100%' }}
className="!rounded-lg"
className='!rounded-lg'
disabled={customRequestMode}
/>
</div>
{/* 模型选择 */}
<div className={customRequestMode ? 'opacity-50' : ''}>
<div className="flex items-center gap-2 mb-2">
<Sparkles size={16} className="text-gray-500" />
<Typography.Text strong className="text-sm">
<div className='flex items-center gap-2 mb-2'>
<Sparkles size={16} className='text-gray-500' />
<Typography.Text strong className='text-sm'>
{t('模型')}
</Typography.Text>
{customRequestMode && (
<Typography.Text className="text-xs text-orange-600">
<Typography.Text className='text-xs text-orange-600'>
(已在自定义模式中忽略)
</Typography.Text>
)}
@@ -183,7 +171,7 @@ const SettingsPanel = ({
optionList={models}
style={{ width: '100%' }}
dropdownStyle={{ width: '100%', maxWidth: '100%' }}
className="!rounded-lg"
className='!rounded-lg'
disabled={customRequestMode}
/>
</div>
@@ -194,7 +182,9 @@ const SettingsPanel = ({
imageUrls={inputs.imageUrls}
imageEnabled={inputs.imageEnabled}
onImageUrlsChange={(urls) => onInputChange('imageUrls', urls)}
onImageEnabledChange={(enabled) => onInputChange('imageEnabled', enabled)}
onImageEnabledChange={(enabled) =>
onInputChange('imageEnabled', enabled)
}
disabled={customRequestMode}
/>
</div>
@@ -212,14 +202,14 @@ const SettingsPanel = ({
{/* 流式输出开关 */}
<div className={customRequestMode ? 'opacity-50' : ''}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<ToggleLeft size={16} className="text-gray-500" />
<Typography.Text strong className="text-sm">
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<ToggleLeft size={16} className='text-gray-500' />
<Typography.Text strong className='text-sm'>
流式输出
</Typography.Text>
{customRequestMode && (
<Typography.Text className="text-xs text-orange-600">
<Typography.Text className='text-xs text-orange-600'>
(已在自定义模式中忽略)
</Typography.Text>
)}
@@ -227,9 +217,9 @@ const SettingsPanel = ({
<Switch
checked={inputs.stream}
onChange={(checked) => onInputChange('stream', checked)}
checkedText="开"
uncheckedText="关"
size="small"
checkedText='开'
uncheckedText='关'
size='small'
disabled={customRequestMode}
/>
</div>
@@ -238,7 +228,7 @@ const SettingsPanel = ({
{/* 桌面端的配置管理放在底部 */}
{!styleState.isMobile && (
<div className="flex-shrink-0 pt-3">
<div className='flex-shrink-0 pt-3'>
<ConfigManager
currentConfig={currentConfig}
onConfigImport={onConfigImport}
@@ -252,4 +242,4 @@ const SettingsPanel = ({
);
};
export default SettingsPanel;
export default SettingsPanel;

View File

@@ -28,17 +28,25 @@ const ThinkingContent = ({
finalExtractedThinkingContent,
thinkingSource,
styleState,
onToggleReasoningExpansion
onToggleReasoningExpansion,
}) => {
const { t } = useTranslation();
const scrollRef = useRef(null);
const lastContentRef = useRef('');
const isThinkingStatus = message.status === 'loading' || message.status === 'incomplete';
const headerText = (isThinkingStatus && !message.isThinkingComplete) ? t('思考中...') : t('思考过程');
const isThinkingStatus =
message.status === 'loading' || message.status === 'incomplete';
const headerText =
isThinkingStatus && !message.isThinkingComplete
? t('思考中...')
: t('思考过程');
useEffect(() => {
if (scrollRef.current && finalExtractedThinkingContent && message.isReasoningExpanded) {
if (
scrollRef.current &&
finalExtractedThinkingContent &&
message.isReasoningExpanded
) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [finalExtractedThinkingContent, message.isReasoningExpanded]);
@@ -63,72 +71,100 @@ const ThinkingContent = ({
}
return (
<div className="rounded-xl sm:rounded-2xl mb-2 sm:mb-4 overflow-hidden shadow-sm backdrop-blur-sm">
<div className='rounded-xl sm:rounded-2xl mb-2 sm:mb-4 overflow-hidden shadow-sm backdrop-blur-sm'>
<div
className="flex items-center justify-between p-3 cursor-pointer hover:bg-gradient-to-r hover:from-white/20 hover:to-purple-50/30 transition-all"
className='flex items-center justify-between p-3 cursor-pointer hover:bg-gradient-to-r hover:from-white/20 hover:to-purple-50/30 transition-all'
style={{
background: 'linear-gradient(135deg, #4c1d95 0%, #6d28d9 50%, #7c3aed 100%)',
position: 'relative'
background:
'linear-gradient(135deg, #4c1d95 0%, #6d28d9 50%, #7c3aed 100%)',
position: 'relative',
}}
onClick={() => onToggleReasoningExpansion(message.id)}
>
<div className="absolute inset-0 overflow-hidden">
<div className="absolute -top-10 -right-10 w-40 h-40 bg-white opacity-5 rounded-full"></div>
<div className="absolute -bottom-8 -left-8 w-24 h-24 bg-white opacity-10 rounded-full"></div>
<div className='absolute inset-0 overflow-hidden'>
<div className='absolute -top-10 -right-10 w-40 h-40 bg-white opacity-5 rounded-full'></div>
<div className='absolute -bottom-8 -left-8 w-24 h-24 bg-white opacity-10 rounded-full'></div>
</div>
<div className="flex items-center gap-2 sm:gap-4 relative">
<div className="w-6 h-6 sm:w-8 sm:h-8 rounded-full bg-white/20 flex items-center justify-center shadow-lg">
<Brain style={{ color: 'white' }} size={styleState.isMobile ? 12 : 16} />
<div className='flex items-center gap-2 sm:gap-4 relative'>
<div className='w-6 h-6 sm:w-8 sm:h-8 rounded-full bg-white/20 flex items-center justify-center shadow-lg'>
<Brain
style={{ color: 'white' }}
size={styleState.isMobile ? 12 : 16}
/>
</div>
<div className="flex flex-col">
<Typography.Text strong style={{ color: 'white' }} className="text-sm sm:text-base">
<div className='flex flex-col'>
<Typography.Text
strong
style={{ color: 'white' }}
className='text-sm sm:text-base'
>
{headerText}
</Typography.Text>
{thinkingSource && (
<Typography.Text style={{ color: 'white' }} className="text-xs mt-0.5 opacity-80 hidden sm:block">
<Typography.Text
style={{ color: 'white' }}
className='text-xs mt-0.5 opacity-80 hidden sm:block'
>
来源: {thinkingSource}
</Typography.Text>
)}
</div>
</div>
<div className="flex items-center gap-2 sm:gap-3 relative">
<div className='flex items-center gap-2 sm:gap-3 relative'>
{isThinkingStatus && !message.isThinkingComplete && (
<div className="flex items-center gap-1 sm:gap-2">
<Loader2 style={{ color: 'white' }} className="animate-spin" size={styleState.isMobile ? 14 : 18} />
<Typography.Text style={{ color: 'white' }} className="text-xs sm:text-sm font-medium opacity-90">
<div className='flex items-center gap-1 sm:gap-2'>
<Loader2
style={{ color: 'white' }}
className='animate-spin'
size={styleState.isMobile ? 14 : 18}
/>
<Typography.Text
style={{ color: 'white' }}
className='text-xs sm:text-sm font-medium opacity-90'
>
思考中
</Typography.Text>
</div>
)}
{(!isThinkingStatus || message.isThinkingComplete) && (
<div className="w-5 h-5 sm:w-6 sm:h-6 rounded-full bg-white/20 flex items-center justify-center">
{message.isReasoningExpanded ?
<ChevronUp size={styleState.isMobile ? 12 : 16} style={{ color: 'white' }} /> :
<ChevronRight size={styleState.isMobile ? 12 : 16} style={{ color: 'white' }} />
}
<div className='w-5 h-5 sm:w-6 sm:h-6 rounded-full bg-white/20 flex items-center justify-center'>
{message.isReasoningExpanded ? (
<ChevronUp
size={styleState.isMobile ? 12 : 16}
style={{ color: 'white' }}
/>
) : (
<ChevronRight
size={styleState.isMobile ? 12 : 16}
style={{ color: 'white' }}
/>
)}
</div>
)}
</div>
</div>
<div
className={`transition-all duration-500 ease-out ${message.isReasoningExpanded ? 'max-h-96 opacity-100' : 'max-h-0 opacity-0'
} overflow-hidden bg-gradient-to-br from-purple-50 via-indigo-50 to-violet-50`}
className={`transition-all duration-500 ease-out ${
message.isReasoningExpanded
? 'max-h-96 opacity-100'
: 'max-h-0 opacity-0'
} overflow-hidden bg-gradient-to-br from-purple-50 via-indigo-50 to-violet-50`}
>
{message.isReasoningExpanded && (
<div className="p-3 sm:p-5 pt-2 sm:pt-4">
<div className='p-3 sm:p-5 pt-2 sm:pt-4'>
<div
ref={scrollRef}
className="bg-white/70 backdrop-blur-sm rounded-lg sm:rounded-xl p-2 shadow-inner overflow-x-auto overflow-y-auto thinking-content-scroll"
className='bg-white/70 backdrop-blur-sm rounded-lg sm:rounded-xl p-2 shadow-inner overflow-x-auto overflow-y-auto thinking-content-scroll'
style={{
maxHeight: '200px',
scrollbarWidth: 'thin',
scrollbarColor: 'rgba(0, 0, 0, 0.3) transparent'
scrollbarColor: 'rgba(0, 0, 0, 0.3) transparent',
}}
>
<div className="prose prose-xs sm:prose-sm prose-purple max-w-none text-xs sm:text-sm">
<div className='prose prose-xs sm:prose-sm prose-purple max-w-none text-xs sm:text-sm'>
<MarkdownRenderer
content={finalExtractedThinkingContent}
className=""
className=''
animated={isThinkingStatus}
previousContentLength={prevLength}
/>
@@ -141,4 +177,4 @@ const ThinkingContent = ({
);
};
export default ThinkingContent;
export default ThinkingContent;

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
*/
import { STORAGE_KEYS, DEFAULT_CONFIG } from '../../constants/playground.constants';
import {
STORAGE_KEYS,
DEFAULT_CONFIG,
} from '../../constants/playground.constants';
const MESSAGES_STORAGE_KEY = 'playground_messages';
@@ -72,9 +75,12 @@ export const loadConfig = () => {
...DEFAULT_CONFIG.parameterEnabled,
...parsedConfig.parameterEnabled,
},
showDebugPanel: parsedConfig.showDebugPanel || DEFAULT_CONFIG.showDebugPanel,
customRequestMode: parsedConfig.customRequestMode || DEFAULT_CONFIG.customRequestMode,
customRequestBody: parsedConfig.customRequestBody || DEFAULT_CONFIG.customRequestBody,
showDebugPanel:
parsedConfig.showDebugPanel || DEFAULT_CONFIG.showDebugPanel,
customRequestMode:
parsedConfig.customRequestMode || DEFAULT_CONFIG.customRequestMode,
customRequestBody:
parsedConfig.customRequestBody || DEFAULT_CONFIG.customRequestBody,
};
return mergedConfig;
@@ -180,7 +186,6 @@ export const exportConfig = (config, messages = null) => {
link.click();
URL.revokeObjectURL(link.href);
} catch (error) {
console.error('导出配置失败:', error);
}
@@ -201,7 +206,10 @@ export const importConfig = (file) => {
if (importedConfig.inputs && importedConfig.parameterEnabled) {
// 如果导入的配置包含消息,也一起导入
if (importedConfig.messages && Array.isArray(importedConfig.messages)) {
if (
importedConfig.messages &&
Array.isArray(importedConfig.messages)
) {
saveMessages(importedConfig.messages);
}
@@ -219,4 +227,4 @@ export const importConfig = (file) => {
reject(new Error('导入配置失败: ' + error.message));
}
});
};
};

View File

@@ -36,4 +36,4 @@ export {
getConfigTimestamp,
exportConfig,
importConfig,
} from './configStorage';
} from './configStorage';

View File

@@ -17,7 +17,12 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
import React, {
useState,
useEffect,
forwardRef,
useImperativeHandle,
} from 'react';
import { useIsMobile } from '../../hooks/common/useIsMobile';
import {
Modal,
@@ -31,220 +36,241 @@ import {
import { IconSearch } from '@douyinfe/semi-icons';
import { CheckCircle, XCircle, AlertCircle, HelpCircle } from 'lucide-react';
const ChannelSelectorModal = forwardRef(({
visible,
onCancel,
onOk,
allChannels,
selectedChannelIds,
setSelectedChannelIds,
channelEndpoints,
updateChannelEndpoint,
t,
}, ref) => {
const [searchText, setSearchText] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const isMobile = useIsMobile();
const [filteredData, setFilteredData] = useState([]);
useImperativeHandle(ref, () => ({
resetPagination: () => {
setCurrentPage(1);
setSearchText('');
const ChannelSelectorModal = forwardRef(
(
{
visible,
onCancel,
onOk,
allChannels,
selectedChannelIds,
setSelectedChannelIds,
channelEndpoints,
updateChannelEndpoint,
t,
},
}));
ref,
) => {
const [searchText, setSearchText] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const isMobile = useIsMobile();
useEffect(() => {
if (!allChannels) return;
const [filteredData, setFilteredData] = useState([]);
const searchLower = searchText.trim().toLowerCase();
const matched = searchLower
? allChannels.filter((item) => {
const name = (item.label || '').toLowerCase();
const baseUrl = (item._originalData?.base_url || '').toLowerCase();
return name.includes(searchLower) || baseUrl.includes(searchLower);
})
: allChannels;
useImperativeHandle(ref, () => ({
resetPagination: () => {
setCurrentPage(1);
setSearchText('');
},
}));
setFilteredData(matched);
}, [allChannels, searchText]);
useEffect(() => {
if (!allChannels) return;
const total = filteredData.length;
const searchLower = searchText.trim().toLowerCase();
const matched = searchLower
? allChannels.filter((item) => {
const name = (item.label || '').toLowerCase();
const baseUrl = (item._originalData?.base_url || '').toLowerCase();
return name.includes(searchLower) || baseUrl.includes(searchLower);
})
: allChannels;
const paginatedData = filteredData.slice(
(currentPage - 1) * pageSize,
currentPage * pageSize,
);
setFilteredData(matched);
}, [allChannels, searchText]);
const updateEndpoint = (channelId, endpoint) => {
if (typeof updateChannelEndpoint === 'function') {
updateChannelEndpoint(channelId, endpoint);
}
};
const total = filteredData.length;
const renderEndpointCell = (text, record) => {
const channelId = record.key || record.value;
const currentEndpoint = channelEndpoints[channelId] || '';
const paginatedData = filteredData.slice(
(currentPage - 1) * pageSize,
currentPage * pageSize,
);
const getEndpointType = (ep) => {
if (ep === '/api/ratio_config') return 'ratio_config';
if (ep === '/api/pricing') return 'pricing';
return 'custom';
};
const currentType = getEndpointType(currentEndpoint);
const handleTypeChange = (val) => {
if (val === 'ratio_config') {
updateEndpoint(channelId, '/api/ratio_config');
} else if (val === 'pricing') {
updateEndpoint(channelId, '/api/pricing');
} else {
if (currentType !== 'custom') {
updateEndpoint(channelId, '');
}
const updateEndpoint = (channelId, endpoint) => {
if (typeof updateChannelEndpoint === 'function') {
updateChannelEndpoint(channelId, endpoint);
}
};
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Select
size="small"
value={currentType}
onChange={handleTypeChange}
style={{ width: 120 }}
optionList={[
{ label: 'ratio_config', value: 'ratio_config' },
{ label: 'pricing', value: 'pricing' },
{ label: 'custom', value: 'custom' },
]}
/>
{currentType === 'custom' && (
<Input
size="small"
value={currentEndpoint}
onChange={(val) => updateEndpoint(channelId, val)}
placeholder="/your/endpoint"
style={{ width: 160, fontSize: 12 }}
const renderEndpointCell = (text, record) => {
const channelId = record.key || record.value;
const currentEndpoint = channelEndpoints[channelId] || '';
const getEndpointType = (ep) => {
if (ep === '/api/ratio_config') return 'ratio_config';
if (ep === '/api/pricing') return 'pricing';
return 'custom';
};
const currentType = getEndpointType(currentEndpoint);
const handleTypeChange = (val) => {
if (val === 'ratio_config') {
updateEndpoint(channelId, '/api/ratio_config');
} else if (val === 'pricing') {
updateEndpoint(channelId, '/api/pricing');
} else {
if (currentType !== 'custom') {
updateEndpoint(channelId, '');
}
}
};
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Select
size='small'
value={currentType}
onChange={handleTypeChange}
style={{ width: 120 }}
optionList={[
{ label: 'ratio_config', value: 'ratio_config' },
{ label: 'pricing', value: 'pricing' },
{ label: 'custom', value: 'custom' },
]}
/>
)}
</div>
{currentType === 'custom' && (
<Input
size='small'
value={currentEndpoint}
onChange={(val) => updateEndpoint(channelId, val)}
placeholder='/your/endpoint'
style={{ width: 160, fontSize: 12 }}
/>
)}
</div>
);
};
const renderStatusCell = (status) => {
switch (status) {
case 1:
return (
<Tag
color='green'
shape='circle'
prefixIcon={<CheckCircle size={14} />}
>
{t('已启用')}
</Tag>
);
case 2:
return (
<Tag color='red' shape='circle' prefixIcon={<XCircle size={14} />}>
{t('已禁用')}
</Tag>
);
case 3:
return (
<Tag
color='yellow'
shape='circle'
prefixIcon={<AlertCircle size={14} />}
>
{t('自动禁用')}
</Tag>
);
default:
return (
<Tag
color='grey'
shape='circle'
prefixIcon={<HelpCircle size={14} />}
>
{t('未知状态')}
</Tag>
);
}
};
const renderNameCell = (text) => (
<Highlight sourceString={text} searchWords={[searchText]} />
);
};
const renderStatusCell = (status) => {
switch (status) {
case 1:
return (
<Tag color='green' shape='circle' prefixIcon={<CheckCircle size={14} />}>
{t('已启用')}
</Tag>
);
case 2:
return (
<Tag color='red' shape='circle' prefixIcon={<XCircle size={14} />}>
{t('已禁用')}
</Tag>
);
case 3:
return (
<Tag color='yellow' shape='circle' prefixIcon={<AlertCircle size={14} />}>
{t('自动禁用')}
</Tag>
);
default:
return (
<Tag color='grey' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知状态')}
</Tag>
);
}
};
const renderBaseUrlCell = (text) => (
<Highlight sourceString={text} searchWords={[searchText]} />
);
const renderNameCell = (text) => (
<Highlight sourceString={text} searchWords={[searchText]} />
);
const columns = [
{
title: t('名称'),
dataIndex: 'label',
render: renderNameCell,
},
{
title: t('源地址'),
dataIndex: '_originalData.base_url',
render: (_, record) =>
renderBaseUrlCell(record._originalData?.base_url || ''),
},
{
title: t('状态'),
dataIndex: '_originalData.status',
render: (_, record) =>
renderStatusCell(record._originalData?.status || 0),
},
{
title: t('同步接口'),
dataIndex: 'endpoint',
fixed: 'right',
render: renderEndpointCell,
},
];
const renderBaseUrlCell = (text) => (
<Highlight sourceString={text} searchWords={[searchText]} />
);
const rowSelection = {
selectedRowKeys: selectedChannelIds,
onChange: (keys) => setSelectedChannelIds(keys),
};
const columns = [
{
title: t('名称'),
dataIndex: 'label',
render: renderNameCell,
},
{
title: t('源地址'),
dataIndex: '_originalData.base_url',
render: (_, record) => renderBaseUrlCell(record._originalData?.base_url || ''),
},
{
title: t('状态'),
dataIndex: '_originalData.status',
render: (_, record) => renderStatusCell(record._originalData?.status || 0),
},
{
title: t('同步接口'),
dataIndex: 'endpoint',
fixed: 'right',
render: renderEndpointCell,
},
];
return (
<Modal
visible={visible}
onCancel={onCancel}
onOk={onOk}
title={
<span className='text-lg font-semibold'>{t('选择同步渠道')}</span>
}
size={isMobile ? 'full-width' : 'large'}
keepDOM
lazyRender={false}
>
<Space vertical style={{ width: '100%' }}>
<Input
prefix={<IconSearch size={14} />}
placeholder={t('搜索渠道名称或地址')}
value={searchText}
onChange={setSearchText}
showClear
/>
const rowSelection = {
selectedRowKeys: selectedChannelIds,
onChange: (keys) => setSelectedChannelIds(keys),
};
<Table
columns={columns}
dataSource={paginatedData}
rowKey='key'
rowSelection={rowSelection}
pagination={{
currentPage: currentPage,
pageSize: pageSize,
total: total,
showSizeChanger: true,
showQuickJumper: true,
pageSizeOptions: ['10', '20', '50', '100'],
onChange: (page, size) => {
setCurrentPage(page);
setPageSize(size);
},
onShowSizeChange: (curr, size) => {
setCurrentPage(1);
setPageSize(size);
},
}}
size='small'
/>
</Space>
</Modal>
);
},
);
return (
<Modal
visible={visible}
onCancel={onCancel}
onOk={onOk}
title={<span className="text-lg font-semibold">{t('选择同步渠道')}</span>}
size={isMobile ? 'full-width' : 'large'}
keepDOM
lazyRender={false}
>
<Space vertical style={{ width: '100%' }}>
<Input
prefix={<IconSearch size={14} />}
placeholder={t('搜索渠道名称或地址')}
value={searchText}
onChange={setSearchText}
showClear
/>
<Table
columns={columns}
dataSource={paginatedData}
rowKey="key"
rowSelection={rowSelection}
pagination={{
currentPage: currentPage,
pageSize: pageSize,
total: total,
showSizeChanger: true,
showQuickJumper: true,
pageSizeOptions: ['10', '20', '50', '100'],
onChange: (page, size) => {
setCurrentPage(page);
setPageSize(size);
},
onShowSizeChange: (curr, size) => {
setCurrentPage(1);
setPageSize(size);
},
}}
size="small"
/>
</Space>
</Modal>
);
});
export default ChannelSelectorModal;
export default ChannelSelectorModal;

View File

@@ -79,4 +79,4 @@ const ChatsSetting = () => {
);
};
export default ChatsSetting;
export default ChatsSetting;

View File

@@ -62,8 +62,7 @@ const DashboardSetting = () => {
if (item.key in inputs) {
newInputs[item.key] = item.value;
}
if (item.key.endsWith('Enabled') &&
(item.key === 'DataExportEnabled')) {
if (item.key.endsWith('Enabled') && item.key === 'DataExportEnabled') {
newInputs[item.key] = toBoolean(item.value);
}
});
@@ -91,8 +90,14 @@ const DashboardSetting = () => {
// 用于迁移检测的旧键,下个版本会删除
const hasLegacyData = useMemo(() => {
const legacyKeys = ['ApiInfo', 'Announcements', 'FAQ', 'UptimeKumaUrl', 'UptimeKumaSlug'];
return legacyKeys.some(k => inputs[k]);
const legacyKeys = [
'ApiInfo',
'Announcements',
'FAQ',
'UptimeKumaUrl',
'UptimeKumaSlug',
];
return legacyKeys.some((k) => inputs[k]);
}, [inputs]);
useEffect(() => {
@@ -121,17 +126,18 @@ const DashboardSetting = () => {
<Spin spinning={loading} size='large'>
{/* 用于迁移检测的旧键模态框,下个版本会删除 */}
<Modal
title="配置迁移确认"
title='配置迁移确认'
visible={showMigrateModal}
onOk={handleMigrate}
onCancel={() => setShowMigrateModal(false)}
confirmLoading={loading}
okText="确认迁移"
cancelText="取消"
okText='确认迁移'
cancelText='取消'
>
<p>检测到旧版本的配置数据是否要迁移到新的配置格式</p>
<p style={{ color: '#f57c00', marginTop: '10px' }}>
<strong>注意</strong>迁移过程中会自动处理数据格式转换迁移完成后旧配置将被清除请在迁移前在数据库中备份好旧配置
<strong>注意</strong>
迁移过程中会自动处理数据格式转换迁移完成后旧配置将被清除请在迁移前在数据库中备份好旧配置
</p>
</Modal>
@@ -164,4 +170,4 @@ const DashboardSetting = () => {
);
};
export default DashboardSetting;
export default DashboardSetting;

View File

@@ -81,4 +81,4 @@ const DrawingSetting = () => {
);
};
export default DrawingSetting;
export default DrawingSetting;

View File

@@ -56,7 +56,11 @@ const PaymentSetting = () => {
switch (item.key) {
case 'TopupGroupRatio':
try {
newInputs[item.key] = JSON.stringify(JSON.parse(item.value), null, 2);
newInputs[item.key] = JSON.stringify(
JSON.parse(item.value),
null,
2,
);
} catch (error) {
console.error('解析TopupGroupRatio出错:', error);
newInputs[item.key] = item.value;
@@ -116,4 +120,4 @@ const PaymentSetting = () => {
);
};
export default PaymentSetting;
export default PaymentSetting;

View File

@@ -19,13 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
import React, { useContext, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
API,
copy,
showError,
showInfo,
showSuccess
} from '../../helpers';
import { API, copy, showError, showInfo, showSuccess } from '../../helpers';
import { UserContext } from '../../context/User';
import { Modal } from '@douyinfe/semi-ui';
import { useTranslation } from 'react-i18next';
@@ -271,7 +265,11 @@ const PersonalSetting = () => {
const handleNotificationSettingChange = (type, value) => {
setNotificationSettings((prev) => ({
...prev,
[type]: value.target ? value.target.value !== undefined ? value.target.value : value.target.checked : value, // handle checkbox properly
[type]: value.target
? value.target.value !== undefined
? value.target.value
: value.target.checked
: value, // handle checkbox properly
}));
};
@@ -302,14 +300,14 @@ const PersonalSetting = () => {
};
return (
<div className="mt-[60px]">
<div className="flex justify-center">
<div className="w-full max-w-7xl mx-auto px-2">
<div className='mt-[60px]'>
<div className='flex justify-center'>
<div className='w-full max-w-7xl mx-auto px-2'>
{/* 顶部用户信息区域 */}
<UserInfoHeader t={t} userState={userState} />
{/* 账户管理和其他设置 */}
<div className="grid grid-cols-1 xl:grid-cols-2 items-start gap-4 md:gap-6 mt-4 md:mt-6">
<div className='grid grid-cols-1 xl:grid-cols-2 items-start gap-4 md:gap-6 mt-4 md:mt-6'>
{/* 左侧:账户管理设置 */}
<AccountManagement
t={t}

View File

@@ -103,34 +103,19 @@ const RatioSetting = () => {
<Card style={{ marginTop: '10px' }}>
<Tabs type='card'>
<Tabs.TabPane tab={t('模型倍率设置')} itemKey='model'>
<ModelRatioSettings
options={inputs}
refresh={onRefresh}
/>
<ModelRatioSettings options={inputs} refresh={onRefresh} />
</Tabs.TabPane>
<Tabs.TabPane tab={t('分组倍率设置')} itemKey='group'>
<GroupRatioSettings
options={inputs}
refresh={onRefresh}
/>
<GroupRatioSettings options={inputs} refresh={onRefresh} />
</Tabs.TabPane>
<Tabs.TabPane tab={t('可视化倍率设置')} itemKey='visual'>
<ModelSettingsVisualEditor
options={inputs}
refresh={onRefresh}
/>
<ModelSettingsVisualEditor options={inputs} refresh={onRefresh} />
</Tabs.TabPane>
<Tabs.TabPane tab={t('未设置倍率模型')} itemKey='unset_models'>
<ModelRatioNotSetEditor
options={inputs}
refresh={onRefresh}
/>
<ModelRatioNotSetEditor options={inputs} refresh={onRefresh} />
</Tabs.TabPane>
<Tabs.TabPane tab={t('上游倍率同步')} itemKey='upstream_sync'>
<UpstreamRatioSync
options={inputs}
refresh={onRefresh}
/>
<UpstreamRatioSync options={inputs} refresh={onRefresh} />
</Tabs.TabPane>
</Tabs>
</Card>
@@ -138,4 +123,4 @@ const RatioSetting = () => {
);
};
export default RatioSetting;
export default RatioSetting;

View File

@@ -473,7 +473,10 @@ const SystemSetting = () => {
value: inputs.LinuxDOClientSecret,
});
}
if (originInputs['LinuxDOMinimumTrustLevel'] !== inputs.LinuxDOMinimumTrustLevel) {
if (
originInputs['LinuxDOMinimumTrustLevel'] !==
inputs.LinuxDOMinimumTrustLevel
) {
options.push({
key: 'LinuxDOMinimumTrustLevel',
value: inputs.LinuxDOMinimumTrustLevel,
@@ -530,11 +533,15 @@ const SystemSetting = () => {
field='ServerAddress'
label={t('服务器地址')}
placeholder='https://yourdomain.com'
extraText={t('该服务器地址将影响支付回调地址以及默认首页展示的地址,请确保正确配置')}
extraText={t(
'该服务器地址将影响支付回调地址以及默认首页展示的地址,请确保正确配置',
)}
/>
</Col>
</Row>
<Button onClick={submitServerAddress}>{t('更新服务器地址')}</Button>
<Button onClick={submitServerAddress}>
{t('更新服务器地址')}
</Button>
</Form.Section>
</Card>
@@ -755,7 +762,10 @@ const SystemSetting = () => {
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Input field='SMTPServer' label={t('SMTP 服务器地址')} />
<Form.Input
field='SMTPServer'
label={t('SMTP 服务器地址')}
/>
</Col>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Input field='SMTPPort' label={t('SMTP 端口')} />
@@ -769,7 +779,10 @@ const SystemSetting = () => {
style={{ marginTop: 16 }}
>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Input field='SMTPFrom' label={t('SMTP 发送者邮箱')} />
<Form.Input
field='SMTPFrom'
label={t('SMTP 发送者邮箱')}
/>
</Col>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Input
@@ -797,7 +810,9 @@ const SystemSetting = () => {
<Card>
<Form.Section text={t('配置 OIDC')}>
<Text>
{t('用以支持通过 OIDC 登录,例如 Okta、Auth0 等兼容 OIDC 协议的 IdP')}
{t(
'用以支持通过 OIDC 登录,例如 Okta、Auth0 等兼容 OIDC 协议的 IdP',
)}
</Text>
<Banner
type='info'
@@ -805,7 +820,9 @@ const SystemSetting = () => {
style={{ marginBottom: 20, marginTop: 16 }}
/>
<Text>
{t('若你的 OIDC Provider 支持 Discovery Endpoint你可以仅填写 OIDC Well-Known URL系统会自动获取 OIDC 配置')}
{t(
'若你的 OIDC Provider 支持 Discovery Endpoint你可以仅填写 OIDC Well-Known URL系统会自动获取 OIDC 配置',
)}
</Text>
<Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
@@ -862,7 +879,9 @@ const SystemSetting = () => {
/>
</Col>
</Row>
<Button onClick={submitOIDCSettings}>{t('保存 OIDC 设置')}</Button>
<Button onClick={submitOIDCSettings}>
{t('保存 OIDC 设置')}
</Button>
</Form.Section>
</Card>
@@ -1033,7 +1052,9 @@ const SystemSetting = () => {
/>
</Col>
</Row>
<Button onClick={submitTurnstile}>{t('保存 Turnstile 设置')}</Button>
<Button onClick={submitTurnstile}>
{t('保存 Turnstile 设置')}
</Button>
</Form.Section>
</Card>
@@ -1048,7 +1069,11 @@ const SystemSetting = () => {
okText={t('确认')}
cancelText={t('取消')}
>
<p>{t('您确定要取消密码登录功能吗?这可能会影响用户的登录方式。')}</p>
<p>
{t(
'您确定要取消密码登录功能吗?这可能会影响用户的登录方式。',
)}
</p>
</Modal>
</div>
)}

View File

@@ -27,7 +27,7 @@ import {
Avatar,
Tabs,
TabPane,
Popover
Popover,
} from '@douyinfe/semi-ui';
import {
IconMail,
@@ -35,7 +35,7 @@ import {
IconGithubLogo,
IconKey,
IconLock,
IconDelete
IconDelete,
} from '@douyinfe/semi-icons';
import { SiTelegram, SiWechat, SiLinux } from 'react-icons/si';
import { UserPlus, ShieldCheck } from 'lucide-react';
@@ -43,7 +43,7 @@ import TelegramLoginButton from 'react-telegram-login';
import {
onGitHubOAuthClicked,
onOIDCClicked,
onLinuxDOOAuthClicked
onLinuxDOOAuthClicked,
} from '../../../../helpers';
import TwoFASetting from '../components/TwoFASetting';
@@ -57,77 +57,89 @@ const AccountManagement = ({
generateAccessToken,
handleSystemTokenClick,
setShowChangePasswordModal,
setShowAccountDeleteModal
setShowAccountDeleteModal,
}) => {
const renderAccountInfo = (accountId, label) => {
if (!accountId || accountId === '') {
return <span className="text-gray-500">{t('未绑定')}</span>;
return <span className='text-gray-500'>{t('未绑定')}</span>;
}
const popContent = (
<div className="text-xs p-2">
<div className='text-xs p-2'>
<Typography.Paragraph copyable={{ content: accountId }}>
{accountId}
</Typography.Paragraph>
{label ? (
<div className="mt-1 text-[11px] text-gray-500">{label}</div>
<div className='mt-1 text-[11px] text-gray-500'>{label}</div>
) : null}
</div>
);
return (
<Popover content={popContent} position="top" trigger="hover">
<span className="block max-w-full truncate text-gray-600 hover:text-blue-600 cursor-pointer">
<Popover content={popContent} position='top' trigger='hover'>
<span className='block max-w-full truncate text-gray-600 hover:text-blue-600 cursor-pointer'>
{accountId}
</span>
</Popover>
);
};
return (
<Card className="!rounded-2xl">
<Card className='!rounded-2xl'>
{/* 卡片头部 */}
<div className="flex items-center mb-4">
<Avatar size="small" color="teal" className="mr-3 shadow-md">
<div className='flex items-center mb-4'>
<Avatar size='small' color='teal' className='mr-3 shadow-md'>
<UserPlus size={16} />
</Avatar>
<div>
<Typography.Text className="text-lg font-medium">{t('账户管理')}</Typography.Text>
<div className="text-xs text-gray-600">{t('账户绑定、安全设置和身份验证')}</div>
<Typography.Text className='text-lg font-medium'>
{t('账户管理')}
</Typography.Text>
<div className='text-xs text-gray-600'>
{t('账户绑定、安全设置和身份验证')}
</div>
</div>
</div>
<Tabs type="card" defaultActiveKey="binding">
<Tabs type='card' defaultActiveKey='binding'>
{/* 账户绑定 Tab */}
<TabPane
tab={
<div className="flex items-center">
<UserPlus size={16} className="mr-2" />
<div className='flex items-center'>
<UserPlus size={16} className='mr-2' />
{t('账户绑定')}
</div>
}
itemKey="binding"
itemKey='binding'
>
<div className="py-4">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className='py-4'>
<div className='grid grid-cols-1 lg:grid-cols-2 gap-4'>
{/* 邮箱绑定 */}
<Card className="!rounded-xl">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center flex-1 min-w-0">
<div className="w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3 flex-shrink-0">
<IconMail size="default" className="text-slate-600 dark:text-slate-300" />
<Card className='!rounded-xl'>
<div className='flex items-center justify-between gap-3'>
<div className='flex items-center flex-1 min-w-0'>
<div className='w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3 flex-shrink-0'>
<IconMail
size='default'
className='text-slate-600 dark:text-slate-300'
/>
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-gray-900">{t('邮箱')}</div>
<div className="text-sm text-gray-500 truncate">
{renderAccountInfo(userState.user?.email, t('邮箱地址'))}
<div className='flex-1 min-w-0'>
<div className='font-medium text-gray-900'>
{t('邮箱')}
</div>
<div className='text-sm text-gray-500 truncate'>
{renderAccountInfo(
userState.user?.email,
t('邮箱地址'),
)}
</div>
</div>
</div>
<div className="flex-shrink-0">
<div className='flex-shrink-0'>
<Button
type="primary"
theme="outline"
size="small"
type='primary'
theme='outline'
size='small'
onClick={() => setShowEmailBindModal(true)}
>
{userState.user && userState.user.email !== ''
@@ -139,26 +151,31 @@ const AccountManagement = ({
</Card>
{/* 微信绑定 */}
<Card className="!rounded-xl">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center flex-1 min-w-0">
<div className="w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3 flex-shrink-0">
<SiWechat size={20} className="text-slate-600 dark:text-slate-300" />
<Card className='!rounded-xl'>
<div className='flex items-center justify-between gap-3'>
<div className='flex items-center flex-1 min-w-0'>
<div className='w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3 flex-shrink-0'>
<SiWechat
size={20}
className='text-slate-600 dark:text-slate-300'
/>
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-gray-900">{t('微信')}</div>
<div className="text-sm text-gray-500 truncate">
<div className='flex-1 min-w-0'>
<div className='font-medium text-gray-900'>
{t('微信')}
</div>
<div className='text-sm text-gray-500 truncate'>
{userState.user && userState.user.wechat_id !== ''
? t('已绑定')
: t('未绑定')}
</div>
</div>
</div>
<div className="flex-shrink-0">
<div className='flex-shrink-0'>
<Button
type="primary"
theme="outline"
size="small"
type='primary'
theme='outline'
size='small'
disabled={!status.wechat_login}
onClick={() => setShowWeChatBindModal(true)}
>
@@ -173,25 +190,35 @@ const AccountManagement = ({
</Card>
{/* GitHub绑定 */}
<Card className="!rounded-xl">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center flex-1 min-w-0">
<div className="w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3 flex-shrink-0">
<IconGithubLogo size="default" className="text-slate-600 dark:text-slate-300" />
<Card className='!rounded-xl'>
<div className='flex items-center justify-between gap-3'>
<div className='flex items-center flex-1 min-w-0'>
<div className='w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3 flex-shrink-0'>
<IconGithubLogo
size='default'
className='text-slate-600 dark:text-slate-300'
/>
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-gray-900">{t('GitHub')}</div>
<div className="text-sm text-gray-500 truncate">
{renderAccountInfo(userState.user?.github_id, t('GitHub ID'))}
<div className='flex-1 min-w-0'>
<div className='font-medium text-gray-900'>
{t('GitHub')}
</div>
<div className='text-sm text-gray-500 truncate'>
{renderAccountInfo(
userState.user?.github_id,
t('GitHub ID'),
)}
</div>
</div>
</div>
<div className="flex-shrink-0">
<div className='flex-shrink-0'>
<Button
type="primary"
theme="outline"
size="small"
onClick={() => onGitHubOAuthClicked(status.github_client_id)}
type='primary'
theme='outline'
size='small'
onClick={() =>
onGitHubOAuthClicked(status.github_client_id)
}
disabled={
(userState.user && userState.user.github_id !== '') ||
!status.github_oauth
@@ -204,28 +231,38 @@ const AccountManagement = ({
</Card>
{/* OIDC绑定 */}
<Card className="!rounded-xl">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center flex-1 min-w-0">
<div className="w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3 flex-shrink-0">
<IconShield size="default" className="text-slate-600 dark:text-slate-300" />
<Card className='!rounded-xl'>
<div className='flex items-center justify-between gap-3'>
<div className='flex items-center flex-1 min-w-0'>
<div className='w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3 flex-shrink-0'>
<IconShield
size='default'
className='text-slate-600 dark:text-slate-300'
/>
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-gray-900">{t('OIDC')}</div>
<div className="text-sm text-gray-500 truncate">
{renderAccountInfo(userState.user?.oidc_id, t('OIDC ID'))}
<div className='flex-1 min-w-0'>
<div className='font-medium text-gray-900'>
{t('OIDC')}
</div>
<div className='text-sm text-gray-500 truncate'>
{renderAccountInfo(
userState.user?.oidc_id,
t('OIDC ID'),
)}
</div>
</div>
</div>
<div className="flex-shrink-0">
<div className='flex-shrink-0'>
<Button
type="primary"
theme="outline"
size="small"
onClick={() => onOIDCClicked(
status.oidc_authorization_endpoint,
status.oidc_client_id,
)}
type='primary'
theme='outline'
size='small'
onClick={() =>
onOIDCClicked(
status.oidc_authorization_endpoint,
status.oidc_client_id,
)
}
disabled={
(userState.user && userState.user.oidc_id !== '') ||
!status.oidc_enabled
@@ -238,27 +275,35 @@ const AccountManagement = ({
</Card>
{/* Telegram绑定 */}
<Card className="!rounded-xl">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center flex-1 min-w-0">
<div className="w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3 flex-shrink-0">
<SiTelegram size={20} className="text-slate-600 dark:text-slate-300" />
<Card className='!rounded-xl'>
<div className='flex items-center justify-between gap-3'>
<div className='flex items-center flex-1 min-w-0'>
<div className='w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3 flex-shrink-0'>
<SiTelegram
size={20}
className='text-slate-600 dark:text-slate-300'
/>
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-gray-900">{t('Telegram')}</div>
<div className="text-sm text-gray-500 truncate">
{renderAccountInfo(userState.user?.telegram_id, t('Telegram ID'))}
<div className='flex-1 min-w-0'>
<div className='font-medium text-gray-900'>
{t('Telegram')}
</div>
<div className='text-sm text-gray-500 truncate'>
{renderAccountInfo(
userState.user?.telegram_id,
t('Telegram ID'),
)}
</div>
</div>
</div>
<div className="flex-shrink-0">
<div className='flex-shrink-0'>
{status.telegram_oauth ? (
userState.user.telegram_id !== '' ? (
<Button disabled={true} size="small">
<Button disabled={true} size='small'>
{t('已绑定')}
</Button>
) : (
<div className="scale-75">
<div className='scale-75'>
<TelegramLoginButton
dataAuthUrl='/api/oauth/telegram/bind'
botName={status.telegram_bot_name}
@@ -266,7 +311,7 @@ const AccountManagement = ({
</div>
)
) : (
<Button disabled={true} size="small">
<Button disabled={true} size='small'>
{t('未启用')}
</Button>
)}
@@ -275,25 +320,35 @@ const AccountManagement = ({
</Card>
{/* LinuxDO绑定 */}
<Card className="!rounded-xl">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center flex-1 min-w-0">
<div className="w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3 flex-shrink-0">
<SiLinux size={20} className="text-slate-600 dark:text-slate-300" />
<Card className='!rounded-xl'>
<div className='flex items-center justify-between gap-3'>
<div className='flex items-center flex-1 min-w-0'>
<div className='w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3 flex-shrink-0'>
<SiLinux
size={20}
className='text-slate-600 dark:text-slate-300'
/>
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-gray-900">{t('LinuxDO')}</div>
<div className="text-sm text-gray-500 truncate">
{renderAccountInfo(userState.user?.linux_do_id, t('LinuxDO ID'))}
<div className='flex-1 min-w-0'>
<div className='font-medium text-gray-900'>
{t('LinuxDO')}
</div>
<div className='text-sm text-gray-500 truncate'>
{renderAccountInfo(
userState.user?.linux_do_id,
t('LinuxDO ID'),
)}
</div>
</div>
</div>
<div className="flex-shrink-0">
<div className='flex-shrink-0'>
<Button
type="primary"
theme="outline"
size="small"
onClick={() => onLinuxDOOAuthClicked(status.linuxdo_client_id)}
type='primary'
theme='outline'
size='small'
onClick={() =>
onLinuxDOOAuthClicked(status.linuxdo_client_id)
}
disabled={
(userState.user && userState.user.linux_do_id !== '') ||
!status.linuxdo_oauth
@@ -311,37 +366,37 @@ const AccountManagement = ({
{/* 安全设置 Tab */}
<TabPane
tab={
<div className="flex items-center">
<ShieldCheck size={16} className="mr-2" />
<div className='flex items-center'>
<ShieldCheck size={16} className='mr-2' />
{t('安全设置')}
</div>
}
itemKey="security"
itemKey='security'
>
<div className="py-4">
<div className="space-y-6">
<div className='py-4'>
<div className='space-y-6'>
<Space vertical className='w-full'>
{/* 系统访问令牌 */}
<Card className="!rounded-xl w-full">
<div className="flex flex-col sm:flex-row items-start sm:justify-between gap-4">
<div className="flex items-start w-full sm:w-auto">
<div className="w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center mr-4 flex-shrink-0">
<IconKey size="large" className="text-slate-600" />
<Card className='!rounded-xl w-full'>
<div className='flex flex-col sm:flex-row items-start sm:justify-between gap-4'>
<div className='flex items-start w-full sm:w-auto'>
<div className='w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center mr-4 flex-shrink-0'>
<IconKey size='large' className='text-slate-600' />
</div>
<div className="flex-1">
<Typography.Title heading={6} className="mb-1">
<div className='flex-1'>
<Typography.Title heading={6} className='mb-1'>
{t('系统访问令牌')}
</Typography.Title>
<Typography.Text type="tertiary" className="text-sm">
<Typography.Text type='tertiary' className='text-sm'>
{t('用于API调用的身份验证令牌请妥善保管')}
</Typography.Text>
{systemToken && (
<div className="mt-3">
<div className='mt-3'>
<Input
readonly
value={systemToken}
onClick={handleSystemTokenClick}
size="large"
size='large'
prefix={<IconKey />}
/>
</div>
@@ -349,10 +404,10 @@ const AccountManagement = ({
</div>
</div>
<Button
type="primary"
theme="solid"
type='primary'
theme='solid'
onClick={generateAccessToken}
className="!bg-slate-600 hover:!bg-slate-700 w-full sm:w-auto"
className='!bg-slate-600 hover:!bg-slate-700 w-full sm:w-auto'
icon={<IconKey />}
>
{systemToken ? t('重新生成') : t('生成令牌')}
@@ -361,26 +416,26 @@ const AccountManagement = ({
</Card>
{/* 密码管理 */}
<Card className="!rounded-xl w-full">
<div className="flex flex-col sm:flex-row items-start sm:justify-between gap-4">
<div className="flex items-start w-full sm:w-auto">
<div className="w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center mr-4 flex-shrink-0">
<IconLock size="large" className="text-slate-600" />
<Card className='!rounded-xl w-full'>
<div className='flex flex-col sm:flex-row items-start sm:justify-between gap-4'>
<div className='flex items-start w-full sm:w-auto'>
<div className='w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center mr-4 flex-shrink-0'>
<IconLock size='large' className='text-slate-600' />
</div>
<div>
<Typography.Title heading={6} className="mb-1">
<Typography.Title heading={6} className='mb-1'>
{t('密码管理')}
</Typography.Title>
<Typography.Text type="tertiary" className="text-sm">
<Typography.Text type='tertiary' className='text-sm'>
{t('定期更改密码可以提高账户安全性')}
</Typography.Text>
</div>
</div>
<Button
type="primary"
theme="solid"
type='primary'
theme='solid'
onClick={() => setShowChangePasswordModal(true)}
className="!bg-slate-600 hover:!bg-slate-700 w-full sm:w-auto"
className='!bg-slate-600 hover:!bg-slate-700 w-full sm:w-auto'
icon={<IconLock />}
>
{t('修改密码')}
@@ -392,26 +447,29 @@ const AccountManagement = ({
<TwoFASetting t={t} />
{/* 危险区域 */}
<Card className="!rounded-xl w-full">
<div className="flex flex-col sm:flex-row items-start sm:justify-between gap-4">
<div className="flex items-start w-full sm:w-auto">
<div className="w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center mr-4 flex-shrink-0">
<IconDelete size="large" className="text-slate-600" />
<Card className='!rounded-xl w-full'>
<div className='flex flex-col sm:flex-row items-start sm:justify-between gap-4'>
<div className='flex items-start w-full sm:w-auto'>
<div className='w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center mr-4 flex-shrink-0'>
<IconDelete size='large' className='text-slate-600' />
</div>
<div>
<Typography.Title heading={6} className="mb-1 text-slate-700">
<Typography.Title
heading={6}
className='mb-1 text-slate-700'
>
{t('删除账户')}
</Typography.Title>
<Typography.Text type="tertiary" className="text-sm">
<Typography.Text type='tertiary' className='text-sm'>
{t('此操作不可逆,所有数据将被永久删除')}
</Typography.Text>
</div>
</div>
<Button
type="danger"
theme="solid"
type='danger'
theme='solid'
onClick={() => setShowAccountDeleteModal(true)}
className="w-full sm:w-auto !bg-slate-500 hover:!bg-slate-600"
className='w-full sm:w-auto !bg-slate-500 hover:!bg-slate-600'
icon={<IconDelete />}
>
{t('删除账户')}

View File

@@ -27,13 +27,13 @@ import {
Tabs,
TabPane,
Typography,
Avatar
Avatar,
} from '@douyinfe/semi-ui';
import { IllustrationNoContent, IllustrationNoContentDark } from '@douyinfe/semi-illustrations';
import {
IconChevronDown,
IconChevronUp
} from '@douyinfe/semi-icons';
IllustrationNoContent,
IllustrationNoContentDark,
} from '@douyinfe/semi-illustrations';
import { IconChevronDown, IconChevronUp } from '@douyinfe/semi-icons';
import { Settings } from 'lucide-react';
import { renderModelTag, getModelCategories } from '../../../../helpers';
@@ -52,38 +52,48 @@ const ModelsList = ({ t, models, modelsLoading, copyText }) => {
}, [isModelsExpanded]);
return (
<div className="py-4">
<div className='py-4'>
{/* 卡片头部 */}
<div className="flex items-center mb-4">
<Avatar size="small" color="green" className="mr-3 shadow-md">
<div className='flex items-center mb-4'>
<Avatar size='small' color='green' className='mr-3 shadow-md'>
<Settings size={16} />
</Avatar>
<div>
<Typography.Text className="text-lg font-medium">{t('可用模型')}</Typography.Text>
<div className="text-xs text-gray-600">{t('查看当前可用的所有模型')}</div>
<Typography.Text className='text-lg font-medium'>
{t('可用模型')}
</Typography.Text>
<div className='text-xs text-gray-600'>
{t('查看当前可用的所有模型')}
</div>
</div>
</div>
{/* 可用模型部分 */}
<div className="bg-gray-50 dark:bg-gray-800 rounded-xl">
<div className='bg-gray-50 dark:bg-gray-800 rounded-xl'>
{modelsLoading ? (
// 骨架屏加载状态 - 模拟实际加载后的布局
<div className="space-y-4">
<div className='space-y-4'>
{/* 模拟分类标签 */}
<div className="mb-4" style={{ borderBottom: '1px solid var(--semi-color-border)' }}>
<div className="flex overflow-x-auto py-2 gap-2">
<div
className='mb-4'
style={{ borderBottom: '1px solid var(--semi-color-border)' }}
>
<div className='flex overflow-x-auto py-2 gap-2'>
{Array.from({ length: 8 }).map((_, index) => (
<Skeleton.Button key={`cat-${index}`} style={{
width: index === 0 ? 130 : 100 + Math.random() * 50,
height: 36,
borderRadius: 8
}} />
<Skeleton.Button
key={`cat-${index}`}
style={{
width: index === 0 ? 130 : 100 + Math.random() * 50,
height: 36,
borderRadius: 8,
}}
/>
))}
</div>
</div>
{/* 模拟模型标签列表 */}
<div className="flex flex-wrap gap-2">
<div className='flex flex-wrap gap-2'>
{Array.from({ length: 20 }).map((_, index) => (
<Skeleton.Button
key={`model-${index}`}
@@ -91,17 +101,23 @@ const ModelsList = ({ t, models, modelsLoading, copyText }) => {
width: 100 + Math.random() * 100,
height: 32,
borderRadius: 16,
margin: '4px'
margin: '4px',
}}
/>
))}
</div>
</div>
) : models.length === 0 ? (
<div className="py-8">
<div className='py-8'>
<Empty
image={<IllustrationNoContent style={{ width: 150, height: 150 }} />}
darkModeImage={<IllustrationNoContentDark style={{ width: 150, height: 150 }} />}
image={
<IllustrationNoContent style={{ width: 150, height: 150 }} />
}
darkModeImage={
<IllustrationNoContentDark
style={{ width: 150, height: 150 }}
/>
}
description={t('没有可用模型')}
style={{ padding: '24px 0' }}
/>
@@ -109,59 +125,81 @@ const ModelsList = ({ t, models, modelsLoading, copyText }) => {
) : (
<>
{/* 模型分类标签页 */}
<div className="mb-4">
<div className='mb-4'>
<Tabs
type="card"
type='card'
activeKey={activeModelCategory}
onChange={key => setActiveModelCategory(key)}
className="mt-2"
onChange={(key) => setActiveModelCategory(key)}
className='mt-2'
collapsible
>
{Object.entries(getModelCategories(t)).map(([key, category]) => {
// 计算该分类下的模型数量
const modelCount = key === 'all'
? models.length
: models.filter(model => category.filter({ model_name: model })).length;
{Object.entries(getModelCategories(t)).map(
([key, category]) => {
// 计算该分类下的模型数量
const modelCount =
key === 'all'
? models.length
: models.filter((model) =>
category.filter({ model_name: model }),
).length;
if (modelCount === 0 && key !== 'all') return null;
if (modelCount === 0 && key !== 'all') return null;
return (
<TabPane
tab={
<span className="flex items-center gap-2">
{category.icon && <span className="w-4 h-4">{category.icon}</span>}
{category.label}
<Tag
color={activeModelCategory === key ? 'red' : 'grey'}
size='small'
shape='circle'
>
{modelCount}
</Tag>
</span>
}
itemKey={key}
key={key}
/>
);
})}
return (
<TabPane
tab={
<span className='flex items-center gap-2'>
{category.icon && (
<span className='w-4 h-4'>{category.icon}</span>
)}
{category.label}
<Tag
color={
activeModelCategory === key ? 'red' : 'grey'
}
size='small'
shape='circle'
>
{modelCount}
</Tag>
</span>
}
itemKey={key}
key={key}
/>
);
},
)}
</Tabs>
</div>
<div className="bg-white dark:bg-gray-700 rounded-lg p-3">
<div className='bg-white dark:bg-gray-700 rounded-lg p-3'>
{(() => {
// 根据当前选中的分类过滤模型
const categories = getModelCategories(t);
const filteredModels = activeModelCategory === 'all'
? models
: models.filter(model => categories[activeModelCategory].filter({ model_name: model }));
const filteredModels =
activeModelCategory === 'all'
? models
: models.filter((model) =>
categories[activeModelCategory].filter({
model_name: model,
}),
);
// 如果过滤后没有模型,显示空状态
if (filteredModels.length === 0) {
return (
<Empty
image={<IllustrationNoContent style={{ width: 120, height: 120 }} />}
darkModeImage={<IllustrationNoContentDark style={{ width: 120, height: 120 }} />}
image={
<IllustrationNoContent
style={{ width: 120, height: 120 }}
/>
}
darkModeImage={
<IllustrationNoContentDark
style={{ width: 120, height: 120 }}
/>
}
description={t('该分类下没有可用模型')}
style={{ padding: '16px 0' }}
/>
@@ -171,13 +209,13 @@ const ModelsList = ({ t, models, modelsLoading, copyText }) => {
if (filteredModels.length <= MODELS_DISPLAY_COUNT) {
return (
<Space wrap>
{filteredModels.map((model) => (
{filteredModels.map((model) =>
renderModelTag(model, {
size: 'small',
shape: 'circle',
onClick: () => copyText(model),
})
))}
}),
)}
</Space>
);
} else {
@@ -185,17 +223,17 @@ const ModelsList = ({ t, models, modelsLoading, copyText }) => {
<>
<Collapsible isOpen={isModelsExpanded}>
<Space wrap>
{filteredModels.map((model) => (
{filteredModels.map((model) =>
renderModelTag(model, {
size: 'small',
shape: 'circle',
onClick: () => copyText(model),
})
))}
}),
)}
<Tag
color='grey'
type='light'
className="cursor-pointer !rounded-lg"
className='cursor-pointer !rounded-lg'
onClick={() => setIsModelsExpanded(false)}
icon={<IconChevronUp />}
>
@@ -207,21 +245,23 @@ const ModelsList = ({ t, models, modelsLoading, copyText }) => {
<Space wrap>
{filteredModels
.slice(0, MODELS_DISPLAY_COUNT)
.map((model) => (
.map((model) =>
renderModelTag(model, {
size: 'small',
shape: 'circle',
onClick: () => copyText(model),
})
))}
}),
)}
<Tag
color='grey'
type='light'
className="cursor-pointer !rounded-lg"
className='cursor-pointer !rounded-lg'
onClick={() => setIsModelsExpanded(true)}
icon={<IconChevronDown />}
>
{t('更多')} {filteredModels.length - MODELS_DISPLAY_COUNT} {t('个模型')}
{t('更多')}{' '}
{filteredModels.length - MODELS_DISPLAY_COUNT}{' '}
{t('个模型')}
</Tag>
</Space>
)}

View File

@@ -27,14 +27,9 @@ import {
Radio,
Toast,
Tabs,
TabPane
TabPane,
} from '@douyinfe/semi-ui';
import {
IconMail,
IconKey,
IconBell,
IconLink
} from '@douyinfe/semi-icons';
import { IconMail, IconKey, IconBell, IconLink } from '@douyinfe/semi-icons';
import { ShieldCheck, Bell, DollarSign } from 'lucide-react';
import { renderQuotaWithPrompt } from '../../../../helpers';
import CodeViewer from '../../../playground/CodeViewer';
@@ -43,7 +38,7 @@ const NotificationSettings = ({
t,
notificationSettings,
handleNotificationSettingChange,
saveNotificationSettings
saveNotificationSettings,
}) => {
const formApiRef = useRef(null);
@@ -62,7 +57,8 @@ const NotificationSettings = ({
// 表单提交
const handleSubmit = () => {
if (formApiRef.current) {
formApiRef.current.validate()
formApiRef.current
.validate()
.then(() => {
saveNotificationSettings();
})
@@ -77,26 +73,27 @@ const NotificationSettings = ({
return (
<Card
className="!rounded-2xl shadow-sm border-0"
className='!rounded-2xl shadow-sm border-0'
footer={
<div className="flex justify-end">
<Button
type='primary'
onClick={handleSubmit}
>
<div className='flex justify-end'>
<Button type='primary' onClick={handleSubmit}>
{t('保存设置')}
</Button>
</div>
}
>
{/* 卡片头部 */}
<div className="flex items-center mb-4">
<Avatar size="small" color="blue" className="mr-3 shadow-md">
<div className='flex items-center mb-4'>
<Avatar size='small' color='blue' className='mr-3 shadow-md'>
<Bell size={16} />
</Avatar>
<div>
<Typography.Text className="text-lg font-medium">{t('其他设置')}</Typography.Text>
<div className="text-xs text-gray-600">{t('通知、价格和隐私相关设置')}</div>
<Typography.Text className='text-lg font-medium'>
{t('其他设置')}
</Typography.Text>
<div className='text-xs text-gray-600'>
{t('通知、价格和隐私相关设置')}
</div>
</div>
</div>
@@ -106,18 +103,18 @@ const NotificationSettings = ({
onSubmit={handleSubmit}
>
{() => (
<Tabs type="card" defaultActiveKey="notification">
<Tabs type='card' defaultActiveKey='notification'>
{/* 通知配置 Tab */}
<TabPane
tab={
<div className="flex items-center">
<Bell size={16} className="mr-2" />
<div className='flex items-center'>
<Bell size={16} className='mr-2' />
{t('通知配置')}
</div>
}
itemKey="notification"
itemKey='notification'
>
<div className="py-4">
<div className='py-4'>
<Form.RadioGroup
field='warningType'
label={t('通知方式')}
@@ -125,15 +122,18 @@ const NotificationSettings = ({
onChange={(value) => handleFormChange('warningType', value)}
rules={[{ required: true, message: t('请选择通知方式') }]}
>
<Radio value="email">{t('邮件通知')}</Radio>
<Radio value="webhook">{t('Webhook通知')}</Radio>
<Radio value='email'>{t('邮件通知')}</Radio>
<Radio value='webhook'>{t('Webhook通知')}</Radio>
</Form.RadioGroup>
<Form.AutoComplete
field='warningThreshold'
label={
<span>
{t('额度预警阈值')} {renderQuotaWithPrompt(notificationSettings.warningThreshold)}
{t('额度预警阈值')}{' '}
{renderQuotaWithPrompt(
notificationSettings.warningThreshold,
)}
</span>
}
placeholder={t('请输入预警额度')}
@@ -145,7 +145,9 @@ const NotificationSettings = ({
]}
onChange={(val) => handleFormChange('warningThreshold', val)}
prefix={<IconBell />}
extraText={t('当剩余额度低于此数值时,系统将通过选择的方式发送通知')}
extraText={t(
'当剩余额度低于此数值时,系统将通过选择的方式发送通知',
)}
style={{ width: '100%', maxWidth: '300px' }}
rules={[
{ required: true, message: t('请输入预警阈值') },
@@ -156,8 +158,8 @@ const NotificationSettings = ({
return Promise.reject(t('预警阈值必须为正数'));
}
return Promise.resolve();
}
}
},
},
]}
/>
@@ -167,9 +169,13 @@ const NotificationSettings = ({
field='notificationEmail'
label={t('通知邮箱')}
placeholder={t('留空则使用账号绑定的邮箱')}
onChange={(val) => handleFormChange('notificationEmail', val)}
onChange={(val) =>
handleFormChange('notificationEmail', val)
}
prefix={<IconMail />}
extraText={t('设置用于接收额度预警的邮箱地址,不填则使用账号绑定的邮箱')}
extraText={t(
'设置用于接收额度预警的邮箱地址,不填则使用账号绑定的邮箱',
)}
showClear
/>
)}
@@ -180,20 +186,25 @@ const NotificationSettings = ({
<Form.Input
field='webhookUrl'
label={t('Webhook地址')}
placeholder={t('请输入Webhook地址例如: https://example.com/webhook')}
placeholder={t(
'请输入Webhook地址例如: https://example.com/webhook',
)}
onChange={(val) => handleFormChange('webhookUrl', val)}
prefix={<IconLink />}
extraText={t('只支持HTTPS系统将以POST方式发送通知请确保地址可以接收POST请求')}
extraText={t(
'只支持HTTPS系统将以POST方式发送通知请确保地址可以接收POST请求',
)}
showClear
rules={[
{
required: notificationSettings.warningType === 'webhook',
message: t('请输入Webhook地址')
required:
notificationSettings.warningType === 'webhook',
message: t('请输入Webhook地址'),
},
{
pattern: /^https:\/\/.+/,
message: t('Webhook地址必须以https://开头')
}
message: t('Webhook地址必须以https://开头'),
},
]}
/>
@@ -203,7 +214,9 @@ const NotificationSettings = ({
placeholder={t('请输入密钥')}
onChange={(val) => handleFormChange('webhookSecret', val)}
prefix={<IconKey />}
extraText={t('密钥将以Bearer方式添加到请求头中用于验证webhook请求的合法性')}
extraText={t(
'密钥将以Bearer方式添加到请求头中用于验证webhook请求的合法性',
)}
showClear
/>
@@ -212,22 +225,36 @@ const NotificationSettings = ({
<div style={{ height: '200px', marginBottom: '12px' }}>
<CodeViewer
content={{
"type": "quota_exceed",
"title": "额度预警通知",
"content": "您的额度即将用尽,当前剩余额度为 {{value}}",
"values": ["$0.99"],
"timestamp": 1739950503
type: 'quota_exceed',
title: '额度预警通知',
content:
'您的额度即将用尽,当前剩余额度为 {{value}}',
values: ['$0.99'],
timestamp: 1739950503,
}}
title="webhook"
language="json"
title='webhook'
language='json'
/>
</div>
<div className="text-xs text-gray-500 leading-relaxed">
<div><strong>type:</strong> {t('通知类型 (quota_exceed: 额度预警)')} </div>
<div><strong>title:</strong> {t('通知标题')}</div>
<div><strong>content:</strong> {t('通知内容,支持 {{value}} 变量占位符')}</div>
<div><strong>values:</strong> {t('按顺序替换content中的变量占位符')}</div>
<div><strong>timestamp:</strong> {t('Unix时间戳')}</div>
<div className='text-xs text-gray-500 leading-relaxed'>
<div>
<strong>type:</strong>{' '}
{t('通知类型 (quota_exceed: 额度预警)')}{' '}
</div>
<div>
<strong>title:</strong> {t('通知标题')}
</div>
<div>
<strong>content:</strong>{' '}
{t('通知内容,支持 {{value}} 变量占位符')}
</div>
<div>
<strong>values:</strong>{' '}
{t('按顺序替换content中的变量占位符')}
</div>
<div>
<strong>timestamp:</strong> {t('Unix时间戳')}
</div>
</div>
</div>
</Form.Slot>
@@ -239,21 +266,25 @@ const NotificationSettings = ({
{/* 价格设置 Tab */}
<TabPane
tab={
<div className="flex items-center">
<DollarSign size={16} className="mr-2" />
<div className='flex items-center'>
<DollarSign size={16} className='mr-2' />
{t('价格设置')}
</div>
}
itemKey="pricing"
itemKey='pricing'
>
<div className="py-4">
<div className='py-4'>
<Form.Switch
field='acceptUnsetModelRatioModel'
label={t('接受未设置价格模型')}
checkedText={t('开')}
uncheckedText={t('关')}
onChange={(value) => handleFormChange('acceptUnsetModelRatioModel', value)}
extraText={t('当模型没有设置价格时仍接受调用,仅当您信任该网站时使用,可能会产生高额费用')}
onChange={(value) =>
handleFormChange('acceptUnsetModelRatioModel', value)
}
extraText={t(
'当模型没有设置价格时仍接受调用,仅当您信任该网站时使用,可能会产生高额费用',
)}
/>
</div>
</TabPane>
@@ -261,21 +292,23 @@ const NotificationSettings = ({
{/* 隐私设置 Tab */}
<TabPane
tab={
<div className="flex items-center">
<ShieldCheck size={16} className="mr-2" />
<div className='flex items-center'>
<ShieldCheck size={16} className='mr-2' />
{t('隐私设置')}
</div>
}
itemKey="privacy"
itemKey='privacy'
>
<div className="py-4">
<div className='py-4'>
<Form.Switch
field='recordIpLog'
label={t('记录请求与错误日志IP')}
checkedText={t('开')}
uncheckedText={t('关')}
onChange={(value) => handleFormChange('recordIpLog', value)}
extraText={t('开启后,仅"消费"和"错误"日志将记录您的客户端IP地址')}
extraText={t(
'开启后,仅"消费"和"错误"日志将记录您的客户端IP地址',
)}
/>
</div>
</TabPane>

View File

@@ -17,12 +17,25 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { API, showError, showSuccess, showWarning } from '../../../../helpers';
import { Banner, Button, Card, Checkbox, Divider, Input, Modal, Tag, Typography, Steps, Space, Badge } from '@douyinfe/semi-ui';
import {
Banner,
Button,
Card,
Checkbox,
Divider,
Input,
Modal,
Tag,
Typography,
Steps,
Space,
Badge,
} from '@douyinfe/semi-ui';
import {
IconShield,
IconAlertTriangle,
IconRefresh,
IconCopy
IconCopy,
} from '@douyinfe/semi-icons';
import React, { useEffect, useState } from 'react';
@@ -35,7 +48,7 @@ const TwoFASetting = ({ t }) => {
const [status, setStatus] = useState({
enabled: false,
locked: false,
backup_codes_remaining: 0
backup_codes_remaining: 0,
});
// 模态框状态
@@ -96,7 +109,7 @@ const TwoFASetting = ({ t }) => {
setLoading(true);
try {
const res = await API.post('/api/user/2fa/enable', {
code: verificationCode
code: verificationCode,
});
if (res.data.success) {
showSuccess(t('两步验证启用成功!'));
@@ -130,7 +143,7 @@ const TwoFASetting = ({ t }) => {
setLoading(true);
try {
const res = await API.post('/api/user/2fa/disable', {
code: verificationCode
code: verificationCode,
});
if (res.data.success) {
showSuccess(t('两步验证已禁用'));
@@ -158,7 +171,7 @@ const TwoFASetting = ({ t }) => {
setLoading(true);
try {
const res = await API.post('/api/user/2fa/backup_codes', {
code: verificationCode
code: verificationCode,
});
if (res.data.success) {
setBackupCodes(res.data.data.backup_codes);
@@ -177,11 +190,14 @@ const TwoFASetting = ({ t }) => {
// 通用复制函数
const copyTextToClipboard = (text, successMessage = t('已复制到剪贴板')) => {
navigator.clipboard.writeText(text).then(() => {
showSuccess(successMessage);
}).catch(() => {
showError(t('复制失败,请手动复制'));
});
navigator.clipboard
.writeText(text)
.then(() => {
showSuccess(successMessage);
})
.catch(() => {
showError(t('复制失败,请手动复制'));
});
};
const copyBackupCodes = () => {
@@ -192,28 +208,25 @@ const TwoFASetting = ({ t }) => {
// 备用码展示组件
const BackupCodesDisplay = ({ codes, title, onCopy }) => {
return (
<Card
className="!rounded-xl"
style={{ width: '100%' }}
>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Text strong className="text-slate-700 dark:text-slate-200">
<Card className='!rounded-xl' style={{ width: '100%' }}>
<div className='space-y-3'>
<div className='flex items-center justify-between'>
<Text strong className='text-slate-700 dark:text-slate-200'>
{title}
</Text>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<div className='grid grid-cols-1 sm:grid-cols-2 gap-2'>
{codes.map((code, index) => (
<div
key={index}
className="rounded-lg p-3"
>
<div className="flex items-center justify-between">
<Text code className="text-sm font-mono text-slate-700 dark:text-slate-200">
<div key={index} className='rounded-lg p-3'>
<div className='flex items-center justify-between'>
<Text
code
className='text-sm font-mono text-slate-700 dark:text-slate-200'
>
{code}
</Text>
<Text type="quaternary" className="text-xs">
<Text type='quaternary' className='text-xs'>
#{(index + 1).toString().padStart(2, '0')}
</Text>
</div>
@@ -223,11 +236,11 @@ const TwoFASetting = ({ t }) => {
<Divider margin={12} />
<Button
type="primary"
theme="solid"
type='primary'
theme='solid'
icon={<IconCopy />}
onClick={onCopy}
className="!rounded-lg !bg-slate-600 hover:!bg-slate-700 w-full"
className='!rounded-lg !bg-slate-600 hover:!bg-slate-700 w-full'
>
{t('复制所有代码')}
</Button>
@@ -243,24 +256,24 @@ const TwoFASetting = ({ t }) => {
{currentStep > 0 && (
<Button
onClick={() => setCurrentStep(currentStep - 1)}
className="!rounded-lg"
className='!rounded-lg'
>
{t('上一步')}
</Button>
)}
{currentStep < 2 ? (
<Button
type="primary"
theme="solid"
type='primary'
theme='solid'
onClick={() => setCurrentStep(currentStep + 1)}
className="!rounded-lg !bg-slate-600 hover:!bg-slate-700"
className='!rounded-lg !bg-slate-600 hover:!bg-slate-700'
>
{t('下一步')}
</Button>
) : (
<Button
type="primary"
theme="solid"
type='primary'
theme='solid'
loading={loading}
onClick={() => {
if (!verificationCode) {
@@ -269,7 +282,7 @@ const TwoFASetting = ({ t }) => {
}
handleEnable2FA();
}}
className="!rounded-lg !bg-slate-600 hover:!bg-slate-700"
className='!rounded-lg !bg-slate-600 hover:!bg-slate-700'
>
{t('完成设置并启用两步验证')}
</Button>
@@ -288,17 +301,17 @@ const TwoFASetting = ({ t }) => {
setVerificationCode('');
setConfirmDisable(false);
}}
className="!rounded-lg"
className='!rounded-lg'
>
{t('取消')}
</Button>
<Button
type="danger"
theme="solid"
type='danger'
theme='solid'
loading={loading}
disabled={!confirmDisable || !verificationCode}
onClick={handleDisable2FA}
className="!rounded-lg !bg-slate-500 hover:!bg-slate-600"
className='!rounded-lg !bg-slate-500 hover:!bg-slate-600'
>
{t('确认禁用')}
</Button>
@@ -311,14 +324,14 @@ const TwoFASetting = ({ t }) => {
if (backupCodes.length > 0) {
return (
<Button
type="primary"
theme="solid"
type='primary'
theme='solid'
onClick={() => {
setBackupModalVisible(false);
setVerificationCode('');
setBackupCodes([]);
}}
className="!rounded-lg !bg-slate-600 hover:!bg-slate-700"
className='!rounded-lg !bg-slate-600 hover:!bg-slate-700'
>
{t('完成')}
</Button>
@@ -333,17 +346,17 @@ const TwoFASetting = ({ t }) => {
setVerificationCode('');
setBackupCodes([]);
}}
className="!rounded-lg"
className='!rounded-lg'
>
{t('取消')}
</Button>
<Button
type="primary"
theme="solid"
type='primary'
theme='solid'
loading={loading}
disabled={!verificationCode}
onClick={handleRegenerateBackupCodes}
className="!rounded-lg !bg-slate-600 hover:!bg-slate-700"
className='!rounded-lg !bg-slate-600 hover:!bg-slate-700'
>
{t('生成新的备用码')}
</Button>
@@ -353,67 +366,82 @@ const TwoFASetting = ({ t }) => {
return (
<>
<Card className="!rounded-xl w-full">
<div className="flex flex-col sm:flex-row items-start sm:justify-between gap-4">
<div className="flex items-start w-full sm:w-auto">
<div className="w-12 h-12 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-4 flex-shrink-0">
<IconShield size="large" className="text-slate-600 dark:text-slate-300" />
<Card className='!rounded-xl w-full'>
<div className='flex flex-col sm:flex-row items-start sm:justify-between gap-4'>
<div className='flex items-start w-full sm:w-auto'>
<div className='w-12 h-12 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-4 flex-shrink-0'>
<IconShield
size='large'
className='text-slate-600 dark:text-slate-300'
/>
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<Typography.Title heading={6} className="mb-0">
<div className='flex-1'>
<div className='flex items-center gap-2 mb-1'>
<Typography.Title heading={6} className='mb-0'>
{t('两步验证设置')}
</Typography.Title>
{status.enabled ? (
<Tag color="green" shape="circle" size="small">{t('已启用')}</Tag>
<Tag color='green' shape='circle' size='small'>
{t('已启用')}
</Tag>
) : (
<Tag color="red" shape="circle" size="small">{t('未启用')}</Tag>
<Tag color='red' shape='circle' size='small'>
{t('未启用')}
</Tag>
)}
{status.locked && (
<Tag color="orange" shape="circle" size="small">{t('账户已锁定')}</Tag>
<Tag color='orange' shape='circle' size='small'>
{t('账户已锁定')}
</Tag>
)}
</div>
<Typography.Text type="tertiary" className="text-sm">
{t('两步验证2FA为您的账户提供额外的安全保护。启用后登录时需要输入密码和验证器应用生成的验证码。')}
<Typography.Text type='tertiary' className='text-sm'>
{t(
'两步验证2FA为您的账户提供额外的安全保护。启用后登录时需要输入密码和验证器应用生成的验证码。',
)}
</Typography.Text>
{status.enabled && (
<div className="mt-2">
<Text size="small" type="secondary">{t('剩余备用码:')}{status.backup_codes_remaining || 0}{t('个')}</Text>
<div className='mt-2'>
<Text size='small' type='secondary'>
{t('剩余备用码:')}
{status.backup_codes_remaining || 0}
{t('个')}
</Text>
</div>
)}
</div>
</div>
<div className="flex flex-col space-y-2 w-full sm:w-auto">
<div className='flex flex-col space-y-2 w-full sm:w-auto'>
{!status.enabled ? (
<Button
type="primary"
theme="solid"
size="default"
type='primary'
theme='solid'
size='default'
onClick={handleSetup2FA}
loading={loading}
className="!rounded-lg !bg-slate-600 hover:!bg-slate-700"
className='!rounded-lg !bg-slate-600 hover:!bg-slate-700'
icon={<IconShield />}
>
{t('启用验证')}
</Button>
) : (
<div className="flex flex-col space-y-2">
<div className='flex flex-col space-y-2'>
<Button
type="danger"
theme="solid"
size="default"
type='danger'
theme='solid'
size='default'
onClick={() => setDisableModalVisible(true)}
className="!rounded-lg !bg-slate-500 hover:!bg-slate-600"
className='!rounded-lg !bg-slate-500 hover:!bg-slate-600'
icon={<IconAlertTriangle />}
>
{t('禁用两步验证')}
</Button>
<Button
type="primary"
theme="solid"
size="default"
type='primary'
theme='solid'
size='default'
onClick={() => setBackupModalVisible(true)}
className="!rounded-lg"
className='!rounded-lg'
icon={<IconRefresh />}
>
{t('重新生成备用码')}
@@ -427,8 +455,8 @@ const TwoFASetting = ({ t }) => {
{/* 2FA设置模态框 */}
<Modal
title={
<div className="flex items-center">
<IconShield className="mr-2 text-slate-600" />
<div className='flex items-center'>
<IconShield className='mr-2 text-slate-600' />
{t('设置两步验证')}
</div>
}
@@ -444,36 +472,50 @@ const TwoFASetting = ({ t }) => {
style={{ maxWidth: '90vw' }}
>
{setupData && (
<div className="space-y-6">
<div className='space-y-6'>
{/* 步骤进度 */}
<Steps type="basic" size="small" current={currentStep}>
<Steps.Step title={t('扫描二维码')} description={t('使用认证器应用扫描二维码')} />
<Steps.Step title={t('保存备用码')} description={t('保存备用码以备不时之需')} />
<Steps.Step title={t('验证设置')} description={t('输入验证码完成设置')} />
<Steps type='basic' size='small' current={currentStep}>
<Steps.Step
title={t('扫描二维码')}
description={t('使用认证器应用扫描二维码')}
/>
<Steps.Step
title={t('保存备用码')}
description={t('保存备用码以备不时之需')}
/>
<Steps.Step
title={t('验证设置')}
description={t('输入验证码完成设置')}
/>
</Steps>
{/* 步骤内容 */}
<div className="rounded-xl">
<div className='rounded-xl'>
{currentStep === 0 && (
<div>
<Paragraph className="text-gray-600 dark:text-gray-300 mb-4">
{t('使用认证器应用(如 Google Authenticator、Microsoft Authenticator扫描下方二维码')}
<Paragraph className='text-gray-600 dark:text-gray-300 mb-4'>
{t(
'使用认证器应用(如 Google Authenticator、Microsoft Authenticator扫描下方二维码',
)}
</Paragraph>
<div className="flex justify-center mb-4">
<div className="bg-white p-4 rounded-lg shadow-sm">
<div className='flex justify-center mb-4'>
<div className='bg-white p-4 rounded-lg shadow-sm'>
<QRCodeSVG value={setupData.qr_code_data} size={180} />
</div>
</div>
<div className="bg-blue-50 dark:bg-blue-900 rounded-lg p-3">
<Text className="text-blue-800 dark:text-blue-200 text-sm">
{t('或手动输入密钥:')}<Text code copyable className="ml-2">{setupData.secret}</Text>
<div className='bg-blue-50 dark:bg-blue-900 rounded-lg p-3'>
<Text className='text-blue-800 dark:text-blue-200 text-sm'>
{t('或手动输入密钥:')}
<Text code copyable className='ml-2'>
{setupData.secret}
</Text>
</Text>
</div>
</div>
)}
{currentStep === 1 && (
<div className="space-y-4">
<div className='space-y-4'>
{/* 备用码展示 */}
<BackupCodesDisplay
codes={setupData.backup_codes}
@@ -491,9 +533,9 @@ const TwoFASetting = ({ t }) => {
placeholder={t('输入认证器应用显示的6位数字验证码')}
value={verificationCode}
onChange={setVerificationCode}
size="large"
size='large'
maxLength={6}
className="!rounded-lg"
className='!rounded-lg'
/>
)}
</div>
@@ -504,8 +546,8 @@ const TwoFASetting = ({ t }) => {
{/* 禁用2FA模态框 */}
<Modal
title={
<div className="flex items-center">
<IconAlertTriangle className="mr-2 text-red-500" />
<div className='flex items-center'>
<IconAlertTriangle className='mr-2 text-red-500' />
{t('禁用两步验证')}
</div>
}
@@ -519,36 +561,41 @@ const TwoFASetting = ({ t }) => {
width={550}
style={{ maxWidth: '90vw' }}
>
<div className="space-y-6">
<div className='space-y-6'>
{/* 警告提示 */}
<div className="rounded-xl">
<div className='rounded-xl'>
<Banner
type="warning"
description={t('警告:禁用两步验证将永久删除您的验证设置和所有备用码,此操作不可撤销!')}
className="!rounded-lg"
type='warning'
description={t(
'警告:禁用两步验证将永久删除您的验证设置和所有备用码,此操作不可撤销!',
)}
className='!rounded-lg'
/>
</div>
{/* 内容区域 */}
<div className="space-y-4">
<div className='space-y-4'>
<div>
<Text strong className="block mb-2 text-slate-700 dark:text-slate-200">
<Text
strong
className='block mb-2 text-slate-700 dark:text-slate-200'
>
{t('禁用后的影响:')}
</Text>
<ul className="space-y-2 text-sm text-slate-600 dark:text-slate-300">
<li className="flex items-start gap-2">
<ul className='space-y-2 text-sm text-slate-600 dark:text-slate-300'>
<li className='flex items-start gap-2'>
<Badge dot type='warning' />
{t('降低您账户的安全性')}
</li>
<li className="flex items-start gap-2">
<li className='flex items-start gap-2'>
<Badge dot type='warning' />
{t('需要重新完整设置才能再次启用')}
</li>
<li className="flex items-start gap-2">
<li className='flex items-start gap-2'>
<Badge dot type='danger' />
{t('永久删除您的两步验证设置')}
</li>
<li className="flex items-start gap-2">
<li className='flex items-start gap-2'>
<Badge dot type='danger' />
{t('永久删除所有备用码(包括未使用的)')}
</li>
@@ -557,17 +604,20 @@ const TwoFASetting = ({ t }) => {
<Divider margin={16} />
<div className="space-y-4">
<div className='space-y-4'>
<div>
<Text strong className="block mb-2 text-slate-700 dark:text-slate-200">
<Text
strong
className='block mb-2 text-slate-700 dark:text-slate-200'
>
{t('验证身份')}
</Text>
<Input
placeholder={t('请输入认证器验证码或备用码')}
value={verificationCode}
onChange={setVerificationCode}
size="large"
className="!rounded-lg"
size='large'
className='!rounded-lg'
/>
</div>
@@ -575,9 +625,11 @@ const TwoFASetting = ({ t }) => {
<Checkbox
checked={confirmDisable}
onChange={(e) => setConfirmDisable(e.target.checked)}
className="text-sm"
className='text-sm'
>
{t('我已了解禁用两步验证将永久删除所有相关设置和备用码,此操作不可撤销')}
{t(
'我已了解禁用两步验证将永久删除所有相关设置和备用码,此操作不可撤销',
)}
</Checkbox>
</div>
</div>
@@ -588,8 +640,8 @@ const TwoFASetting = ({ t }) => {
{/* 重新生成备用码模态框 */}
<Modal
title={
<div className="flex items-center">
<IconRefresh className="mr-2 text-slate-600" />
<div className='flex items-center'>
<IconRefresh className='mr-2 text-slate-600' />
{t('重新生成备用码')}
</div>
}
@@ -603,30 +655,35 @@ const TwoFASetting = ({ t }) => {
width={500}
style={{ maxWidth: '90vw' }}
>
<div className="space-y-6">
<div className='space-y-6'>
{backupCodes.length === 0 ? (
<>
{/* 警告提示 */}
<div className="rounded-xl">
<div className='rounded-xl'>
<Banner
type="warning"
description={t('重新生成备用码将使现有的备用码失效,请确保您已保存了当前的备用码。')}
className="!rounded-lg"
type='warning'
description={t(
'重新生成备用码将使现有的备用码失效,请确保您已保存了当前的备用码。',
)}
className='!rounded-lg'
/>
</div>
{/* 验证区域 */}
<div className="space-y-4">
<div className='space-y-4'>
<div>
<Text strong className="block mb-2 text-slate-700 dark:text-slate-200">
<Text
strong
className='block mb-2 text-slate-700 dark:text-slate-200'
>
{t('验证身份')}
</Text>
<Input
placeholder={t('请输入认证器验证码')}
value={verificationCode}
onChange={setVerificationCode}
size="large"
className="!rounded-lg"
size='large'
className='!rounded-lg'
/>
</div>
</div>
@@ -635,13 +692,16 @@ const TwoFASetting = ({ t }) => {
<>
{/* 成功提示 */}
<Space vertical style={{ width: '100%' }}>
<div className="flex items-center justify-center gap-2">
<div className='flex items-center justify-center gap-2'>
<Badge dot type='success' />
<Text strong className="text-lg text-slate-700 dark:text-slate-200">
<Text
strong
className='text-lg text-slate-700 dark:text-slate-200'
>
{t('新的备用码已生成')}
</Text>
</div>
<Text className="text-slate-500 dark:text-slate-400 text-sm">
<Text className='text-slate-500 dark:text-slate-400 text-sm'>
{t('旧的备用码已失效,请保存新的备用码')}
</Text>
@@ -660,4 +720,4 @@ const TwoFASetting = ({ t }) => {
);
};
export default TwoFASetting;
export default TwoFASetting;

View File

@@ -18,12 +18,23 @@ For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react';
import { Avatar, Card, Tag, Divider, Typography, Badge } from '@douyinfe/semi-ui';
import { isRoot, isAdmin, renderQuota, stringToColor } from '../../../../helpers';
import {
Avatar,
Card,
Tag,
Divider,
Typography,
Badge,
} from '@douyinfe/semi-ui';
import {
isRoot,
isAdmin,
renderQuota,
stringToColor,
} from '../../../../helpers';
import { Coins, BarChart2, Users } from 'lucide-react';
const UserInfoHeader = ({ t, userState }) => {
const getUsername = () => {
if (userState.user) {
return userState.user.username;
@@ -42,31 +53,33 @@ const UserInfoHeader = ({ t, userState }) => {
return (
<Card
className="!rounded-2xl overflow-hidden"
className='!rounded-2xl overflow-hidden'
cover={
<div
className="relative h-32"
className='relative h-32'
style={{
'--palette-primary-darkerChannel': '0 75 80',
backgroundImage: `linear-gradient(0deg, rgba(var(--palette-primary-darkerChannel) / 80%), rgba(var(--palette-primary-darkerChannel) / 80%)), url('/cover-4.webp')`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat'
backgroundRepeat: 'no-repeat',
}}
>
{/* 用户信息内容 */}
<div className="relative z-10 h-full flex flex-col justify-end p-6">
<div className="flex items-center">
<div className="flex items-stretch gap-3 sm:gap-4 flex-1 min-w-0">
<Avatar
size='large'
color={stringToColor(getUsername())}
>
<div className='relative z-10 h-full flex flex-col justify-end p-6'>
<div className='flex items-center'>
<div className='flex items-stretch gap-3 sm:gap-4 flex-1 min-w-0'>
<Avatar size='large' color={stringToColor(getUsername())}>
{getAvatarText()}
</Avatar>
<div className="flex-1 min-w-0 flex flex-col justify-between">
<div className="text-3xl font-bold truncate" style={{ color: 'white' }}>{getUsername()}</div>
<div className="flex flex-wrap items-center gap-2">
<div className='flex-1 min-w-0 flex flex-col justify-between'>
<div
className='text-3xl font-bold truncate'
style={{ color: 'white' }}
>
{getUsername()}
</div>
<div className='flex flex-wrap items-center gap-2'>
{isRoot() ? (
<Tag
size='large'
@@ -92,11 +105,7 @@ const UserInfoHeader = ({ t, userState }) => {
{t('普通用户')}
</Tag>
)}
<Tag
size='large'
shape='circle'
style={{ color: 'white' }}
>
<Tag size='large' shape='circle' style={{ color: 'white' }}>
ID: {userState?.user?.id}
</Tag>
</div>
@@ -108,34 +117,50 @@ const UserInfoHeader = ({ t, userState }) => {
}
>
{/* 当前余额和桌面版统计信息 */}
<div className="flex items-start justify-between gap-6">
<div className='flex items-start justify-between gap-6'>
{/* 当前余额显示 */}
<Badge count={t('当前余额')} position='rightTop' type='danger'>
<div className="text-2xl sm:text-3xl md:text-4xl font-bold tracking-wide">
<div className='text-2xl sm:text-3xl md:text-4xl font-bold tracking-wide'>
{renderQuota(userState?.user?.quota)}
</div>
</Badge>
{/* 桌面版统计信息Semi UI 卡片) */}
<div className="hidden lg:block flex-shrink-0">
<Card size="small" className="!rounded-xl" bodyStyle={{ padding: '12px 16px' }}>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<div className='hidden lg:block flex-shrink-0'>
<Card
size='small'
className='!rounded-xl'
bodyStyle={{ padding: '12px 16px' }}
>
<div className='flex items-center gap-4'>
<div className='flex items-center gap-2'>
<Coins size={16} />
<Typography.Text size="small" type="tertiary">{t('历史消耗')}</Typography.Text>
<Typography.Text size="small" type="tertiary" strong>{renderQuota(userState?.user?.used_quota)}</Typography.Text>
<Typography.Text size='small' type='tertiary'>
{t('历史消耗')}
</Typography.Text>
<Typography.Text size='small' type='tertiary' strong>
{renderQuota(userState?.user?.used_quota)}
</Typography.Text>
</div>
<Divider layout="vertical" />
<div className="flex items-center gap-2">
<Divider layout='vertical' />
<div className='flex items-center gap-2'>
<BarChart2 size={16} />
<Typography.Text size="small" type="tertiary">{t('请求次数')}</Typography.Text>
<Typography.Text size="small" type="tertiary" strong>{userState.user?.request_count || 0}</Typography.Text>
<Typography.Text size='small' type='tertiary'>
{t('请求次数')}
</Typography.Text>
<Typography.Text size='small' type='tertiary' strong>
{userState.user?.request_count || 0}
</Typography.Text>
</div>
<Divider layout="vertical" />
<div className="flex items-center gap-2">
<Divider layout='vertical' />
<div className='flex items-center gap-2'>
<Users size={16} />
<Typography.Text size="small" type="tertiary">{t('用户分组')}</Typography.Text>
<Typography.Text size="small" type="tertiary" strong>{userState?.user?.group || t('默认')}</Typography.Text>
<Typography.Text size='small' type='tertiary'>
{t('用户分组')}
</Typography.Text>
<Typography.Text size='small' type='tertiary' strong>
{userState?.user?.group || t('默认')}
</Typography.Text>
</div>
</div>
</Card>
@@ -143,31 +168,47 @@ const UserInfoHeader = ({ t, userState }) => {
</div>
{/* 移动端和中等屏幕统计信息卡片 */}
<div className="lg:hidden mt-2">
<Card size="small" className="!rounded-xl" bodyStyle={{ padding: '12px 16px' }} >
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className='lg:hidden mt-2'>
<Card
size='small'
className='!rounded-xl'
bodyStyle={{ padding: '12px 16px' }}
>
<div className='space-y-3'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<Coins size={16} />
<Typography.Text size="small" type="tertiary">{t('历史消耗')}</Typography.Text>
<Typography.Text size='small' type='tertiary'>
{t('历史消耗')}
</Typography.Text>
</div>
<Typography.Text size="small" type="tertiary" strong>{renderQuota(userState?.user?.used_quota)}</Typography.Text>
<Typography.Text size='small' type='tertiary' strong>
{renderQuota(userState?.user?.used_quota)}
</Typography.Text>
</div>
<Divider margin='8px' />
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<BarChart2 size={16} />
<Typography.Text size="small" type="tertiary">{t('请求次数')}</Typography.Text>
<Typography.Text size='small' type='tertiary'>
{t('请求次数')}
</Typography.Text>
</div>
<Typography.Text size="small" type="tertiary" strong>{userState.user?.request_count || 0}</Typography.Text>
<Typography.Text size='small' type='tertiary' strong>
{userState.user?.request_count || 0}
</Typography.Text>
</div>
<Divider margin='8px' />
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<Users size={16} />
<Typography.Text size="small" type="tertiary">{t('用户分组')}</Typography.Text>
<Typography.Text size='small' type='tertiary'>
{t('用户分组')}
</Typography.Text>
</div>
<Typography.Text size="small" type="tertiary" strong>{userState?.user?.group || t('默认')}</Typography.Text>
<Typography.Text size='small' type='tertiary' strong>
{userState?.user?.group || t('默认')}
</Typography.Text>
</div>
</div>
</Card>
@@ -176,4 +217,4 @@ const UserInfoHeader = ({ t, userState }) => {
);
};
export default UserInfoHeader;
export default UserInfoHeader;

View File

@@ -32,13 +32,13 @@ const AccountDeleteModal = ({
userState,
turnstileEnabled,
turnstileSiteKey,
setTurnstileToken
setTurnstileToken,
}) => {
return (
<Modal
title={
<div className="flex items-center">
<IconDelete className="mr-2 text-red-500" />
<div className='flex items-center'>
<IconDelete className='mr-2 text-red-500' />
{t('删除账户确认')}
</div>
}
@@ -47,35 +47,37 @@ const AccountDeleteModal = ({
onOk={deleteAccount}
size={'small'}
centered={true}
className="modern-modal"
className='modern-modal'
>
<div className="space-y-4 py-4">
<div className='space-y-4 py-4'>
<Banner
type='danger'
description={t('您正在删除自己的帐户,将清空所有数据且不可恢复')}
closeIcon={null}
className="!rounded-lg"
className='!rounded-lg'
/>
<div>
<Typography.Text strong className="block mb-2 text-red-600">
<Typography.Text strong className='block mb-2 text-red-600'>
{t('请输入您的用户名以确认删除')}
</Typography.Text>
<Input
placeholder={t('输入你的账户名{{username}}以确认删除', { username: ` ${userState?.user?.username} ` })}
placeholder={t('输入你的账户名{{username}}以确认删除', {
username: ` ${userState?.user?.username} `,
})}
name='self_account_deletion_confirmation'
value={inputs.self_account_deletion_confirmation}
onChange={(value) =>
handleInputChange('self_account_deletion_confirmation', value)
}
size="large"
className="!rounded-lg"
size='large'
className='!rounded-lg'
prefix={<IconUser />}
/>
</div>
{turnstileEnabled && (
<div className="flex justify-center">
<div className='flex justify-center'>
<Turnstile
sitekey={turnstileSiteKey}
onVerify={(token) => {

View File

@@ -31,13 +31,13 @@ const ChangePasswordModal = ({
changePassword,
turnstileEnabled,
turnstileSiteKey,
setTurnstileToken
setTurnstileToken,
}) => {
return (
<Modal
title={
<div className="flex items-center">
<IconLock className="mr-2 text-orange-500" />
<div className='flex items-center'>
<IconLock className='mr-2 text-orange-500' />
{t('修改密码')}
</div>
}
@@ -46,43 +46,45 @@ const ChangePasswordModal = ({
onOk={changePassword}
size={'small'}
centered={true}
className="modern-modal"
className='modern-modal'
>
<div className="space-y-4 py-4">
<div className='space-y-4 py-4'>
<div>
<Typography.Text strong className="block mb-2">{t('原密码')}</Typography.Text>
<Typography.Text strong className='block mb-2'>
{t('原密码')}
</Typography.Text>
<Input
name='original_password'
placeholder={t('请输入原密码')}
type='password'
value={inputs.original_password}
onChange={(value) =>
handleInputChange('original_password', value)
}
size="large"
className="!rounded-lg"
onChange={(value) => handleInputChange('original_password', value)}
size='large'
className='!rounded-lg'
prefix={<IconLock />}
/>
</div>
<div>
<Typography.Text strong className="block mb-2">{t('新密码')}</Typography.Text>
<Typography.Text strong className='block mb-2'>
{t('新密码')}
</Typography.Text>
<Input
name='set_new_password'
placeholder={t('请输入新密码')}
type='password'
value={inputs.set_new_password}
onChange={(value) =>
handleInputChange('set_new_password', value)
}
size="large"
className="!rounded-lg"
onChange={(value) => handleInputChange('set_new_password', value)}
size='large'
className='!rounded-lg'
prefix={<IconLock />}
/>
</div>
<div>
<Typography.Text strong className="block mb-2">{t('确认新密码')}</Typography.Text>
<Typography.Text strong className='block mb-2'>
{t('确认新密码')}
</Typography.Text>
<Input
name='set_new_password_confirmation'
placeholder={t('请再次输入新密码')}
@@ -91,14 +93,14 @@ const ChangePasswordModal = ({
onChange={(value) =>
handleInputChange('set_new_password_confirmation', value)
}
size="large"
className="!rounded-lg"
size='large'
className='!rounded-lg'
prefix={<IconLock />}
/>
</div>
{turnstileEnabled && (
<div className="flex justify-center">
<div className='flex justify-center'>
<Turnstile
sitekey={turnstileSiteKey}
onVerify={(token) => {

View File

@@ -35,13 +35,13 @@ const EmailBindModal = ({
countdown,
turnstileEnabled,
turnstileSiteKey,
setTurnstileToken
setTurnstileToken,
}) => {
return (
<Modal
title={
<div className="flex items-center">
<IconMail className="mr-2 text-blue-500" />
<div className='flex items-center'>
<IconMail className='mr-2 text-blue-500' />
{t('绑定邮箱地址')}
</div>
}
@@ -51,28 +51,30 @@ const EmailBindModal = ({
size={'small'}
centered={true}
maskClosable={false}
className="modern-modal"
className='modern-modal'
>
<div className="space-y-4 py-4">
<div className="flex gap-3">
<div className='space-y-4 py-4'>
<div className='flex gap-3'>
<Input
placeholder={t('输入邮箱地址')}
onChange={(value) => handleInputChange('email', value)}
name='email'
type='email'
size="large"
className="!rounded-lg flex-1"
size='large'
className='!rounded-lg flex-1'
prefix={<IconMail />}
/>
<Button
onClick={sendVerificationCode}
disabled={disableButton || loading}
className="!rounded-lg"
type="primary"
theme="outline"
className='!rounded-lg'
type='primary'
theme='outline'
size='large'
>
{disableButton ? `${t('重新发送')} (${countdown})` : t('获取验证码')}
{disableButton
? `${t('重新发送')} (${countdown})`
: t('获取验证码')}
</Button>
</div>
@@ -83,13 +85,13 @@ const EmailBindModal = ({
onChange={(value) =>
handleInputChange('email_verification_code', value)
}
size="large"
className="!rounded-lg"
size='large'
className='!rounded-lg'
prefix={<IconKey />}
/>
{turnstileEnabled && (
<div className="flex justify-center">
<div className='flex justify-center'>
<Turnstile
sitekey={turnstileSiteKey}
onVerify={(token) => {

View File

@@ -29,13 +29,13 @@ const WeChatBindModal = ({
inputs,
handleInputChange,
bindWeChat,
status
status,
}) => {
return (
<Modal
title={
<div className="flex items-center">
<SiWechat className="mr-2 text-green-500" size={20} />
<div className='flex items-center'>
<SiWechat className='mr-2 text-green-500' size={20} />
{t('绑定微信账户')}
</div>
}
@@ -44,30 +44,30 @@ const WeChatBindModal = ({
footer={null}
size={'small'}
centered={true}
className="modern-modal"
className='modern-modal'
>
<div className="space-y-4 py-4 text-center">
<Image src={status.wechat_qrcode} className="mx-auto" />
<div className="text-gray-600">
<p>{t('微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)')}</p>
<div className='space-y-4 py-4 text-center'>
<Image src={status.wechat_qrcode} className='mx-auto' />
<div className='text-gray-600'>
<p>
{t('微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)')}
</p>
</div>
<Input
placeholder={t('验证码')}
name='wechat_verification_code'
value={inputs.wechat_verification_code}
onChange={(v) =>
handleInputChange('wechat_verification_code', v)
}
size="large"
className="!rounded-lg"
onChange={(v) => handleInputChange('wechat_verification_code', v)}
size='large'
className='!rounded-lg'
prefix={<IconKey />}
/>
<Button
type="primary"
theme="solid"
type='primary'
theme='solid'
size='large'
onClick={bindWeChat}
className="!rounded-lg w-full !bg-slate-600 hover:!bg-slate-700"
className='!rounded-lg w-full !bg-slate-600 hover:!bg-slate-700'
icon={<SiWechat size={16} />}
>
{t('绑定')}

View File

@@ -130,7 +130,11 @@ const SetupWizard = () => {
return true; // 如果已经初始化,可以继续
}
// 检查必填字段
if (!formData.username || !formData.password || !formData.confirmPassword) {
if (
!formData.username ||
!formData.password ||
!formData.confirmPassword
) {
showError(t('请填写完整的管理员账号信息'));
return false;
}
@@ -226,12 +230,7 @@ const SetupWizard = () => {
const getStepContent = (step) => {
switch (step) {
case 0:
return (
<DatabaseStep
setupStatus={setupStatus}
t={t}
/>
);
return <DatabaseStep setupStatus={setupStatus} t={t} />;
case 1:
return (
<AdminStep
@@ -252,11 +251,7 @@ const SetupWizard = () => {
);
case 3:
return (
<CompleteStep
setupStatus={setupStatus}
formData={formData}
t={t}
/>
<CompleteStep setupStatus={setupStatus} formData={formData} t={t} />
);
default:
return null;
@@ -275,21 +270,25 @@ const SetupWizard = () => {
return (
<div className='min-h-screen flex items-center justify-center px-4'>
<div className="w-full max-w-4xl">
<Card className="!rounded-2xl shadow-sm border-0">
<div className="mb-4">
<div className="text-xl font-semibold">{t('系统初始化')}</div>
<div className="text-xs text-gray-600">
<div className='w-full max-w-4xl'>
<Card className='!rounded-2xl shadow-sm border-0'>
<div className='mb-4'>
<div className='text-xl font-semibold'>{t('系统初始化')}</div>
<div className='text-xs text-gray-600'>
{t('欢迎使用,请完成以下设置以开始使用系统')}
</div>
</div>
<div className="px-2 py-2">
<Steps type="basic" current={currentStep}>
<div className='px-2 py-2'>
<Steps type='basic' current={currentStep}>
{steps.map((item, index) => (
<Steps.Step
key={item.title}
title={<span className={currentStep === index ? 'shine-text' : ''}>{item.title}</span>}
title={
<span className={currentStep === index ? 'shine-text' : ''}>
{item.title}
</span>
}
description={item.description}
/>
))}
@@ -306,9 +305,12 @@ const SetupWizard = () => {
initValues={formData}
>
{/* 步骤内容:保持所有字段挂载,仅隐藏非当前步骤 */}
<div className="steps-content">
<div className='steps-content'>
{[0, 1, 2, 3].map((idx) => (
<div key={idx} style={{ display: currentStep === idx ? 'block' : 'none' }}>
<div
key={idx}
style={{ display: currentStep === idx ? 'block' : 'none' }}
>
{React.cloneElement(getStepContent(idx), {
...stepNavigationProps,
renderNavigationButtons: () => (

View File

@@ -32,29 +32,22 @@ const StepNavigation = ({
next,
onSubmit,
loading,
t
t,
}) => {
return (
<div className="flex justify-between items-center pt-4">
<div className='flex justify-between items-center pt-4'>
{/* 上一步按钮 */}
{currentStep > 0 && (
<Button
onClick={prev}
className="!rounded-lg"
>
<Button onClick={prev} className='!rounded-lg'>
{t('上一步')}
</Button>
)}
<div className="flex-1"></div>
<div className='flex-1'></div>
{/* 下一步按钮 */}
{currentStep < steps.length - 1 && (
<Button
type="primary"
onClick={next}
className="!rounded-lg"
>
<Button type='primary' onClick={next} className='!rounded-lg'>
{t('下一步')}
</Button>
)}
@@ -62,10 +55,10 @@ const StepNavigation = ({
{/* 完成按钮 */}
{currentStep === steps.length - 1 && (
<Button
type="primary"
type='primary'
onClick={onSubmit}
loading={loading}
className="!rounded-lg"
className='!rounded-lg'
icon={<IconCheckCircleStroked />}
>
{t('初始化系统')}

View File

@@ -31,7 +31,7 @@ const AdminStep = ({
setFormData,
formRef,
renderNavigationButtons,
t
t,
}) => {
return (
<>
@@ -40,11 +40,11 @@ const AdminStep = ({
type='info'
closeIcon={null}
description={
<div className="flex items-center">
<div className='flex items-center'>
<span>{t('管理员账号已经初始化过,请继续设置其他参数')}</span>
</div>
}
className="!rounded-lg"
className='!rounded-lg'
/>
) : (
<>
@@ -55,7 +55,7 @@ const AdminStep = ({
prefix={<IconUser />}
showClear
noLabel={false}
validateStatus="default"
validateStatus='default'
rules={[{ required: true, message: t('请输入管理员用户名') }]}
initValue={formData.username || ''}
onChange={(value) => {
@@ -70,11 +70,11 @@ const AdminStep = ({
prefix={<IconLock />}
showClear
noLabel={false}
mode="password"
validateStatus="default"
mode='password'
validateStatus='default'
rules={[
{ required: true, message: t('请输入管理员密码') },
{ min: 8, message: t('密码长度至少为8个字符') }
{ min: 8, message: t('密码长度至少为8个字符') },
]}
initValue={formData.password || ''}
onChange={(value) => {
@@ -89,8 +89,8 @@ const AdminStep = ({
prefix={<IconLock />}
showClear
noLabel={false}
mode="password"
validateStatus="default"
mode='password'
validateStatus='default'
rules={[
{ required: true, message: t('请确认管理员密码') },
{
@@ -102,8 +102,8 @@ const AdminStep = ({
}
}
return Promise.resolve();
}
}
},
},
]}
initValue={formData.confirmPassword || ''}
onChange={(value) => {

View File

@@ -31,29 +31,39 @@ const CompleteStep = ({
setupStatus,
formData,
renderNavigationButtons,
t
t,
}) => {
return (
<div className="text-center">
<Avatar color="green" className="mx-auto mb-4 shadow-lg">
<div className='text-center'>
<Avatar color='green' className='mx-auto mb-4 shadow-lg'>
<CheckCircle size={24} />
</Avatar>
<Title heading={3} className="mb-2">{t('准备完成初始化')}</Title>
<Text type="secondary" className="mb-6 block">
<Title heading={3} className='mb-2'>
{t('准备完成初始化')}
</Title>
<Text type='secondary' className='mb-6 block'>
{t('请确认以下设置信息,点击"初始化系统"开始配置')}
</Text>
<Descriptions>
<Descriptions.Item itemKey={t('数据库类型')}>
{setupStatus.database_type === 'sqlite' ? 'SQLite' :
setupStatus.database_type === 'mysql' ? 'MySQL' : 'PostgreSQL'}
{setupStatus.database_type === 'sqlite'
? 'SQLite'
: setupStatus.database_type === 'mysql'
? 'MySQL'
: 'PostgreSQL'}
</Descriptions.Item>
<Descriptions.Item itemKey={t('管理员账号')}>
{setupStatus.root_init ? t('已初始化') : (formData.username || t('未设置'))}
{setupStatus.root_init
? t('已初始化')
: formData.username || t('未设置')}
</Descriptions.Item>
<Descriptions.Item itemKey={t('使用模式')}>
{formData.usageMode === 'external' ? t('对外运营模式') :
formData.usageMode === 'self' ? t('自用模式') : t('演示站点模式')}
{formData.usageMode === 'external'
? t('对外运营模式')
: formData.usageMode === 'self'
? t('自用模式')
: t('演示站点模式')}
</Descriptions.Item>
</Descriptions>

View File

@@ -40,14 +40,16 @@ const DatabaseStep = ({ setupStatus, renderNavigationButtons, t }) => {
'您正在使用 SQLite 数据库。如果您在容器环境中运行,请确保已正确设置数据库文件的持久化映射,否则容器重启后所有数据将丢失!',
)}
</p>
<p className="mt-1">
<strong>{t(
'建议在生产环境中使用 MySQL 或 PostgreSQL 数据库,或确保 SQLite 数据库文件已映射到宿主机的持久化存储。',
)}</strong>
<p className='mt-1'>
<strong>
{t(
'建议在生产环境中使用 MySQL 或 PostgreSQL 数据库,或确保 SQLite 数据库文件已映射到宿主机的持久化存储。',
)}
</strong>
</p>
</div>
}
className="!rounded-lg"
className='!rounded-lg'
fullMode={false}
bordered
/>
@@ -68,7 +70,7 @@ const DatabaseStep = ({ setupStatus, renderNavigationButtons, t }) => {
</p>
</div>
}
className="!rounded-lg"
className='!rounded-lg'
fullMode={false}
bordered
/>
@@ -89,7 +91,7 @@ const DatabaseStep = ({ setupStatus, renderNavigationButtons, t }) => {
</p>
</div>
}
className="!rounded-lg"
className='!rounded-lg'
fullMode={false}
bordered
/>

View File

@@ -28,7 +28,7 @@ const UsageModeStep = ({
formData,
handleUsageModeChange,
renderNavigationButtons,
t
t,
}) => {
return (
<>
@@ -37,9 +37,9 @@ const UsageModeStep = ({
onChange={handleUsageModeChange}
type='card'
direction='horizontal'
className="mt-4"
aria-label="使用模式选择"
name="usage-mode-selection"
className='mt-4'
aria-label='使用模式选择'
name='usage-mode-selection'
>
<Radio
value='external'

View File

@@ -26,4 +26,4 @@ export { default as StepNavigation } from './components/StepNavigation';
export { default as DatabaseStep } from './components/steps/DatabaseStep';
export { default as AdminStep } from './components/steps/AdminStep';
export { default as UsageModeStep } from './components/steps/UsageModeStep';
export { default as CompleteStep } from './components/steps/CompleteStep';
export { default as CompleteStep } from './components/steps/CompleteStep';

View File

@@ -24,7 +24,7 @@ import {
Modal,
Switch,
Typography,
Select
Select,
} from '@douyinfe/semi-ui';
import CompactModeToggle from '../../common/ui/CompactModeToggle';
@@ -52,19 +52,19 @@ const ChannelsActions = ({
activePage,
pageSize,
setActivePage,
t
t,
}) => {
return (
<div className="flex flex-col gap-2">
<div className='flex flex-col gap-2'>
{/* 第一行:批量操作按钮 + 设置开关 */}
<div className="flex flex-col md:flex-row justify-between gap-2">
<div className='flex flex-col md:flex-row justify-between gap-2'>
{/* 左侧:批量操作按钮 */}
<div className="flex flex-wrap md:flex-nowrap items-center gap-2 w-full md:w-auto order-2 md:order-1">
<div className='flex flex-wrap md:flex-nowrap items-center gap-2 w-full md:w-auto order-2 md:order-1'>
<Button
size='small'
disabled={!enableBatchDelete}
type='danger'
className="w-full md:w-auto"
className='w-full md:w-auto'
onClick={() => {
Modal.confirm({
title: t('确定是否要删除所选通道?'),
@@ -81,7 +81,7 @@ const ChannelsActions = ({
disabled={!enableBatchDelete}
type='tertiary'
onClick={() => setShowBatchSetTag(true)}
className="w-full md:w-auto"
className='w-full md:w-auto'
>
{t('批量设置标签')}
</Button>
@@ -95,7 +95,7 @@ const ChannelsActions = ({
<Button
size='small'
type='tertiary'
className="w-full"
className='w-full'
onClick={() => {
Modal.confirm({
title: t('确定?'),
@@ -112,11 +112,13 @@ const ChannelsActions = ({
<Dropdown.Item>
<Button
size='small'
className="w-full"
className='w-full'
onClick={() => {
Modal.confirm({
title: t('确定是否要修复数据库一致性?'),
content: t('进行该操作时,可能导致渠道访问错误,请仅在数据库出现问题时使用'),
content: t(
'进行该操作时,可能导致渠道访问错误,请仅在数据库出现问题时使用',
),
onOk: () => fixChannelsAbilities(),
size: 'sm',
centered: true,
@@ -130,7 +132,7 @@ const ChannelsActions = ({
<Button
size='small'
type='secondary'
className="w-full"
className='w-full'
onClick={() => {
Modal.confirm({
title: t('确定?'),
@@ -148,7 +150,7 @@ const ChannelsActions = ({
<Button
size='small'
type='danger'
className="w-full"
className='w-full'
onClick={() => {
Modal.confirm({
title: t('确定是否要删除禁用通道?'),
@@ -165,7 +167,12 @@ const ChannelsActions = ({
</Dropdown.Menu>
}
>
<Button size='small' theme='light' type='tertiary' className="w-full md:w-auto">
<Button
size='small'
theme='light'
type='tertiary'
className='w-full md:w-auto'
>
{t('批量操作')}
</Button>
</Dropdown>
@@ -178,9 +185,9 @@ const ChannelsActions = ({
</div>
{/* 右侧:设置开关区域 */}
<div className="flex flex-col md:flex-row items-start md:items-center gap-2 w-full md:w-auto order-1 md:order-2">
<div className="flex items-center justify-between w-full md:w-auto">
<Typography.Text strong className="mr-2">
<div className='flex flex-col md:flex-row items-start md:items-center gap-2 w-full md:w-auto order-1 md:order-2'>
<div className='flex items-center justify-between w-full md:w-auto'>
<Typography.Text strong className='mr-2'>
{t('使用ID排序')}
</Typography.Text>
<Switch
@@ -189,18 +196,30 @@ const ChannelsActions = ({
onChange={(v) => {
localStorage.setItem('id-sort', v + '');
setIdSort(v);
const { searchKeyword, searchGroup, searchModel } = getFormValues();
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
const { searchKeyword, searchGroup, searchModel } =
getFormValues();
if (
searchKeyword === '' &&
searchGroup === '' &&
searchModel === ''
) {
loadChannels(activePage, pageSize, v, enableTagMode);
} else {
searchChannels(enableTagMode, activeTypeKey, statusFilter, activePage, pageSize, v);
searchChannels(
enableTagMode,
activeTypeKey,
statusFilter,
activePage,
pageSize,
v,
);
}
}}
/>
</div>
<div className="flex items-center justify-between w-full md:w-auto">
<Typography.Text strong className="mr-2">
<div className='flex items-center justify-between w-full md:w-auto'>
<Typography.Text strong className='mr-2'>
{t('开启批量操作')}
</Typography.Text>
<Switch
@@ -213,8 +232,8 @@ const ChannelsActions = ({
/>
</div>
<div className="flex items-center justify-between w-full md:w-auto">
<Typography.Text strong className="mr-2">
<div className='flex items-center justify-between w-full md:w-auto'>
<Typography.Text strong className='mr-2'>
{t('标签聚合模式')}
</Typography.Text>
<Switch
@@ -229,8 +248,8 @@ const ChannelsActions = ({
/>
</div>
<div className="flex items-center justify-between w-full md:w-auto">
<Typography.Text strong className="mr-2">
<div className='flex items-center justify-between w-full md:w-auto'>
<Typography.Text strong className='mr-2'>
{t('状态筛选')}
</Typography.Text>
<Select
@@ -240,12 +259,19 @@ const ChannelsActions = ({
localStorage.setItem('channel-status-filter', v);
setStatusFilter(v);
setActivePage(1);
loadChannels(1, pageSize, idSort, enableTagMode, activeTypeKey, v);
loadChannels(
1,
pageSize,
idSort,
enableTagMode,
activeTypeKey,
v,
);
}}
>
<Select.Option value="all">{t('全部')}</Select.Option>
<Select.Option value="enabled">{t('已启用')}</Select.Option>
<Select.Option value="disabled">{t('已禁用')}</Select.Option>
<Select.Option value='all'>{t('全部')}</Select.Option>
<Select.Option value='enabled'>{t('已启用')}</Select.Option>
<Select.Option value='disabled'>{t('已禁用')}</Select.Option>
</Select>
</div>
</div>
@@ -254,4 +280,4 @@ const ChannelsActions = ({
);
};
export default ChannelsActions;
export default ChannelsActions;

View File

@@ -27,14 +27,14 @@ import {
SplitButtonGroup,
Tag,
Tooltip,
Typography
Typography,
} from '@douyinfe/semi-ui';
import {
timestamp2string,
renderGroup,
renderQuota,
getChannelIcon,
renderQuotaWithAmount
renderQuotaWithAmount,
} from '../../../helpers';
import { CHANNEL_OPTIONS } from '../../../constants';
import { IconTreeTriangleDown, IconMore } from '@douyinfe/semi-icons';
@@ -51,27 +51,22 @@ const renderType = (type, channelInfo = undefined, t) => {
let icon = getChannelIcon(type);
if (channelInfo?.is_multi_key) {
icon = (
icon =
channelInfo?.multi_key_mode === 'random' ? (
<div className="flex items-center gap-1">
<FaRandom className="text-blue-500" />
<div className='flex items-center gap-1'>
<FaRandom className='text-blue-500' />
{icon}
</div>
) : (
<div className="flex items-center gap-1">
<IconTreeTriangleDown className="text-blue-500" />
<div className='flex items-center gap-1'>
<IconTreeTriangleDown className='text-blue-500' />
{icon}
</div>
)
)
);
}
return (
<Tag
color={type2label[type]?.color}
shape='circle'
prefixIcon={icon}
>
<Tag color={type2label[type]?.color} shape='circle' prefixIcon={icon}>
{type2label[type]?.label}
</Tag>
);
@@ -79,11 +74,7 @@ const renderType = (type, channelInfo = undefined, t) => {
const renderTagType = (t) => {
return (
<Tag
color='light-blue'
shape='circle'
type='light'
>
<Tag color='light-blue' shape='circle' type='light'>
{t('标签聚合')}
</Tag>
);
@@ -95,7 +86,8 @@ const renderStatus = (status, channelInfo = undefined, t) => {
let keySize = channelInfo.multi_key_size;
let enabledKeySize = keySize;
if (channelInfo.multi_key_status_list) {
enabledKeySize = keySize - Object.keys(channelInfo.multi_key_status_list).length;
enabledKeySize =
keySize - Object.keys(channelInfo.multi_key_status_list).length;
}
return renderMultiKeyStatus(status, keySize, enabledKeySize, t);
}
@@ -155,7 +147,7 @@ const renderMultiKeyStatus = (status, keySize, enabledKeySize, t) => {
</Tag>
);
}
}
};
const renderResponseTime = (responseTime, t) => {
let time = responseTime / 1000;
@@ -212,7 +204,7 @@ export const getChannelsColumns = ({
activePage,
channels,
setShowMultiKeyManageModal,
setCurrentMultiKeyChannel
setCurrentMultiKeyChannel,
}) => {
return [
{
@@ -276,7 +268,9 @@ export const getChannelsColumns = ({
return (
<div>
<Tooltip
content={t('原因:') + reason + t(',时间:') + timestamp2string(time)}
content={
t('原因:') + reason + t(',时间:') + timestamp2string(time)
}
>
{renderStatus(text, record.channel_info, t)}
</Tooltip>
@@ -291,9 +285,7 @@ export const getChannelsColumns = ({
key: COLUMN_KEYS.RESPONSE_TIME,
title: t('响应时间'),
dataIndex: 'response_time',
render: (text, record, index) => (
<div>{renderResponseTime(text, t)}</div>
),
render: (text, record, index) => <div>{renderResponseTime(text, t)}</div>,
},
{
key: COLUMN_KEYS.BALANCE,
@@ -309,7 +301,9 @@ export const getChannelsColumns = ({
{renderQuota(record.used_quota)}
</Tag>
</Tooltip>
<Tooltip content={t('剩余额度$') + record.balance + t(',点击更新')}>
<Tooltip
content={t('剩余额度$') + record.balance + t(',点击更新')}
>
<Tag
color='white'
type='ghost'
@@ -351,7 +345,7 @@ export const getChannelsColumns = ({
innerButtons
defaultValue={record.priority}
min={-999}
size="small"
size='small'
/>
</div>
);
@@ -364,7 +358,10 @@ export const getChannelsColumns = ({
onBlur={(e) => {
Modal.warning({
title: t('修改子渠道优先级'),
content: t('确定要修改所有子渠道优先级为 ') + e.target.value + t(' 吗?'),
content:
t('确定要修改所有子渠道优先级为 ') +
e.target.value +
t(' 吗?'),
onOk: () => {
if (e.target.value === '') {
return;
@@ -379,7 +376,7 @@ export const getChannelsColumns = ({
innerButtons
defaultValue={record.priority}
min={-999}
size="small"
size='small'
/>
);
}
@@ -403,7 +400,7 @@ export const getChannelsColumns = ({
innerButtons
defaultValue={record.weight}
min={0}
size="small"
size='small'
/>
</div>
);
@@ -416,7 +413,10 @@ export const getChannelsColumns = ({
onBlur={(e) => {
Modal.warning({
title: t('修改子渠道权重'),
content: t('确定要修改所有子渠道权重为 ') + e.target.value + t(' 吗?'),
content:
t('确定要修改所有子渠道权重为 ') +
e.target.value +
t(' 吗?'),
onOk: () => {
if (e.target.value === '') {
return;
@@ -431,7 +431,7 @@ export const getChannelsColumns = ({
innerButtons
defaultValue={record.weight}
min={-999}
size="small"
size='small'
/>
);
}
@@ -484,18 +484,18 @@ export const getChannelsColumns = ({
return (
<Space wrap>
<SplitButtonGroup
className="overflow-hidden"
className='overflow-hidden'
aria-label={t('测试单个渠道操作项目组')}
>
<Button
size="small"
size='small'
type='tertiary'
onClick={() => testChannel(record, '')}
>
{t('测试')}
</Button>
<Button
size="small"
size='small'
type='tertiary'
icon={<IconTreeTriangleDown />}
onClick={() => {
@@ -505,32 +505,28 @@ export const getChannelsColumns = ({
/>
</SplitButtonGroup>
{
record.status === 1 ? (
<Button
type='danger'
size="small"
onClick={() => manageChannel(record.id, 'disable', record)}
>
{t('禁用')}
</Button>
) : (
<Button
size="small"
onClick={() => manageChannel(record.id, 'enable', record)}
>
{t('启用')}
</Button>
)
}
{record.status === 1 ? (
<Button
type='danger'
size='small'
onClick={() => manageChannel(record.id, 'disable', record)}
>
{t('禁用')}
</Button>
) : (
<Button
size='small'
onClick={() => manageChannel(record.id, 'enable', record)}
>
{t('启用')}
</Button>
)}
{record.channel_info?.is_multi_key ? (
<SplitButtonGroup
aria-label={t('多密钥渠道操作项目组')}
>
<SplitButtonGroup aria-label={t('多密钥渠道操作项目组')}>
<Button
type='tertiary'
size="small"
size='small'
onClick={() => {
setEditingChannel(record);
setShowEdit(true);
@@ -549,12 +545,12 @@ export const getChannelsColumns = ({
setCurrentMultiKeyChannel(record);
setShowMultiKeyManageModal(true);
},
}
},
]}
>
<Button
type='tertiary'
size="small"
size='small'
icon={<IconTreeTriangleDown />}
/>
</Dropdown>
@@ -562,7 +558,7 @@ export const getChannelsColumns = ({
) : (
<Button
type='tertiary'
size="small"
size='small'
onClick={() => {
setEditingChannel(record);
setShowEdit(true);
@@ -577,11 +573,7 @@ export const getChannelsColumns = ({
position='bottomRight'
menu={moreMenuItems}
>
<Button
icon={<IconMore />}
type='tertiary'
size="small"
/>
<Button icon={<IconMore />} type='tertiary' size='small' />
</Dropdown>
</Space>
);
@@ -591,21 +583,21 @@ export const getChannelsColumns = ({
<Space wrap>
<Button
type='tertiary'
size="small"
size='small'
onClick={() => manageTag(record.key, 'enable')}
>
{t('启用全部')}
</Button>
<Button
type='tertiary'
size="small"
size='small'
onClick={() => manageTag(record.key, 'disable')}
>
{t('禁用全部')}
</Button>
<Button
type='tertiary'
size="small"
size='small'
onClick={() => {
setShowEditTag(true);
setEditingTag(record.key);
@@ -619,4 +611,4 @@ export const getChannelsColumns = ({
},
},
];
};
};

View File

@@ -34,16 +34,16 @@ const ChannelsFilters = ({
groupOptions,
loading,
searching,
t
t,
}) => {
return (
<div className="flex flex-col md:flex-row justify-between items-center gap-2 w-full">
<div className="flex gap-2 w-full md:w-auto order-2 md:order-1">
<div className='flex flex-col md:flex-row justify-between items-center gap-2 w-full'>
<div className='flex gap-2 w-full md:w-auto order-2 md:order-1'>
<Button
size='small'
theme='light'
type='primary'
className="w-full md:w-auto"
className='w-full md:w-auto'
onClick={() => {
setEditingChannel({
id: undefined,
@@ -57,7 +57,7 @@ const ChannelsFilters = ({
<Button
size='small'
type='tertiary'
className="w-full md:w-auto"
className='w-full md:w-auto'
onClick={refresh}
>
{t('刷新')}
@@ -67,54 +67,54 @@ const ChannelsFilters = ({
size='small'
type='tertiary'
onClick={() => setShowColumnSelector(true)}
className="w-full md:w-auto"
className='w-full md:w-auto'
>
{t('列设置')}
</Button>
</div>
<div className="flex flex-col md:flex-row items-center gap-2 w-full md:w-auto order-1 md:order-2">
<div className='flex flex-col md:flex-row items-center gap-2 w-full md:w-auto order-1 md:order-2'>
<Form
initValues={formInitValues}
getFormApi={(api) => setFormApi(api)}
onSubmit={() => searchChannels(enableTagMode)}
allowEmpty={true}
autoComplete="off"
layout="horizontal"
trigger="change"
autoComplete='off'
layout='horizontal'
trigger='change'
stopValidateWithError={false}
className="flex flex-col md:flex-row items-center gap-2 w-full"
className='flex flex-col md:flex-row items-center gap-2 w-full'
>
<div className="relative w-full md:w-64">
<div className='relative w-full md:w-64'>
<Form.Input
size='small'
field="searchKeyword"
field='searchKeyword'
prefix={<IconSearch />}
placeholder={t('渠道ID名称密钥API地址')}
showClear
pure
/>
</div>
<div className="w-full md:w-48">
<div className='w-full md:w-48'>
<Form.Input
size='small'
field="searchModel"
field='searchModel'
prefix={<IconSearch />}
placeholder={t('模型关键字')}
showClear
pure
/>
</div>
<div className="w-full md:w-32">
<div className='w-full md:w-32'>
<Form.Select
size='small'
field="searchGroup"
field='searchGroup'
placeholder={t('选择分组')}
optionList={[
{ label: t('选择分组'), value: null },
...groupOptions,
]}
className="w-full"
className='w-full'
showClear
pure
onChange={() => {
@@ -127,10 +127,10 @@ const ChannelsFilters = ({
</div>
<Button
size='small'
type="tertiary"
htmlType="submit"
type='tertiary'
htmlType='submit'
loading={loading || searching}
className="w-full md:w-auto"
className='w-full md:w-auto'
>
{t('查询')}
</Button>
@@ -146,7 +146,7 @@ const ChannelsFilters = ({
}, 100);
}
}}
className="w-full md:w-auto"
className='w-full md:w-auto'
>
{t('重置')}
</Button>
@@ -156,4 +156,4 @@ const ChannelsFilters = ({
);
};
export default ChannelsFilters;
export default ChannelsFilters;

View File

@@ -22,7 +22,7 @@ import { Empty } from '@douyinfe/semi-ui';
import CardTable from '../../common/ui/CardTable';
import {
IllustrationNoResult,
IllustrationNoResultDark
IllustrationNoResultDark,
} from '@douyinfe/semi-illustrations';
import { getChannelsColumns } from './ChannelsColumnDefs';
@@ -142,25 +142,27 @@ const ChannelsTable = (channelsData) => {
rowSelection={
enableBatchDelete
? {
onChange: (selectedRowKeys, selectedRows) => {
setSelectedChannels(selectedRows);
},
}
onChange: (selectedRowKeys, selectedRows) => {
setSelectedChannels(selectedRows);
},
}
: null
}
empty={
<Empty
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
darkModeImage={
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
}
description={t('搜索无结果')}
style={{ padding: 30 }}
/>
}
className="rounded-xl overflow-hidden"
size="middle"
className='rounded-xl overflow-hidden'
size='middle'
loading={loading || searching}
/>
);
};
export default ChannelsTable;
export default ChannelsTable;

View File

@@ -33,7 +33,7 @@ const ChannelsTabs = ({
pageSize,
idSort,
setActivePage,
t
t,
}) => {
if (enableTagMode) return null;
@@ -46,24 +46,29 @@ const ChannelsTabs = ({
return (
<Tabs
activeKey={activeTypeKey}
type="card"
type='card'
collapsible
onChange={handleTabChange}
className="mb-2"
className='mb-2'
>
<TabPane
itemKey="all"
itemKey='all'
tab={
<span className="flex items-center gap-2">
<span className='flex items-center gap-2'>
{t('全部')}
<Tag color={activeTypeKey === 'all' ? 'red' : 'grey'} shape='circle'>
<Tag
color={activeTypeKey === 'all' ? 'red' : 'grey'}
shape='circle'
>
{channelTypeCounts['all'] || 0}
</Tag>
</span>
}
/>
{CHANNEL_OPTIONS.filter((opt) => availableTypeKeys.includes(String(opt.value))).map((option) => {
{CHANNEL_OPTIONS.filter((opt) =>
availableTypeKeys.includes(String(opt.value)),
).map((option) => {
const key = String(option.value);
const count = channelTypeCounts[option.value] || 0;
return (
@@ -71,10 +76,13 @@ const ChannelsTabs = ({
key={key}
itemKey={key}
tab={
<span className="flex items-center gap-2">
<span className='flex items-center gap-2'>
{getChannelIcon(option.value)}
{option.label}
<Tag color={activeTypeKey === key ? 'red' : 'grey'} shape='circle'>
<Tag
color={activeTypeKey === key ? 'red' : 'grey'}
shape='circle'
>
{count}
</Tag>
</span>
@@ -86,4 +94,4 @@ const ChannelsTabs = ({
);
};
export default ChannelsTabs;
export default ChannelsTabs;

View File

@@ -64,7 +64,7 @@ const ChannelsPage = () => {
{/* Main Content */}
<CardPro
type="type3"
type='type3'
tabsArea={<ChannelsTabs {...channelsData} />}
actionsArea={<ChannelsActions {...channelsData} />}
searchArea={<ChannelsFilters {...channelsData} />}
@@ -85,4 +85,4 @@ const ChannelsPage = () => {
);
};
export default ChannelsPage;
export default ChannelsPage;

View File

@@ -27,7 +27,7 @@ const BatchTagModal = ({
batchSetTagValue,
setBatchSetTagValue,
selectedChannels,
t
t,
}) => {
return (
<Modal
@@ -37,10 +37,10 @@ const BatchTagModal = ({
onCancel={() => setShowBatchSetTag(false)}
maskClosable={false}
centered={true}
size="small"
className="!rounded-lg"
size='small'
className='!rounded-lg'
>
<div className="mb-5">
<div className='mb-5'>
<Typography.Text>{t('请输入要设置的标签名称')}</Typography.Text>
</div>
<Input
@@ -48,13 +48,16 @@ const BatchTagModal = ({
value={batchSetTagValue}
onChange={(v) => setBatchSetTagValue(v)}
/>
<div className="mt-4">
<div className='mt-4'>
<Typography.Text type='secondary'>
{t('已选择 ${count} 个渠道').replace('${count}', selectedChannels.length)}
{t('已选择 ${count} 个渠道').replace(
'${count}',
selectedChannels.length,
)}
</Typography.Text>
</div>
</Modal>
);
};
export default BatchTagModal;
export default BatchTagModal;

View File

@@ -74,10 +74,8 @@ const ColumnSelectorModal = ({
visible={showColumnSelector}
onCancel={() => setShowColumnSelector(false)}
footer={
<div className="flex justify-end">
<Button onClick={() => initDefaultColumns()}>
{t('重置')}
</Button>
<div className='flex justify-end'>
<Button onClick={() => initDefaultColumns()}>{t('重置')}</Button>
<Button onClick={() => setShowColumnSelector(false)}>
{t('取消')}
</Button>
@@ -100,7 +98,7 @@ const ColumnSelectorModal = ({
</Checkbox>
</div>
<div
className="flex flex-wrap max-h-96 overflow-y-auto rounded-lg p-4"
className='flex flex-wrap max-h-96 overflow-y-auto rounded-lg p-4'
style={{ border: '1px solid var(--semi-color-border)' }}
>
{allColumns.map((column) => {
@@ -110,10 +108,7 @@ const ColumnSelectorModal = ({
}
return (
<div
key={column.key}
className="w-1/2 mb-4 pr-2"
>
<div key={column.key} className='w-1/2 mb-4 pr-2'>
<Checkbox
checked={!!visibleColumns[column.key]}
onChange={(e) =>
@@ -130,4 +125,4 @@ const ColumnSelectorModal = ({
);
};
export default ColumnSelectorModal;
export default ColumnSelectorModal;

File diff suppressed because it is too large Load Diff

View File

@@ -289,7 +289,7 @@ const EditTagModal = (props) => {
t('已新增 {{count}} 个模型:{{list}}', {
count: addedModels.length,
list: addedModels.join(', '),
})
}),
);
} else {
showInfo(t('未发现新增模型'));
@@ -301,8 +301,10 @@ const EditTagModal = (props) => {
placement='right'
title={
<Space>
<Tag color="blue" shape="circle">{t('编辑')}</Tag>
<Title heading={4} className="m-0">
<Tag color='blue' shape='circle'>
{t('编辑')}
</Tag>
<Title heading={4} className='m-0'>
{t('编辑标签')}
</Title>
</Space>
@@ -312,10 +314,10 @@ const EditTagModal = (props) => {
width={600}
onCancel={handleClose}
footer={
<div className="flex justify-end bg-white">
<div className='flex justify-end bg-white'>
<Space>
<Button
theme="solid"
theme='solid'
onClick={() => formApiRef.current?.submitForm()}
loading={loading}
icon={<IconSave />}
@@ -323,8 +325,8 @@ const EditTagModal = (props) => {
{t('保存')}
</Button>
<Button
theme="light"
type="primary"
theme='light'
type='primary'
onClick={handleClose}
icon={<IconClose />}
>
@@ -343,26 +345,28 @@ const EditTagModal = (props) => {
>
{() => (
<Spin spinning={loading}>
<div className="p-2">
<Card className="!rounded-2xl shadow-sm border-0 mb-6">
<div className='p-2'>
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
{/* Header: Tag Info */}
<div className="flex items-center mb-2">
<Avatar size="small" color="blue" className="mr-2 shadow-md">
<div className='flex items-center mb-2'>
<Avatar size='small' color='blue' className='mr-2 shadow-md'>
<IconBookmark size={16} />
</Avatar>
<div>
<Text className="text-lg font-medium">{t('标签信息')}</Text>
<div className="text-xs text-gray-600">{t('标签的基本配置')}</div>
<Text className='text-lg font-medium'>{t('标签信息')}</Text>
<div className='text-xs text-gray-600'>
{t('标签的基本配置')}
</div>
</div>
</div>
<Banner
type="warning"
type='warning'
description={t('所有编辑均为覆盖操作,留空则不更改')}
className="!rounded-lg mb-4"
className='!rounded-lg mb-4'
/>
<div className="space-y-4">
<div className='space-y-4'>
<Form.Input
field='new_tag'
label={t('标签名称')}
@@ -372,23 +376,31 @@ const EditTagModal = (props) => {
</div>
</Card>
<Card className="!rounded-2xl shadow-sm border-0 mb-6">
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
{/* Header: Model Config */}
<div className="flex items-center mb-2">
<Avatar size="small" color="purple" className="mr-2 shadow-md">
<div className='flex items-center mb-2'>
<Avatar
size='small'
color='purple'
className='mr-2 shadow-md'
>
<IconCode size={16} />
</Avatar>
<div>
<Text className="text-lg font-medium">{t('模型配置')}</Text>
<div className="text-xs text-gray-600">{t('模型选择和映射设置')}</div>
<Text className='text-lg font-medium'>{t('模型配置')}</Text>
<div className='text-xs text-gray-600'>
{t('模型选择和映射设置')}
</div>
</div>
</div>
<div className="space-y-4">
<div className='space-y-4'>
<Banner
type="info"
description={t('当前模型列表为该标签下所有渠道模型列表最长的一个,并非所有渠道的并集,请注意可能导致某些渠道模型丢失。')}
className="!rounded-lg mb-4"
type='info'
description={t(
'当前模型列表为该标签下所有渠道模型列表最长的一个,并非所有渠道的并集,请注意可能导致某些渠道模型丢失。',
)}
className='!rounded-lg mb-4'
/>
<Form.Select
field='models'
@@ -408,46 +420,87 @@ const EditTagModal = (props) => {
label={t('自定义模型名称')}
placeholder={t('输入自定义模型名称')}
onChange={(value) => setCustomModel(value.trim())}
suffix={<Button size='small' type='primary' onClick={addCustomModels}>{t('填入')}</Button>}
suffix={
<Button
size='small'
type='primary'
onClick={addCustomModels}
>
{t('填入')}
</Button>
}
/>
<Form.TextArea
field='model_mapping'
label={t('模型重定向')}
placeholder={t('此项可选,用于修改请求体中的模型名称,为一个 JSON 字符串,键为请求中模型名称,值为要替换的模型名称,留空则不更改')}
autosize
onChange={(value) => handleInputChange('model_mapping', value)}
extraText={(
<Space>
<Text className="!text-semi-color-primary cursor-pointer" onClick={() => handleInputChange('model_mapping', JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2))}>{t('填入模板')}</Text>
<Text className="!text-semi-color-primary cursor-pointer" onClick={() => handleInputChange('model_mapping', JSON.stringify({}, null, 2))}>{t('清空重定向')}</Text>
<Text className="!text-semi-color-primary cursor-pointer" onClick={() => handleInputChange('model_mapping', '')}>{t('不更改')}</Text>
</Space>
placeholder={t(
'此项可选,用于修改请求体中的模型名称,为一个 JSON 字符串,键为请求中模型名称,值为要替换的模型名称,留空则不更改',
)}
autosize
onChange={(value) =>
handleInputChange('model_mapping', value)
}
extraText={
<Space>
<Text
className='!text-semi-color-primary cursor-pointer'
onClick={() =>
handleInputChange(
'model_mapping',
JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2),
)
}
>
{t('填入模板')}
</Text>
<Text
className='!text-semi-color-primary cursor-pointer'
onClick={() =>
handleInputChange(
'model_mapping',
JSON.stringify({}, null, 2),
)
}
>
{t('清空重定向')}
</Text>
<Text
className='!text-semi-color-primary cursor-pointer'
onClick={() => handleInputChange('model_mapping', '')}
>
{t('不更改')}
</Text>
</Space>
}
/>
</div>
</Card>
<Card className="!rounded-2xl shadow-sm border-0">
<Card className='!rounded-2xl shadow-sm border-0'>
{/* Header: Group Settings */}
<div className="flex items-center mb-2">
<Avatar size="small" color="green" className="mr-2 shadow-md">
<div className='flex items-center mb-2'>
<Avatar size='small' color='green' className='mr-2 shadow-md'>
<IconUser size={16} />
</Avatar>
<div>
<Text className="text-lg font-medium">{t('分组设置')}</Text>
<div className="text-xs text-gray-600">{t('用户分组配置')}</div>
<Text className='text-lg font-medium'>{t('分组设置')}</Text>
<div className='text-xs text-gray-600'>
{t('用户分组配置')}
</div>
</div>
</div>
<div className="space-y-4">
<div className='space-y-4'>
<Form.Select
field='groups'
label={t('分组')}
placeholder={t('请选择可以使用该渠道的分组,留空则不更改')}
multiple
allowAdditions
additionLabel={t('请在系统设置页面编辑分组倍率以添加新的分组:')}
additionLabel={t(
'请在系统设置页面编辑分组倍率以添加新的分组:',
)}
optionList={groupOptions}
style={{ width: '100%' }}
onChange={(value) => handleInputChange('groups', value)}
@@ -462,4 +515,4 @@ const EditTagModal = (props) => {
);
};
export default EditTagModal;
export default EditTagModal;

View File

@@ -19,16 +19,31 @@ For commercial licensing, please contact support@quantumnous.com
import React, { useState, useEffect } from 'react';
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
import { Modal, Checkbox, Spin, Input, Typography, Empty, Tabs, Collapse } from '@douyinfe/semi-ui';
import {
Modal,
Checkbox,
Spin,
Input,
Typography,
Empty,
Tabs,
Collapse,
} from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
IllustrationNoResultDark
IllustrationNoResultDark,
} from '@douyinfe/semi-illustrations';
import { IconSearch } from '@douyinfe/semi-icons';
import { useTranslation } from 'react-i18next';
import { getModelCategories } from '../../../../helpers/render';
const ModelSelectModal = ({ visible, models = [], selected = [], onConfirm, onCancel }) => {
const ModelSelectModal = ({
visible,
models = [],
selected = [],
onConfirm,
onCancel,
}) => {
const { t } = useTranslation();
const [checkedList, setCheckedList] = useState(selected);
const [keyword, setKeyword] = useState('');
@@ -36,11 +51,15 @@ const ModelSelectModal = ({ visible, models = [], selected = [], onConfirm, onCa
const isMobile = useIsMobile();
const filteredModels = models.filter((m) => m.toLowerCase().includes(keyword.toLowerCase()));
const filteredModels = models.filter((m) =>
m.toLowerCase().includes(keyword.toLowerCase()),
);
// 分类模型:新获取的模型和已有模型
const newModels = filteredModels.filter(model => !selected.includes(model));
const existingModels = filteredModels.filter(model => selected.includes(model));
const newModels = filteredModels.filter((model) => !selected.includes(model));
const existingModels = filteredModels.filter((model) =>
selected.includes(model),
);
// 同步外部选中值
useEffect(() => {
@@ -68,7 +87,7 @@ const ModelSelectModal = ({ visible, models = [], selected = [], onConfirm, onCa
const categorizedModels = {};
const uncategorizedModels = [];
models.forEach(model => {
models.forEach((model) => {
let foundCategory = false;
for (const [key, category] of Object.entries(categories)) {
if (key !== 'all' && category.filter({ model_name: model })) {
@@ -76,7 +95,7 @@ const ModelSelectModal = ({ visible, models = [], selected = [], onConfirm, onCa
categorizedModels[key] = {
label: category.label,
icon: category.icon,
models: []
models: [],
};
}
categorizedModels[key].models.push(model);
@@ -94,7 +113,7 @@ const ModelSelectModal = ({ visible, models = [], selected = [], onConfirm, onCa
categorizedModels['other'] = {
label: t('其他'),
icon: null,
models: uncategorizedModels
models: uncategorizedModels,
};
}
@@ -106,14 +125,22 @@ const ModelSelectModal = ({ visible, models = [], selected = [], onConfirm, onCa
// Tab列表配置
const tabList = [
...(newModels.length > 0 ? [{
tab: `${t('新获取的模型')} (${newModels.length})`,
itemKey: 'new'
}] : []),
...(existingModels.length > 0 ? [{
tab: `${t('已有的模型')} (${existingModels.length})`,
itemKey: 'existing'
}] : [])
...(newModels.length > 0
? [
{
tab: `${t('新获取的模型')} (${newModels.length})`,
itemKey: 'new',
},
]
: []),
...(existingModels.length > 0
? [
{
tab: `${t('已有的模型')} (${existingModels.length})`,
itemKey: 'existing',
},
]
: []),
];
// 处理分类全选/取消全选
@@ -122,14 +149,16 @@ const ModelSelectModal = ({ visible, models = [], selected = [], onConfirm, onCa
if (isChecked) {
// 全选:添加该分类下所有未选中的模型
categoryModels.forEach(model => {
categoryModels.forEach((model) => {
if (!newCheckedList.includes(model)) {
newCheckedList.push(model);
}
});
} else {
// 取消全选:移除该分类下所有已选中的模型
newCheckedList = newCheckedList.filter(model => !categoryModels.includes(model));
newCheckedList = newCheckedList.filter(
(model) => !categoryModels.includes(model),
);
}
setCheckedList(newCheckedList);
@@ -137,12 +166,17 @@ const ModelSelectModal = ({ visible, models = [], selected = [], onConfirm, onCa
// 检查分类是否全选
const isCategoryAllSelected = (categoryModels) => {
return categoryModels.length > 0 && categoryModels.every(model => checkedList.includes(model));
return (
categoryModels.length > 0 &&
categoryModels.every((model) => checkedList.includes(model))
);
};
// 检查分类是否部分选中
const isCategoryIndeterminate = (categoryModels) => {
const selectedCount = categoryModels.filter(model => checkedList.includes(model)).length;
const selectedCount = categoryModels.filter((model) =>
checkedList.includes(model),
).length;
return selectedCount > 0 && selectedCount < categoryModels.length;
};
@@ -151,10 +185,15 @@ const ModelSelectModal = ({ visible, models = [], selected = [], onConfirm, onCa
if (categoryEntries.length === 0) return null;
// 生成所有面板的key确保都展开
const allActiveKeys = categoryEntries.map((_, index) => `${categoryKeyPrefix}_${index}`);
const allActiveKeys = categoryEntries.map(
(_, index) => `${categoryKeyPrefix}_${index}`,
);
return (
<Collapse key={`${categoryKeyPrefix}_${categoryEntries.length}`} defaultActiveKey={[]}>
<Collapse
key={`${categoryKeyPrefix}_${categoryEntries.length}`}
defaultActiveKey={[]}
>
{categoryEntries.map(([key, categoryData], index) => (
<Collapse.Panel
key={`${categoryKeyPrefix}_${index}`}
@@ -166,24 +205,29 @@ const ModelSelectModal = ({ visible, models = [], selected = [], onConfirm, onCa
indeterminate={isCategoryIndeterminate(categoryData.models)}
onChange={(e) => {
e.stopPropagation(); // 防止触发面板折叠
handleCategorySelectAll(categoryData.models, e.target.checked);
handleCategorySelectAll(
categoryData.models,
e.target.checked,
);
}}
onClick={(e) => e.stopPropagation()} // 防止点击checkbox时折叠面板
/>
}
>
<div className="flex items-center gap-2 mb-3">
<div className='flex items-center gap-2 mb-3'>
{categoryData.icon}
<Typography.Text type="secondary" size="small">
<Typography.Text type='secondary' size='small'>
{t('已选择 {{selected}} / {{total}}', {
selected: categoryData.models.filter(model => checkedList.includes(model)).length,
total: categoryData.models.length
selected: categoryData.models.filter((model) =>
checkedList.includes(model),
).length,
total: categoryData.models.length,
})}
</Typography.Text>
</div>
<div className="grid grid-cols-2 gap-x-4">
<div className='grid grid-cols-2 gap-x-4'>
{categoryData.models.map((model) => (
<Checkbox key={model} value={model} className="my-1">
<Checkbox key={model} value={model} className='my-1'>
{model}
</Checkbox>
))}
@@ -197,14 +241,14 @@ const ModelSelectModal = ({ visible, models = [], selected = [], onConfirm, onCa
return (
<Modal
header={
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-4 py-4">
<Typography.Title heading={5} className="m-0">
<div className='flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-4 py-4'>
<Typography.Title heading={5} className='m-0'>
{t('选择模型')}
</Typography.Title>
<div className="flex-shrink-0">
<div className='flex-shrink-0'>
<Tabs
type="slash"
size="small"
type='slash'
size='small'
tabList={tabList}
activeKey={activeTab}
onChange={(key) => setActiveTab(key)}
@@ -234,17 +278,22 @@ const ModelSelectModal = ({ visible, models = [], selected = [], onConfirm, onCa
<div style={{ maxHeight: 400, overflowY: 'auto', paddingRight: 8 }}>
{filteredModels.length === 0 ? (
<Empty
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
image={
<IllustrationNoResult style={{ width: 150, height: 150 }} />
}
darkModeImage={
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
}
description={t('暂无匹配模型')}
style={{ padding: 30 }}
/>
) : (
<Checkbox.Group value={checkedList} onChange={(vals) => setCheckedList(vals)}>
<Checkbox.Group
value={checkedList}
onChange={(vals) => setCheckedList(vals)}
>
{activeTab === 'new' && newModels.length > 0 && (
<div>
{renderModelsByCategory(newModelsByCategory, 'new')}
</div>
<div>{renderModelsByCategory(newModelsByCategory, 'new')}</div>
)}
{activeTab === 'existing' && existingModels.length > 0 && (
<div>
@@ -256,20 +305,30 @@ const ModelSelectModal = ({ visible, models = [], selected = [], onConfirm, onCa
</div>
</Spin>
<Typography.Text type="secondary" size="small" className="block text-right mt-4">
<div className="flex items-center justify-end gap-2">
<Typography.Text
type='secondary'
size='small'
className='block text-right mt-4'
>
<div className='flex items-center justify-end gap-2'>
{(() => {
const currentModels = activeTab === 'new' ? newModels : existingModels;
const currentSelected = currentModels.filter(model => checkedList.includes(model)).length;
const isAllSelected = currentModels.length > 0 && currentSelected === currentModels.length;
const isIndeterminate = currentSelected > 0 && currentSelected < currentModels.length;
const currentModels =
activeTab === 'new' ? newModels : existingModels;
const currentSelected = currentModels.filter((model) =>
checkedList.includes(model),
).length;
const isAllSelected =
currentModels.length > 0 &&
currentSelected === currentModels.length;
const isIndeterminate =
currentSelected > 0 && currentSelected < currentModels.length;
return (
<>
<span>
{t('已选择 {{selected}} / {{total}}', {
selected: currentSelected,
total: currentModels.length
total: currentModels.length,
})}
</span>
<Checkbox
@@ -288,4 +347,4 @@ const ModelSelectModal = ({ visible, models = [], selected = [], onConfirm, onCa
);
};
export default ModelSelectModal;
export default ModelSelectModal;

View File

@@ -24,7 +24,7 @@ import {
Input,
Table,
Tag,
Typography
Typography,
} from '@douyinfe/semi-ui';
import { IconSearch } from '@douyinfe/semi-icons';
import { copy, showError, showInfo, showSuccess } from '../../../../helpers';
@@ -47,16 +47,16 @@ const ModelTestModal = ({
setModelTablePage,
allSelectingRef,
isMobile,
t
t,
}) => {
const hasChannel = Boolean(currentTestChannel);
const filteredModels = hasChannel
? currentTestChannel.models
.split(',')
.filter((model) =>
model.toLowerCase().includes(modelSearchKeyword.toLowerCase())
)
.split(',')
.filter((model) =>
model.toLowerCase().includes(modelSearchKeyword.toLowerCase()),
)
: [];
const handleCopySelected = () => {
@@ -66,7 +66,12 @@ const ModelTestModal = ({
}
copy(selectedModelKeys.join(',')).then((ok) => {
if (ok) {
showSuccess(t('已复制 ${count} 个模型').replace('${count}', selectedModelKeys.length));
showSuccess(
t('已复制 ${count} 个模型').replace(
'${count}',
selectedModelKeys.length,
),
);
} else {
showError(t('复制失败,请手动复制'));
}
@@ -93,16 +98,17 @@ const ModelTestModal = ({
title: t('模型名称'),
dataIndex: 'model',
render: (text) => (
<div className="flex items-center">
<div className='flex items-center'>
<Typography.Text strong>{text}</Typography.Text>
</div>
)
),
},
{
title: t('状态'),
dataIndex: 'status',
render: (text, record) => {
const testResult = modelTestResults[`${currentTestChannel.id}-${record.model}`];
const testResult =
modelTestResults[`${currentTestChannel.id}-${record.model}`];
const isTesting = testingModels.has(record.model);
if (isTesting) {
@@ -122,21 +128,21 @@ const ModelTestModal = ({
}
return (
<div className="flex items-center gap-2">
<Tag
color={testResult.success ? 'green' : 'red'}
shape='circle'
>
<div className='flex items-center gap-2'>
<Tag color={testResult.success ? 'green' : 'red'} shape='circle'>
{testResult.success ? t('成功') : t('失败')}
</Tag>
{testResult.success && (
<Typography.Text type="tertiary">
{t('请求时长: ${time}s').replace('${time}', testResult.time.toFixed(2))}
<Typography.Text type='tertiary'>
{t('请求时长: ${time}s').replace(
'${time}',
testResult.time.toFixed(2),
)}
</Typography.Text>
)}
</div>
);
}
},
},
{
title: '',
@@ -153,8 +159,8 @@ const ModelTestModal = ({
{t('测试')}
</Button>
);
}
}
},
},
];
const dataSource = (() => {
@@ -169,108 +175,109 @@ const ModelTestModal = ({
return (
<Modal
title={hasChannel ? (
<div className="flex flex-col gap-2 w-full">
<div className="flex items-center gap-2">
<Typography.Text strong className="!text-[var(--semi-color-text-0)] !text-base">
{currentTestChannel.name} {t('渠道的模型测试')}
</Typography.Text>
<Typography.Text type="tertiary" size="small">
{t('共')} {currentTestChannel.models.split(',').length} {t('个模型')}
</Typography.Text>
title={
hasChannel ? (
<div className='flex flex-col gap-2 w-full'>
<div className='flex items-center gap-2'>
<Typography.Text
strong
className='!text-[var(--semi-color-text-0)] !text-base'
>
{currentTestChannel.name} {t('渠道的模型测试')}
</Typography.Text>
<Typography.Text type='tertiary' size='small'>
{t('共')} {currentTestChannel.models.split(',').length}{' '}
{t('个模型')}
</Typography.Text>
</div>
</div>
</div>
) : null}
) : null
}
visible={showModelTestModal}
onCancel={handleCloseModal}
footer={hasChannel ? (
<div className="flex justify-end">
{isBatchTesting ? (
<Button
type='danger'
onClick={handleCloseModal}
>
{t('停止测试')}
</Button>
) : (
<Button
type='tertiary'
onClick={handleCloseModal}
>
{t('取消')}
</Button>
)}
<Button
onClick={batchTestModels}
loading={isBatchTesting}
disabled={isBatchTesting}
>
{isBatchTesting ? t('测试中...') : t('批量测试${count}个模型').replace(
'${count}',
filteredModels.length
footer={
hasChannel ? (
<div className='flex justify-end'>
{isBatchTesting ? (
<Button type='danger' onClick={handleCloseModal}>
{t('停止测试')}
</Button>
) : (
<Button type='tertiary' onClick={handleCloseModal}>
{t('取消')}
</Button>
)}
</Button>
</div>
) : null}
<Button
onClick={batchTestModels}
loading={isBatchTesting}
disabled={isBatchTesting}
>
{isBatchTesting
? t('测试中...')
: t('批量测试${count}个模型').replace(
'${count}',
filteredModels.length,
)}
</Button>
</div>
) : null
}
maskClosable={!isBatchTesting}
className="!rounded-lg"
className='!rounded-lg'
size={isMobile ? 'full-width' : 'large'}
>
{hasChannel && (<div className="model-test-scroll">
{/* 搜索与操作按钮 */}
<div className="flex items-center justify-end gap-2 w-full mb-2">
<Input
placeholder={t('搜索模型...')}
value={modelSearchKeyword}
onChange={(v) => {
setModelSearchKeyword(v);
setModelTablePage(1);
{hasChannel && (
<div className='model-test-scroll'>
{/* 搜索与操作按钮 */}
<div className='flex items-center justify-end gap-2 w-full mb-2'>
<Input
placeholder={t('搜索模型...')}
value={modelSearchKeyword}
onChange={(v) => {
setModelSearchKeyword(v);
setModelTablePage(1);
}}
className='!w-full'
prefix={<IconSearch />}
showClear
/>
<Button onClick={handleCopySelected}>{t('复制已选')}</Button>
<Button type='tertiary' onClick={handleSelectSuccess}>
{t('选择成功')}
</Button>
</div>
<Table
columns={columns}
dataSource={dataSource}
rowSelection={{
selectedRowKeys: selectedModelKeys,
onChange: (keys) => {
if (allSelectingRef.current) {
allSelectingRef.current = false;
return;
}
setSelectedModelKeys(keys);
},
onSelectAll: (checked) => {
allSelectingRef.current = true;
setSelectedModelKeys(checked ? filteredModels : []);
},
}}
pagination={{
currentPage: modelTablePage,
pageSize: MODEL_TABLE_PAGE_SIZE,
total: filteredModels.length,
showSizeChanger: false,
onPageChange: (page) => setModelTablePage(page),
}}
className="!w-full"
prefix={<IconSearch />}
showClear
/>
<Button onClick={handleCopySelected}>
{t('复制已选')}
</Button>
<Button
type='tertiary'
onClick={handleSelectSuccess}
>
{t('选择成功')}
</Button>
</div>
<Table
columns={columns}
dataSource={dataSource}
rowSelection={{
selectedRowKeys: selectedModelKeys,
onChange: (keys) => {
if (allSelectingRef.current) {
allSelectingRef.current = false;
return;
}
setSelectedModelKeys(keys);
},
onSelectAll: (checked) => {
allSelectingRef.current = true;
setSelectedModelKeys(checked ? filteredModels : []);
},
}}
pagination={{
currentPage: modelTablePage,
pageSize: MODEL_TABLE_PAGE_SIZE,
total: filteredModels.length,
showSizeChanger: false,
onPageChange: (page) => setModelTablePage(page),
}}
/>
</div>)}
)}
</Modal>
);
};
export default ModelTestModal;
export default ModelTestModal;

View File

@@ -35,19 +35,22 @@ import {
Col,
Badge,
Progress,
Card
Card,
} from '@douyinfe/semi-ui';
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
import { API, showError, showSuccess, timestamp2string } from '../../../../helpers';
import {
IllustrationNoResult,
IllustrationNoResultDark,
} from '@douyinfe/semi-illustrations';
import {
API,
showError,
showSuccess,
timestamp2string,
} from '../../../../helpers';
const { Text } = Typography;
const MultiKeyManageModal = ({
visible,
onCancel,
channel,
onRefresh
}) => {
const MultiKeyManageModal = ({ visible, onCancel, channel, onRefresh }) => {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [keyStatusList, setKeyStatusList] = useState([]);
@@ -68,7 +71,11 @@ const MultiKeyManageModal = ({
const [statusFilter, setStatusFilter] = useState(null); // null=all, 1=enabled, 2=manual_disabled, 3=auto_disabled
// Load key status data
const loadKeyStatus = async (page = currentPage, size = pageSize, status = statusFilter) => {
const loadKeyStatus = async (
page = currentPage,
size = pageSize,
status = statusFilter,
) => {
if (!channel?.id) return;
setLoading(true);
@@ -77,7 +84,7 @@ const MultiKeyManageModal = ({
channel_id: channel.id,
action: 'get_key_status',
page: page,
page_size: size
page_size: size,
};
// Add status filter if specified
@@ -113,13 +120,13 @@ const MultiKeyManageModal = ({
// Disable a specific key
const handleDisableKey = async (keyIndex) => {
const operationId = `disable_${keyIndex}`;
setOperationLoading(prev => ({ ...prev, [operationId]: true }));
setOperationLoading((prev) => ({ ...prev, [operationId]: true }));
try {
const res = await API.post('/api/channel/multi_key/manage', {
channel_id: channel.id,
action: 'disable_key',
key_index: keyIndex
key_index: keyIndex,
});
if (res.data.success) {
@@ -132,20 +139,20 @@ const MultiKeyManageModal = ({
} catch (error) {
showError(t('禁用密钥失败'));
} finally {
setOperationLoading(prev => ({ ...prev, [operationId]: false }));
setOperationLoading((prev) => ({ ...prev, [operationId]: false }));
}
};
// Enable a specific key
const handleEnableKey = async (keyIndex) => {
const operationId = `enable_${keyIndex}`;
setOperationLoading(prev => ({ ...prev, [operationId]: true }));
setOperationLoading((prev) => ({ ...prev, [operationId]: true }));
try {
const res = await API.post('/api/channel/multi_key/manage', {
channel_id: channel.id,
action: 'enable_key',
key_index: keyIndex
key_index: keyIndex,
});
if (res.data.success) {
@@ -158,18 +165,18 @@ const MultiKeyManageModal = ({
} catch (error) {
showError(t('启用密钥失败'));
} finally {
setOperationLoading(prev => ({ ...prev, [operationId]: false }));
setOperationLoading((prev) => ({ ...prev, [operationId]: false }));
}
};
// Enable all disabled keys
const handleEnableAll = async () => {
setOperationLoading(prev => ({ ...prev, enable_all: true }));
setOperationLoading((prev) => ({ ...prev, enable_all: true }));
try {
const res = await API.post('/api/channel/multi_key/manage', {
channel_id: channel.id,
action: 'enable_all_keys'
action: 'enable_all_keys',
});
if (res.data.success) {
@@ -184,18 +191,18 @@ const MultiKeyManageModal = ({
} catch (error) {
showError(t('启用所有密钥失败'));
} finally {
setOperationLoading(prev => ({ ...prev, enable_all: false }));
setOperationLoading((prev) => ({ ...prev, enable_all: false }));
}
};
// Disable all enabled keys
const handleDisableAll = async () => {
setOperationLoading(prev => ({ ...prev, disable_all: true }));
setOperationLoading((prev) => ({ ...prev, disable_all: true }));
try {
const res = await API.post('/api/channel/multi_key/manage', {
channel_id: channel.id,
action: 'disable_all_keys'
action: 'disable_all_keys',
});
if (res.data.success) {
@@ -210,18 +217,18 @@ const MultiKeyManageModal = ({
} catch (error) {
showError(t('禁用所有密钥失败'));
} finally {
setOperationLoading(prev => ({ ...prev, disable_all: false }));
setOperationLoading((prev) => ({ ...prev, disable_all: false }));
}
};
// Delete all disabled keys
const handleDeleteDisabledKeys = async () => {
setOperationLoading(prev => ({ ...prev, delete_disabled: true }));
setOperationLoading((prev) => ({ ...prev, delete_disabled: true }));
try {
const res = await API.post('/api/channel/multi_key/manage', {
channel_id: channel.id,
action: 'delete_disabled_keys'
action: 'delete_disabled_keys',
});
if (res.data.success) {
@@ -236,7 +243,7 @@ const MultiKeyManageModal = ({
} catch (error) {
showError(t('删除禁用密钥失败'));
} finally {
setOperationLoading(prev => ({ ...prev, delete_disabled: false }));
setOperationLoading((prev) => ({ ...prev, delete_disabled: false }));
}
};
@@ -246,7 +253,7 @@ const MultiKeyManageModal = ({
loadKeyStatus(page, pageSize);
};
// Handle page size change
// Handle page size change
const handlePageSizeChange = (size) => {
setPageSize(size);
setCurrentPage(1); // Reset to first page
@@ -283,9 +290,12 @@ const MultiKeyManageModal = ({
}, [visible]);
// Percentages for progress display
const enabledPercent = total > 0 ? Math.round((enabledCount / total) * 100) : 0;
const manualDisabledPercent = total > 0 ? Math.round((manualDisabledCount / total) * 100) : 0;
const autoDisabledPercent = total > 0 ? Math.round((autoDisabledCount / total) * 100) : 0;
const enabledPercent =
total > 0 ? Math.round((enabledCount / total) * 100) : 0;
const manualDisabledPercent =
total > 0 ? Math.round((manualDisabledCount / total) * 100) : 0;
const autoDisabledPercent =
total > 0 ? Math.round((autoDisabledCount / total) * 100) : 0;
// 取消饼图:不再需要图表数据与配置
@@ -293,13 +303,29 @@ const MultiKeyManageModal = ({
const renderStatusTag = (status) => {
switch (status) {
case 1:
return <Tag color='green' shape='circle' size='small'>{t('已启用')}</Tag>;
return (
<Tag color='green' shape='circle' size='small'>
{t('已启用')}
</Tag>
);
case 2:
return <Tag color='red' shape='circle' size='small'>{t('已禁用')}</Tag>;
return (
<Tag color='red' shape='circle' size='small'>
{t('已禁用')}
</Tag>
);
case 3:
return <Tag color='orange' shape='circle' size='small'>{t('自动禁用')}</Tag>;
return (
<Tag color='orange' shape='circle' size='small'>
{t('自动禁用')}
</Tag>
);
default:
return <Tag color='grey' shape='circle' size='small'>{t('未知状态')}</Tag>;
return (
<Tag color='grey' shape='circle' size='small'>
{t('未知状态')}
</Tag>
);
}
};
@@ -349,9 +375,7 @@ const MultiKeyManageModal = ({
}
return (
<Tooltip content={timestamp2string(time)}>
<Text style={{ fontSize: '12px' }}>
{timestamp2string(time)}
</Text>
<Text style={{ fontSize: '12px' }}>{timestamp2string(time)}</Text>
</Tooltip>
);
},
@@ -393,14 +417,18 @@ const MultiKeyManageModal = ({
<Space>
<Text>{t('多密钥管理')}</Text>
{channel?.name && (
<Tag size='small' shape='circle' color='white'>{channel.name}</Tag>
<Tag size='small' shape='circle' color='white'>
{channel.name}
</Tag>
)}
<Tag size='small' shape='circle' color='white'>
{t('总密钥数')}: {total}
</Tag>
{channel?.channel_info?.multi_key_mode && (
<Tag size='small' shape='circle' color='white'>
{channel.channel_info.multi_key_mode === 'random' ? t('随机模式') : t('轮询模式')}
{channel.channel_info.multi_key_mode === 'random'
? t('随机模式')
: t('轮询模式')}
</Tag>
)}
</Space>
@@ -410,60 +438,123 @@ const MultiKeyManageModal = ({
width={900}
footer={null}
>
<div className="flex flex-col mb-5">
<div className='flex flex-col mb-5'>
{/* Stats & Mode */}
<div
className="rounded-xl p-4 mb-3"
className='rounded-xl p-4 mb-3'
style={{
background: 'var(--semi-color-bg-1)',
border: '1px solid var(--semi-color-border)'
border: '1px solid var(--semi-color-border)',
}}
>
<Row gutter={16} align="middle">
<Row gutter={16} align='middle'>
<Col span={8}>
<div style={{ background: 'var(--semi-color-bg-0)', border: '1px solid var(--semi-color-border)', borderRadius: 12, padding: 12 }}>
<div className="flex items-center gap-2 mb-2">
<div
style={{
background: 'var(--semi-color-bg-0)',
border: '1px solid var(--semi-color-border)',
borderRadius: 12,
padding: 12,
}}
>
<div className='flex items-center gap-2 mb-2'>
<Badge dot type='success' />
<Text type='tertiary'>{t('已启用')}</Text>
</div>
<div className="flex items-end gap-2 mb-2">
<Text style={{ fontSize: 18, fontWeight: 700, color: '#22c55e' }}>{enabledCount}</Text>
<Text style={{ fontSize: 18, color: 'var(--semi-color-text-2)' }}>/ {total}</Text>
<div className='flex items-end gap-2 mb-2'>
<Text
style={{ fontSize: 18, fontWeight: 700, color: '#22c55e' }}
>
{enabledCount}
</Text>
<Text
style={{ fontSize: 18, color: 'var(--semi-color-text-2)' }}
>
/ {total}
</Text>
</div>
<Progress percent={enabledPercent} showInfo={false} size="small" stroke="#22c55e" style={{ height: 6, borderRadius: 999 }} />
<Progress
percent={enabledPercent}
showInfo={false}
size='small'
stroke='#22c55e'
style={{ height: 6, borderRadius: 999 }}
/>
</div>
</Col>
<Col span={8}>
<div style={{ background: 'var(--semi-color-bg-0)', border: '1px solid var(--semi-color-border)', borderRadius: 12, padding: 12 }}>
<div className="flex items-center gap-2 mb-2">
<div
style={{
background: 'var(--semi-color-bg-0)',
border: '1px solid var(--semi-color-border)',
borderRadius: 12,
padding: 12,
}}
>
<div className='flex items-center gap-2 mb-2'>
<Badge dot type='danger' />
<Text type='tertiary'>{t('手动禁用')}</Text>
</div>
<div className="flex items-end gap-2 mb-2">
<Text style={{ fontSize: 18, fontWeight: 700, color: '#ef4444' }}>{manualDisabledCount}</Text>
<Text style={{ fontSize: 18, color: 'var(--semi-color-text-2)' }}>/ {total}</Text>
<div className='flex items-end gap-2 mb-2'>
<Text
style={{ fontSize: 18, fontWeight: 700, color: '#ef4444' }}
>
{manualDisabledCount}
</Text>
<Text
style={{ fontSize: 18, color: 'var(--semi-color-text-2)' }}
>
/ {total}
</Text>
</div>
<Progress percent={manualDisabledPercent} showInfo={false} size="small" stroke="#ef4444" style={{ height: 6, borderRadius: 999 }} />
<Progress
percent={manualDisabledPercent}
showInfo={false}
size='small'
stroke='#ef4444'
style={{ height: 6, borderRadius: 999 }}
/>
</div>
</Col>
<Col span={8}>
<div style={{ background: 'var(--semi-color-bg-0)', border: '1px solid var(--semi-color-border)', borderRadius: 12, padding: 12 }}>
<div className="flex items-center gap-2 mb-2">
<div
style={{
background: 'var(--semi-color-bg-0)',
border: '1px solid var(--semi-color-border)',
borderRadius: 12,
padding: 12,
}}
>
<div className='flex items-center gap-2 mb-2'>
<Badge dot type='warning' />
<Text type='tertiary'>{t('自动禁用')}</Text>
</div>
<div className="flex items-end gap-2 mb-2">
<Text style={{ fontSize: 18, fontWeight: 700, color: '#f59e0b' }}>{autoDisabledCount}</Text>
<Text style={{ fontSize: 18, color: 'var(--semi-color-text-2)' }}>/ {total}</Text>
<div className='flex items-end gap-2 mb-2'>
<Text
style={{ fontSize: 18, fontWeight: 700, color: '#f59e0b' }}
>
{autoDisabledCount}
</Text>
<Text
style={{ fontSize: 18, color: 'var(--semi-color-text-2)' }}
>
/ {total}
</Text>
</div>
<Progress percent={autoDisabledPercent} showInfo={false} size="small" stroke="#f59e0b" style={{ height: 6, borderRadius: 999 }} />
<Progress
percent={autoDisabledPercent}
showInfo={false}
size='small'
stroke='#f59e0b'
style={{ height: 6, borderRadius: 999 }}
/>
</div>
</Col>
</Row>
</div>
{/* Table */}
<div className="flex-1 flex flex-col min-h-0">
<div className='flex-1 flex flex-col min-h-0'>
<Spin spinning={loading}>
<Card className='!rounded-xl'>
<Table
@@ -478,15 +569,26 @@ const MultiKeyManageModal = ({
size='small'
placeholder={t('全部状态')}
>
<Select.Option value={null}>{t('全部状态')}</Select.Option>
<Select.Option value={1}>{t('已启用')}</Select.Option>
<Select.Option value={2}>{t('手动禁用')}</Select.Option>
<Select.Option value={3}>{t('自动禁用')}</Select.Option>
<Select.Option value={null}>
{t('全部状态')}
</Select.Option>
<Select.Option value={1}>
{t('已启用')}
</Select.Option>
<Select.Option value={2}>
{t('手动禁用')}
</Select.Option>
<Select.Option value={3}>
{t('自动禁用')}
</Select.Option>
</Select>
</Col>
</Row>
</Col>
<Col span={10} style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Col
span={10}
style={{ display: 'flex', justifyContent: 'flex-end' }}
>
<Space>
<Button
size='small'
@@ -496,7 +598,7 @@ const MultiKeyManageModal = ({
>
{t('刷新')}
</Button>
{(manualDisabledCount + autoDisabledCount) > 0 && (
{manualDisabledCount + autoDisabledCount > 0 && (
<Popconfirm
title={t('确定要启用所有密钥吗?')}
onConfirm={handleEnableAll}
@@ -529,7 +631,9 @@ const MultiKeyManageModal = ({
)}
<Popconfirm
title={t('确定要删除所有已自动禁用的密钥吗?')}
content={t('此操作不可撤销,将永久删除已自动禁用的密钥')}
content={t(
'此操作不可撤销,将永久删除已自动禁用的密钥',
)}
onConfirm={handleDeleteDisabledKeys}
okType={'danger'}
position={'topRight'}
@@ -562,7 +666,7 @@ const MultiKeyManageModal = ({
onShowSizeChange: (current, size) => {
setCurrentPage(1);
handlePageSizeChange(size);
}
},
}}
size='small'
bordered={false}
@@ -570,8 +674,16 @@ const MultiKeyManageModal = ({
scroll={{ x: 'max-content' }}
empty={
<Empty
image={<IllustrationNoResult style={{ width: 140, height: 140 }} />}
darkModeImage={<IllustrationNoResultDark style={{ width: 140, height: 140 }} />}
image={
<IllustrationNoResult
style={{ width: 140, height: 140 }}
/>
}
darkModeImage={
<IllustrationNoResultDark
style={{ width: 140, height: 140 }}
/>
}
title={t('暂无密钥数据')}
description={t('请检查渠道配置或刷新重试')}
style={{ padding: 30 }}
@@ -586,4 +698,4 @@ const MultiKeyManageModal = ({
);
};
export default MultiKeyManageModal;
export default MultiKeyManageModal;

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