Merge branch 'alpha' into imageratio-and-audioratio-edit
This commit is contained in:
@@ -135,7 +135,7 @@ const TwoFactorAuthModal = ({
|
||||
autoFocus
|
||||
/>
|
||||
<Typography.Text type='tertiary' size='small' className='mt-2 block'>
|
||||
{t('支持6位TOTP验证码或8位备用码')}
|
||||
{t('支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -443,7 +443,7 @@ const JSONEditor = ({
|
||||
|
||||
return (
|
||||
<Row key={pair.id} gutter={8} align='middle'>
|
||||
<Col span={6}>
|
||||
<Col span={10}>
|
||||
<div className='relative'>
|
||||
<Input
|
||||
placeholder={t('键名')}
|
||||
@@ -470,7 +470,7 @@ const JSONEditor = ({
|
||||
)}
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={16}>{renderValueInput(pair.id, pair.value)}</Col>
|
||||
<Col span={12}>{renderValueInput(pair.id, pair.value)}</Col>
|
||||
<Col span={2}>
|
||||
<Button
|
||||
icon={<IconDelete />}
|
||||
|
||||
@@ -100,7 +100,7 @@ const ApiInfoPanel = ({
|
||||
</React.Fragment>
|
||||
))
|
||||
) : (
|
||||
<div className='flex justify-center items-center py-8'>
|
||||
<div className='flex justify-center items-center min-h-[20rem] w-full'>
|
||||
<Empty
|
||||
image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
|
||||
darkModeImage={
|
||||
|
||||
@@ -20,11 +20,6 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
import React from 'react';
|
||||
import { Card, Tabs, TabPane } from '@douyinfe/semi-ui';
|
||||
import { PieChart } from 'lucide-react';
|
||||
import {
|
||||
IconHistogram,
|
||||
IconPulse,
|
||||
IconPieChart2Stroked,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { VChart } from '@visactor/react-vchart';
|
||||
|
||||
const ChartsPanel = ({
|
||||
@@ -51,46 +46,14 @@ const ChartsPanel = ({
|
||||
{t('模型数据分析')}
|
||||
</div>
|
||||
<Tabs
|
||||
type='button'
|
||||
type='slash'
|
||||
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>{t('消耗分布')}</span>} itemKey='1' />
|
||||
<TabPane tab={<span>{t('消耗趋势')}</span>} itemKey='2' />
|
||||
<TabPane tab={<span>{t('调用次数分布')}</span>} itemKey='3' />
|
||||
<TabPane tab={<span>{t('调用次数排行')}</span>} itemKey='4' />
|
||||
</Tabs>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
/*
|
||||
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
|
||||
*/
|
||||
|
||||
export { default } from './HeaderBar/index';
|
||||
@@ -1,148 +0,0 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Skeleton } from '@douyinfe/semi-ui';
|
||||
|
||||
const SkeletonWrapper = ({
|
||||
loading = false,
|
||||
type = 'text',
|
||||
count = 1,
|
||||
width = 60,
|
||||
height = 16,
|
||||
isMobile = false,
|
||||
className = '',
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
if (!loading) {
|
||||
return children;
|
||||
}
|
||||
|
||||
// 导航链接骨架屏
|
||||
const renderNavigationSkeleton = () => {
|
||||
const skeletonLinkClasses = isMobile
|
||||
? 'flex items-center gap-1 p-1 w-full rounded-md'
|
||||
: 'flex items-center gap-1 p-2 rounded-md';
|
||||
|
||||
return Array(count)
|
||||
.fill(null)
|
||||
.map((_, index) => (
|
||||
<div key={index} className={skeletonLinkClasses}>
|
||||
<Skeleton
|
||||
loading={true}
|
||||
active
|
||||
placeholder={
|
||||
<Skeleton.Title
|
||||
active
|
||||
style={{ width: isMobile ? 40 : width, height }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
// 用户区域骨架屏 (头像 + 文本)
|
||||
const renderUserAreaSkeleton = () => {
|
||||
return (
|
||||
<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' />
|
||||
}
|
||||
/>
|
||||
<div className='ml-1.5 mr-1'>
|
||||
<Skeleton
|
||||
loading={true}
|
||||
active
|
||||
placeholder={
|
||||
<Skeleton.Title
|
||||
active
|
||||
style={{ width: isMobile ? 15 : width, height: 12 }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Logo图片骨架屏
|
||||
const renderImageSkeleton = () => {
|
||||
return (
|
||||
<Skeleton
|
||||
loading={true}
|
||||
active
|
||||
placeholder={
|
||||
<Skeleton.Image
|
||||
active
|
||||
className={`absolute inset-0 !rounded-full ${className}`}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 系统名称骨架屏
|
||||
const renderTitleSkeleton = () => {
|
||||
return (
|
||||
<Skeleton
|
||||
loading={true}
|
||||
active
|
||||
placeholder={<Skeleton.Title active style={{ width, height: 24 }} />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 通用文本骨架屏
|
||||
const renderTextSkeleton = () => {
|
||||
return (
|
||||
<div className={className}>
|
||||
<Skeleton
|
||||
loading={true}
|
||||
active
|
||||
placeholder={<Skeleton.Title active style={{ width, height }} />}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 根据类型渲染不同的骨架屏
|
||||
switch (type) {
|
||||
case 'navigation':
|
||||
return renderNavigationSkeleton();
|
||||
case 'userArea':
|
||||
return renderUserAreaSkeleton();
|
||||
case 'image':
|
||||
return renderImageSkeleton();
|
||||
case 'title':
|
||||
return renderTitleSkeleton();
|
||||
case 'text':
|
||||
default:
|
||||
return renderTextSkeleton();
|
||||
}
|
||||
};
|
||||
|
||||
export default SkeletonWrapper;
|
||||
@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import HeaderBar from './HeaderBar';
|
||||
import HeaderBar from './headerbar';
|
||||
import { Layout } from '@douyinfe/semi-ui';
|
||||
import SiderBar from './SiderBar';
|
||||
import App from '../../App';
|
||||
|
||||
@@ -23,7 +23,10 @@ import { useTranslation } from 'react-i18next';
|
||||
import { getLucideIcon } from '../../helpers/render';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { useSidebarCollapsed } from '../../hooks/common/useSidebarCollapsed';
|
||||
import { useSidebar } from '../../hooks/common/useSidebar';
|
||||
import { useMinimumLoadingTime } from '../../hooks/common/useMinimumLoadingTime';
|
||||
import { isAdmin, isRoot, showError } from '../../helpers';
|
||||
import SkeletonWrapper from './components/SkeletonWrapper';
|
||||
|
||||
import { Nav, Divider, Button } from '@douyinfe/semi-ui';
|
||||
|
||||
@@ -49,6 +52,13 @@ const routerMap = {
|
||||
const SiderBar = ({ onNavigate = () => {} }) => {
|
||||
const { t } = useTranslation();
|
||||
const [collapsed, toggleCollapsed] = useSidebarCollapsed();
|
||||
const {
|
||||
isModuleVisible,
|
||||
hasSectionVisibleModules,
|
||||
loading: sidebarLoading,
|
||||
} = useSidebar();
|
||||
|
||||
const showSkeleton = useMinimumLoadingTime(sidebarLoading);
|
||||
|
||||
const [selectedKeys, setSelectedKeys] = useState(['home']);
|
||||
const [chatItems, setChatItems] = useState([]);
|
||||
@@ -56,8 +66,8 @@ const SiderBar = ({ onNavigate = () => {} }) => {
|
||||
const location = useLocation();
|
||||
const [routerMapState, setRouterMapState] = useState(routerMap);
|
||||
|
||||
const workspaceItems = useMemo(
|
||||
() => [
|
||||
const workspaceItems = useMemo(() => {
|
||||
const items = [
|
||||
{
|
||||
text: t('数据看板'),
|
||||
itemKey: 'detail',
|
||||
@@ -93,17 +103,25 @@ const SiderBar = ({ onNavigate = () => {} }) => {
|
||||
className:
|
||||
localStorage.getItem('enable_task') === 'true' ? '' : 'tableHiddle',
|
||||
},
|
||||
],
|
||||
[
|
||||
localStorage.getItem('enable_data_export'),
|
||||
localStorage.getItem('enable_drawing'),
|
||||
localStorage.getItem('enable_task'),
|
||||
t,
|
||||
],
|
||||
);
|
||||
];
|
||||
|
||||
const financeItems = useMemo(
|
||||
() => [
|
||||
// 根据配置过滤项目
|
||||
const filteredItems = items.filter((item) => {
|
||||
const configVisible = isModuleVisible('console', item.itemKey);
|
||||
return configVisible;
|
||||
});
|
||||
|
||||
return filteredItems;
|
||||
}, [
|
||||
localStorage.getItem('enable_data_export'),
|
||||
localStorage.getItem('enable_drawing'),
|
||||
localStorage.getItem('enable_task'),
|
||||
t,
|
||||
isModuleVisible,
|
||||
]);
|
||||
|
||||
const financeItems = useMemo(() => {
|
||||
const items = [
|
||||
{
|
||||
text: t('钱包管理'),
|
||||
itemKey: 'topup',
|
||||
@@ -114,12 +132,19 @@ const SiderBar = ({ onNavigate = () => {} }) => {
|
||||
itemKey: 'personal',
|
||||
to: '/personal',
|
||||
},
|
||||
],
|
||||
[t],
|
||||
);
|
||||
];
|
||||
|
||||
const adminItems = useMemo(
|
||||
() => [
|
||||
// 根据配置过滤项目
|
||||
const filteredItems = items.filter((item) => {
|
||||
const configVisible = isModuleVisible('personal', item.itemKey);
|
||||
return configVisible;
|
||||
});
|
||||
|
||||
return filteredItems;
|
||||
}, [t, isModuleVisible]);
|
||||
|
||||
const adminItems = useMemo(() => {
|
||||
const items = [
|
||||
{
|
||||
text: t('渠道管理'),
|
||||
itemKey: 'channel',
|
||||
@@ -150,12 +175,19 @@ const SiderBar = ({ onNavigate = () => {} }) => {
|
||||
to: '/setting',
|
||||
className: isRoot() ? '' : 'tableHiddle',
|
||||
},
|
||||
],
|
||||
[isAdmin(), isRoot(), t],
|
||||
);
|
||||
];
|
||||
|
||||
const chatMenuItems = useMemo(
|
||||
() => [
|
||||
// 根据配置过滤项目
|
||||
const filteredItems = items.filter((item) => {
|
||||
const configVisible = isModuleVisible('admin', item.itemKey);
|
||||
return configVisible;
|
||||
});
|
||||
|
||||
return filteredItems;
|
||||
}, [isAdmin(), isRoot(), t, isModuleVisible]);
|
||||
|
||||
const chatMenuItems = useMemo(() => {
|
||||
const items = [
|
||||
{
|
||||
text: t('操练场'),
|
||||
itemKey: 'playground',
|
||||
@@ -166,9 +198,16 @@ const SiderBar = ({ onNavigate = () => {} }) => {
|
||||
itemKey: 'chat',
|
||||
items: chatItems,
|
||||
},
|
||||
],
|
||||
[chatItems, t],
|
||||
);
|
||||
];
|
||||
|
||||
// 根据配置过滤项目
|
||||
const filteredItems = items.filter((item) => {
|
||||
const configVisible = isModuleVisible('chat', item.itemKey);
|
||||
return configVisible;
|
||||
});
|
||||
|
||||
return filteredItems;
|
||||
}, [chatItems, t, isModuleVisible]);
|
||||
|
||||
// 更新路由映射,添加聊天路由
|
||||
const updateRouterMapWithChats = (chats) => {
|
||||
@@ -213,7 +252,6 @@ const SiderBar = ({ onNavigate = () => {} }) => {
|
||||
updateRouterMapWithChats(chats);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showError('聊天数据解析失败');
|
||||
}
|
||||
}
|
||||
@@ -267,14 +305,12 @@ 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 }}
|
||||
>
|
||||
{item.text}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className='truncate font-medium text-sm'
|
||||
style={{ color: textColor }}
|
||||
>
|
||||
{item.text}
|
||||
</span>
|
||||
}
|
||||
icon={
|
||||
<div className='sidebar-icon-container flex-shrink-0'>
|
||||
@@ -297,14 +333,12 @@ 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 }}
|
||||
>
|
||||
{item.text}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className='truncate font-medium text-sm'
|
||||
style={{ color: textColor }}
|
||||
>
|
||||
{item.text}
|
||||
</span>
|
||||
}
|
||||
icon={
|
||||
<div className='sidebar-icon-container flex-shrink-0'>
|
||||
@@ -341,110 +375,142 @@ const SiderBar = ({ onNavigate = () => {} }) => {
|
||||
return (
|
||||
<div
|
||||
className='sidebar-container'
|
||||
style={{ width: 'var(--sidebar-current-width)' }}
|
||||
style={{
|
||||
width: 'var(--sidebar-current-width)',
|
||||
background: 'var(--semi-color-bg-0)',
|
||||
}}
|
||||
>
|
||||
<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'
|
||||
renderWrapper={({ itemElement, props }) => {
|
||||
const to = routerMapState[props.itemKey] || routerMap[props.itemKey];
|
||||
|
||||
// 如果没有路由,直接返回元素
|
||||
if (!to) return itemElement;
|
||||
|
||||
return (
|
||||
<Link
|
||||
style={{ textDecoration: 'none' }}
|
||||
to={to}
|
||||
onClick={onNavigate}
|
||||
>
|
||||
{itemElement}
|
||||
</Link>
|
||||
);
|
||||
}}
|
||||
onSelect={(key) => {
|
||||
// 如果点击的是已经展开的子菜单的父项,则收起子菜单
|
||||
if (openedKeys.includes(key.itemKey)) {
|
||||
setOpenedKeys(openedKeys.filter((k) => k !== key.itemKey));
|
||||
}
|
||||
|
||||
setSelectedKeys([key.itemKey]);
|
||||
}}
|
||||
openKeys={openedKeys}
|
||||
onOpenChange={(data) => {
|
||||
setOpenedKeys(data.openKeys);
|
||||
}}
|
||||
<SkeletonWrapper
|
||||
loading={showSkeleton}
|
||||
type='sidebar'
|
||||
className=''
|
||||
collapsed={collapsed}
|
||||
showAdmin={isAdmin()}
|
||||
>
|
||||
{/* 聊天区域 */}
|
||||
<div className='sidebar-section'>
|
||||
{!collapsed && <div className='sidebar-group-label'>{t('聊天')}</div>}
|
||||
{chatMenuItems.map((item) => renderSubItem(item))}
|
||||
</div>
|
||||
<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'
|
||||
renderWrapper={({ itemElement, props }) => {
|
||||
const to =
|
||||
routerMapState[props.itemKey] || routerMap[props.itemKey];
|
||||
|
||||
{/* 控制台区域 */}
|
||||
<Divider className='sidebar-divider' />
|
||||
<div>
|
||||
{!collapsed && (
|
||||
<div className='sidebar-group-label'>{t('控制台')}</div>
|
||||
)}
|
||||
{workspaceItems.map((item) => renderNavItem(item))}
|
||||
</div>
|
||||
// 如果没有路由,直接返回元素
|
||||
if (!to) return itemElement;
|
||||
|
||||
{/* 个人中心区域 */}
|
||||
<Divider className='sidebar-divider' />
|
||||
<div>
|
||||
{!collapsed && (
|
||||
<div className='sidebar-group-label'>{t('个人中心')}</div>
|
||||
)}
|
||||
{financeItems.map((item) => renderNavItem(item))}
|
||||
</div>
|
||||
return (
|
||||
<Link
|
||||
style={{ textDecoration: 'none' }}
|
||||
to={to}
|
||||
onClick={onNavigate}
|
||||
>
|
||||
{itemElement}
|
||||
</Link>
|
||||
);
|
||||
}}
|
||||
onSelect={(key) => {
|
||||
// 如果点击的是已经展开的子菜单的父项,则收起子菜单
|
||||
if (openedKeys.includes(key.itemKey)) {
|
||||
setOpenedKeys(openedKeys.filter((k) => k !== key.itemKey));
|
||||
}
|
||||
|
||||
{/* 管理员区域 - 只在管理员时显示 */}
|
||||
{isAdmin() && (
|
||||
<>
|
||||
<Divider className='sidebar-divider' />
|
||||
<div>
|
||||
setSelectedKeys([key.itemKey]);
|
||||
}}
|
||||
openKeys={openedKeys}
|
||||
onOpenChange={(data) => {
|
||||
setOpenedKeys(data.openKeys);
|
||||
}}
|
||||
>
|
||||
{/* 聊天区域 */}
|
||||
{hasSectionVisibleModules('chat') && (
|
||||
<div className='sidebar-section'>
|
||||
{!collapsed && (
|
||||
<div className='sidebar-group-label'>{t('管理员')}</div>
|
||||
<div className='sidebar-group-label'>{t('聊天')}</div>
|
||||
)}
|
||||
{adminItems.map((item) => renderNavItem(item))}
|
||||
{chatMenuItems.map((item) => renderSubItem(item))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Nav>
|
||||
)}
|
||||
|
||||
{/* 控制台区域 */}
|
||||
{hasSectionVisibleModules('console') && (
|
||||
<>
|
||||
<Divider className='sidebar-divider' />
|
||||
<div>
|
||||
{!collapsed && (
|
||||
<div className='sidebar-group-label'>{t('控制台')}</div>
|
||||
)}
|
||||
{workspaceItems.map((item) => renderNavItem(item))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 个人中心区域 */}
|
||||
{hasSectionVisibleModules('personal') && (
|
||||
<>
|
||||
<Divider className='sidebar-divider' />
|
||||
<div>
|
||||
{!collapsed && (
|
||||
<div className='sidebar-group-label'>{t('个人中心')}</div>
|
||||
)}
|
||||
{financeItems.map((item) => renderNavItem(item))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 管理员区域 - 只在管理员时显示且配置允许时显示 */}
|
||||
{isAdmin() && hasSectionVisibleModules('admin') && (
|
||||
<>
|
||||
<Divider className='sidebar-divider' />
|
||||
<div>
|
||||
{!collapsed && (
|
||||
<div className='sidebar-group-label'>{t('管理员')}</div>
|
||||
)}
|
||||
{adminItems.map((item) => renderNavItem(item))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Nav>
|
||||
</SkeletonWrapper>
|
||||
|
||||
{/* 底部折叠按钮 */}
|
||||
<div className='sidebar-collapse-button'>
|
||||
<Button
|
||||
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)',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
onClick={toggleCollapsed}
|
||||
icononly={collapsed}
|
||||
style={
|
||||
collapsed
|
||||
? { padding: '4px', width: '100%' }
|
||||
: { padding: '4px 12px', width: '100%' }
|
||||
}
|
||||
<SkeletonWrapper
|
||||
loading={showSkeleton}
|
||||
type='button'
|
||||
width={collapsed ? 36 : 156}
|
||||
height={24}
|
||||
className='w-full'
|
||||
>
|
||||
{!collapsed ? t('收起侧边栏') : null}
|
||||
</Button>
|
||||
<Button
|
||||
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)',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
onClick={toggleCollapsed}
|
||||
icononly={collapsed}
|
||||
style={
|
||||
collapsed
|
||||
? { width: 36, height: 24, padding: 0 }
|
||||
: { padding: '4px 12px', width: '100%' }
|
||||
}
|
||||
>
|
||||
{!collapsed ? t('收起侧边栏') : null}
|
||||
</Button>
|
||||
</SkeletonWrapper>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
394
web/src/components/layout/components/SkeletonWrapper.jsx
Normal file
394
web/src/components/layout/components/SkeletonWrapper.jsx
Normal file
@@ -0,0 +1,394 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Skeleton } from '@douyinfe/semi-ui';
|
||||
|
||||
const SkeletonWrapper = ({
|
||||
loading = false,
|
||||
type = 'text',
|
||||
count = 1,
|
||||
width = 60,
|
||||
height = 16,
|
||||
isMobile = false,
|
||||
className = '',
|
||||
collapsed = false,
|
||||
showAdmin = true,
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
if (!loading) {
|
||||
return children;
|
||||
}
|
||||
|
||||
// 导航链接骨架屏
|
||||
const renderNavigationSkeleton = () => {
|
||||
const skeletonLinkClasses = isMobile
|
||||
? 'flex items-center gap-1 p-1 w-full rounded-md'
|
||||
: 'flex items-center gap-1 p-2 rounded-md';
|
||||
|
||||
return Array(count)
|
||||
.fill(null)
|
||||
.map((_, index) => (
|
||||
<div key={index} className={skeletonLinkClasses}>
|
||||
<Skeleton
|
||||
loading={true}
|
||||
active
|
||||
placeholder={
|
||||
<Skeleton.Title
|
||||
active
|
||||
style={{ width: isMobile ? 40 : width, height }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
// 用户区域骨架屏 (头像 + 文本)
|
||||
const renderUserAreaSkeleton = () => {
|
||||
return (
|
||||
<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' />
|
||||
}
|
||||
/>
|
||||
<div className='ml-1.5 mr-1'>
|
||||
<Skeleton
|
||||
loading={true}
|
||||
active
|
||||
placeholder={
|
||||
<Skeleton.Title
|
||||
active
|
||||
style={{ width: isMobile ? 15 : width, height: 12 }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Logo图片骨架屏
|
||||
const renderImageSkeleton = () => {
|
||||
return (
|
||||
<Skeleton
|
||||
loading={true}
|
||||
active
|
||||
placeholder={
|
||||
<Skeleton.Image
|
||||
active
|
||||
className={`absolute inset-0 !rounded-full ${className}`}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 系统名称骨架屏
|
||||
const renderTitleSkeleton = () => {
|
||||
return (
|
||||
<Skeleton
|
||||
loading={true}
|
||||
active
|
||||
placeholder={<Skeleton.Title active style={{ width, height: 24 }} />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 通用文本骨架屏
|
||||
const renderTextSkeleton = () => {
|
||||
return (
|
||||
<div className={className}>
|
||||
<Skeleton
|
||||
loading={true}
|
||||
active
|
||||
placeholder={<Skeleton.Title active style={{ width, height }} />}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 按钮骨架屏(支持圆角)
|
||||
const renderButtonSkeleton = () => {
|
||||
return (
|
||||
<div className={className}>
|
||||
<Skeleton
|
||||
loading={true}
|
||||
active
|
||||
placeholder={
|
||||
<Skeleton.Title
|
||||
active
|
||||
style={{ width, height, borderRadius: 9999 }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 侧边栏导航项骨架屏 (图标 + 文本)
|
||||
const renderSidebarNavItemSkeleton = () => {
|
||||
return Array(count)
|
||||
.fill(null)
|
||||
.map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex items-center p-2 mb-1 rounded-md ${className}`}
|
||||
>
|
||||
{/* 图标骨架屏 */}
|
||||
<div className='sidebar-icon-container flex-shrink-0'>
|
||||
<Skeleton
|
||||
loading={true}
|
||||
active
|
||||
placeholder={
|
||||
<Skeleton.Avatar active size='extra-small' shape='square' />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{/* 文本骨架屏 */}
|
||||
<Skeleton
|
||||
loading={true}
|
||||
active
|
||||
placeholder={
|
||||
<Skeleton.Title
|
||||
active
|
||||
style={{ width: width || 80, height: height || 14 }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
// 侧边栏组标题骨架屏
|
||||
const renderSidebarGroupTitleSkeleton = () => {
|
||||
return (
|
||||
<div className={`mb-2 ${className}`}>
|
||||
<Skeleton
|
||||
loading={true}
|
||||
active
|
||||
placeholder={
|
||||
<Skeleton.Title
|
||||
active
|
||||
style={{ width: width || 60, height: height || 12 }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 完整侧边栏骨架屏 - 1:1 还原,去重实现
|
||||
const renderSidebarSkeleton = () => {
|
||||
const NAV_WIDTH = 164;
|
||||
const NAV_HEIGHT = 30;
|
||||
const COLLAPSED_WIDTH = 44;
|
||||
const COLLAPSED_HEIGHT = 44;
|
||||
const ICON_SIZE = 16;
|
||||
const TITLE_HEIGHT = 12;
|
||||
const TEXT_HEIGHT = 16;
|
||||
|
||||
const renderIcon = () => (
|
||||
<Skeleton
|
||||
loading={true}
|
||||
active
|
||||
placeholder={
|
||||
<Skeleton.Avatar
|
||||
active
|
||||
shape='square'
|
||||
style={{ width: ICON_SIZE, height: ICON_SIZE }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderLabel = (labelWidth) => (
|
||||
<Skeleton
|
||||
loading={true}
|
||||
active
|
||||
placeholder={
|
||||
<Skeleton.Title
|
||||
active
|
||||
style={{ width: labelWidth, height: TEXT_HEIGHT }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
const NavRow = ({ labelWidth }) => (
|
||||
<div
|
||||
className='flex items-center p-2 mb-1 rounded-md'
|
||||
style={{
|
||||
width: `${NAV_WIDTH}px`,
|
||||
height: `${NAV_HEIGHT}px`,
|
||||
margin: '3px 8px',
|
||||
}}
|
||||
>
|
||||
<div className='sidebar-icon-container flex-shrink-0'>
|
||||
{renderIcon()}
|
||||
</div>
|
||||
{renderLabel(labelWidth)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const CollapsedRow = ({ keyPrefix, index }) => (
|
||||
<div
|
||||
key={`${keyPrefix}-${index}`}
|
||||
className='flex items-center justify-center'
|
||||
style={{
|
||||
width: `${COLLAPSED_WIDTH}px`,
|
||||
height: `${COLLAPSED_HEIGHT}px`,
|
||||
margin: '0 8px 4px 8px',
|
||||
}}
|
||||
>
|
||||
<Skeleton
|
||||
loading={true}
|
||||
active
|
||||
placeholder={
|
||||
<Skeleton.Avatar
|
||||
active
|
||||
shape='square'
|
||||
style={{ width: ICON_SIZE, height: ICON_SIZE }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<div className={`w-full ${className}`} style={{ paddingTop: '12px' }}>
|
||||
{Array(2)
|
||||
.fill(null)
|
||||
.map((_, i) => (
|
||||
<CollapsedRow keyPrefix='c-chat' index={i} />
|
||||
))}
|
||||
{Array(5)
|
||||
.fill(null)
|
||||
.map((_, i) => (
|
||||
<CollapsedRow keyPrefix='c-console' index={i} />
|
||||
))}
|
||||
{Array(2)
|
||||
.fill(null)
|
||||
.map((_, i) => (
|
||||
<CollapsedRow keyPrefix='c-personal' index={i} />
|
||||
))}
|
||||
{Array(5)
|
||||
.fill(null)
|
||||
.map((_, i) => (
|
||||
<CollapsedRow keyPrefix='c-admin' index={i} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const sections = [
|
||||
{ key: 'chat', titleWidth: 32, itemWidths: [54, 32], wrapper: 'section' },
|
||||
{ key: 'console', titleWidth: 48, itemWidths: [64, 64, 64, 64, 64] },
|
||||
{ key: 'personal', titleWidth: 64, itemWidths: [64, 64] },
|
||||
...(showAdmin
|
||||
? [{ key: 'admin', titleWidth: 48, itemWidths: [64, 64, 80, 64, 64] }]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={`w-full ${className}`} style={{ paddingTop: '12px' }}>
|
||||
{sections.map((sec, idx) => (
|
||||
<React.Fragment key={sec.key}>
|
||||
{sec.wrapper === 'section' ? (
|
||||
<div className='sidebar-section'>
|
||||
<div
|
||||
className='sidebar-group-label'
|
||||
style={{ padding: '4px 15px 8px' }}
|
||||
>
|
||||
<Skeleton
|
||||
loading={true}
|
||||
active
|
||||
placeholder={
|
||||
<Skeleton.Title
|
||||
active
|
||||
style={{ width: sec.titleWidth, height: TITLE_HEIGHT }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{sec.itemWidths.map((w, i) => (
|
||||
<NavRow key={`${sec.key}-${i}`} labelWidth={w} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div
|
||||
className='sidebar-group-label'
|
||||
style={{ padding: '4px 15px 8px' }}
|
||||
>
|
||||
<Skeleton
|
||||
loading={true}
|
||||
active
|
||||
placeholder={
|
||||
<Skeleton.Title
|
||||
active
|
||||
style={{ width: sec.titleWidth, height: TITLE_HEIGHT }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{sec.itemWidths.map((w, i) => (
|
||||
<NavRow key={`${sec.key}-${i}`} labelWidth={w} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 根据类型渲染不同的骨架屏
|
||||
switch (type) {
|
||||
case 'navigation':
|
||||
return renderNavigationSkeleton();
|
||||
case 'userArea':
|
||||
return renderUserAreaSkeleton();
|
||||
case 'image':
|
||||
return renderImageSkeleton();
|
||||
case 'title':
|
||||
return renderTitleSkeleton();
|
||||
case 'sidebarNavItem':
|
||||
return renderSidebarNavItemSkeleton();
|
||||
case 'sidebarGroupTitle':
|
||||
return renderSidebarGroupTitleSkeleton();
|
||||
case 'sidebar':
|
||||
return renderSidebarSkeleton();
|
||||
case 'button':
|
||||
return renderButtonSkeleton();
|
||||
case 'text':
|
||||
default:
|
||||
return renderTextSkeleton();
|
||||
}
|
||||
};
|
||||
|
||||
export default SkeletonWrapper;
|
||||
@@ -20,7 +20,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Typography, Tag } from '@douyinfe/semi-ui';
|
||||
import SkeletonWrapper from './SkeletonWrapper';
|
||||
import SkeletonWrapper from '../components/SkeletonWrapper';
|
||||
|
||||
const HeaderLogo = ({
|
||||
isMobile,
|
||||
@@ -19,9 +19,15 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import SkeletonWrapper from './SkeletonWrapper';
|
||||
import SkeletonWrapper from '../components/SkeletonWrapper';
|
||||
|
||||
const Navigation = ({ mainNavLinks, isMobile, isLoading, userState }) => {
|
||||
const Navigation = ({
|
||||
mainNavLinks,
|
||||
isMobile,
|
||||
isLoading,
|
||||
userState,
|
||||
pricingRequireAuth,
|
||||
}) => {
|
||||
const renderNavLinks = () => {
|
||||
const baseClasses =
|
||||
'flex-shrink-0 flex items-center gap-1 font-semibold rounded-md transition-all duration-200 ease-in-out';
|
||||
@@ -51,6 +57,9 @@ const Navigation = ({ mainNavLinks, isMobile, isLoading, userState }) => {
|
||||
if (link.itemKey === 'console' && !userState.user) {
|
||||
targetPath = '/login';
|
||||
}
|
||||
if (link.itemKey === 'pricing' && pricingRequireAuth && !userState.user) {
|
||||
targetPath = '/login';
|
||||
}
|
||||
|
||||
return (
|
||||
<Link key={link.itemKey} to={targetPath} className={commonLinkClasses}>
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
IconKey,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { stringToColor } from '../../../helpers';
|
||||
import SkeletonWrapper from './SkeletonWrapper';
|
||||
import SkeletonWrapper from '../components/SkeletonWrapper';
|
||||
|
||||
const UserArea = ({
|
||||
userState,
|
||||
@@ -44,6 +44,8 @@ const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
|
||||
isDemoSiteMode,
|
||||
isConsoleRoute,
|
||||
theme,
|
||||
headerNavModules,
|
||||
pricingRequireAuth,
|
||||
logout,
|
||||
handleLanguageChange,
|
||||
handleThemeToggle,
|
||||
@@ -60,7 +62,7 @@ const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
|
||||
getUnreadKeys,
|
||||
} = useNotifications(statusState);
|
||||
|
||||
const { mainNavLinks } = useNavigation(t, docsLink);
|
||||
const { mainNavLinks } = useNavigation(t, docsLink, headerNavModules);
|
||||
|
||||
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'>
|
||||
@@ -102,6 +104,7 @@ const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
|
||||
isMobile={isMobile}
|
||||
isLoading={isLoading}
|
||||
userState={userState}
|
||||
pricingRequireAuth={pricingRequireAuth}
|
||||
/>
|
||||
|
||||
<ActionButtons
|
||||
@@ -34,7 +34,6 @@ import {
|
||||
Tag,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { IconSearch } from '@douyinfe/semi-icons';
|
||||
import { CheckCircle, XCircle, AlertCircle, HelpCircle } from 'lucide-react';
|
||||
|
||||
const ChannelSelectorModal = forwardRef(
|
||||
(
|
||||
@@ -65,6 +64,18 @@ const ChannelSelectorModal = forwardRef(
|
||||
},
|
||||
}));
|
||||
|
||||
// 官方渠道识别
|
||||
const isOfficialChannel = (record) => {
|
||||
const id = record?.key ?? record?.value ?? record?._originalData?.id;
|
||||
const base = record?._originalData?.base_url || '';
|
||||
const name = record?.label || '';
|
||||
return (
|
||||
id === -100 ||
|
||||
base === 'https://basellm.github.io' ||
|
||||
name === '官方倍率预设'
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!allChannels) return;
|
||||
|
||||
@@ -77,7 +88,13 @@ const ChannelSelectorModal = forwardRef(
|
||||
})
|
||||
: allChannels;
|
||||
|
||||
setFilteredData(matched);
|
||||
const sorted = [...matched].sort((a, b) => {
|
||||
const wa = isOfficialChannel(a) ? 0 : 1;
|
||||
const wb = isOfficialChannel(b) ? 0 : 1;
|
||||
return wa - wb;
|
||||
});
|
||||
|
||||
setFilteredData(sorted);
|
||||
}, [allChannels, searchText]);
|
||||
|
||||
const total = filteredData.length;
|
||||
@@ -143,45 +160,49 @@ const ChannelSelectorModal = forwardRef(
|
||||
);
|
||||
};
|
||||
|
||||
const renderStatusCell = (status) => {
|
||||
const renderStatusCell = (record) => {
|
||||
const status = record?._originalData?.status || 0;
|
||||
const official = isOfficialChannel(record);
|
||||
let statusTag = null;
|
||||
switch (status) {
|
||||
case 1:
|
||||
return (
|
||||
<Tag
|
||||
color='green'
|
||||
shape='circle'
|
||||
prefixIcon={<CheckCircle size={14} />}
|
||||
>
|
||||
statusTag = (
|
||||
<Tag color='green' shape='circle'>
|
||||
{t('已启用')}
|
||||
</Tag>
|
||||
);
|
||||
break;
|
||||
case 2:
|
||||
return (
|
||||
<Tag color='red' shape='circle' prefixIcon={<XCircle size={14} />}>
|
||||
statusTag = (
|
||||
<Tag color='red' shape='circle'>
|
||||
{t('已禁用')}
|
||||
</Tag>
|
||||
);
|
||||
break;
|
||||
case 3:
|
||||
return (
|
||||
<Tag
|
||||
color='yellow'
|
||||
shape='circle'
|
||||
prefixIcon={<AlertCircle size={14} />}
|
||||
>
|
||||
statusTag = (
|
||||
<Tag color='yellow' shape='circle'>
|
||||
{t('自动禁用')}
|
||||
</Tag>
|
||||
);
|
||||
break;
|
||||
default:
|
||||
return (
|
||||
<Tag
|
||||
color='grey'
|
||||
shape='circle'
|
||||
prefixIcon={<HelpCircle size={14} />}
|
||||
>
|
||||
statusTag = (
|
||||
<Tag color='grey' shape='circle'>
|
||||
{t('未知状态')}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{statusTag}
|
||||
{official && (
|
||||
<Tag color='green' shape='circle' type='light'>
|
||||
{t('官方')}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderNameCell = (text) => (
|
||||
@@ -207,8 +228,7 @@ const ChannelSelectorModal = forwardRef(
|
||||
{
|
||||
title: t('状态'),
|
||||
dataIndex: '_originalData.status',
|
||||
render: (_, record) =>
|
||||
renderStatusCell(record._originalData?.status || 0),
|
||||
render: (_, record) => renderStatusCell(record),
|
||||
},
|
||||
{
|
||||
title: t('同步接口'),
|
||||
|
||||
@@ -20,6 +20,8 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Card, Spin } from '@douyinfe/semi-ui';
|
||||
import SettingsGeneral from '../../pages/Setting/Operation/SettingsGeneral';
|
||||
import SettingsHeaderNavModules from '../../pages/Setting/Operation/SettingsHeaderNavModules';
|
||||
import SettingsSidebarModulesAdmin from '../../pages/Setting/Operation/SettingsSidebarModulesAdmin';
|
||||
import SettingsSensitiveWords from '../../pages/Setting/Operation/SettingsSensitiveWords';
|
||||
import SettingsLog from '../../pages/Setting/Operation/SettingsLog';
|
||||
import SettingsMonitoring from '../../pages/Setting/Operation/SettingsMonitoring';
|
||||
@@ -46,6 +48,12 @@ const OperationSetting = () => {
|
||||
DemoSiteEnabled: false,
|
||||
SelfUseModeEnabled: false,
|
||||
|
||||
/* 顶栏模块管理 */
|
||||
HeaderNavModules: '',
|
||||
|
||||
/* 左侧边栏模块管理(管理员) */
|
||||
SidebarModulesAdmin: '',
|
||||
|
||||
/* 敏感词设置 */
|
||||
CheckSensitiveEnabled: false,
|
||||
CheckSensitiveOnPromptEnabled: false,
|
||||
@@ -60,6 +68,8 @@ const OperationSetting = () => {
|
||||
AutomaticDisableChannelEnabled: false,
|
||||
AutomaticEnableChannelEnabled: false,
|
||||
AutomaticDisableKeywords: '',
|
||||
'monitor_setting.auto_test_channel_enabled': false,
|
||||
'monitor_setting.auto_test_channel_minutes': 10,
|
||||
});
|
||||
|
||||
let [loading, setLoading] = useState(false);
|
||||
@@ -70,10 +80,7 @@ const OperationSetting = () => {
|
||||
if (success) {
|
||||
let newInputs = {};
|
||||
data.forEach((item) => {
|
||||
if (
|
||||
item.key.endsWith('Enabled') ||
|
||||
['DefaultCollapseSidebar'].includes(item.key)
|
||||
) {
|
||||
if (typeof inputs[item.key] === 'boolean') {
|
||||
newInputs[item.key] = toBoolean(item.value);
|
||||
} else {
|
||||
newInputs[item.key] = item.value;
|
||||
@@ -108,6 +115,14 @@ const OperationSetting = () => {
|
||||
<Card style={{ marginTop: '10px' }}>
|
||||
<SettingsGeneral options={inputs} refresh={onRefresh} />
|
||||
</Card>
|
||||
{/* 顶栏模块管理 */}
|
||||
<div style={{ marginTop: '10px' }}>
|
||||
<SettingsHeaderNavModules options={inputs} refresh={onRefresh} />
|
||||
</div>
|
||||
{/* 左侧边栏模块管理(管理员) */}
|
||||
<div style={{ marginTop: '10px' }}>
|
||||
<SettingsSidebarModulesAdmin options={inputs} refresh={onRefresh} />
|
||||
</div>
|
||||
{/* 屏蔽词过滤设置 */}
|
||||
<Card style={{ marginTop: '10px' }}>
|
||||
<SettingsSensitiveWords options={inputs} refresh={onRefresh} />
|
||||
|
||||
@@ -37,6 +37,8 @@ const PaymentSetting = () => {
|
||||
TopupGroupRatio: '',
|
||||
CustomCallbackAddress: '',
|
||||
PayMethods: '',
|
||||
AmountOptions: '',
|
||||
AmountDiscount: '',
|
||||
|
||||
StripeApiSecret: '',
|
||||
StripeWebhookSecret: '',
|
||||
@@ -66,6 +68,30 @@ const PaymentSetting = () => {
|
||||
newInputs[item.key] = item.value;
|
||||
}
|
||||
break;
|
||||
case 'payment_setting.amount_options':
|
||||
try {
|
||||
newInputs['AmountOptions'] = JSON.stringify(
|
||||
JSON.parse(item.value),
|
||||
null,
|
||||
2,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('解析AmountOptions出错:', error);
|
||||
newInputs['AmountOptions'] = item.value;
|
||||
}
|
||||
break;
|
||||
case 'payment_setting.amount_discount':
|
||||
try {
|
||||
newInputs['AmountDiscount'] = JSON.stringify(
|
||||
JSON.parse(item.value),
|
||||
null,
|
||||
2,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('解析AmountDiscount出错:', error);
|
||||
newInputs['AmountDiscount'] = item.value;
|
||||
}
|
||||
break;
|
||||
case 'Price':
|
||||
case 'MinTopUp':
|
||||
case 'StripeUnitPrice':
|
||||
|
||||
@@ -65,6 +65,7 @@ const PersonalSetting = () => {
|
||||
webhookUrl: '',
|
||||
webhookSecret: '',
|
||||
notificationEmail: '',
|
||||
barkUrl: '',
|
||||
acceptUnsetModelRatioModel: false,
|
||||
recordIpLog: false,
|
||||
});
|
||||
@@ -106,6 +107,7 @@ const PersonalSetting = () => {
|
||||
webhookUrl: settings.webhook_url || '',
|
||||
webhookSecret: settings.webhook_secret || '',
|
||||
notificationEmail: settings.notification_email || '',
|
||||
barkUrl: settings.bark_url || '',
|
||||
acceptUnsetModelRatioModel:
|
||||
settings.accept_unset_model_ratio_model || false,
|
||||
recordIpLog: settings.record_ip_log || false,
|
||||
@@ -283,6 +285,7 @@ const PersonalSetting = () => {
|
||||
webhook_url: notificationSettings.webhookUrl,
|
||||
webhook_secret: notificationSettings.webhookSecret,
|
||||
notification_email: notificationSettings.notificationEmail,
|
||||
bark_url: notificationSettings.barkUrl,
|
||||
accept_unset_model_ratio_model:
|
||||
notificationSettings.acceptUnsetModelRatioModel,
|
||||
record_ip_log: notificationSettings.recordIpLog,
|
||||
|
||||
@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import React, { useRef, useEffect, useState, useContext } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Typography,
|
||||
@@ -28,11 +28,22 @@ import {
|
||||
Toast,
|
||||
Tabs,
|
||||
TabPane,
|
||||
Switch,
|
||||
Row,
|
||||
Col,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { IconMail, IconKey, IconBell, IconLink } from '@douyinfe/semi-icons';
|
||||
import { ShieldCheck, Bell, DollarSign } from 'lucide-react';
|
||||
import { renderQuotaWithPrompt } from '../../../../helpers';
|
||||
import { ShieldCheck, Bell, DollarSign, Settings } from 'lucide-react';
|
||||
import {
|
||||
renderQuotaWithPrompt,
|
||||
API,
|
||||
showSuccess,
|
||||
showError,
|
||||
} from '../../../../helpers';
|
||||
import CodeViewer from '../../../playground/CodeViewer';
|
||||
import { StatusContext } from '../../../../context/Status';
|
||||
import { UserContext } from '../../../../context/User';
|
||||
import { useUserPermissions } from '../../../../hooks/common/useUserPermissions';
|
||||
|
||||
const NotificationSettings = ({
|
||||
t,
|
||||
@@ -41,6 +52,142 @@ const NotificationSettings = ({
|
||||
saveNotificationSettings,
|
||||
}) => {
|
||||
const formApiRef = useRef(null);
|
||||
const [statusState] = useContext(StatusContext);
|
||||
const [userState] = useContext(UserContext);
|
||||
|
||||
// 左侧边栏设置相关状态
|
||||
const [sidebarLoading, setSidebarLoading] = useState(false);
|
||||
const [activeTabKey, setActiveTabKey] = useState('notification');
|
||||
const [sidebarModulesUser, setSidebarModulesUser] = useState({
|
||||
chat: {
|
||||
enabled: true,
|
||||
playground: true,
|
||||
chat: true,
|
||||
},
|
||||
console: {
|
||||
enabled: true,
|
||||
detail: true,
|
||||
token: true,
|
||||
log: true,
|
||||
midjourney: true,
|
||||
task: true,
|
||||
},
|
||||
personal: {
|
||||
enabled: true,
|
||||
topup: true,
|
||||
personal: true,
|
||||
},
|
||||
admin: {
|
||||
enabled: true,
|
||||
channel: true,
|
||||
models: true,
|
||||
redemption: true,
|
||||
user: true,
|
||||
setting: true,
|
||||
},
|
||||
});
|
||||
const [adminConfig, setAdminConfig] = useState(null);
|
||||
|
||||
// 使用后端权限验证替代前端角色判断
|
||||
const {
|
||||
permissions,
|
||||
loading: permissionsLoading,
|
||||
hasSidebarSettingsPermission,
|
||||
isSidebarSectionAllowed,
|
||||
isSidebarModuleAllowed,
|
||||
} = useUserPermissions();
|
||||
|
||||
// 左侧边栏设置处理函数
|
||||
const handleSectionChange = (sectionKey) => {
|
||||
return (checked) => {
|
||||
const newModules = {
|
||||
...sidebarModulesUser,
|
||||
[sectionKey]: {
|
||||
...sidebarModulesUser[sectionKey],
|
||||
enabled: checked,
|
||||
},
|
||||
};
|
||||
setSidebarModulesUser(newModules);
|
||||
};
|
||||
};
|
||||
|
||||
const handleModuleChange = (sectionKey, moduleKey) => {
|
||||
return (checked) => {
|
||||
const newModules = {
|
||||
...sidebarModulesUser,
|
||||
[sectionKey]: {
|
||||
...sidebarModulesUser[sectionKey],
|
||||
[moduleKey]: checked,
|
||||
},
|
||||
};
|
||||
setSidebarModulesUser(newModules);
|
||||
};
|
||||
};
|
||||
|
||||
const saveSidebarSettings = async () => {
|
||||
setSidebarLoading(true);
|
||||
try {
|
||||
const res = await API.put('/api/user/self', {
|
||||
sidebar_modules: JSON.stringify(sidebarModulesUser),
|
||||
});
|
||||
if (res.data.success) {
|
||||
showSuccess(t('侧边栏设置保存成功'));
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('保存失败'));
|
||||
}
|
||||
setSidebarLoading(false);
|
||||
};
|
||||
|
||||
const resetSidebarModules = () => {
|
||||
const defaultConfig = {
|
||||
chat: { enabled: true, playground: true, chat: true },
|
||||
console: {
|
||||
enabled: true,
|
||||
detail: true,
|
||||
token: true,
|
||||
log: true,
|
||||
midjourney: true,
|
||||
task: true,
|
||||
},
|
||||
personal: { enabled: true, topup: true, personal: true },
|
||||
admin: {
|
||||
enabled: true,
|
||||
channel: true,
|
||||
models: true,
|
||||
redemption: true,
|
||||
user: true,
|
||||
setting: true,
|
||||
},
|
||||
};
|
||||
setSidebarModulesUser(defaultConfig);
|
||||
};
|
||||
|
||||
// 加载左侧边栏配置
|
||||
useEffect(() => {
|
||||
const loadSidebarConfigs = async () => {
|
||||
try {
|
||||
// 获取管理员全局配置
|
||||
if (statusState?.status?.SidebarModulesAdmin) {
|
||||
const adminConf = JSON.parse(statusState.status.SidebarModulesAdmin);
|
||||
setAdminConfig(adminConf);
|
||||
}
|
||||
|
||||
// 获取用户个人配置
|
||||
const userRes = await API.get('/api/user/self');
|
||||
if (userRes.data.success && userRes.data.data.sidebar_modules) {
|
||||
const userConf = JSON.parse(userRes.data.data.sidebar_modules);
|
||||
setSidebarModulesUser(userConf);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载边栏配置失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
loadSidebarConfigs();
|
||||
}, [statusState]);
|
||||
|
||||
// 初始化表单值
|
||||
useEffect(() => {
|
||||
@@ -54,6 +201,101 @@ const NotificationSettings = ({
|
||||
handleNotificationSettingChange(field, value);
|
||||
};
|
||||
|
||||
// 检查功能是否被管理员允许
|
||||
const isAllowedByAdmin = (sectionKey, moduleKey = null) => {
|
||||
if (!adminConfig) return true;
|
||||
|
||||
if (moduleKey) {
|
||||
return (
|
||||
adminConfig[sectionKey]?.enabled && adminConfig[sectionKey]?.[moduleKey]
|
||||
);
|
||||
} else {
|
||||
return adminConfig[sectionKey]?.enabled;
|
||||
}
|
||||
};
|
||||
|
||||
// 区域配置数据(根据权限过滤)
|
||||
const sectionConfigs = [
|
||||
{
|
||||
key: 'chat',
|
||||
title: t('聊天区域'),
|
||||
description: t('操练场和聊天功能'),
|
||||
modules: [
|
||||
{
|
||||
key: 'playground',
|
||||
title: t('操练场'),
|
||||
description: t('AI模型测试环境'),
|
||||
},
|
||||
{ key: 'chat', title: t('聊天'), description: t('聊天会话管理') },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'console',
|
||||
title: t('控制台区域'),
|
||||
description: t('数据管理和日志查看'),
|
||||
modules: [
|
||||
{ key: 'detail', title: t('数据看板'), description: t('系统数据统计') },
|
||||
{ key: 'token', title: t('令牌管理'), description: t('API令牌管理') },
|
||||
{ key: 'log', title: t('使用日志'), description: t('API使用记录') },
|
||||
{
|
||||
key: 'midjourney',
|
||||
title: t('绘图日志'),
|
||||
description: t('绘图任务记录'),
|
||||
},
|
||||
{ key: 'task', title: t('任务日志'), description: t('系统任务记录') },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'personal',
|
||||
title: t('个人中心区域'),
|
||||
description: t('用户个人功能'),
|
||||
modules: [
|
||||
{ key: 'topup', title: t('钱包管理'), description: t('余额充值管理') },
|
||||
{
|
||||
key: 'personal',
|
||||
title: t('个人设置'),
|
||||
description: t('个人信息设置'),
|
||||
},
|
||||
],
|
||||
},
|
||||
// 管理员区域:根据后端权限控制显示
|
||||
{
|
||||
key: 'admin',
|
||||
title: t('管理员区域'),
|
||||
description: t('系统管理功能'),
|
||||
modules: [
|
||||
{ key: 'channel', title: t('渠道管理'), description: t('API渠道配置') },
|
||||
{ key: 'models', title: t('模型管理'), description: t('AI模型配置') },
|
||||
{
|
||||
key: 'redemption',
|
||||
title: t('兑换码管理'),
|
||||
description: t('兑换码生成管理'),
|
||||
},
|
||||
{ key: 'user', title: t('用户管理'), description: t('用户账户管理') },
|
||||
{
|
||||
key: 'setting',
|
||||
title: t('系统设置'),
|
||||
description: t('系统参数配置'),
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
.filter((section) => {
|
||||
// 使用后端权限验证替代前端角色判断
|
||||
return isSidebarSectionAllowed(section.key);
|
||||
})
|
||||
.map((section) => ({
|
||||
...section,
|
||||
modules: section.modules.filter((module) =>
|
||||
isSidebarModuleAllowed(section.key, module.key),
|
||||
),
|
||||
}))
|
||||
.filter(
|
||||
(section) =>
|
||||
// 过滤掉没有可用模块的区域
|
||||
section.modules.length > 0 && isAllowedByAdmin(section.key),
|
||||
);
|
||||
|
||||
// 表单提交
|
||||
const handleSubmit = () => {
|
||||
if (formApiRef.current) {
|
||||
@@ -75,10 +317,32 @@ const NotificationSettings = ({
|
||||
<Card
|
||||
className='!rounded-2xl shadow-sm border-0'
|
||||
footer={
|
||||
<div className='flex justify-end'>
|
||||
<Button type='primary' onClick={handleSubmit}>
|
||||
{t('保存设置')}
|
||||
</Button>
|
||||
<div className='flex justify-end gap-3'>
|
||||
{activeTabKey === 'sidebar' ? (
|
||||
// 边栏设置标签页的按钮
|
||||
<>
|
||||
<Button
|
||||
type='tertiary'
|
||||
onClick={resetSidebarModules}
|
||||
className='!rounded-lg'
|
||||
>
|
||||
{t('重置为默认')}
|
||||
</Button>
|
||||
<Button
|
||||
type='primary'
|
||||
onClick={saveSidebarSettings}
|
||||
loading={sidebarLoading}
|
||||
className='!rounded-lg'
|
||||
>
|
||||
{t('保存边栏设置')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
// 其他标签页的通用保存按钮
|
||||
<Button type='primary' onClick={handleSubmit}>
|
||||
{t('保存设置')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
@@ -103,7 +367,11 @@ const NotificationSettings = ({
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
{() => (
|
||||
<Tabs type='card' defaultActiveKey='notification'>
|
||||
<Tabs
|
||||
type='card'
|
||||
defaultActiveKey='notification'
|
||||
onChange={(key) => setActiveTabKey(key)}
|
||||
>
|
||||
{/* 通知配置 Tab */}
|
||||
<TabPane
|
||||
tab={
|
||||
@@ -124,6 +392,7 @@ const NotificationSettings = ({
|
||||
>
|
||||
<Radio value='email'>{t('邮件通知')}</Radio>
|
||||
<Radio value='webhook'>{t('Webhook通知')}</Radio>
|
||||
<Radio value='bark'>{t('Bark通知')}</Radio>
|
||||
</Form.RadioGroup>
|
||||
|
||||
<Form.AutoComplete
|
||||
@@ -260,6 +529,66 @@ const NotificationSettings = ({
|
||||
</Form.Slot>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Bark推送设置 */}
|
||||
{notificationSettings.warningType === 'bark' && (
|
||||
<>
|
||||
<Form.Input
|
||||
field='barkUrl'
|
||||
label={t('Bark推送URL')}
|
||||
placeholder={t(
|
||||
'请输入Bark推送URL,例如: https://api.day.app/yourkey/{{title}}/{{content}}',
|
||||
)}
|
||||
onChange={(val) => handleFormChange('barkUrl', val)}
|
||||
prefix={<IconLink />}
|
||||
extraText={t(
|
||||
'支持HTTP和HTTPS,模板变量: {{title}} (通知标题), {{content}} (通知内容)',
|
||||
)}
|
||||
showClear
|
||||
rules={[
|
||||
{
|
||||
required: notificationSettings.warningType === 'bark',
|
||||
message: t('请输入Bark推送URL'),
|
||||
},
|
||||
{
|
||||
pattern: /^https?:\/\/.+/,
|
||||
message: t('Bark推送URL必须以http://或https://开头'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className='mt-3 p-4 bg-gray-50/50 rounded-xl'>
|
||||
<div className='text-sm text-gray-700 mb-3'>
|
||||
<strong>{t('模板示例')}</strong>
|
||||
</div>
|
||||
<div className='text-xs text-gray-600 font-mono bg-white p-3 rounded-lg shadow-sm mb-4'>
|
||||
https://api.day.app/yourkey/{'{{title}}'}/
|
||||
{'{{content}}'}?sound=alarm&group=quota
|
||||
</div>
|
||||
<div className='text-xs text-gray-500 space-y-2'>
|
||||
<div>
|
||||
• <strong>{'title'}:</strong> {t('通知标题')}
|
||||
</div>
|
||||
<div>
|
||||
• <strong>{'content'}:</strong> {t('通知内容')}
|
||||
</div>
|
||||
<div className='mt-3 pt-3 border-t border-gray-200'>
|
||||
<span className='text-gray-400'>
|
||||
{t('更多参数请参考')}
|
||||
</span>{' '}
|
||||
<a
|
||||
href='https://github.com/Finb/Bark'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-blue-500 hover:text-blue-600 font-medium'
|
||||
>
|
||||
Bark 官方文档
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TabPane>
|
||||
|
||||
@@ -312,6 +641,147 @@ const NotificationSettings = ({
|
||||
/>
|
||||
</div>
|
||||
</TabPane>
|
||||
|
||||
{/* 左侧边栏设置 Tab - 根据后端权限控制显示 */}
|
||||
{hasSidebarSettingsPermission() && (
|
||||
<TabPane
|
||||
tab={
|
||||
<div className='flex items-center'>
|
||||
<Settings size={16} className='mr-2' />
|
||||
{t('边栏设置')}
|
||||
</div>
|
||||
}
|
||||
itemKey='sidebar'
|
||||
>
|
||||
<div className='py-4'>
|
||||
<div className='mb-4'>
|
||||
<Typography.Text
|
||||
type='secondary'
|
||||
size='small'
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
lineHeight: '1.5',
|
||||
color: 'var(--semi-color-text-2)',
|
||||
}}
|
||||
>
|
||||
{t('您可以个性化设置侧边栏的要显示功能')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
{/* 边栏设置功能区域容器 */}
|
||||
<div
|
||||
className='border rounded-xl p-4'
|
||||
style={{
|
||||
borderColor: 'var(--semi-color-border)',
|
||||
backgroundColor: 'var(--semi-color-bg-1)',
|
||||
}}
|
||||
>
|
||||
{sectionConfigs.map((section) => (
|
||||
<div key={section.key} className='mb-6'>
|
||||
{/* 区域标题和总开关 */}
|
||||
<div
|
||||
className='flex justify-between items-center mb-4 p-4 rounded-lg'
|
||||
style={{
|
||||
backgroundColor: 'var(--semi-color-fill-0)',
|
||||
border: '1px solid var(--semi-color-border-light)',
|
||||
borderColor: 'var(--semi-color-fill-1)',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div className='font-semibold text-base text-gray-900 mb-1'>
|
||||
{section.title}
|
||||
</div>
|
||||
<Typography.Text
|
||||
type='secondary'
|
||||
size='small'
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
lineHeight: '1.5',
|
||||
color: 'var(--semi-color-text-2)',
|
||||
}}
|
||||
>
|
||||
{section.description}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={sidebarModulesUser[section.key]?.enabled}
|
||||
onChange={handleSectionChange(section.key)}
|
||||
size='default'
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 功能模块网格 */}
|
||||
<Row gutter={[12, 12]}>
|
||||
{section.modules
|
||||
.filter((module) =>
|
||||
isAllowedByAdmin(section.key, module.key),
|
||||
)
|
||||
.map((module) => (
|
||||
<Col
|
||||
key={module.key}
|
||||
xs={24}
|
||||
sm={24}
|
||||
md={12}
|
||||
lg={8}
|
||||
xl={8}
|
||||
>
|
||||
<Card
|
||||
className={`!rounded-xl border border-gray-200 hover:border-blue-300 transition-all duration-200 ${
|
||||
sidebarModulesUser[section.key]?.enabled
|
||||
? ''
|
||||
: 'opacity-50'
|
||||
}`}
|
||||
bodyStyle={{ padding: '16px' }}
|
||||
hoverable
|
||||
>
|
||||
<div className='flex justify-between items-center h-full'>
|
||||
<div className='flex-1 text-left'>
|
||||
<div className='font-semibold text-sm text-gray-900 mb-1'>
|
||||
{module.title}
|
||||
</div>
|
||||
<Typography.Text
|
||||
type='secondary'
|
||||
size='small'
|
||||
className='block'
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
lineHeight: '1.5',
|
||||
color: 'var(--semi-color-text-2)',
|
||||
marginTop: '4px',
|
||||
}}
|
||||
>
|
||||
{module.description}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className='ml-4'>
|
||||
<Switch
|
||||
checked={
|
||||
sidebarModulesUser[section.key]?.[
|
||||
module.key
|
||||
]
|
||||
}
|
||||
onChange={handleModuleChange(
|
||||
section.key,
|
||||
module.key,
|
||||
)}
|
||||
size='default'
|
||||
disabled={
|
||||
!sidebarModulesUser[section.key]
|
||||
?.enabled
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</div>
|
||||
))}
|
||||
</div>{' '}
|
||||
{/* 关闭边栏设置功能区域容器 */}
|
||||
</div>
|
||||
</TabPane>
|
||||
)}
|
||||
</Tabs>
|
||||
)}
|
||||
</Form>
|
||||
|
||||
@@ -35,6 +35,8 @@ import {
|
||||
renderQuota,
|
||||
getChannelIcon,
|
||||
renderQuotaWithAmount,
|
||||
showSuccess,
|
||||
showError,
|
||||
} from '../../../helpers';
|
||||
import { CHANNEL_OPTIONS } from '../../../constants';
|
||||
import { IconTreeTriangleDown, IconMore } from '@douyinfe/semi-icons';
|
||||
@@ -216,6 +218,42 @@ export const getChannelsColumns = ({
|
||||
key: COLUMN_KEYS.NAME,
|
||||
title: t('名称'),
|
||||
dataIndex: 'name',
|
||||
render: (text, record, index) => {
|
||||
if (record.remark && record.remark.trim() !== '') {
|
||||
return (
|
||||
<Tooltip
|
||||
content={
|
||||
<div className='flex flex-col gap-2 max-w-xs'>
|
||||
<div className='text-sm'>{record.remark}</div>
|
||||
<Button
|
||||
size='small'
|
||||
type='primary'
|
||||
theme='outline'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard
|
||||
.writeText(record.remark)
|
||||
.then(() => {
|
||||
showSuccess(t('复制成功'));
|
||||
})
|
||||
.catch(() => {
|
||||
showError(t('复制失败'));
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t('复制')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
trigger='hover'
|
||||
position='topLeft'
|
||||
>
|
||||
<span>{text}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return text;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.GROUP,
|
||||
|
||||
@@ -142,6 +142,8 @@ const EditChannelModal = (props) => {
|
||||
system_prompt: '',
|
||||
system_prompt_override: false,
|
||||
settings: '',
|
||||
// 仅 Vertex: 密钥格式(存入 settings.vertex_key_type)
|
||||
vertex_key_type: 'json',
|
||||
};
|
||||
const [batch, setBatch] = useState(false);
|
||||
const [multiToSingle, setMultiToSingle] = useState(false);
|
||||
@@ -409,11 +411,17 @@ const EditChannelModal = (props) => {
|
||||
const parsedSettings = JSON.parse(data.settings);
|
||||
data.azure_responses_version =
|
||||
parsedSettings.azure_responses_version || '';
|
||||
// 读取 Vertex 密钥格式
|
||||
data.vertex_key_type = parsedSettings.vertex_key_type || 'json';
|
||||
} catch (error) {
|
||||
console.error('解析其他设置失败:', error);
|
||||
data.azure_responses_version = '';
|
||||
data.region = '';
|
||||
data.vertex_key_type = 'json';
|
||||
}
|
||||
} else {
|
||||
// 兼容历史数据:老渠道没有 settings 时,默认按 json 展示
|
||||
data.vertex_key_type = 'json';
|
||||
}
|
||||
|
||||
setInputs(data);
|
||||
@@ -745,59 +753,56 @@ const EditChannelModal = (props) => {
|
||||
let localInputs = { ...formValues };
|
||||
|
||||
if (localInputs.type === 41) {
|
||||
if (useManualInput) {
|
||||
// 手动输入模式
|
||||
if (localInputs.key && localInputs.key.trim() !== '') {
|
||||
try {
|
||||
// 验证 JSON 格式
|
||||
const parsedKey = JSON.parse(localInputs.key);
|
||||
// 确保是有效的密钥格式
|
||||
localInputs.key = JSON.stringify(parsedKey);
|
||||
} catch (err) {
|
||||
showError(t('密钥格式无效,请输入有效的 JSON 格式密钥'));
|
||||
return;
|
||||
}
|
||||
} else if (!isEdit) {
|
||||
const keyType = localInputs.vertex_key_type || 'json';
|
||||
if (keyType === 'api_key') {
|
||||
// 直接作为普通字符串密钥处理
|
||||
if (!isEdit && (!localInputs.key || localInputs.key.trim() === '')) {
|
||||
showInfo(t('请输入密钥!'));
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// 文件上传模式
|
||||
let keys = vertexKeys;
|
||||
|
||||
// 若当前未选择文件,尝试从已上传文件列表解析(异步读取)
|
||||
if (keys.length === 0 && vertexFileList.length > 0) {
|
||||
try {
|
||||
const parsed = await Promise.all(
|
||||
vertexFileList.map(async (item) => {
|
||||
const fileObj = item.fileInstance;
|
||||
if (!fileObj) return null;
|
||||
const txt = await fileObj.text();
|
||||
return JSON.parse(txt);
|
||||
}),
|
||||
);
|
||||
keys = parsed.filter(Boolean);
|
||||
} catch (err) {
|
||||
showError(t('解析密钥文件失败: {{msg}}', { msg: err.message }));
|
||||
// JSON 服务账号密钥
|
||||
if (useManualInput) {
|
||||
if (localInputs.key && localInputs.key.trim() !== '') {
|
||||
try {
|
||||
const parsedKey = JSON.parse(localInputs.key);
|
||||
localInputs.key = JSON.stringify(parsedKey);
|
||||
} catch (err) {
|
||||
showError(t('密钥格式无效,请输入有效的 JSON 格式密钥'));
|
||||
return;
|
||||
}
|
||||
} else if (!isEdit) {
|
||||
showInfo(t('请输入密钥!'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 创建模式必须上传密钥;编辑模式可选
|
||||
if (keys.length === 0) {
|
||||
if (!isEdit) {
|
||||
showInfo(t('请上传密钥文件!'));
|
||||
return;
|
||||
} else {
|
||||
// 编辑模式且未上传新密钥,不修改 key
|
||||
delete localInputs.key;
|
||||
}
|
||||
} else {
|
||||
// 有新密钥,则覆盖
|
||||
if (batch) {
|
||||
localInputs.key = JSON.stringify(keys);
|
||||
// 文件上传模式
|
||||
let keys = vertexKeys;
|
||||
if (keys.length === 0 && vertexFileList.length > 0) {
|
||||
try {
|
||||
const parsed = await Promise.all(
|
||||
vertexFileList.map(async (item) => {
|
||||
const fileObj = item.fileInstance;
|
||||
if (!fileObj) return null;
|
||||
const txt = await fileObj.text();
|
||||
return JSON.parse(txt);
|
||||
}),
|
||||
);
|
||||
keys = parsed.filter(Boolean);
|
||||
} catch (err) {
|
||||
showError(t('解析密钥文件失败: {{msg}}', { msg: err.message }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (keys.length === 0) {
|
||||
if (!isEdit) {
|
||||
showInfo(t('请上传密钥文件!'));
|
||||
return;
|
||||
} else {
|
||||
delete localInputs.key;
|
||||
}
|
||||
} else {
|
||||
localInputs.key = JSON.stringify(keys[0]);
|
||||
localInputs.key = batch ? JSON.stringify(keys) : JSON.stringify(keys[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -853,6 +858,8 @@ const EditChannelModal = (props) => {
|
||||
delete localInputs.pass_through_body_enabled;
|
||||
delete localInputs.system_prompt;
|
||||
delete localInputs.system_prompt_override;
|
||||
// 顶层的 vertex_key_type 不应发送给后端
|
||||
delete localInputs.vertex_key_type;
|
||||
|
||||
let res;
|
||||
localInputs.auto_ban = localInputs.auto_ban ? 1 : 0;
|
||||
@@ -1178,8 +1185,40 @@ const EditChannelModal = (props) => {
|
||||
autoComplete='new-password'
|
||||
/>
|
||||
|
||||
{inputs.type === 41 && (
|
||||
<Form.Select
|
||||
field='vertex_key_type'
|
||||
label={t('密钥格式')}
|
||||
placeholder={t('请选择密钥格式')}
|
||||
optionList={[
|
||||
{ label: 'JSON', value: 'json' },
|
||||
{ label: 'API Key', value: 'api_key' },
|
||||
]}
|
||||
style={{ width: '100%' }}
|
||||
value={inputs.vertex_key_type || 'json'}
|
||||
onChange={(value) => {
|
||||
// 更新设置中的 vertex_key_type
|
||||
handleChannelOtherSettingsChange('vertex_key_type', value);
|
||||
// 切换为 api_key 时,关闭批量与手动/文件切换,并清理已选文件
|
||||
if (value === 'api_key') {
|
||||
setBatch(false);
|
||||
setUseManualInput(false);
|
||||
setVertexKeys([]);
|
||||
setVertexFileList([]);
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValue('vertex_files', []);
|
||||
}
|
||||
}
|
||||
}}
|
||||
extraText={
|
||||
inputs.vertex_key_type === 'api_key'
|
||||
? t('API Key 模式下不支持批量创建')
|
||||
: t('JSON 模式支持手动输入或上传服务账号 JSON')
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{batch ? (
|
||||
inputs.type === 41 ? (
|
||||
inputs.type === 41 && (inputs.vertex_key_type || 'json') === 'json' ? (
|
||||
<Form.Upload
|
||||
field='vertex_files'
|
||||
label={t('密钥文件 (.json)')}
|
||||
@@ -1243,7 +1282,7 @@ const EditChannelModal = (props) => {
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{inputs.type === 41 ? (
|
||||
{inputs.type === 41 && (inputs.vertex_key_type || 'json') === 'json' ? (
|
||||
<>
|
||||
{!batch && (
|
||||
<div className='flex items-center justify-between mb-3'>
|
||||
@@ -1993,6 +2032,14 @@ const EditChannelModal = (props) => {
|
||||
showClear
|
||||
onChange={(value) => handleInputChange('tag', value)}
|
||||
/>
|
||||
<Form.TextArea
|
||||
field='remark'
|
||||
label={t('备注')}
|
||||
placeholder={t('请输入备注(仅管理员可见)')}
|
||||
maxLength={255}
|
||||
showClear
|
||||
onChange={(value) => handleInputChange('remark', value)}
|
||||
/>
|
||||
|
||||
<Row gutter={12}>
|
||||
<Col span={12}>
|
||||
|
||||
@@ -21,10 +21,12 @@ import React, { useState } from 'react';
|
||||
import MissingModelsModal from './modals/MissingModelsModal';
|
||||
import PrefillGroupManagement from './modals/PrefillGroupManagement';
|
||||
import EditPrefillGroupModal from './modals/EditPrefillGroupModal';
|
||||
import { Button, Modal } from '@douyinfe/semi-ui';
|
||||
import { Button, Modal, Popover, RadioGroup, Radio } from '@douyinfe/semi-ui';
|
||||
import { showSuccess, showError, copy } from '../../../helpers';
|
||||
import CompactModeToggle from '../../common/ui/CompactModeToggle';
|
||||
import SelectionNotification from './components/SelectionNotification';
|
||||
import UpstreamConflictModal from './modals/UpstreamConflictModal';
|
||||
import SyncWizardModal from './modals/SyncWizardModal';
|
||||
|
||||
const ModelsActions = ({
|
||||
selectedKeys,
|
||||
@@ -32,6 +34,11 @@ const ModelsActions = ({
|
||||
setEditingModel,
|
||||
setShowEdit,
|
||||
batchDeleteModels,
|
||||
syncing,
|
||||
previewing,
|
||||
syncUpstream,
|
||||
previewUpstreamDiff,
|
||||
applyUpstreamOverwrite,
|
||||
compactMode,
|
||||
setCompactMode,
|
||||
t,
|
||||
@@ -42,6 +49,23 @@ const ModelsActions = ({
|
||||
const [showGroupManagement, setShowGroupManagement] = useState(false);
|
||||
const [showAddPrefill, setShowAddPrefill] = useState(false);
|
||||
const [prefillInit, setPrefillInit] = useState({ id: undefined });
|
||||
const [showConflict, setShowConflict] = useState(false);
|
||||
const [conflicts, setConflicts] = useState([]);
|
||||
const [showSyncModal, setShowSyncModal] = useState(false);
|
||||
const [syncLocale, setSyncLocale] = useState('zh');
|
||||
|
||||
const handleSyncUpstream = async (locale) => {
|
||||
// 先预览
|
||||
const data = await previewUpstreamDiff?.({ locale });
|
||||
const conflictItems = data?.conflicts || [];
|
||||
if (conflictItems.length > 0) {
|
||||
setConflicts(conflictItems);
|
||||
setShowConflict(true);
|
||||
return;
|
||||
}
|
||||
// 无冲突,直接同步缺失
|
||||
await syncUpstream?.({ locale });
|
||||
};
|
||||
|
||||
// Handle delete selected models with confirmation
|
||||
const handleDeleteSelectedModels = () => {
|
||||
@@ -104,6 +128,41 @@ const ModelsActions = ({
|
||||
{t('未配置模型')}
|
||||
</Button>
|
||||
|
||||
<Popover
|
||||
position='bottom'
|
||||
trigger='hover'
|
||||
content={
|
||||
<div className='p-2 max-w-[360px]'>
|
||||
<div className='text-[var(--semi-color-text-2)] text-sm'>
|
||||
{t(
|
||||
'模型社区需要大家的共同维护,如发现数据有误或想贡献新的模型数据,请访问:',
|
||||
)}
|
||||
</div>
|
||||
<a
|
||||
href='https://github.com/basellm/llm-metadata'
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
className='text-blue-600 underline'
|
||||
>
|
||||
https://github.com/basellm/llm-metadata
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type='secondary'
|
||||
className='flex-1 md:flex-initial'
|
||||
size='small'
|
||||
loading={syncing || previewing}
|
||||
onClick={() => {
|
||||
setSyncLocale('zh');
|
||||
setShowSyncModal(true);
|
||||
}}
|
||||
>
|
||||
{t('同步')}
|
||||
</Button>
|
||||
</Popover>
|
||||
|
||||
<Button
|
||||
type='secondary'
|
||||
className='flex-1 md:flex-initial'
|
||||
@@ -143,6 +202,20 @@ const ModelsActions = ({
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<SyncWizardModal
|
||||
visible={showSyncModal}
|
||||
onClose={() => setShowSyncModal(false)}
|
||||
loading={syncing || previewing}
|
||||
t={t}
|
||||
onConfirm={async ({ option, locale }) => {
|
||||
setSyncLocale(locale);
|
||||
if (option === 'official') {
|
||||
await handleSyncUpstream(locale);
|
||||
}
|
||||
setShowSyncModal(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<MissingModelsModal
|
||||
visible={showMissingModal}
|
||||
onClose={() => setShowMissingModal(false)}
|
||||
@@ -165,6 +238,20 @@ const ModelsActions = ({
|
||||
editingGroup={prefillInit}
|
||||
onSuccess={() => setShowAddPrefill(false)}
|
||||
/>
|
||||
|
||||
<UpstreamConflictModal
|
||||
visible={showConflict}
|
||||
onClose={() => setShowConflict(false)}
|
||||
conflicts={conflicts}
|
||||
onSubmit={async (payload) => {
|
||||
return await applyUpstreamOverwrite?.({
|
||||
overwrite: payload,
|
||||
locale: syncLocale,
|
||||
});
|
||||
}}
|
||||
t={t}
|
||||
loading={syncing}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -303,6 +303,15 @@ export const getModelsColumns = ({
|
||||
dataIndex: 'name_rule',
|
||||
render: (val, record) => renderNameRule(val, record, t),
|
||||
},
|
||||
{
|
||||
title: t('参与官方同步'),
|
||||
dataIndex: 'sync_official',
|
||||
render: (val) => (
|
||||
<Tag size='small' shape='circle' color={val === 1 ? 'green' : 'orange'}>
|
||||
{val === 1 ? t('是') : t('否')}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('描述'),
|
||||
dataIndex: 'description',
|
||||
|
||||
@@ -105,6 +105,11 @@ const ModelsPage = () => {
|
||||
setEditingModel={setEditingModel}
|
||||
setShowEdit={setShowEdit}
|
||||
batchDeleteModels={batchDeleteModels}
|
||||
syncing={modelsData.syncing}
|
||||
syncUpstream={modelsData.syncUpstream}
|
||||
previewing={modelsData.previewing}
|
||||
previewUpstreamDiff={modelsData.previewUpstreamDiff}
|
||||
applyUpstreamOverwrite={modelsData.applyUpstreamOverwrite}
|
||||
compactMode={compactMode}
|
||||
setCompactMode={setCompactMode}
|
||||
t={t}
|
||||
|
||||
@@ -121,6 +121,7 @@ const EditModelModal = (props) => {
|
||||
endpoints: '',
|
||||
name_rule: props.editingModel?.model_name ? 0 : undefined, // 通过未配置模型过来的固定为精确匹配
|
||||
status: true,
|
||||
sync_official: true,
|
||||
});
|
||||
|
||||
const handleCancel = () => {
|
||||
@@ -145,8 +146,9 @@ const EditModelModal = (props) => {
|
||||
if (!data.endpoints) {
|
||||
data.endpoints = '';
|
||||
}
|
||||
// 处理status,将数字转为布尔值
|
||||
// 处理status/sync_official,将数字转为布尔值
|
||||
data.status = data.status === 1;
|
||||
data.sync_official = (data.sync_official ?? 1) === 1;
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValues({ ...getInitValues(), ...data });
|
||||
}
|
||||
@@ -193,6 +195,7 @@ const EditModelModal = (props) => {
|
||||
tags: Array.isArray(values.tags) ? values.tags.join(',') : values.tags,
|
||||
endpoints: values.endpoints || '',
|
||||
status: values.status ? 1 : 0,
|
||||
sync_official: values.sync_official ? 1 : 0,
|
||||
};
|
||||
|
||||
if (isEdit) {
|
||||
@@ -505,6 +508,16 @@ const EditModelModal = (props) => {
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Switch
|
||||
field='sync_official'
|
||||
label={t('参与官方同步')}
|
||||
extraText={t(
|
||||
'关闭后,此模型将不会被“同步官方”自动覆盖或创建',
|
||||
)}
|
||||
size='large'
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Switch
|
||||
field='status'
|
||||
|
||||
@@ -96,7 +96,7 @@ const MissingModelsModal = ({ visible, onClose, onConfigureModel, t }) => {
|
||||
title: '',
|
||||
dataIndex: 'operate',
|
||||
fixed: 'right',
|
||||
width: 100,
|
||||
width: 120,
|
||||
render: (text, record) => (
|
||||
<Button
|
||||
type='primary'
|
||||
|
||||
132
web/src/components/table/models/modals/SyncWizardModal.jsx
Normal file
132
web/src/components/table/models/modals/SyncWizardModal.jsx
Normal file
@@ -0,0 +1,132 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Modal, RadioGroup, Radio, Steps, Button } from '@douyinfe/semi-ui';
|
||||
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
||||
|
||||
const SyncWizardModal = ({ visible, onClose, onConfirm, loading, t }) => {
|
||||
const [step, setStep] = useState(0);
|
||||
const [option, setOption] = useState('official');
|
||||
const [locale, setLocale] = useState('zh');
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setStep(0);
|
||||
setOption('official');
|
||||
setLocale('zh');
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('同步向导')}
|
||||
visible={visible}
|
||||
onCancel={onClose}
|
||||
footer={
|
||||
<div className='flex justify-end'>
|
||||
{step === 1 && (
|
||||
<Button onClick={() => setStep(0)}>{t('上一步')}</Button>
|
||||
)}
|
||||
<Button onClick={onClose}>{t('取消')}</Button>
|
||||
{step === 0 && (
|
||||
<Button
|
||||
type='primary'
|
||||
onClick={() => setStep(1)}
|
||||
disabled={option !== 'official'}
|
||||
>
|
||||
{t('下一步')}
|
||||
</Button>
|
||||
)}
|
||||
{step === 1 && (
|
||||
<Button
|
||||
type='primary'
|
||||
theme='solid'
|
||||
loading={loading}
|
||||
onClick={async () => {
|
||||
await onConfirm?.({ option, locale });
|
||||
}}
|
||||
>
|
||||
{t('开始同步')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
width={isMobile ? '100%' : 'small'}
|
||||
>
|
||||
<div className='mb-3'>
|
||||
<Steps type='basic' current={step} size='small'>
|
||||
<Steps.Step title={t('选择方式')} description={t('选择同步来源')} />
|
||||
<Steps.Step title={t('选择语言')} description={t('选择同步语言')} />
|
||||
</Steps>
|
||||
</div>
|
||||
|
||||
{step === 0 && (
|
||||
<div className='mt-2 flex justify-center'>
|
||||
<RadioGroup
|
||||
value={option}
|
||||
onChange={(e) => setOption(e?.target?.value ?? e)}
|
||||
type='card'
|
||||
direction='horizontal'
|
||||
aria-label='同步方式选择'
|
||||
name='sync-mode-selection'
|
||||
>
|
||||
<Radio value='official' extra={t('从官方模型库同步')}>
|
||||
{t('官方模型同步')}
|
||||
</Radio>
|
||||
<Radio value='config' extra={t('从配置文件同步')} disabled>
|
||||
{t('配置文件同步')}
|
||||
</Radio>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<div className='mt-2'>
|
||||
<div className='mb-2 text-[var(--semi-color-text-2)]'>
|
||||
{t('请选择同步语言')}
|
||||
</div>
|
||||
<div className='flex justify-center'>
|
||||
<RadioGroup
|
||||
value={locale}
|
||||
onChange={(e) => setLocale(e?.target?.value ?? e)}
|
||||
type='card'
|
||||
direction='horizontal'
|
||||
aria-label='语言选择'
|
||||
name='sync-locale-selection'
|
||||
>
|
||||
<Radio value='en' extra='English'>
|
||||
EN
|
||||
</Radio>
|
||||
<Radio value='zh' extra='中文'>
|
||||
ZH
|
||||
</Radio>
|
||||
<Radio value='ja' extra='日本語'>
|
||||
JA
|
||||
</Radio>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default SyncWizardModal;
|
||||
324
web/src/components/table/models/modals/UpstreamConflictModal.jsx
Normal file
324
web/src/components/table/models/modals/UpstreamConflictModal.jsx
Normal file
@@ -0,0 +1,324 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useMemo, useState, useCallback } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Table,
|
||||
Checkbox,
|
||||
Typography,
|
||||
Empty,
|
||||
Tag,
|
||||
Popover,
|
||||
Input,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { MousePointerClick } from 'lucide-react';
|
||||
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
||||
import { MODEL_TABLE_PAGE_SIZE } from '../../../../constants';
|
||||
import { IconSearch } from '@douyinfe/semi-icons';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const FIELD_LABELS = {
|
||||
description: '描述',
|
||||
icon: '图标',
|
||||
tags: '标签',
|
||||
vendor: '供应商',
|
||||
name_rule: '命名规则',
|
||||
status: '状态',
|
||||
};
|
||||
const FIELD_KEYS = Object.keys(FIELD_LABELS);
|
||||
|
||||
const UpstreamConflictModal = ({
|
||||
visible,
|
||||
onClose,
|
||||
conflicts = [],
|
||||
onSubmit,
|
||||
t,
|
||||
loading = false,
|
||||
}) => {
|
||||
const [selections, setSelections] = useState({});
|
||||
const isMobile = useIsMobile();
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
|
||||
const formatValue = (v) => {
|
||||
if (v === null || v === undefined) return '-';
|
||||
if (typeof v === 'string') return v || '-';
|
||||
try {
|
||||
return JSON.stringify(v, null, 2);
|
||||
} catch (_) {
|
||||
return String(v);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
const init = {};
|
||||
conflicts.forEach((item) => {
|
||||
init[item.model_name] = new Set();
|
||||
});
|
||||
setSelections(init);
|
||||
setCurrentPage(1);
|
||||
setSearchKeyword('');
|
||||
} else {
|
||||
setSelections({});
|
||||
}
|
||||
}, [visible, conflicts]);
|
||||
|
||||
const toggleField = useCallback((modelName, field, checked) => {
|
||||
setSelections((prev) => {
|
||||
const next = { ...prev };
|
||||
const set = new Set(next[modelName] || []);
|
||||
if (checked) set.add(field);
|
||||
else set.delete(field);
|
||||
next[modelName] = set;
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 构造数据源与过滤后的数据源
|
||||
const dataSource = useMemo(
|
||||
() =>
|
||||
(conflicts || []).map((c) => ({
|
||||
key: c.model_name,
|
||||
model_name: c.model_name,
|
||||
fields: c.fields || [],
|
||||
})),
|
||||
[conflicts],
|
||||
);
|
||||
|
||||
const filteredDataSource = useMemo(() => {
|
||||
const kw = (searchKeyword || '').toLowerCase();
|
||||
if (!kw) return dataSource;
|
||||
return dataSource.filter((item) =>
|
||||
(item.model_name || '').toLowerCase().includes(kw),
|
||||
);
|
||||
}, [dataSource, searchKeyword]);
|
||||
|
||||
// 列头工具:当前过滤范围内可操作的行集合/勾选状态/批量设置
|
||||
const getPresentRowsForField = useCallback(
|
||||
(fieldKey) =>
|
||||
(filteredDataSource || []).filter((row) =>
|
||||
(row.fields || []).some((f) => f.field === fieldKey),
|
||||
),
|
||||
[filteredDataSource],
|
||||
);
|
||||
|
||||
const getHeaderState = useCallback(
|
||||
(fieldKey) => {
|
||||
const presentRows = getPresentRowsForField(fieldKey);
|
||||
const selectedCount = presentRows.filter((row) =>
|
||||
selections[row.model_name]?.has(fieldKey),
|
||||
).length;
|
||||
const allCount = presentRows.length;
|
||||
return {
|
||||
headerChecked: allCount > 0 && selectedCount === allCount,
|
||||
headerIndeterminate: selectedCount > 0 && selectedCount < allCount,
|
||||
hasAny: allCount > 0,
|
||||
};
|
||||
},
|
||||
[getPresentRowsForField, selections],
|
||||
);
|
||||
|
||||
const applyHeaderChange = useCallback(
|
||||
(fieldKey, checked) => {
|
||||
setSelections((prev) => {
|
||||
const next = { ...prev };
|
||||
getPresentRowsForField(fieldKey).forEach((row) => {
|
||||
const set = new Set(next[row.model_name] || []);
|
||||
if (checked) set.add(fieldKey);
|
||||
else set.delete(fieldKey);
|
||||
next[row.model_name] = set;
|
||||
});
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[getPresentRowsForField],
|
||||
);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
const base = [
|
||||
{
|
||||
title: t('模型'),
|
||||
dataIndex: 'model_name',
|
||||
fixed: 'left',
|
||||
render: (text) => <Text strong>{text}</Text>,
|
||||
},
|
||||
];
|
||||
|
||||
const cols = FIELD_KEYS.map((fieldKey) => {
|
||||
const rawLabel = FIELD_LABELS[fieldKey] || fieldKey;
|
||||
const label = t(rawLabel);
|
||||
|
||||
const { headerChecked, headerIndeterminate, hasAny } =
|
||||
getHeaderState(fieldKey);
|
||||
if (!hasAny) return null;
|
||||
const onHeaderChange = (e) =>
|
||||
applyHeaderChange(fieldKey, e?.target?.checked);
|
||||
|
||||
return {
|
||||
title: (
|
||||
<div className='flex items-center gap-2'>
|
||||
<Checkbox
|
||||
checked={headerChecked}
|
||||
indeterminate={headerIndeterminate}
|
||||
onChange={onHeaderChange}
|
||||
/>
|
||||
<Text>{label}</Text>
|
||||
</div>
|
||||
),
|
||||
dataIndex: fieldKey,
|
||||
render: (_, record) => {
|
||||
const f = (record.fields || []).find((x) => x.field === fieldKey);
|
||||
if (!f) return <Text type='tertiary'>-</Text>;
|
||||
const checked = selections[record.model_name]?.has(fieldKey) || false;
|
||||
return (
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onChange={(e) =>
|
||||
toggleField(record.model_name, fieldKey, e?.target?.checked)
|
||||
}
|
||||
>
|
||||
<Popover
|
||||
trigger='hover'
|
||||
position='top'
|
||||
content={
|
||||
<div className='p-2 max-w-[520px]'>
|
||||
<div className='mb-2'>
|
||||
<Text type='tertiary' size='small'>
|
||||
{t('本地')}
|
||||
</Text>
|
||||
<pre className='whitespace-pre-wrap m-0'>
|
||||
{formatValue(f.local)}
|
||||
</pre>
|
||||
</div>
|
||||
<div>
|
||||
<Text type='tertiary' size='small'>
|
||||
{t('官方')}
|
||||
</Text>
|
||||
<pre className='whitespace-pre-wrap m-0'>
|
||||
{formatValue(f.upstream)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Tag
|
||||
color='white'
|
||||
size='small'
|
||||
prefixIcon={<MousePointerClick size={14} />}
|
||||
>
|
||||
{t('点击查看差异')}
|
||||
</Tag>
|
||||
</Popover>
|
||||
</Checkbox>
|
||||
);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return [...base, ...cols.filter(Boolean)];
|
||||
}, [
|
||||
t,
|
||||
selections,
|
||||
filteredDataSource,
|
||||
getHeaderState,
|
||||
applyHeaderChange,
|
||||
toggleField,
|
||||
]);
|
||||
|
||||
const pagedDataSource = useMemo(() => {
|
||||
const start = (currentPage - 1) * MODEL_TABLE_PAGE_SIZE;
|
||||
const end = start + MODEL_TABLE_PAGE_SIZE;
|
||||
return filteredDataSource.slice(start, end);
|
||||
}, [filteredDataSource, currentPage]);
|
||||
|
||||
const handleOk = async () => {
|
||||
const payload = Object.entries(selections)
|
||||
.map(([modelName, set]) => ({
|
||||
model_name: modelName,
|
||||
fields: Array.from(set || []),
|
||||
}))
|
||||
.filter((x) => x.fields.length > 0);
|
||||
|
||||
const ok = await onSubmit?.(payload);
|
||||
if (ok) onClose?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('选择要覆盖的冲突项')}
|
||||
visible={visible}
|
||||
onCancel={onClose}
|
||||
onOk={handleOk}
|
||||
confirmLoading={loading}
|
||||
okText={t('应用覆盖')}
|
||||
cancelText={t('取消')}
|
||||
width={isMobile ? '100%' : 1000}
|
||||
>
|
||||
{dataSource.length === 0 ? (
|
||||
<Empty description={t('无冲突项')} className='p-6' />
|
||||
) : (
|
||||
<>
|
||||
<div className='mb-3 text-[var(--semi-color-text-2)]'>
|
||||
{t('仅会覆盖你勾选的字段,未勾选的字段保持本地不变。')}
|
||||
</div>
|
||||
{/* 搜索框 */}
|
||||
<div className='flex items-center justify-end gap-2 w-full mb-4'>
|
||||
<Input
|
||||
placeholder={t('搜索模型...')}
|
||||
value={searchKeyword}
|
||||
onChange={(v) => {
|
||||
setSearchKeyword(v);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className='!w-full'
|
||||
prefix={<IconSearch />}
|
||||
showClear
|
||||
/>
|
||||
</div>
|
||||
{filteredDataSource.length > 0 ? (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={pagedDataSource}
|
||||
pagination={{
|
||||
currentPage: currentPage,
|
||||
pageSize: MODEL_TABLE_PAGE_SIZE,
|
||||
total: filteredDataSource.length,
|
||||
showSizeChanger: false,
|
||||
onPageChange: (page) => setCurrentPage(page),
|
||||
}}
|
||||
scroll={{ x: 'max-content' }}
|
||||
/>
|
||||
) : (
|
||||
<Empty
|
||||
description={
|
||||
searchKeyword ? t('未找到匹配的模型') : t('无冲突项')
|
||||
}
|
||||
className='p-6'
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpstreamConflictModal;
|
||||
@@ -21,6 +21,7 @@ import React, { useRef } from 'react';
|
||||
import {
|
||||
Avatar,
|
||||
Typography,
|
||||
Tag,
|
||||
Card,
|
||||
Button,
|
||||
Banner,
|
||||
@@ -29,7 +30,7 @@ import {
|
||||
Space,
|
||||
Row,
|
||||
Col,
|
||||
Spin,
|
||||
Spin, Tooltip
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { SiAlipay, SiWechat, SiStripe } from 'react-icons/si';
|
||||
import { CreditCard, Coins, Wallet, BarChart2, TrendingUp } from 'lucide-react';
|
||||
@@ -68,6 +69,7 @@ const RechargeCard = ({
|
||||
userState,
|
||||
renderQuota,
|
||||
statusLoading,
|
||||
topupInfo,
|
||||
}) => {
|
||||
const onlineFormApiRef = useRef(null);
|
||||
const redeemFormApiRef = useRef(null);
|
||||
@@ -261,44 +263,58 @@ const RechargeCard = ({
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={24} lg={14} xl={14}>
|
||||
<Form.Slot label={t('选择支付方式')}>
|
||||
<Space wrap>
|
||||
{payMethods.map((payMethod) => (
|
||||
<Button
|
||||
key={payMethod.type}
|
||||
theme='outline'
|
||||
type='tertiary'
|
||||
onClick={() => preTopUp(payMethod.type)}
|
||||
disabled={
|
||||
(!enableOnlineTopUp &&
|
||||
payMethod.type !== 'stripe') ||
|
||||
(!enableStripeTopUp &&
|
||||
payMethod.type === 'stripe')
|
||||
}
|
||||
loading={
|
||||
paymentLoading && payWay === payMethod.type
|
||||
}
|
||||
icon={
|
||||
payMethod.type === 'alipay' ? (
|
||||
<SiAlipay size={18} color='#1677FF' />
|
||||
) : payMethod.type === 'wxpay' ? (
|
||||
<SiWechat size={18} color='#07C160' />
|
||||
) : payMethod.type === 'stripe' ? (
|
||||
<SiStripe size={18} color='#635BFF' />
|
||||
) : (
|
||||
<CreditCard
|
||||
size={18}
|
||||
color={
|
||||
payMethod.color ||
|
||||
'var(--semi-color-text-2)'
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
>
|
||||
{payMethod.name}
|
||||
</Button>
|
||||
))}
|
||||
</Space>
|
||||
{payMethods && payMethods.length > 0 ? (
|
||||
<Space wrap>
|
||||
{payMethods.map((payMethod) => {
|
||||
const minTopupVal = Number(payMethod.min_topup) || 0;
|
||||
const isStripe = payMethod.type === 'stripe';
|
||||
const disabled =
|
||||
(!enableOnlineTopUp && !isStripe) ||
|
||||
(!enableStripeTopUp && isStripe) ||
|
||||
minTopupVal > Number(topUpCount || 0);
|
||||
|
||||
const buttonEl = (
|
||||
<Button
|
||||
key={payMethod.type}
|
||||
theme='outline'
|
||||
type='tertiary'
|
||||
onClick={() => preTopUp(payMethod.type)}
|
||||
disabled={disabled}
|
||||
loading={paymentLoading && payWay === payMethod.type}
|
||||
icon={
|
||||
payMethod.type === 'alipay' ? (
|
||||
<SiAlipay size={18} color='#1677FF' />
|
||||
) : payMethod.type === 'wxpay' ? (
|
||||
<SiWechat size={18} color='#07C160' />
|
||||
) : payMethod.type === 'stripe' ? (
|
||||
<SiStripe size={18} color='#635BFF' />
|
||||
) : (
|
||||
<CreditCard
|
||||
size={18}
|
||||
color={payMethod.color || 'var(--semi-color-text-2)'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
className='!rounded-lg !px-4 !py-2'
|
||||
>
|
||||
{payMethod.name}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return disabled && minTopupVal > Number(topUpCount || 0) ? (
|
||||
<Tooltip content={t('此支付方式最低充值金额为') + ' ' + minTopupVal} key={payMethod.type}>
|
||||
{buttonEl}
|
||||
</Tooltip>
|
||||
) : (
|
||||
<React.Fragment key={payMethod.type}>{buttonEl}</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</Space>
|
||||
) : (
|
||||
<div className='text-gray-500 text-sm p-3 bg-gray-50 rounded-lg border border-dashed border-gray-300'>
|
||||
{t('暂无可用的支付方式,请联系管理员配置')}
|
||||
</div>
|
||||
)}
|
||||
</Form.Slot>
|
||||
</Col>
|
||||
</Row>
|
||||
@@ -306,41 +322,60 @@ const RechargeCard = ({
|
||||
|
||||
{(enableOnlineTopUp || enableStripeTopUp) && (
|
||||
<Form.Slot label={t('选择充值额度')}>
|
||||
<Space wrap>
|
||||
{presetAmounts.map((preset, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
theme={
|
||||
selectedPreset === preset.value
|
||||
? 'solid'
|
||||
: 'outline'
|
||||
}
|
||||
type={
|
||||
selectedPreset === preset.value
|
||||
? 'primary'
|
||||
: 'tertiary'
|
||||
}
|
||||
onClick={() => {
|
||||
selectPresetAmount(preset);
|
||||
onlineFormApiRef.current?.setValue(
|
||||
'topUpCount',
|
||||
preset.value,
|
||||
);
|
||||
}}
|
||||
className='!rounded-lg !py-2 !px-3'
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Coins size={14} className='opacity-80' />
|
||||
<span className='font-medium'>
|
||||
{formatLargeNumber(preset.value)}
|
||||
</span>
|
||||
<span className='text-xs text-gray-500'>
|
||||
¥{(preset.value * priceRatio).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
))}
|
||||
</Space>
|
||||
<div className='grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2'>
|
||||
{presetAmounts.map((preset, index) => {
|
||||
const discount = preset.discount || topupInfo?.discount?.[preset.value] || 1.0;
|
||||
const originalPrice = preset.value * priceRatio;
|
||||
const discountedPrice = originalPrice * discount;
|
||||
const hasDiscount = discount < 1.0;
|
||||
const actualPay = discountedPrice;
|
||||
const save = originalPrice - discountedPrice;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={index}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
border: selectedPreset === preset.value
|
||||
? '2px solid var(--semi-color-primary)'
|
||||
: '1px solid var(--semi-color-border)',
|
||||
height: '100%',
|
||||
width: '100%'
|
||||
}}
|
||||
bodyStyle={{ padding: '12px' }}
|
||||
onClick={() => {
|
||||
selectPresetAmount(preset);
|
||||
onlineFormApiRef.current?.setValue(
|
||||
'topUpCount',
|
||||
preset.value,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Typography.Title heading={6} style={{ margin: '0 0 8px 0' }}>
|
||||
<Coins size={18} />
|
||||
{formatLargeNumber(preset.value)}
|
||||
{hasDiscount && (
|
||||
<Tag style={{ marginLeft: 4 }} color="green">
|
||||
{t('折').includes('off') ?
|
||||
((1 - parseFloat(discount)) * 100).toFixed(1) :
|
||||
(discount * 10).toFixed(1)}{t('折')}
|
||||
</Tag>
|
||||
)}
|
||||
</Typography.Title>
|
||||
<div style={{
|
||||
color: 'var(--semi-color-text-2)',
|
||||
fontSize: '12px',
|
||||
margin: '4px 0'
|
||||
}}>
|
||||
{t('实付')} {actualPay.toFixed(2)},
|
||||
{hasDiscount ? `${t('节省')} ${save.toFixed(2)}` : `${t('节省')} 0.00`}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Form.Slot>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -80,6 +80,12 @@ const TopUp = () => {
|
||||
// 预设充值额度选项
|
||||
const [presetAmounts, setPresetAmounts] = useState([]);
|
||||
const [selectedPreset, setSelectedPreset] = useState(null);
|
||||
|
||||
// 充值配置信息
|
||||
const [topupInfo, setTopupInfo] = useState({
|
||||
amount_options: [],
|
||||
discount: {}
|
||||
});
|
||||
|
||||
const topUp = async () => {
|
||||
if (redemptionCode === '') {
|
||||
@@ -248,6 +254,99 @@ const TopUp = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 获取充值配置信息
|
||||
const getTopupInfo = async () => {
|
||||
try {
|
||||
const res = await API.get('/api/user/topup/info');
|
||||
const { message, data, success } = res.data;
|
||||
if (success) {
|
||||
setTopupInfo({
|
||||
amount_options: data.amount_options || [],
|
||||
discount: data.discount || {}
|
||||
});
|
||||
|
||||
// 处理支付方式
|
||||
let payMethods = data.pay_methods || [];
|
||||
try {
|
||||
if (typeof payMethods === 'string') {
|
||||
payMethods = JSON.parse(payMethods);
|
||||
}
|
||||
if (payMethods && payMethods.length > 0) {
|
||||
// 检查name和type是否为空
|
||||
payMethods = payMethods.filter((method) => {
|
||||
return method.name && method.type;
|
||||
});
|
||||
// 如果没有color,则设置默认颜色
|
||||
payMethods = payMethods.map((method) => {
|
||||
// 规范化最小充值数
|
||||
const normalizedMinTopup = Number(method.min_topup);
|
||||
method.min_topup = Number.isFinite(normalizedMinTopup) ? normalizedMinTopup : 0;
|
||||
|
||||
// Stripe 的最小充值从后端字段回填
|
||||
if (method.type === 'stripe' && (!method.min_topup || method.min_topup <= 0)) {
|
||||
const stripeMin = Number(data.stripe_min_topup);
|
||||
if (Number.isFinite(stripeMin)) {
|
||||
method.min_topup = stripeMin;
|
||||
}
|
||||
}
|
||||
|
||||
if (!method.color) {
|
||||
if (method.type === 'alipay') {
|
||||
method.color = 'rgba(var(--semi-blue-5), 1)';
|
||||
} else if (method.type === 'wxpay') {
|
||||
method.color = 'rgba(var(--semi-green-5), 1)';
|
||||
} else if (method.type === 'stripe') {
|
||||
method.color = 'rgba(var(--semi-purple-5), 1)';
|
||||
} else {
|
||||
method.color = 'rgba(var(--semi-primary-5), 1)';
|
||||
}
|
||||
}
|
||||
return method;
|
||||
});
|
||||
} else {
|
||||
payMethods = [];
|
||||
}
|
||||
|
||||
// 如果启用了 Stripe 支付,添加到支付方法列表
|
||||
// 这个逻辑现在由后端处理,如果 Stripe 启用,后端会在 pay_methods 中包含它
|
||||
|
||||
setPayMethods(payMethods);
|
||||
const enableStripeTopUp = data.enable_stripe_topup || false;
|
||||
const enableOnlineTopUp = data.enable_online_topup || false;
|
||||
const minTopUpValue = enableOnlineTopUp? data.min_topup : enableStripeTopUp? data.stripe_min_topup : 1;
|
||||
setEnableOnlineTopUp(enableOnlineTopUp);
|
||||
setEnableStripeTopUp(enableStripeTopUp);
|
||||
setMinTopUp(minTopUpValue);
|
||||
setTopUpCount(minTopUpValue);
|
||||
|
||||
// 如果没有自定义充值数量选项,根据最小充值金额生成预设充值额度选项
|
||||
if (topupInfo.amount_options.length === 0) {
|
||||
setPresetAmounts(generatePresetAmounts(minTopUpValue));
|
||||
}
|
||||
|
||||
// 初始化显示实付金额
|
||||
getAmount(minTopUpValue);
|
||||
} catch (e) {
|
||||
console.log('解析支付方式失败:', e);
|
||||
setPayMethods([]);
|
||||
}
|
||||
|
||||
// 如果有自定义充值数量选项,使用它们替换默认的预设选项
|
||||
if (data.amount_options && data.amount_options.length > 0) {
|
||||
const customPresets = data.amount_options.map(amount => ({
|
||||
value: amount,
|
||||
discount: data.discount[amount] || 1.0
|
||||
}));
|
||||
setPresetAmounts(customPresets);
|
||||
}
|
||||
} else {
|
||||
console.error('获取充值配置失败:', data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取充值配置异常:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取邀请链接
|
||||
const getAffLink = async () => {
|
||||
const res = await API.get('/api/user/aff');
|
||||
@@ -290,52 +389,7 @@ const TopUp = () => {
|
||||
getUserQuota().then();
|
||||
}
|
||||
setTransferAmount(getQuotaPerUnit());
|
||||
|
||||
let payMethods = localStorage.getItem('pay_methods');
|
||||
try {
|
||||
payMethods = JSON.parse(payMethods);
|
||||
if (payMethods && payMethods.length > 0) {
|
||||
// 检查name和type是否为空
|
||||
payMethods = payMethods.filter((method) => {
|
||||
return method.name && method.type;
|
||||
});
|
||||
// 如果没有color,则设置默认颜色
|
||||
payMethods = payMethods.map((method) => {
|
||||
if (!method.color) {
|
||||
if (method.type === 'alipay') {
|
||||
method.color = 'rgba(var(--semi-blue-5), 1)';
|
||||
} else if (method.type === 'wxpay') {
|
||||
method.color = 'rgba(var(--semi-green-5), 1)';
|
||||
} else if (method.type === 'stripe') {
|
||||
method.color = 'rgba(var(--semi-purple-5), 1)';
|
||||
} else {
|
||||
method.color = 'rgba(var(--semi-primary-5), 1)';
|
||||
}
|
||||
}
|
||||
return method;
|
||||
});
|
||||
} else {
|
||||
payMethods = [];
|
||||
}
|
||||
|
||||
// 如果启用了 Stripe 支付,添加到支付方法列表
|
||||
if (statusState?.status?.enable_stripe_topup) {
|
||||
const hasStripe = payMethods.some((method) => method.type === 'stripe');
|
||||
if (!hasStripe) {
|
||||
payMethods.push({
|
||||
name: 'Stripe',
|
||||
type: 'stripe',
|
||||
color: 'rgba(var(--semi-purple-5), 1)',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setPayMethods(payMethods);
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
showError(t('支付方式配置错误, 请联系管理员'));
|
||||
}
|
||||
}, [statusState?.status?.enable_stripe_topup]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (affFetchedRef.current) return;
|
||||
@@ -343,20 +397,18 @@ const TopUp = () => {
|
||||
getAffLink().then();
|
||||
}, []);
|
||||
|
||||
// 在 statusState 可用时获取充值信息
|
||||
useEffect(() => {
|
||||
getTopupInfo().then();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (statusState?.status) {
|
||||
const minTopUpValue = statusState.status.min_topup || 1;
|
||||
setMinTopUp(minTopUpValue);
|
||||
setTopUpCount(minTopUpValue);
|
||||
// const minTopUpValue = statusState.status.min_topup || 1;
|
||||
// setMinTopUp(minTopUpValue);
|
||||
// setTopUpCount(minTopUpValue);
|
||||
setTopUpLink(statusState.status.top_up_link || '');
|
||||
setEnableOnlineTopUp(statusState.status.enable_online_topup || false);
|
||||
setPriceRatio(statusState.status.price || 1);
|
||||
setEnableStripeTopUp(statusState.status.enable_stripe_topup || false);
|
||||
|
||||
// 根据最小充值金额生成预设充值额度选项
|
||||
setPresetAmounts(generatePresetAmounts(minTopUpValue));
|
||||
// 初始化显示实付金额
|
||||
getAmount(minTopUpValue);
|
||||
|
||||
setStatusLoading(false);
|
||||
}
|
||||
@@ -431,7 +483,11 @@ const TopUp = () => {
|
||||
const selectPresetAmount = (preset) => {
|
||||
setTopUpCount(preset.value);
|
||||
setSelectedPreset(preset.value);
|
||||
setAmount(preset.value * priceRatio);
|
||||
|
||||
// 计算实际支付金额,考虑折扣
|
||||
const discount = preset.discount || topupInfo.discount[preset.value] || 1.0;
|
||||
const discountedAmount = preset.value * priceRatio * discount;
|
||||
setAmount(discountedAmount);
|
||||
};
|
||||
|
||||
// 格式化大数字显示
|
||||
@@ -475,6 +531,8 @@ const TopUp = () => {
|
||||
renderAmount={renderAmount}
|
||||
payWay={payWay}
|
||||
payMethods={payMethods}
|
||||
amountNumber={amount}
|
||||
discountRate={topupInfo?.discount?.[topUpCount] || 1.0}
|
||||
/>
|
||||
|
||||
{/* 用户信息头部 */}
|
||||
@@ -512,6 +570,7 @@ const TopUp = () => {
|
||||
userState={userState}
|
||||
renderQuota={renderQuota}
|
||||
statusLoading={statusLoading}
|
||||
topupInfo={topupInfo}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -36,7 +36,13 @@ const PaymentConfirmModal = ({
|
||||
renderAmount,
|
||||
payWay,
|
||||
payMethods,
|
||||
// 新增:用于显示折扣明细
|
||||
amountNumber,
|
||||
discountRate,
|
||||
}) => {
|
||||
const hasDiscount = discountRate && discountRate > 0 && discountRate < 1 && amountNumber > 0;
|
||||
const originalAmount = hasDiscount ? (amountNumber / discountRate) : 0;
|
||||
const discountAmount = hasDiscount ? (originalAmount - amountNumber) : 0;
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
@@ -71,11 +77,38 @@ const PaymentConfirmModal = ({
|
||||
{amountLoading ? (
|
||||
<Skeleton.Title style={{ width: '60px', height: '16px' }} />
|
||||
) : (
|
||||
<Text strong className='font-bold' style={{ color: 'red' }}>
|
||||
{renderAmount()}
|
||||
</Text>
|
||||
<div className='flex items-baseline space-x-2'>
|
||||
<Text strong className='font-bold' style={{ color: 'red' }}>
|
||||
{renderAmount()}
|
||||
</Text>
|
||||
{hasDiscount && (
|
||||
<Text size='small' className='text-rose-500'>
|
||||
{Math.round(discountRate * 100)}%
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{hasDiscount && !amountLoading && (
|
||||
<>
|
||||
<div className='flex justify-between items-center'>
|
||||
<Text className='text-slate-500 dark:text-slate-400'>
|
||||
{t('原价')}:
|
||||
</Text>
|
||||
<Text delete className='text-slate-500 dark:text-slate-400'>
|
||||
{`${originalAmount.toFixed(2)} ${t('元')}`}
|
||||
</Text>
|
||||
</div>
|
||||
<div className='flex justify-between items-center'>
|
||||
<Text className='text-slate-500 dark:text-slate-400'>
|
||||
{t('优惠')}:
|
||||
</Text>
|
||||
<Text className='text-emerald-600 dark:text-emerald-400'>
|
||||
{`- ${discountAmount.toFixed(2)} ${t('元')}`}
|
||||
</Text>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className='flex justify-between items-center'>
|
||||
<Text strong className='text-slate-700 dark:text-slate-200'>
|
||||
{t('支付方式')}:
|
||||
|
||||
Reference in New Issue
Block a user