Merge branch 'alpha' into imageratio-and-audioratio-edit
This commit is contained in:
@@ -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, { lazy, Suspense } from 'react';
|
||||
import React, { lazy, Suspense, useContext, useMemo } from 'react';
|
||||
import { Route, Routes, useLocation } from 'react-router-dom';
|
||||
import Loading from './components/common/ui/Loading';
|
||||
import User from './pages/User';
|
||||
@@ -27,6 +27,7 @@ import LoginForm from './components/auth/LoginForm';
|
||||
import NotFound from './pages/NotFound';
|
||||
import Forbidden from './pages/Forbidden';
|
||||
import Setting from './pages/Setting';
|
||||
import { StatusContext } from './context/Status';
|
||||
|
||||
import PasswordResetForm from './components/auth/PasswordResetForm';
|
||||
import PasswordResetConfirm from './components/auth/PasswordResetConfirm';
|
||||
@@ -53,6 +54,29 @@ const About = lazy(() => import('./pages/About'));
|
||||
|
||||
function App() {
|
||||
const location = useLocation();
|
||||
const [statusState] = useContext(StatusContext);
|
||||
|
||||
// 获取模型广场权限配置
|
||||
const pricingRequireAuth = useMemo(() => {
|
||||
const headerNavModulesConfig = statusState?.status?.HeaderNavModules;
|
||||
if (headerNavModulesConfig) {
|
||||
try {
|
||||
const modules = JSON.parse(headerNavModulesConfig);
|
||||
|
||||
// 处理向后兼容性:如果pricing是boolean,默认不需要登录
|
||||
if (typeof modules.pricing === 'boolean') {
|
||||
return false; // 默认不需要登录鉴权
|
||||
}
|
||||
|
||||
// 如果是对象格式,使用requireAuth配置
|
||||
return modules.pricing?.requireAuth === true;
|
||||
} catch (error) {
|
||||
console.error('解析顶栏模块配置失败:', error);
|
||||
return false; // 默认不需要登录
|
||||
}
|
||||
}
|
||||
return false; // 默认不需要登录
|
||||
}, [statusState?.status?.HeaderNavModules]);
|
||||
|
||||
return (
|
||||
<SetupCheck>
|
||||
@@ -253,9 +277,20 @@ function App() {
|
||||
<Route
|
||||
path='/pricing'
|
||||
element={
|
||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
||||
<Pricing />
|
||||
</Suspense>
|
||||
pricingRequireAuth ? (
|
||||
<PrivateRoute>
|
||||
<Suspense
|
||||
fallback={<Loading></Loading>}
|
||||
key={location.pathname}
|
||||
>
|
||||
<Pricing />
|
||||
</Suspense>
|
||||
</PrivateRoute>
|
||||
) : (
|
||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
||||
<Pricing />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
|
||||
@@ -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('支付方式')}:
|
||||
|
||||
@@ -28,7 +28,6 @@ export function setStatusData(data) {
|
||||
localStorage.setItem('enable_task', data.enable_task);
|
||||
localStorage.setItem('enable_data_export', data.enable_data_export);
|
||||
localStorage.setItem('chats', JSON.stringify(data.chats));
|
||||
localStorage.setItem('pay_methods', JSON.stringify(data.pay_methods));
|
||||
localStorage.setItem(
|
||||
'data_export_default_time',
|
||||
data.data_export_default_time,
|
||||
|
||||
@@ -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 { useState, useEffect, useContext, useCallback } from 'react';
|
||||
import { useState, useEffect, useContext, useCallback, useMemo } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { UserContext } from '../../context/User';
|
||||
@@ -51,6 +51,42 @@ export const useHeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
|
||||
const docsLink = statusState?.status?.docs_link || '';
|
||||
const isDemoSiteMode = statusState?.status?.demo_site_enabled || false;
|
||||
|
||||
// 获取顶栏模块配置
|
||||
const headerNavModulesConfig = statusState?.status?.HeaderNavModules;
|
||||
|
||||
// 使用useMemo确保headerNavModules正确响应statusState变化
|
||||
const headerNavModules = useMemo(() => {
|
||||
if (headerNavModulesConfig) {
|
||||
try {
|
||||
const modules = JSON.parse(headerNavModulesConfig);
|
||||
|
||||
// 处理向后兼容性:如果pricing是boolean,转换为对象格式
|
||||
if (typeof modules.pricing === 'boolean') {
|
||||
modules.pricing = {
|
||||
enabled: modules.pricing,
|
||||
requireAuth: false, // 默认不需要登录鉴权
|
||||
};
|
||||
}
|
||||
|
||||
return modules;
|
||||
} catch (error) {
|
||||
console.error('解析顶栏模块配置失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [headerNavModulesConfig]);
|
||||
|
||||
// 获取模型广场权限配置
|
||||
const pricingRequireAuth = useMemo(() => {
|
||||
if (headerNavModules?.pricing) {
|
||||
return typeof headerNavModules.pricing === 'object'
|
||||
? headerNavModules.pricing.requireAuth
|
||||
: false; // 默认不需要登录
|
||||
}
|
||||
return false; // 默认不需要登录
|
||||
}, [headerNavModules]);
|
||||
|
||||
const isConsoleRoute = location.pathname.startsWith('/console');
|
||||
|
||||
const theme = useTheme();
|
||||
@@ -156,6 +192,8 @@ export const useHeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
|
||||
isConsoleRoute,
|
||||
theme,
|
||||
drawerOpen,
|
||||
headerNavModules,
|
||||
pricingRequireAuth,
|
||||
|
||||
// Actions
|
||||
logout,
|
||||
|
||||
@@ -19,9 +19,21 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
export const useNavigation = (t, docsLink) => {
|
||||
const mainNavLinks = useMemo(
|
||||
() => [
|
||||
export const useNavigation = (t, docsLink, headerNavModules) => {
|
||||
const mainNavLinks = useMemo(() => {
|
||||
// 默认配置,如果没有传入配置则显示所有模块
|
||||
const defaultModules = {
|
||||
home: true,
|
||||
console: true,
|
||||
pricing: true,
|
||||
docs: true,
|
||||
about: true,
|
||||
};
|
||||
|
||||
// 使用传入的配置或默认配置
|
||||
const modules = headerNavModules || defaultModules;
|
||||
|
||||
const allLinks = [
|
||||
{
|
||||
text: t('首页'),
|
||||
itemKey: 'home',
|
||||
@@ -52,9 +64,22 @@ export const useNavigation = (t, docsLink) => {
|
||||
itemKey: 'about',
|
||||
to: '/about',
|
||||
},
|
||||
],
|
||||
[t, docsLink],
|
||||
);
|
||||
];
|
||||
|
||||
// 根据配置过滤导航链接
|
||||
return allLinks.filter((link) => {
|
||||
if (link.itemKey === 'docs') {
|
||||
return docsLink && modules.docs;
|
||||
}
|
||||
if (link.itemKey === 'pricing') {
|
||||
// 支持新的pricing配置格式
|
||||
return typeof modules.pricing === 'object'
|
||||
? modules.pricing.enabled
|
||||
: modules.pricing;
|
||||
}
|
||||
return modules[link.itemKey] === true;
|
||||
});
|
||||
}, [t, docsLink, headerNavModules]);
|
||||
|
||||
return {
|
||||
mainNavLinks,
|
||||
|
||||
227
web/src/hooks/common/useSidebar.js
Normal file
227
web/src/hooks/common/useSidebar.js
Normal file
@@ -0,0 +1,227 @@
|
||||
/*
|
||||
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 { useState, useEffect, useMemo, useContext } from 'react';
|
||||
import { StatusContext } from '../../context/Status';
|
||||
import { API } from '../../helpers';
|
||||
|
||||
export const useSidebar = () => {
|
||||
const [statusState] = useContext(StatusContext);
|
||||
const [userConfig, setUserConfig] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// 默认配置
|
||||
const defaultAdminConfig = {
|
||||
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 = useMemo(() => {
|
||||
if (statusState?.status?.SidebarModulesAdmin) {
|
||||
try {
|
||||
const config = JSON.parse(statusState.status.SidebarModulesAdmin);
|
||||
return config;
|
||||
} catch (error) {
|
||||
return defaultAdminConfig;
|
||||
}
|
||||
}
|
||||
return defaultAdminConfig;
|
||||
}, [statusState?.status?.SidebarModulesAdmin]);
|
||||
|
||||
// 加载用户配置的通用方法
|
||||
const loadUserConfig = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await API.get('/api/user/self');
|
||||
if (res.data.success && res.data.data.sidebar_modules) {
|
||||
let config;
|
||||
// 检查sidebar_modules是字符串还是对象
|
||||
if (typeof res.data.data.sidebar_modules === 'string') {
|
||||
config = JSON.parse(res.data.data.sidebar_modules);
|
||||
} else {
|
||||
config = res.data.data.sidebar_modules;
|
||||
}
|
||||
setUserConfig(config);
|
||||
} else {
|
||||
// 当用户没有配置时,生成一个基于管理员配置的默认用户配置
|
||||
// 这样可以确保权限控制正确生效
|
||||
const defaultUserConfig = {};
|
||||
Object.keys(adminConfig).forEach((sectionKey) => {
|
||||
if (adminConfig[sectionKey]?.enabled) {
|
||||
defaultUserConfig[sectionKey] = { enabled: true };
|
||||
// 为每个管理员允许的模块设置默认值为true
|
||||
Object.keys(adminConfig[sectionKey]).forEach((moduleKey) => {
|
||||
if (
|
||||
moduleKey !== 'enabled' &&
|
||||
adminConfig[sectionKey][moduleKey]
|
||||
) {
|
||||
defaultUserConfig[sectionKey][moduleKey] = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
setUserConfig(defaultUserConfig);
|
||||
}
|
||||
} catch (error) {
|
||||
// 出错时也生成默认配置,而不是设置为空对象
|
||||
const defaultUserConfig = {};
|
||||
Object.keys(adminConfig).forEach((sectionKey) => {
|
||||
if (adminConfig[sectionKey]?.enabled) {
|
||||
defaultUserConfig[sectionKey] = { enabled: true };
|
||||
Object.keys(adminConfig[sectionKey]).forEach((moduleKey) => {
|
||||
if (moduleKey !== 'enabled' && adminConfig[sectionKey][moduleKey]) {
|
||||
defaultUserConfig[sectionKey][moduleKey] = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
setUserConfig(defaultUserConfig);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 刷新用户配置的方法(供外部调用)
|
||||
const refreshUserConfig = async () => {
|
||||
if (Object.keys(adminConfig).length > 0) {
|
||||
await loadUserConfig();
|
||||
}
|
||||
};
|
||||
|
||||
// 加载用户配置
|
||||
useEffect(() => {
|
||||
// 只有当管理员配置加载完成后才加载用户配置
|
||||
if (Object.keys(adminConfig).length > 0) {
|
||||
loadUserConfig();
|
||||
}
|
||||
}, [adminConfig]);
|
||||
|
||||
// 计算最终的显示配置
|
||||
const finalConfig = useMemo(() => {
|
||||
const result = {};
|
||||
|
||||
// 确保adminConfig已加载
|
||||
if (!adminConfig || Object.keys(adminConfig).length === 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// 如果userConfig未加载,等待加载完成
|
||||
if (!userConfig) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// 遍历所有区域
|
||||
Object.keys(adminConfig).forEach((sectionKey) => {
|
||||
const adminSection = adminConfig[sectionKey];
|
||||
const userSection = userConfig[sectionKey];
|
||||
|
||||
// 如果管理员禁用了整个区域,则该区域不显示
|
||||
if (!adminSection?.enabled) {
|
||||
result[sectionKey] = { enabled: false };
|
||||
return;
|
||||
}
|
||||
|
||||
// 区域级别:用户可以选择隐藏管理员允许的区域
|
||||
// 当userSection存在时检查enabled状态,否则默认为true
|
||||
const sectionEnabled = userSection ? userSection.enabled !== false : true;
|
||||
result[sectionKey] = { enabled: sectionEnabled };
|
||||
|
||||
// 功能级别:只有管理员和用户都允许的功能才显示
|
||||
Object.keys(adminSection).forEach((moduleKey) => {
|
||||
if (moduleKey === 'enabled') return;
|
||||
|
||||
const adminAllowed = adminSection[moduleKey];
|
||||
// 当userSection存在时检查模块状态,否则默认为true
|
||||
const userAllowed = userSection
|
||||
? userSection[moduleKey] !== false
|
||||
: true;
|
||||
|
||||
result[sectionKey][moduleKey] =
|
||||
adminAllowed && userAllowed && sectionEnabled;
|
||||
});
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [adminConfig, userConfig]);
|
||||
|
||||
// 检查特定功能是否应该显示
|
||||
const isModuleVisible = (sectionKey, moduleKey = null) => {
|
||||
if (moduleKey) {
|
||||
return finalConfig[sectionKey]?.[moduleKey] === true;
|
||||
} else {
|
||||
return finalConfig[sectionKey]?.enabled === true;
|
||||
}
|
||||
};
|
||||
|
||||
// 检查区域是否有任何可见的功能
|
||||
const hasSectionVisibleModules = (sectionKey) => {
|
||||
const section = finalConfig[sectionKey];
|
||||
if (!section?.enabled) return false;
|
||||
|
||||
return Object.keys(section).some(
|
||||
(key) => key !== 'enabled' && section[key] === true,
|
||||
);
|
||||
};
|
||||
|
||||
// 获取区域的可见功能列表
|
||||
const getVisibleModules = (sectionKey) => {
|
||||
const section = finalConfig[sectionKey];
|
||||
if (!section?.enabled) return [];
|
||||
|
||||
return Object.keys(section).filter(
|
||||
(key) => key !== 'enabled' && section[key] === true,
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
loading,
|
||||
adminConfig,
|
||||
userConfig,
|
||||
finalConfig,
|
||||
isModuleVisible,
|
||||
hasSectionVisibleModules,
|
||||
getVisibleModules,
|
||||
refreshUserConfig,
|
||||
};
|
||||
};
|
||||
119
web/src/hooks/common/useUserPermissions.js
Normal file
119
web/src/hooks/common/useUserPermissions.js
Normal file
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
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 { useState, useEffect } from 'react';
|
||||
import { API } from '../../helpers';
|
||||
|
||||
/**
|
||||
* 用户权限钩子 - 从后端获取用户权限,替代前端角色判断
|
||||
* 确保权限控制的安全性,防止前端绕过
|
||||
*/
|
||||
export const useUserPermissions = () => {
|
||||
const [permissions, setPermissions] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// 加载用户权限(从用户信息接口获取)
|
||||
const loadPermissions = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await API.get('/api/user/self');
|
||||
if (res.data.success) {
|
||||
const userPermissions = res.data.data.permissions;
|
||||
setPermissions(userPermissions);
|
||||
console.log('用户权限加载成功:', userPermissions);
|
||||
} else {
|
||||
setError(res.data.message || '获取权限失败');
|
||||
console.error('获取权限失败:', res.data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
setError('网络错误,请重试');
|
||||
console.error('加载用户权限异常:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadPermissions();
|
||||
}, []);
|
||||
|
||||
// 检查是否有边栏设置权限
|
||||
const hasSidebarSettingsPermission = () => {
|
||||
return permissions?.sidebar_settings === true;
|
||||
};
|
||||
|
||||
// 检查是否允许访问特定的边栏区域
|
||||
const isSidebarSectionAllowed = (sectionKey) => {
|
||||
if (!permissions?.sidebar_modules) return true;
|
||||
const sectionPerms = permissions.sidebar_modules[sectionKey];
|
||||
return sectionPerms !== false;
|
||||
};
|
||||
|
||||
// 检查是否允许访问特定的边栏模块
|
||||
const isSidebarModuleAllowed = (sectionKey, moduleKey) => {
|
||||
if (!permissions?.sidebar_modules) return true;
|
||||
const sectionPerms = permissions.sidebar_modules[sectionKey];
|
||||
|
||||
// 如果整个区域被禁用
|
||||
if (sectionPerms === false) return false;
|
||||
|
||||
// 如果区域存在但模块被禁用
|
||||
if (sectionPerms && sectionPerms[moduleKey] === false) return false;
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// 获取允许的边栏区域列表
|
||||
const getAllowedSidebarSections = () => {
|
||||
if (!permissions?.sidebar_modules) return [];
|
||||
|
||||
return Object.keys(permissions.sidebar_modules).filter((sectionKey) =>
|
||||
isSidebarSectionAllowed(sectionKey),
|
||||
);
|
||||
};
|
||||
|
||||
// 获取特定区域允许的模块列表
|
||||
const getAllowedSidebarModules = (sectionKey) => {
|
||||
if (!permissions?.sidebar_modules) return [];
|
||||
const sectionPerms = permissions.sidebar_modules[sectionKey];
|
||||
|
||||
if (sectionPerms === false) return [];
|
||||
if (!sectionPerms || typeof sectionPerms !== 'object') return [];
|
||||
|
||||
return Object.keys(sectionPerms).filter(
|
||||
(moduleKey) =>
|
||||
moduleKey !== 'enabled' && sectionPerms[moduleKey] === true,
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
permissions,
|
||||
loading,
|
||||
error,
|
||||
loadPermissions,
|
||||
hasSidebarSettingsPermission,
|
||||
isSidebarSectionAllowed,
|
||||
isSidebarModuleAllowed,
|
||||
getAllowedSidebarSections,
|
||||
getAllowedSidebarModules,
|
||||
};
|
||||
};
|
||||
|
||||
export default useUserPermissions;
|
||||
@@ -95,6 +95,8 @@ export const useModelsData = () => {
|
||||
const [showAddVendor, setShowAddVendor] = useState(false);
|
||||
const [showEditVendor, setShowEditVendor] = useState(false);
|
||||
const [editingVendor, setEditingVendor] = useState({ id: undefined });
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [previewing, setPreviewing] = useState(false);
|
||||
|
||||
const vendorMap = useMemo(() => {
|
||||
const map = {};
|
||||
@@ -163,6 +165,91 @@ export const useModelsData = () => {
|
||||
await loadModels(page, pageSize);
|
||||
};
|
||||
|
||||
// Sync upstream models/vendors for missing models only
|
||||
const syncUpstream = async (opts = {}) => {
|
||||
const locale = opts?.locale;
|
||||
setSyncing(true);
|
||||
try {
|
||||
const body = {};
|
||||
if (locale) body.locale = locale;
|
||||
const res = await API.post('/api/models/sync_upstream', body);
|
||||
const { success, message, data } = res.data || {};
|
||||
if (success) {
|
||||
const createdModels = data?.created_models || 0;
|
||||
const createdVendors = data?.created_vendors || 0;
|
||||
const skipped = (data?.skipped_models || []).length || 0;
|
||||
showSuccess(
|
||||
t(
|
||||
`已同步:新增 ${createdModels} 模型,新增 ${createdVendors} 供应商,跳过 ${skipped} 项`,
|
||||
),
|
||||
);
|
||||
await loadVendors();
|
||||
await refresh();
|
||||
} else {
|
||||
showError(message || t('同步失败'));
|
||||
}
|
||||
} catch (e) {
|
||||
showError(t('同步失败'));
|
||||
}
|
||||
setSyncing(false);
|
||||
};
|
||||
|
||||
// Preview upstream differences
|
||||
const previewUpstreamDiff = async (opts = {}) => {
|
||||
const locale = opts?.locale;
|
||||
setPreviewing(true);
|
||||
try {
|
||||
const url = `/api/models/sync_upstream/preview${locale ? `?locale=${locale}` : ''}`;
|
||||
const res = await API.get(url);
|
||||
const { success, message, data } = res.data || {};
|
||||
if (success) {
|
||||
return data || { missing: [], conflicts: [] };
|
||||
}
|
||||
showError(message || t('预览失败'));
|
||||
return { missing: [], conflicts: [] };
|
||||
} catch (e) {
|
||||
showError(t('预览失败'));
|
||||
return { missing: [], conflicts: [] };
|
||||
} finally {
|
||||
setPreviewing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Apply selected overwrite
|
||||
const applyUpstreamOverwrite = async (payloadOrArray = []) => {
|
||||
const isArray = Array.isArray(payloadOrArray);
|
||||
const overwrite = isArray ? payloadOrArray : payloadOrArray.overwrite || [];
|
||||
const locale = isArray ? undefined : payloadOrArray.locale;
|
||||
setSyncing(true);
|
||||
try {
|
||||
const body = { overwrite };
|
||||
if (locale) body.locale = locale;
|
||||
const res = await API.post('/api/models/sync_upstream', body);
|
||||
const { success, message, data } = res.data || {};
|
||||
if (success) {
|
||||
const createdModels = data?.created_models || 0;
|
||||
const updatedModels = data?.updated_models || 0;
|
||||
const createdVendors = data?.created_vendors || 0;
|
||||
const skipped = (data?.skipped_models || []).length || 0;
|
||||
showSuccess(
|
||||
t(
|
||||
`完成:新增 ${createdModels} 模型,更新 ${updatedModels} 模型,新增 ${createdVendors} 供应商,跳过 ${skipped} 项`,
|
||||
),
|
||||
);
|
||||
await loadVendors();
|
||||
await refresh();
|
||||
return true;
|
||||
}
|
||||
showError(message || t('同步失败'));
|
||||
return false;
|
||||
} catch (e) {
|
||||
showError(t('同步失败'));
|
||||
return false;
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Search models with keyword and vendor
|
||||
const searchModels = async () => {
|
||||
const { searchKeyword = '', searchVendor = '' } = getFormValues();
|
||||
@@ -375,6 +462,7 @@ export const useModelsData = () => {
|
||||
copyText,
|
||||
|
||||
// Pagination
|
||||
setActivePage,
|
||||
handlePageChange,
|
||||
handlePageSizeChange,
|
||||
|
||||
@@ -398,5 +486,12 @@ export const useModelsData = () => {
|
||||
|
||||
// Translation
|
||||
t,
|
||||
|
||||
// Upstream sync
|
||||
syncing,
|
||||
previewing,
|
||||
syncUpstream,
|
||||
previewUpstreamDiff,
|
||||
applyUpstreamOverwrite,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"主页": "Home",
|
||||
"文档": "Docs",
|
||||
"控制台": "Console",
|
||||
"$%.6f 额度": "$%.6f quota",
|
||||
"或": "or",
|
||||
@@ -165,7 +164,6 @@
|
||||
"出现错误,第 ${count} 次重试中...": "Error occurred, retry attempt ${count}...",
|
||||
"首页": "Home",
|
||||
"渠道": "Channel",
|
||||
"渠道管理": "Channels",
|
||||
"令牌": "Tokens",
|
||||
"兑换额度": "Redeem",
|
||||
"充值": "Recharge",
|
||||
@@ -174,7 +172,6 @@
|
||||
"设置": "Settings",
|
||||
"关于": "About",
|
||||
"价格": "Pricing",
|
||||
"聊天": "Chat",
|
||||
"注销成功!": "Logout successful!",
|
||||
"注销": "Logout",
|
||||
"登录": "Sign in",
|
||||
@@ -443,9 +440,7 @@
|
||||
"兑换码": "Redeem Code",
|
||||
"管理用户": "Manage Users",
|
||||
"额度明细": "Quota Details",
|
||||
"个人设置": "Personal Settings",
|
||||
"运营设置": "Operation Settings",
|
||||
"系统设置": "System Settings",
|
||||
"其他设置": "Other Settings",
|
||||
"项目仓库地址": "Project Repository Address",
|
||||
"可在设置页面设置关于内容,支持 HTML & Markdown": "The About content can be set on the settings page, supporting HTML & Markdown",
|
||||
@@ -585,10 +580,7 @@
|
||||
"确定是否要修复数据库一致性?": "Are you sure you want to repair database consistency?",
|
||||
"进行该操作时,可能导致渠道访问错误,请仅在数据库出现问题时使用": "When performing this operation, it may cause channel access errors. Please only use it when there is a problem with the database.",
|
||||
"当前没有可用的启用令牌,请确认是否有令牌处于启用状态!": "There are currently no enablement tokens available, please confirm if one is enabled!",
|
||||
"令牌管理": "API Keys",
|
||||
"使用日志": "Usage log",
|
||||
"Midjourney日志": "Midjourney",
|
||||
"数据看板": "Dashboard",
|
||||
"模型列表": "Model list",
|
||||
"常见问题": "FAQ",
|
||||
"免费体验": "Free trial",
|
||||
@@ -628,7 +620,6 @@
|
||||
"重置成功": "Reset successful",
|
||||
"加载数据出错:": "Error loading data:",
|
||||
"加载数据时发生错误: ": "An error occurred while loading data:",
|
||||
"保存成功": "Saved successfully",
|
||||
"部分保存失败,请重试": "Partial saving failed, please try again",
|
||||
"请检查输入": "Please check your input",
|
||||
"如何区分不同分组不同模型的价格:供参考的配置方式": "How to distinguish the prices of different models in different groups: configuration method for reference",
|
||||
@@ -653,7 +644,6 @@
|
||||
"窗口等待": "window wait",
|
||||
"失败": "Failed",
|
||||
"绘图": "Drawing",
|
||||
"绘图日志": "Drawing log",
|
||||
"放大": "Upscalers",
|
||||
"微妙放大": "Upscale (Subtle)",
|
||||
"创造放大": "Upscale (Creative)",
|
||||
@@ -793,7 +783,6 @@
|
||||
"邮箱": "Email",
|
||||
"已有账户?": "Already have an account?",
|
||||
"创意任务": "Tasks",
|
||||
"用户管理": "User Management",
|
||||
"任务ID(点击查看详情)": "Task ID (click to view details)",
|
||||
"进度": "schedule",
|
||||
"花费时间": "spend time",
|
||||
@@ -943,7 +932,6 @@
|
||||
"不是合法的 JSON 字符串": "Not a valid JSON string",
|
||||
"个人中心": "Personal center",
|
||||
"代理商": "Agent",
|
||||
"钱包管理": "Wallet",
|
||||
"备注": "Remark",
|
||||
"工作台": "Workbench",
|
||||
"已复制:": "Copied:",
|
||||
@@ -957,7 +945,6 @@
|
||||
"黑夜模式": "Dark mode",
|
||||
"管理员设置": "Admin",
|
||||
"待更新": "To be updated",
|
||||
"模型广场": "Pricing",
|
||||
"支付中..": "Paying",
|
||||
"查看图片": "View pictures",
|
||||
"并发限制": "Concurrency limit",
|
||||
@@ -1043,7 +1030,6 @@
|
||||
"在iframe中加载": "Load in iframe",
|
||||
"补全倍率": "Completion ratio",
|
||||
"保存分组数据失败": "Failed to save group data",
|
||||
"保存失败,请重试": "Save failed, please try again",
|
||||
"没有可用的使用信息": "No usage information available",
|
||||
"使用详情": "Usage details",
|
||||
"收起": "Collapse",
|
||||
@@ -1187,7 +1173,6 @@
|
||||
"知识库 ID": "Knowledge Base ID",
|
||||
"请输入知识库 ID,例如:123456": "Please enter knowledge base ID, e.g.: 123456",
|
||||
"可选值": "Optional value",
|
||||
"任务日志": "Task log",
|
||||
"你好": "Hello",
|
||||
"你好,请问有什么可以帮助您的吗?": "Hello, how may I help you?",
|
||||
"用户分组": "Your default group",
|
||||
@@ -1329,7 +1314,6 @@
|
||||
"当剩余额度低于此数值时,系统将通过选择的方式发送通知": "When the remaining quota is lower than this value, the system will send a notification through the selected method",
|
||||
"Webhook请求结构": "Webhook request structure",
|
||||
"只支持https,系统将以 POST 方式发送通知,请确保地址可以接收 POST 请求": "Only https is supported, the system will send a notification through POST, please ensure the address can receive POST requests",
|
||||
"保存设置": "Save settings",
|
||||
"通知邮箱": "Notification email",
|
||||
"设置用于接收额度预警的邮箱地址,不填则使用账号绑定的邮箱": "Set the email address for receiving quota warning notifications, if not set, the email address bound to the account will be used",
|
||||
"留空则使用账号绑定的邮箱": "If left blank, the email address bound to the account will be used",
|
||||
@@ -1501,7 +1485,6 @@
|
||||
"收益": "Earnings",
|
||||
"无邀请人": "No Inviter",
|
||||
"邀请人": "Inviter",
|
||||
"兑换码管理": "Redemption Code",
|
||||
"设置兑换码的基本信息": "Set redemption code basic information",
|
||||
"设置兑换码的额度和数量": "Set redemption code quota and quantity",
|
||||
"编辑用户": "Edit User",
|
||||
@@ -1595,7 +1578,6 @@
|
||||
"加载中...": "Loading...",
|
||||
"正在跳转...": "Redirecting...",
|
||||
"暂无公告": "No Notice",
|
||||
"操练场": "Playground",
|
||||
"欢迎使用,请完成以下设置以开始使用系统": "Welcome to use, please complete the following settings to start using the system",
|
||||
"数据库检查": "Database Check",
|
||||
"验证数据库连接状态": "Verify database connection status",
|
||||
@@ -1811,7 +1793,6 @@
|
||||
"系统提示覆盖": "System prompt override",
|
||||
"模型: {{ratio}}": "Model: {{ratio}}",
|
||||
"专属倍率": "Exclusive group ratio",
|
||||
"模型管理": "Models",
|
||||
"匹配类型": "Matching type",
|
||||
"描述": "Description",
|
||||
"供应商": "Vendor",
|
||||
@@ -1820,7 +1801,7 @@
|
||||
"已绑定渠道": "Bound channels",
|
||||
"更新时间": "Update time",
|
||||
"未配置模型": "No model configured",
|
||||
"预填组管理": "Pre-filled group management",
|
||||
"预填组管理": "Pre-filled group",
|
||||
"搜索供应商": "Search vendor",
|
||||
"新增供应商": "Add vendor",
|
||||
"创建新的模型": "Create new model",
|
||||
@@ -2012,7 +1993,7 @@
|
||||
"安全验证": "Security verification",
|
||||
"验证": "Verify",
|
||||
"为了保护账户安全,请验证您的两步验证码。": "To protect account security, please verify your two-factor authentication code.",
|
||||
"支持6位TOTP验证码或8位备用码": "Supports 6-digit TOTP verification code or 8-digit backup code",
|
||||
"支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。": "Supports 6-digit TOTP verification code or 8-digit backup code, can be configured or viewed in `Personal Settings - Security Settings - Two-Factor Authentication Settings`.",
|
||||
"获取密钥失败": "Failed to get key",
|
||||
"查看密钥": "View key",
|
||||
"查看渠道密钥": "View channel key",
|
||||
@@ -2027,5 +2008,91 @@
|
||||
"音频输出补全相关的倍率设置,键为模型名称,值为倍率": "Audio output completion related ratio settings, key is model name, value is ratio",
|
||||
"为一个 JSON 文本,键为模型名称,值为倍率,例如:{\"gpt-image-1\": 2}": "A JSON text with model name as key and ratio as value, e.g.: {\"gpt-image-1\": 2}",
|
||||
"为一个 JSON 文本,键为模型名称,值为倍率,例如:{\"gpt-4o-audio-preview\": 16}": "A JSON text with model name as key and ratio as value, e.g.: {\"gpt-4o-audio-preview\": 16}",
|
||||
"为一个 JSON 文本,键为模型名称,值为倍率,例如:{\"gpt-4o-realtime\": 2}": "A JSON text with model name as key and ratio as value, e.g.: {\"gpt-4o-realtime\": 2}"
|
||||
"为一个 JSON 文本,键为模型名称,值为倍率,例如:{\"gpt-4o-realtime\": 2}": "A JSON text with model name as key and ratio as value, e.g.: {\"gpt-4o-realtime\": 2}",
|
||||
"顶栏管理": "Header Management",
|
||||
"控制顶栏模块显示状态,全局生效": "Control header module display status, global effect",
|
||||
"用户主页,展示系统信息": "User homepage, displaying system information",
|
||||
"用户控制面板,管理账户": "User control panel for account management",
|
||||
"模型广场": "Model Marketplace",
|
||||
"模型定价,需要登录访问": "Model pricing, requires login to access",
|
||||
"文档": "Documentation",
|
||||
"系统文档和帮助信息": "System documentation and help information",
|
||||
"关于系统的详细信息": "Detailed information about the system",
|
||||
"重置为默认": "Reset to Default",
|
||||
"保存设置": "Save Settings",
|
||||
"已重置为默认配置": "Reset to default configuration",
|
||||
"保存成功": "Saved successfully",
|
||||
"保存失败,请重试": "Save failed, please try again",
|
||||
"侧边栏管理(全局控制)": "Sidebar Management (Global Control)",
|
||||
"全局控制侧边栏区域和功能显示,管理员隐藏的功能用户无法启用": "Global control of sidebar areas and functions, users cannot enable functions hidden by administrators",
|
||||
"聊天区域": "Chat Area",
|
||||
"操练场和聊天功能": "Playground and chat functions",
|
||||
"操练场": "Playground",
|
||||
"AI模型测试环境": "AI model testing environment",
|
||||
"聊天": "Chat",
|
||||
"聊天会话管理": "Chat session management",
|
||||
"控制台区域": "Console Area",
|
||||
"数据管理和日志查看": "Data management and log viewing",
|
||||
"数据看板": "Dashboard",
|
||||
"系统数据统计": "System data statistics",
|
||||
"令牌管理": "Token Management",
|
||||
"API令牌管理": "API token management",
|
||||
"使用日志": "Usage Logs",
|
||||
"API使用记录": "API usage records",
|
||||
"绘图日志": "Drawing Logs",
|
||||
"绘图任务记录": "Drawing task records",
|
||||
"任务日志": "Task Logs",
|
||||
"系统任务记录": "System task records",
|
||||
"个人中心区域": "Personal Center Area",
|
||||
"用户个人功能": "User personal functions",
|
||||
"钱包管理": "Wallet Management",
|
||||
"余额充值管理": "Balance recharge management",
|
||||
"个人设置": "Personal Settings",
|
||||
"个人信息设置": "Personal information settings",
|
||||
"管理员区域": "Administrator Area",
|
||||
"系统管理功能": "System management functions",
|
||||
"渠道管理": "Channel Management",
|
||||
"API渠道配置": "API channel configuration",
|
||||
"模型管理": "Model Management",
|
||||
"AI模型配置": "AI model configuration",
|
||||
"兑换码管理": "Redemption Code Management",
|
||||
"兑换码生成管理": "Redemption code generation management",
|
||||
"用户管理": "User Management",
|
||||
"用户账户管理": "User account management",
|
||||
"系统设置": "System Settings",
|
||||
"系统参数配置": "System parameter configuration",
|
||||
"边栏设置": "Sidebar Settings",
|
||||
"您可以个性化设置侧边栏的要显示功能": "You can customize the sidebar functions to display",
|
||||
"保存边栏设置": "Save Sidebar Settings",
|
||||
"侧边栏设置保存成功": "Sidebar settings saved successfully",
|
||||
"需要登录访问": "Require Login",
|
||||
"开启后未登录用户无法访问模型广场": "When enabled, unauthenticated users cannot access the model marketplace",
|
||||
"参与官方同步": "Participate in official sync",
|
||||
"关闭后,此模型将不会被\"同步官方\"自动覆盖或创建": "When turned off, this model will be skipped by Sync official (no auto create/overwrite)",
|
||||
"同步": "Sync",
|
||||
"同步向导": "Sync Wizard",
|
||||
"选择方式": "Select method",
|
||||
"选择同步来源": "Select sync source",
|
||||
"选择语言": "Select language",
|
||||
"选择同步语言": "Select sync language",
|
||||
"请选择同步语言": "Please select sync language",
|
||||
"从官方模型库同步": "Sync from official model library",
|
||||
"官方模型同步": "Official models sync",
|
||||
"从配置文件同步": "Sync from config file",
|
||||
"配置文件同步": "Config file sync",
|
||||
"开始同步": "Start sync",
|
||||
"选择要覆盖的冲突项": "Select conflict items to overwrite",
|
||||
"点击查看差异": "Click to view differences",
|
||||
"无冲突项": "No conflict items",
|
||||
"应用覆盖": "Apply overwrite",
|
||||
"仅会覆盖你勾选的字段,未勾选的字段保持本地不变。": "Only selected fields will be overwritten, unselected fields remain unchanged.",
|
||||
"本地": "Local",
|
||||
"官方": "Official",
|
||||
"模型社区需要大家的共同维护,如发现数据有误或想贡献新的模型数据,请访问:": "The model community needs everyone's contribution. If you find incorrect data or want to contribute new models, please visit:",
|
||||
"是": "Yes",
|
||||
"否": "No",
|
||||
"原价": "Original price",
|
||||
"优惠": "Discount",
|
||||
"折": "% off",
|
||||
"节省": "Save"
|
||||
}
|
||||
|
||||
@@ -71,6 +71,11 @@ code {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.sidebar-nav .semi-navigation-item-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.semi-navigation-item,
|
||||
.semi-navigation-sub-title {
|
||||
height: 100% !important;
|
||||
@@ -184,6 +189,7 @@ code {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
margin-top: auto;
|
||||
cursor: pointer;
|
||||
background-color: var(--semi-color-bg-0);
|
||||
position: sticky;
|
||||
|
||||
@@ -130,17 +130,19 @@ export default function GeneralSettings(props) {
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
field={'QuotaPerUnit'}
|
||||
label={t('单位美元额度')}
|
||||
initValue={''}
|
||||
placeholder={t('一单位货币能兑换的额度')}
|
||||
onChange={handleFieldChange('QuotaPerUnit')}
|
||||
showClear
|
||||
onClick={() => setShowQuotaWarning(true)}
|
||||
/>
|
||||
</Col>
|
||||
{inputs.QuotaPerUnit !== '500000' && inputs.QuotaPerUnit !== 500000 && (
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
field={'QuotaPerUnit'}
|
||||
label={t('单位美元额度')}
|
||||
initValue={''}
|
||||
placeholder={t('一单位货币能兑换的额度')}
|
||||
onChange={handleFieldChange('QuotaPerUnit')}
|
||||
showClear
|
||||
onClick={() => setShowQuotaWarning(true)}
|
||||
/>
|
||||
</Col>
|
||||
)}
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
field={'USDExchangeRate'}
|
||||
@@ -194,7 +196,7 @@ export default function GeneralSettings(props) {
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Switch
|
||||
field={'DemoSiteEnabled'}
|
||||
|
||||
356
web/src/pages/Setting/Operation/SettingsHeaderNavModules.jsx
Normal file
356
web/src/pages/Setting/Operation/SettingsHeaderNavModules.jsx
Normal file
@@ -0,0 +1,356 @@
|
||||
/*
|
||||
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, useContext } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Form,
|
||||
Row,
|
||||
Switch,
|
||||
Typography,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { API, showError, showSuccess } from '../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { StatusContext } from '../../../context/Status';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export default function SettingsHeaderNavModules(props) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [statusState, statusDispatch] = useContext(StatusContext);
|
||||
|
||||
// 顶栏模块管理状态
|
||||
const [headerNavModules, setHeaderNavModules] = useState({
|
||||
home: true,
|
||||
console: true,
|
||||
pricing: {
|
||||
enabled: true,
|
||||
requireAuth: false, // 默认不需要登录鉴权
|
||||
},
|
||||
docs: true,
|
||||
about: true,
|
||||
});
|
||||
|
||||
// 处理顶栏模块配置变更
|
||||
function handleHeaderNavModuleChange(moduleKey) {
|
||||
return (checked) => {
|
||||
const newModules = { ...headerNavModules };
|
||||
if (moduleKey === 'pricing') {
|
||||
// 对于pricing模块,只更新enabled属性
|
||||
newModules[moduleKey] = {
|
||||
...newModules[moduleKey],
|
||||
enabled: checked,
|
||||
};
|
||||
} else {
|
||||
newModules[moduleKey] = checked;
|
||||
}
|
||||
setHeaderNavModules(newModules);
|
||||
};
|
||||
}
|
||||
|
||||
// 处理模型广场权限控制变更
|
||||
function handlePricingAuthChange(checked) {
|
||||
const newModules = { ...headerNavModules };
|
||||
newModules.pricing = {
|
||||
...newModules.pricing,
|
||||
requireAuth: checked,
|
||||
};
|
||||
setHeaderNavModules(newModules);
|
||||
}
|
||||
|
||||
// 重置顶栏模块为默认配置
|
||||
function resetHeaderNavModules() {
|
||||
const defaultModules = {
|
||||
home: true,
|
||||
console: true,
|
||||
pricing: {
|
||||
enabled: true,
|
||||
requireAuth: false,
|
||||
},
|
||||
docs: true,
|
||||
about: true,
|
||||
};
|
||||
setHeaderNavModules(defaultModules);
|
||||
showSuccess(t('已重置为默认配置'));
|
||||
}
|
||||
|
||||
// 保存配置
|
||||
async function onSubmit() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.put('/api/option/', {
|
||||
key: 'HeaderNavModules',
|
||||
value: JSON.stringify(headerNavModules),
|
||||
});
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess(t('保存成功'));
|
||||
|
||||
// 立即更新StatusContext中的状态
|
||||
statusDispatch({
|
||||
type: 'set',
|
||||
payload: {
|
||||
...statusState.status,
|
||||
HeaderNavModules: JSON.stringify(headerNavModules),
|
||||
},
|
||||
});
|
||||
|
||||
// 刷新父组件状态
|
||||
if (props.refresh) {
|
||||
await props.refresh();
|
||||
}
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('保存失败,请重试'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// 从 props.options 中获取配置
|
||||
if (props.options && props.options.HeaderNavModules) {
|
||||
try {
|
||||
const modules = JSON.parse(props.options.HeaderNavModules);
|
||||
|
||||
// 处理向后兼容性:如果pricing是boolean,转换为对象格式
|
||||
if (typeof modules.pricing === 'boolean') {
|
||||
modules.pricing = {
|
||||
enabled: modules.pricing,
|
||||
requireAuth: false, // 默认不需要登录鉴权
|
||||
};
|
||||
}
|
||||
|
||||
setHeaderNavModules(modules);
|
||||
} catch (error) {
|
||||
// 使用默认配置
|
||||
const defaultModules = {
|
||||
home: true,
|
||||
console: true,
|
||||
pricing: {
|
||||
enabled: true,
|
||||
requireAuth: false,
|
||||
},
|
||||
docs: true,
|
||||
about: true,
|
||||
};
|
||||
setHeaderNavModules(defaultModules);
|
||||
}
|
||||
}
|
||||
}, [props.options]);
|
||||
|
||||
// 模块配置数据
|
||||
const moduleConfigs = [
|
||||
{
|
||||
key: 'home',
|
||||
title: t('首页'),
|
||||
description: t('用户主页,展示系统信息'),
|
||||
},
|
||||
{
|
||||
key: 'console',
|
||||
title: t('控制台'),
|
||||
description: t('用户控制面板,管理账户'),
|
||||
},
|
||||
{
|
||||
key: 'pricing',
|
||||
title: t('模型广场'),
|
||||
description: t('模型定价,需要登录访问'),
|
||||
hasSubConfig: true, // 标识该模块有子配置
|
||||
},
|
||||
{
|
||||
key: 'docs',
|
||||
title: t('文档'),
|
||||
description: t('系统文档和帮助信息'),
|
||||
},
|
||||
{
|
||||
key: 'about',
|
||||
title: t('关于'),
|
||||
description: t('关于系统的详细信息'),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Form.Section
|
||||
text={t('顶栏管理')}
|
||||
extraText={t('控制顶栏模块显示状态,全局生效')}
|
||||
>
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: '24px' }}>
|
||||
{moduleConfigs.map((module) => (
|
||||
<Col key={module.key} xs={24} sm={12} md={6} lg={6} xl={6}>
|
||||
<Card
|
||||
style={{
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--semi-color-border)',
|
||||
transition: 'all 0.2s ease',
|
||||
background: 'var(--semi-color-bg-1)',
|
||||
minHeight: '80px',
|
||||
}}
|
||||
bodyStyle={{ padding: '16px' }}
|
||||
hoverable
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, textAlign: 'left' }}>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: '600',
|
||||
fontSize: '14px',
|
||||
color: 'var(--semi-color-text-0)',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
{module.title}
|
||||
</div>
|
||||
<Text
|
||||
type='secondary'
|
||||
size='small'
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: 'var(--semi-color-text-2)',
|
||||
lineHeight: '1.4',
|
||||
display: 'block',
|
||||
}}
|
||||
>
|
||||
{module.description}
|
||||
</Text>
|
||||
</div>
|
||||
<div style={{ marginLeft: '16px' }}>
|
||||
<Switch
|
||||
checked={
|
||||
module.key === 'pricing'
|
||||
? headerNavModules[module.key]?.enabled
|
||||
: headerNavModules[module.key]
|
||||
}
|
||||
onChange={handleHeaderNavModuleChange(module.key)}
|
||||
size='default'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 为模型广场添加权限控制子开关 */}
|
||||
{module.key === 'pricing' &&
|
||||
(module.key === 'pricing'
|
||||
? headerNavModules[module.key]?.enabled
|
||||
: headerNavModules[module.key]) && (
|
||||
<div
|
||||
style={{
|
||||
borderTop: '1px solid var(--semi-color-border)',
|
||||
marginTop: '12px',
|
||||
paddingTop: '12px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, textAlign: 'left' }}>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: '500',
|
||||
fontSize: '12px',
|
||||
color: 'var(--semi-color-text-1)',
|
||||
marginBottom: '2px',
|
||||
}}
|
||||
>
|
||||
{t('需要登录访问')}
|
||||
</div>
|
||||
<Text
|
||||
type='secondary'
|
||||
size='small'
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: 'var(--semi-color-text-2)',
|
||||
lineHeight: '1.4',
|
||||
display: 'block',
|
||||
}}
|
||||
>
|
||||
{t('开启后未登录用户无法访问模型广场')}
|
||||
</Text>
|
||||
</div>
|
||||
<div style={{ marginLeft: '16px' }}>
|
||||
<Switch
|
||||
checked={
|
||||
headerNavModules.pricing?.requireAuth || false
|
||||
}
|
||||
onChange={handlePricingAuthChange}
|
||||
size='default'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '12px',
|
||||
justifyContent: 'flex-start',
|
||||
alignItems: 'center',
|
||||
paddingTop: '8px',
|
||||
borderTop: '1px solid var(--semi-color-border)',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
size='default'
|
||||
type='tertiary'
|
||||
onClick={resetHeaderNavModules}
|
||||
style={{
|
||||
borderRadius: '6px',
|
||||
fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
{t('重置为默认')}
|
||||
</Button>
|
||||
<Button
|
||||
size='default'
|
||||
type='primary'
|
||||
onClick={onSubmit}
|
||||
loading={loading}
|
||||
style={{
|
||||
borderRadius: '6px',
|
||||
fontWeight: '500',
|
||||
minWidth: '100px',
|
||||
}}
|
||||
>
|
||||
{t('保存设置')}
|
||||
</Button>
|
||||
</div>
|
||||
</Form.Section>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -38,6 +38,8 @@ export default function SettingsMonitoring(props) {
|
||||
AutomaticDisableChannelEnabled: false,
|
||||
AutomaticEnableChannelEnabled: false,
|
||||
AutomaticDisableKeywords: '',
|
||||
'monitor_setting.auto_test_channel_enabled': false,
|
||||
'monitor_setting.auto_test_channel_minutes': 10,
|
||||
});
|
||||
const refForm = useRef();
|
||||
const [inputsRow, setInputsRow] = useState(inputs);
|
||||
@@ -98,6 +100,40 @@ export default function SettingsMonitoring(props) {
|
||||
style={{ marginBottom: 15 }}
|
||||
>
|
||||
<Form.Section text={t('监控设置')}>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Switch
|
||||
field={'monitor_setting.auto_test_channel_enabled'}
|
||||
label={t('定时测试所有通道')}
|
||||
size='default'
|
||||
checkedText='|'
|
||||
uncheckedText='〇'
|
||||
onChange={(value) =>
|
||||
setInputs({
|
||||
...inputs,
|
||||
'monitor_setting.auto_test_channel_enabled': value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.InputNumber
|
||||
label={t('自动测试所有通道间隔时间')}
|
||||
step={1}
|
||||
min={1}
|
||||
suffix={t('分钟')}
|
||||
extraText={t('每隔多少分钟测试一次所有通道')}
|
||||
placeholder={''}
|
||||
field={'monitor_setting.auto_test_channel_minutes'}
|
||||
onChange={(value) =>
|
||||
setInputs({
|
||||
...inputs,
|
||||
'monitor_setting.auto_test_channel_minutes': parseInt(value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.InputNumber
|
||||
|
||||
422
web/src/pages/Setting/Operation/SettingsSidebarModulesAdmin.jsx
Normal file
422
web/src/pages/Setting/Operation/SettingsSidebarModulesAdmin.jsx
Normal file
@@ -0,0 +1,422 @@
|
||||
/*
|
||||
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, { useState, useEffect, useContext } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Card,
|
||||
Form,
|
||||
Button,
|
||||
Switch,
|
||||
Row,
|
||||
Col,
|
||||
Typography,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { API, showSuccess, showError } from '../../../helpers';
|
||||
import { StatusContext } from '../../../context/Status';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export default function SettingsSidebarModulesAdmin(props) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [statusState, statusDispatch] = useContext(StatusContext);
|
||||
|
||||
// 左侧边栏模块管理状态(管理员全局控制)
|
||||
const [sidebarModulesAdmin, setSidebarModulesAdmin] = 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,
|
||||
},
|
||||
});
|
||||
|
||||
// 处理区域级别开关变更
|
||||
function handleSectionChange(sectionKey) {
|
||||
return (checked) => {
|
||||
const newModules = {
|
||||
...sidebarModulesAdmin,
|
||||
[sectionKey]: {
|
||||
...sidebarModulesAdmin[sectionKey],
|
||||
enabled: checked,
|
||||
},
|
||||
};
|
||||
setSidebarModulesAdmin(newModules);
|
||||
};
|
||||
}
|
||||
|
||||
// 处理功能级别开关变更
|
||||
function handleModuleChange(sectionKey, moduleKey) {
|
||||
return (checked) => {
|
||||
const newModules = {
|
||||
...sidebarModulesAdmin,
|
||||
[sectionKey]: {
|
||||
...sidebarModulesAdmin[sectionKey],
|
||||
[moduleKey]: checked,
|
||||
},
|
||||
};
|
||||
setSidebarModulesAdmin(newModules);
|
||||
};
|
||||
}
|
||||
|
||||
// 重置为默认配置
|
||||
function resetSidebarModules() {
|
||||
const defaultModules = {
|
||||
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,
|
||||
},
|
||||
};
|
||||
setSidebarModulesAdmin(defaultModules);
|
||||
showSuccess(t('已重置为默认配置'));
|
||||
}
|
||||
|
||||
// 保存配置
|
||||
async function onSubmit() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.put('/api/option/', {
|
||||
key: 'SidebarModulesAdmin',
|
||||
value: JSON.stringify(sidebarModulesAdmin),
|
||||
});
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess(t('保存成功'));
|
||||
|
||||
// 立即更新StatusContext中的状态
|
||||
statusDispatch({
|
||||
type: 'set',
|
||||
payload: {
|
||||
...statusState.status,
|
||||
SidebarModulesAdmin: JSON.stringify(sidebarModulesAdmin),
|
||||
},
|
||||
});
|
||||
|
||||
// 刷新父组件状态
|
||||
if (props.refresh) {
|
||||
await props.refresh();
|
||||
}
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('保存失败,请重试'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// 从 props.options 中获取配置
|
||||
if (props.options && props.options.SidebarModulesAdmin) {
|
||||
try {
|
||||
const modules = JSON.parse(props.options.SidebarModulesAdmin);
|
||||
setSidebarModulesAdmin(modules);
|
||||
} catch (error) {
|
||||
// 使用默认配置
|
||||
const defaultModules = {
|
||||
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,
|
||||
},
|
||||
};
|
||||
setSidebarModulesAdmin(defaultModules);
|
||||
}
|
||||
}
|
||||
}, [props.options]);
|
||||
|
||||
// 区域配置数据
|
||||
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('系统参数配置'),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Form.Section
|
||||
text={t('侧边栏管理(全局控制)')}
|
||||
extraText={t(
|
||||
'全局控制侧边栏区域和功能显示,管理员隐藏的功能用户无法启用',
|
||||
)}
|
||||
>
|
||||
{sectionConfigs.map((section) => (
|
||||
<div key={section.key} style={{ marginBottom: '32px' }}>
|
||||
{/* 区域标题和总开关 */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '16px',
|
||||
padding: '12px 16px',
|
||||
backgroundColor: 'var(--semi-color-fill-0)',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--semi-color-border)',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: '600',
|
||||
fontSize: '16px',
|
||||
color: 'var(--semi-color-text-0)',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
{section.title}
|
||||
</div>
|
||||
<Text
|
||||
type='secondary'
|
||||
size='small'
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: 'var(--semi-color-text-2)',
|
||||
lineHeight: '1.4',
|
||||
}}
|
||||
>
|
||||
{section.description}
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={sidebarModulesAdmin[section.key]?.enabled}
|
||||
onChange={handleSectionChange(section.key)}
|
||||
size='default'
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 功能模块网格 */}
|
||||
<Row gutter={[16, 16]}>
|
||||
{section.modules.map((module) => (
|
||||
<Col key={module.key} xs={24} sm={12} md={8} lg={6} xl={6}>
|
||||
<Card
|
||||
bodyStyle={{ padding: '16px' }}
|
||||
hoverable
|
||||
style={{
|
||||
opacity: sidebarModulesAdmin[section.key]?.enabled
|
||||
? 1
|
||||
: 0.5,
|
||||
transition: 'opacity 0.2s',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, textAlign: 'left' }}>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: '600',
|
||||
fontSize: '14px',
|
||||
color: 'var(--semi-color-text-0)',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
{module.title}
|
||||
</div>
|
||||
<Text
|
||||
type='secondary'
|
||||
size='small'
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: 'var(--semi-color-text-2)',
|
||||
lineHeight: '1.4',
|
||||
display: 'block',
|
||||
}}
|
||||
>
|
||||
{module.description}
|
||||
</Text>
|
||||
</div>
|
||||
<div style={{ marginLeft: '16px' }}>
|
||||
<Switch
|
||||
checked={
|
||||
sidebarModulesAdmin[section.key]?.[module.key]
|
||||
}
|
||||
onChange={handleModuleChange(section.key, module.key)}
|
||||
size='default'
|
||||
disabled={!sidebarModulesAdmin[section.key]?.enabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '12px',
|
||||
justifyContent: 'flex-start',
|
||||
alignItems: 'center',
|
||||
paddingTop: '8px',
|
||||
borderTop: '1px solid var(--semi-color-border)',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
size='default'
|
||||
type='tertiary'
|
||||
onClick={resetSidebarModules}
|
||||
style={{
|
||||
borderRadius: '6px',
|
||||
fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
{t('重置为默认')}
|
||||
</Button>
|
||||
<Button
|
||||
size='default'
|
||||
type='primary'
|
||||
onClick={onSubmit}
|
||||
loading={loading}
|
||||
style={{
|
||||
borderRadius: '6px',
|
||||
fontWeight: '500',
|
||||
minWidth: '100px',
|
||||
}}
|
||||
>
|
||||
{t('保存设置')}
|
||||
</Button>
|
||||
</div>
|
||||
</Form.Section>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -41,6 +41,8 @@ export default function SettingsPaymentGateway(props) {
|
||||
TopupGroupRatio: '',
|
||||
CustomCallbackAddress: '',
|
||||
PayMethods: '',
|
||||
AmountOptions: '',
|
||||
AmountDiscount: '',
|
||||
});
|
||||
const [originInputs, setOriginInputs] = useState({});
|
||||
const formApiRef = useRef(null);
|
||||
@@ -62,7 +64,30 @@ export default function SettingsPaymentGateway(props) {
|
||||
TopupGroupRatio: props.options.TopupGroupRatio || '',
|
||||
CustomCallbackAddress: props.options.CustomCallbackAddress || '',
|
||||
PayMethods: props.options.PayMethods || '',
|
||||
AmountOptions: props.options.AmountOptions || '',
|
||||
AmountDiscount: props.options.AmountDiscount || '',
|
||||
};
|
||||
|
||||
// 美化 JSON 展示
|
||||
try {
|
||||
if (currentInputs.AmountOptions) {
|
||||
currentInputs.AmountOptions = JSON.stringify(
|
||||
JSON.parse(currentInputs.AmountOptions),
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
} catch {}
|
||||
try {
|
||||
if (currentInputs.AmountDiscount) {
|
||||
currentInputs.AmountDiscount = JSON.stringify(
|
||||
JSON.parse(currentInputs.AmountDiscount),
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
} catch {}
|
||||
|
||||
setInputs(currentInputs);
|
||||
setOriginInputs({ ...currentInputs });
|
||||
formApiRef.current.setValues(currentInputs);
|
||||
@@ -93,6 +118,20 @@ export default function SettingsPaymentGateway(props) {
|
||||
}
|
||||
}
|
||||
|
||||
if (originInputs['AmountOptions'] !== inputs.AmountOptions && inputs.AmountOptions.trim() !== '') {
|
||||
if (!verifyJSON(inputs.AmountOptions)) {
|
||||
showError(t('自定义充值数量选项不是合法的 JSON 数组'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (originInputs['AmountDiscount'] !== inputs.AmountDiscount && inputs.AmountDiscount.trim() !== '') {
|
||||
if (!verifyJSON(inputs.AmountDiscount)) {
|
||||
showError(t('充值金额折扣配置不是合法的 JSON 对象'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const options = [
|
||||
@@ -123,6 +162,12 @@ export default function SettingsPaymentGateway(props) {
|
||||
if (originInputs['PayMethods'] !== inputs.PayMethods) {
|
||||
options.push({ key: 'PayMethods', value: inputs.PayMethods });
|
||||
}
|
||||
if (originInputs['AmountOptions'] !== inputs.AmountOptions) {
|
||||
options.push({ key: 'payment_setting.amount_options', value: inputs.AmountOptions });
|
||||
}
|
||||
if (originInputs['AmountDiscount'] !== inputs.AmountDiscount) {
|
||||
options.push({ key: 'payment_setting.amount_discount', value: inputs.AmountDiscount });
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
const requestQueue = options.map((opt) =>
|
||||
@@ -228,6 +273,37 @@ export default function SettingsPaymentGateway(props) {
|
||||
placeholder={t('为一个 JSON 文本')}
|
||||
autosize
|
||||
/>
|
||||
|
||||
<Row
|
||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
<Col span={24}>
|
||||
<Form.TextArea
|
||||
field='AmountOptions'
|
||||
label={t('自定义充值数量选项')}
|
||||
placeholder={t('为一个 JSON 数组,例如:[10, 20, 50, 100, 200, 500]')}
|
||||
autosize
|
||||
extraText={t('设置用户可选择的充值数量选项,例如:[10, 20, 50, 100, 200, 500]')}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row
|
||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
<Col span={24}>
|
||||
<Form.TextArea
|
||||
field='AmountDiscount'
|
||||
label={t('充值金额折扣配置')}
|
||||
placeholder={t('为一个 JSON 对象,例如:{"100": 0.95, "200": 0.9, "500": 0.85}')}
|
||||
autosize
|
||||
extraText={t('设置不同充值金额对应的折扣,键为充值金额,值为折扣率,例如:{"100": 0.95, "200": 0.9, "500": 0.85}')}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Button onClick={submitPayAddress}>{t('更新支付设置')}</Button>
|
||||
</Form.Section>
|
||||
</Form>
|
||||
|
||||
453
web/src/pages/Setting/Personal/SettingsSidebarModulesUser.jsx
Normal file
453
web/src/pages/Setting/Personal/SettingsSidebarModulesUser.jsx
Normal file
@@ -0,0 +1,453 @@
|
||||
/*
|
||||
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 { useState, useEffect, useContext } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
Switch,
|
||||
Typography,
|
||||
Row,
|
||||
Col,
|
||||
Avatar,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { API, showSuccess, showError } from '../../../helpers';
|
||||
import { StatusContext } from '../../../context/Status';
|
||||
import { UserContext } from '../../../context/User';
|
||||
import { useUserPermissions } from '../../../hooks/common/useUserPermissions';
|
||||
import { useSidebar } from '../../../hooks/common/useSidebar';
|
||||
import { Settings } from 'lucide-react';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export default function SettingsSidebarModulesUser() {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [statusState] = useContext(StatusContext);
|
||||
|
||||
// 使用后端权限验证替代前端角色判断
|
||||
const {
|
||||
permissions,
|
||||
loading: permissionsLoading,
|
||||
hasSidebarSettingsPermission,
|
||||
isSidebarSectionAllowed,
|
||||
isSidebarModuleAllowed,
|
||||
} = useUserPermissions();
|
||||
|
||||
// 使用useSidebar钩子获取刷新方法
|
||||
const { refreshUserConfig } = useSidebar();
|
||||
|
||||
// 如果没有边栏设置权限,不显示此组件
|
||||
if (!permissionsLoading && !hasSidebarSettingsPermission()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 权限加载中,显示加载状态
|
||||
if (permissionsLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 根据用户权限生成默认配置
|
||||
const generateDefaultConfig = () => {
|
||||
const defaultConfig = {};
|
||||
|
||||
// 聊天区域 - 所有用户都可以访问
|
||||
if (isSidebarSectionAllowed('chat')) {
|
||||
defaultConfig.chat = {
|
||||
enabled: true,
|
||||
playground: isSidebarModuleAllowed('chat', 'playground'),
|
||||
chat: isSidebarModuleAllowed('chat', 'chat'),
|
||||
};
|
||||
}
|
||||
|
||||
// 控制台区域 - 所有用户都可以访问
|
||||
if (isSidebarSectionAllowed('console')) {
|
||||
defaultConfig.console = {
|
||||
enabled: true,
|
||||
detail: isSidebarModuleAllowed('console', 'detail'),
|
||||
token: isSidebarModuleAllowed('console', 'token'),
|
||||
log: isSidebarModuleAllowed('console', 'log'),
|
||||
midjourney: isSidebarModuleAllowed('console', 'midjourney'),
|
||||
task: isSidebarModuleAllowed('console', 'task'),
|
||||
};
|
||||
}
|
||||
|
||||
// 个人中心区域 - 所有用户都可以访问
|
||||
if (isSidebarSectionAllowed('personal')) {
|
||||
defaultConfig.personal = {
|
||||
enabled: true,
|
||||
topup: isSidebarModuleAllowed('personal', 'topup'),
|
||||
personal: isSidebarModuleAllowed('personal', 'personal'),
|
||||
};
|
||||
}
|
||||
|
||||
// 管理员区域 - 只有管理员可以访问
|
||||
if (isSidebarSectionAllowed('admin')) {
|
||||
defaultConfig.admin = {
|
||||
enabled: true,
|
||||
channel: isSidebarModuleAllowed('admin', 'channel'),
|
||||
models: isSidebarModuleAllowed('admin', 'models'),
|
||||
redemption: isSidebarModuleAllowed('admin', 'redemption'),
|
||||
user: isSidebarModuleAllowed('admin', 'user'),
|
||||
setting: isSidebarModuleAllowed('admin', 'setting'),
|
||||
};
|
||||
}
|
||||
|
||||
return defaultConfig;
|
||||
};
|
||||
|
||||
// 用户个人左侧边栏模块设置
|
||||
const [sidebarModulesUser, setSidebarModulesUser] = useState({});
|
||||
|
||||
// 管理员全局配置
|
||||
const [adminConfig, setAdminConfig] = useState(null);
|
||||
|
||||
// 处理区域级别开关变更
|
||||
function handleSectionChange(sectionKey) {
|
||||
return (checked) => {
|
||||
const newModules = {
|
||||
...sidebarModulesUser,
|
||||
[sectionKey]: {
|
||||
...sidebarModulesUser[sectionKey],
|
||||
enabled: checked,
|
||||
},
|
||||
};
|
||||
setSidebarModulesUser(newModules);
|
||||
console.log('用户边栏区域配置变更:', sectionKey, checked, newModules);
|
||||
};
|
||||
}
|
||||
|
||||
// 处理功能级别开关变更
|
||||
function handleModuleChange(sectionKey, moduleKey) {
|
||||
return (checked) => {
|
||||
const newModules = {
|
||||
...sidebarModulesUser,
|
||||
[sectionKey]: {
|
||||
...sidebarModulesUser[sectionKey],
|
||||
[moduleKey]: checked,
|
||||
},
|
||||
};
|
||||
setSidebarModulesUser(newModules);
|
||||
console.log(
|
||||
'用户边栏功能配置变更:',
|
||||
sectionKey,
|
||||
moduleKey,
|
||||
checked,
|
||||
newModules,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
// 重置为默认配置(基于权限过滤)
|
||||
function resetSidebarModules() {
|
||||
const defaultConfig = generateDefaultConfig();
|
||||
setSidebarModulesUser(defaultConfig);
|
||||
showSuccess(t('已重置为默认配置'));
|
||||
console.log('用户边栏配置重置为默认:', defaultConfig);
|
||||
}
|
||||
|
||||
// 保存配置
|
||||
async function onSubmit() {
|
||||
setLoading(true);
|
||||
try {
|
||||
console.log('保存用户边栏配置:', sidebarModulesUser);
|
||||
const res = await API.put('/api/user/self', {
|
||||
sidebar_modules: JSON.stringify(sidebarModulesUser),
|
||||
});
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess(t('保存成功'));
|
||||
console.log('用户边栏配置保存成功');
|
||||
|
||||
// 刷新useSidebar钩子中的用户配置,实现实时更新
|
||||
await refreshUserConfig();
|
||||
console.log('用户边栏配置已刷新,边栏将立即更新');
|
||||
} else {
|
||||
showError(message);
|
||||
console.error('用户边栏配置保存失败:', message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('保存失败,请重试'));
|
||||
console.error('用户边栏配置保存异常:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// 统一的配置加载逻辑
|
||||
useEffect(() => {
|
||||
const loadConfigs = async () => {
|
||||
try {
|
||||
// 获取管理员全局配置
|
||||
if (statusState?.status?.SidebarModulesAdmin) {
|
||||
const adminConf = JSON.parse(statusState.status.SidebarModulesAdmin);
|
||||
setAdminConfig(adminConf);
|
||||
console.log('加载管理员边栏配置:', adminConf);
|
||||
}
|
||||
|
||||
// 获取用户个人配置
|
||||
const userRes = await API.get('/api/user/self');
|
||||
if (userRes.data.success && userRes.data.data.sidebar_modules) {
|
||||
let userConf;
|
||||
// 检查sidebar_modules是字符串还是对象
|
||||
if (typeof userRes.data.data.sidebar_modules === 'string') {
|
||||
userConf = JSON.parse(userRes.data.data.sidebar_modules);
|
||||
} else {
|
||||
userConf = userRes.data.data.sidebar_modules;
|
||||
}
|
||||
console.log('从API加载的用户配置:', userConf);
|
||||
|
||||
// 确保用户配置也经过权限过滤
|
||||
const filteredUserConf = {};
|
||||
Object.keys(userConf).forEach((sectionKey) => {
|
||||
if (isSidebarSectionAllowed(sectionKey)) {
|
||||
filteredUserConf[sectionKey] = { ...userConf[sectionKey] };
|
||||
// 过滤不允许的模块
|
||||
Object.keys(userConf[sectionKey]).forEach((moduleKey) => {
|
||||
if (
|
||||
moduleKey !== 'enabled' &&
|
||||
!isSidebarModuleAllowed(sectionKey, moduleKey)
|
||||
) {
|
||||
delete filteredUserConf[sectionKey][moduleKey];
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
setSidebarModulesUser(filteredUserConf);
|
||||
console.log('权限过滤后的用户配置:', filteredUserConf);
|
||||
} else {
|
||||
// 如果用户没有配置,使用权限过滤后的默认配置
|
||||
const defaultConfig = generateDefaultConfig();
|
||||
setSidebarModulesUser(defaultConfig);
|
||||
console.log('用户无配置,使用默认配置:', defaultConfig);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载边栏配置失败:', error);
|
||||
// 出错时也使用默认配置
|
||||
const defaultConfig = generateDefaultConfig();
|
||||
setSidebarModulesUser(defaultConfig);
|
||||
}
|
||||
};
|
||||
|
||||
// 只有权限加载完成且有边栏设置权限时才加载配置
|
||||
if (!permissionsLoading && hasSidebarSettingsPermission()) {
|
||||
loadConfigs();
|
||||
}
|
||||
}, [
|
||||
statusState,
|
||||
permissionsLoading,
|
||||
hasSidebarSettingsPermission,
|
||||
isSidebarSectionAllowed,
|
||||
isSidebarModuleAllowed,
|
||||
]);
|
||||
|
||||
// 检查功能是否被管理员允许
|
||||
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),
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className='!rounded-2xl shadow-sm border-0'>
|
||||
{/* 卡片头部 */}
|
||||
<div className='flex items-center mb-4'>
|
||||
<Avatar size='small' color='purple' className='mr-3 shadow-md'>
|
||||
<Settings size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Typography.Text className='text-lg font-medium'>
|
||||
{t('左侧边栏个人设置')}
|
||||
</Typography.Text>
|
||||
<div className='text-xs text-gray-600'>
|
||||
{t('个性化设置左侧边栏的显示内容')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mb-4'>
|
||||
<Text type='secondary' className='text-sm text-gray-600'>
|
||||
{t('您可以个性化设置侧边栏的要显示功能')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{sectionConfigs.map((section) => (
|
||||
<div key={section.key} className='mb-6'>
|
||||
{/* 区域标题和总开关 */}
|
||||
<div className='flex justify-between items-center mb-4 p-4 bg-gray-50 rounded-xl border border-gray-200'>
|
||||
<div>
|
||||
<div className='font-semibold text-base text-gray-900 mb-1'>
|
||||
{section.title}
|
||||
</div>
|
||||
<Text className='text-xs text-gray-600'>
|
||||
{section.description}
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={sidebarModulesUser[section.key]?.enabled}
|
||||
onChange={handleSectionChange(section.key)}
|
||||
size='default'
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 功能模块网格 */}
|
||||
<Row gutter={[12, 12]}>
|
||||
{section.modules.map((module) => (
|
||||
<Col key={module.key} xs={24} sm={12} md={8} lg={6} xl={6}>
|
||||
<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>
|
||||
<Text className='text-xs text-gray-600 leading-relaxed block'>
|
||||
{module.description}
|
||||
</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 className='flex justify-end gap-3 mt-6 pt-4 border-t border-gray-200'>
|
||||
<Button
|
||||
type='tertiary'
|
||||
onClick={resetSidebarModules}
|
||||
className='!rounded-lg'
|
||||
>
|
||||
{t('重置为默认')}
|
||||
</Button>
|
||||
<Button
|
||||
type='primary'
|
||||
onClick={onSubmit}
|
||||
loading={loading}
|
||||
className='!rounded-lg'
|
||||
>
|
||||
{t('保存设置')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -130,9 +130,7 @@ export default function ModelRatioNotSetEditor(props) {
|
||||
|
||||
// 在 return 语句之前,先处理过滤和分页逻辑
|
||||
const filteredModels = models.filter((model) =>
|
||||
searchText
|
||||
? model.name.toLowerCase().includes(searchText.toLowerCase())
|
||||
: true,
|
||||
searchText ? model.name.includes(searchText) : true,
|
||||
);
|
||||
|
||||
// 然后基于过滤后的数据计算分页数据
|
||||
|
||||
@@ -99,9 +99,7 @@ export default function ModelSettingsVisualEditor(props) {
|
||||
|
||||
// 在 return 语句之前,先处理过滤和分页逻辑
|
||||
const filteredModels = models.filter((model) => {
|
||||
const keywordMatch = searchText
|
||||
? model.name.toLowerCase().includes(searchText.toLowerCase())
|
||||
: true;
|
||||
const keywordMatch = searchText ? model.name.includes(searchText) : true;
|
||||
const conflictMatch = conflictOnly ? model.hasConflict : true;
|
||||
return keywordMatch && conflictMatch;
|
||||
});
|
||||
|
||||
@@ -151,8 +151,17 @@ export default function UpstreamRatioSync(props) {
|
||||
setChannelEndpoints((prev) => {
|
||||
const merged = { ...prev };
|
||||
transferData.forEach((channel) => {
|
||||
if (!merged[channel.key]) {
|
||||
merged[channel.key] = DEFAULT_ENDPOINT;
|
||||
const id = channel.key;
|
||||
const base = channel._originalData?.base_url || '';
|
||||
const name = channel.label || '';
|
||||
const isOfficial =
|
||||
id === -100 ||
|
||||
base === 'https://basellm.github.io' ||
|
||||
name === '官方倍率预设';
|
||||
if (!merged[id]) {
|
||||
merged[id] = isOfficial
|
||||
? '/llm-metadata/api/newapi/ratio_config-v1-base.json'
|
||||
: DEFAULT_ENDPOINT;
|
||||
}
|
||||
});
|
||||
return merged;
|
||||
|
||||
Reference in New Issue
Block a user