Merge branch 'alpha' into imageratio-and-audioratio-edit

This commit is contained in:
creamlike1024
2025-09-15 14:12:24 +08:00
139 changed files with 7093 additions and 1483 deletions

View File

@@ -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>

View File

@@ -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 />}

View File

@@ -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={

View File

@@ -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>
}

View File

@@ -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';

View File

@@ -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;

View File

@@ -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';

View File

@@ -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>
);

View 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;

View File

@@ -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,

View File

@@ -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}>

View File

@@ -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,

View File

@@ -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

View File

@@ -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('同步接口'),

View File

@@ -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} />

View File

@@ -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':

View File

@@ -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,

View File

@@ -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>

View File

@@ -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,

View File

@@ -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}>

View File

@@ -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}
/>
</>
);
};

View File

@@ -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',

View File

@@ -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}

View File

@@ -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'

View File

@@ -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'

View 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;

View 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;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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('支付方式')}