Merge branch 'main' into feat_subscribe_sp1

This commit is contained in:
Little Write
2025-09-27 22:54:52 +08:00
617 changed files with 64415 additions and 27156 deletions

View File

@@ -1,11 +1,24 @@
/*
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, useRef } from 'react';
import {
Banner,
Button,
Form,
Space,
Spin,
} from '@douyinfe/semi-ui';
import { Banner, Button, Form, Space, Spin } from '@douyinfe/semi-ui';
import {
compareObjects,
API,

View File

@@ -1,3 +1,22 @@
/*
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 {
Button,
@@ -10,19 +29,13 @@ import {
Avatar,
Modal,
Tag,
Switch
Switch,
} from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
IllustrationNoResultDark
IllustrationNoResultDark,
} from '@douyinfe/semi-illustrations';
import {
Plus,
Edit,
Trash2,
Save,
Settings
} from 'lucide-react';
import { Plus, Edit, Trash2, Save, Settings } from 'lucide-react';
import { API, showError, showSuccess } from '../../../helpers';
import { useTranslation } from 'react-i18next';
@@ -43,7 +56,7 @@ const SettingsAPIInfo = ({ options, refresh }) => {
url: '',
description: '',
route: '',
color: 'blue'
color: 'blue',
});
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
@@ -68,7 +81,7 @@ const SettingsAPIInfo = ({ options, refresh }) => {
{ value: 'light-blue', label: 'light-blue' },
{ value: 'indigo', label: 'indigo' },
{ value: 'violet', label: 'violet' },
{ value: 'grey', label: 'grey' }
{ value: 'grey', label: 'grey' },
];
const updateOption = async (key, value) => {
@@ -105,7 +118,7 @@ const SettingsAPIInfo = ({ options, refresh }) => {
url: '',
description: '',
route: '',
color: 'blue'
color: 'blue',
});
setShowApiModal(true);
};
@@ -116,7 +129,7 @@ const SettingsAPIInfo = ({ options, refresh }) => {
url: api.url,
description: api.description,
route: api.route,
color: api.color
color: api.color,
});
setShowApiModal(true);
};
@@ -128,7 +141,7 @@ const SettingsAPIInfo = ({ options, refresh }) => {
const confirmDeleteApi = () => {
if (deletingApi) {
const newList = apiInfoList.filter(api => api.id !== deletingApi.id);
const newList = apiInfoList.filter((api) => api.id !== deletingApi.id);
setApiInfoList(newList);
setHasChanges(true);
showSuccess('API信息已删除请及时点击“保存设置”进行保存');
@@ -148,16 +161,14 @@ const SettingsAPIInfo = ({ options, refresh }) => {
let newList;
if (editingApi) {
newList = apiInfoList.map(api =>
api.id === editingApi.id
? { ...api, ...apiForm }
: api
newList = apiInfoList.map((api) =>
api.id === editingApi.id ? { ...api, ...apiForm } : api,
);
} else {
const newId = Math.max(...apiInfoList.map(api => api.id), 0) + 1;
const newId = Math.max(...apiInfoList.map((api) => api.id), 0) + 1;
const newApi = {
id: newId,
...apiForm
...apiForm,
};
newList = [...apiInfoList, newApi];
}
@@ -165,7 +176,11 @@ const SettingsAPIInfo = ({ options, refresh }) => {
setApiInfoList(newList);
setHasChanges(true);
setShowApiModal(false);
showSuccess(editingApi ? 'API信息已更新请及时点击“保存设置”进行保存' : 'API信息已添加请及时点击“保存设置”进行保存');
showSuccess(
editingApi
? 'API信息已更新请及时点击“保存设置”进行保存'
: 'API信息已添加请及时点击“保存设置”进行保存',
);
} catch (error) {
showError('操作失败: ' + error.message);
} finally {
@@ -197,7 +212,11 @@ const SettingsAPIInfo = ({ options, refresh }) => {
useEffect(() => {
const enabledStr = options['console_setting.api_info_enabled'];
setPanelEnabled(enabledStr === undefined ? true : enabledStr === 'true' || enabledStr === true);
setPanelEnabled(
enabledStr === undefined
? true
: enabledStr === 'true' || enabledStr === true,
);
}, [options['console_setting.api_info_enabled']]);
const handleToggleEnabled = async (checked) => {
@@ -228,11 +247,7 @@ const SettingsAPIInfo = ({ options, refresh }) => {
title: t('API地址'),
dataIndex: 'url',
render: (text, record) => (
<Tag
color={record.color}
shape='circle'
style={{ maxWidth: '280px' }}
>
<Tag color={record.color} shape='circle' style={{ maxWidth: '280px' }}>
{text}
</Tag>
),
@@ -240,31 +255,18 @@ const SettingsAPIInfo = ({ options, refresh }) => {
{
title: t('线路描述'),
dataIndex: 'route',
render: (text, record) => (
<Tag shape='circle'>
{text}
</Tag>
),
render: (text, record) => <Tag shape='circle'>{text}</Tag>,
},
{
title: t('说明'),
dataIndex: 'description',
ellipsis: true,
render: (text, record) => (
<Tag shape='circle'>
{text || '-'}
</Tag>
),
render: (text, record) => <Tag shape='circle'>{text || '-'}</Tag>,
},
{
title: t('颜色'),
dataIndex: 'color',
render: (color) => (
<Avatar
size="extra-extra-small"
color={color}
/>
),
render: (color) => <Avatar size='extra-extra-small' color={color} />,
},
{
title: t('操作'),
@@ -301,31 +303,39 @@ const SettingsAPIInfo = ({ options, refresh }) => {
return;
}
const newList = apiInfoList.filter(api => !selectedRowKeys.includes(api.id));
const newList = apiInfoList.filter(
(api) => !selectedRowKeys.includes(api.id),
);
setApiInfoList(newList);
setSelectedRowKeys([]);
setHasChanges(true);
showSuccess(`已删除 ${selectedRowKeys.length} 个API信息请及时点击“保存设置”进行保存`);
showSuccess(
`已删除 ${selectedRowKeys.length} 个API信息请及时点击“保存设置”进行保存`,
);
};
const renderHeader = () => (
<div className="flex flex-col w-full">
<div className="mb-2">
<div className="flex items-center text-blue-500">
<Settings size={16} className="mr-2" />
<Text>{t('API信息管理可以配置多个API地址用于状态展示和负载均衡最多50个')}</Text>
<div className='flex flex-col w-full'>
<div className='mb-2'>
<div className='flex items-center text-blue-500'>
<Settings size={16} className='mr-2' />
<Text>
{t(
'API信息管理可以配置多个API地址用于状态展示和负载均衡最多50个',
)}
</Text>
</div>
</div>
<Divider margin="12px" />
<Divider margin='12px' />
<div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full">
<div className="flex gap-2 w-full md:w-auto order-2 md:order-1">
<div className='flex flex-col md:flex-row justify-between items-center gap-4 w-full'>
<div className='flex gap-2 w-full md:w-auto order-2 md:order-1'>
<Button
theme='light'
type='primary'
icon={<Plus size={14} />}
className="w-full md:w-auto"
className='w-full md:w-auto'
onClick={handleAddApi}
>
{t('添加API')}
@@ -336,9 +346,10 @@ const SettingsAPIInfo = ({ options, refresh }) => {
theme='light'
onClick={handleBatchDelete}
disabled={selectedRowKeys.length === 0}
className="w-full md:w-auto"
className='w-full md:w-auto'
>
{t('批量删除')} {selectedRowKeys.length > 0 && `(${selectedRowKeys.length})`}
{t('批量删除')}{' '}
{selectedRowKeys.length > 0 && `(${selectedRowKeys.length})`}
</Button>
<Button
icon={<Save size={14} />}
@@ -346,18 +357,15 @@ const SettingsAPIInfo = ({ options, refresh }) => {
loading={loading}
disabled={!hasChanges}
type='secondary'
className="w-full md:w-auto"
className='w-full md:w-auto'
>
{t('保存设置')}
</Button>
</div>
{/* 启用开关 */}
<div className="order-1 md:order-2 flex items-center gap-2">
<Switch
checked={panelEnabled}
onChange={handleToggleEnabled}
/>
<div className='order-1 md:order-2 flex items-center gap-2'>
<Switch checked={panelEnabled} onChange={handleToggleEnabled} />
<Text>{panelEnabled ? t('已启用') : t('已禁用')}</Text>
</div>
</div>
@@ -395,7 +403,7 @@ const SettingsAPIInfo = ({ options, refresh }) => {
columns={columns}
dataSource={getCurrentPageData()}
rowSelection={rowSelection}
rowKey="id"
rowKey='id'
scroll={{ x: 'max-content' }}
pagination={{
currentPage: currentPage,
@@ -403,11 +411,6 @@ const SettingsAPIInfo = ({ options, refresh }) => {
total: apiInfoList.length,
showSizeChanger: true,
showQuickJumper: true,
formatPageText: (page) => t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
start: page.currentStart,
end: page.currentEnd,
total: apiInfoList.length,
}),
pageSizeOptions: ['5', '10', '20', '50'],
onChange: (page, size) => {
setCurrentPage(page);
@@ -416,19 +419,23 @@ const SettingsAPIInfo = ({ options, refresh }) => {
onShowSizeChange: (current, size) => {
setCurrentPage(1);
setPageSize(size);
}
},
}}
size='middle'
loading={loading}
empty={
<Empty
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
image={
<IllustrationNoResult style={{ width: 150, height: 150 }} />
}
darkModeImage={
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
}
description={t('暂无API信息')}
style={{ padding: 30 }}
/>
}
className="overflow-hidden"
className='overflow-hidden'
/>
</Form.Section>
@@ -441,7 +448,11 @@ const SettingsAPIInfo = ({ options, refresh }) => {
cancelText={t('取消')}
confirmLoading={modalLoading}
>
<Form layout='vertical' initValues={apiForm} key={editingApi ? editingApi.id : 'new'}>
<Form
layout='vertical'
initValues={apiForm}
key={editingApi ? editingApi.id : 'new'}
>
<Form.Input
field='url'
label={t('API地址')}
@@ -470,10 +481,7 @@ const SettingsAPIInfo = ({ options, refresh }) => {
onChange={(value) => setApiForm({ ...apiForm, color: value })}
render={(option) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Avatar
size="extra-extra-small"
color={option.value}
/>
<Avatar size='extra-extra-small' color={option.value} />
{option.label}
</div>
)}
@@ -491,10 +499,10 @@ const SettingsAPIInfo = ({ options, refresh }) => {
}}
okText={t('确认删除')}
cancelText={t('取消')}
type="warning"
type='warning'
okButtonProps={{
type: 'danger',
theme: 'solid'
theme: 'solid',
}}
>
<Text>{t('确定要删除此API信息吗')}</Text>
@@ -503,4 +511,4 @@ const SettingsAPIInfo = ({ options, refresh }) => {
);
};
export default SettingsAPIInfo;
export default SettingsAPIInfo;

View File

@@ -1,3 +1,22 @@
/*
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, useRef } from 'react';
import {
Button,
@@ -11,21 +30,20 @@ import {
Tag,
Switch,
TextArea,
Tooltip
Tooltip,
} from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
IllustrationNoResultDark
IllustrationNoResultDark,
} from '@douyinfe/semi-illustrations';
import { Plus, Edit, Trash2, Save, Bell, Maximize2 } from 'lucide-react';
import {
Plus,
Edit,
Trash2,
Save,
Bell,
Maximize2
} from 'lucide-react';
import { API, showError, showSuccess, getRelativeTime, formatDateTimeString } from '../../../helpers';
API,
showError,
showSuccess,
getRelativeTime,
formatDateTimeString,
} from '../../../helpers';
import { useTranslation } from 'react-i18next';
const { Text } = Typography;
@@ -46,7 +64,7 @@ const SettingsAnnouncements = ({ options, refresh }) => {
content: '',
publishDate: new Date(),
type: 'default',
extra: ''
extra: '',
});
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
@@ -62,7 +80,7 @@ const SettingsAnnouncements = ({ options, refresh }) => {
{ value: 'ongoing', label: t('进行中') },
{ value: 'success', label: t('成功') },
{ value: 'warning', label: t('警告') },
{ value: 'error', label: t('错误') }
{ value: 'error', label: t('错误') },
];
const getTypeColor = (type) => {
@@ -71,7 +89,7 @@ const SettingsAnnouncements = ({ options, refresh }) => {
ongoing: 'blue',
success: 'green',
warning: 'orange',
error: 'red'
error: 'red',
};
return colorMap[type] || 'grey';
};
@@ -83,16 +101,18 @@ const SettingsAnnouncements = ({ options, refresh }) => {
key: 'content',
render: (text) => (
<Tooltip content={text} position='topLeft' showArrow>
<div style={{
maxWidth: '300px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}>
<div
style={{
maxWidth: '300px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{text}
</div>
</Tooltip>
)
),
},
{
title: t('发布时间'),
@@ -104,15 +124,17 @@ const SettingsAnnouncements = ({ options, refresh }) => {
<div style={{ fontWeight: 'bold' }}>
{getRelativeTime(publishDate)}
</div>
<div style={{
fontSize: '12px',
color: 'var(--semi-color-text-2)',
marginTop: '2px'
}}>
<div
style={{
fontSize: '12px',
color: 'var(--semi-color-text-2)',
marginTop: '2px',
}}
>
{publishDate ? formatDateTimeString(new Date(publishDate)) : '-'}
</div>
</div>
)
),
},
{
title: t('类型'),
@@ -121,9 +143,9 @@ const SettingsAnnouncements = ({ options, refresh }) => {
width: 100,
render: (type) => (
<Tag color={getTypeColor(type)} shape='circle'>
{typeOptions.find(opt => opt.value === type)?.label || type}
{typeOptions.find((opt) => opt.value === type)?.label || type}
</Tag>
)
),
},
{
title: t('说明'),
@@ -131,17 +153,19 @@ const SettingsAnnouncements = ({ options, refresh }) => {
key: 'extra',
render: (text) => (
<Tooltip content={text || '-'} showArrow>
<div style={{
maxWidth: '200px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
color: 'var(--semi-color-text-2)'
}}>
<div
style={{
maxWidth: '200px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
color: 'var(--semi-color-text-2)',
}}
>
{text || '-'}
</div>
</Tooltip>
)
),
},
{
title: t('操作'),
@@ -169,8 +193,8 @@ const SettingsAnnouncements = ({ options, refresh }) => {
{t('删除')}
</Button>
</Space>
)
}
),
},
];
const updateOption = async (key, value) => {
@@ -207,7 +231,7 @@ const SettingsAnnouncements = ({ options, refresh }) => {
content: '',
publishDate: new Date(),
type: 'default',
extra: ''
extra: '',
});
setShowAnnouncementModal(true);
};
@@ -216,9 +240,11 @@ const SettingsAnnouncements = ({ options, refresh }) => {
setEditingAnnouncement(announcement);
setAnnouncementForm({
content: announcement.content,
publishDate: announcement.publishDate ? new Date(announcement.publishDate) : new Date(),
publishDate: announcement.publishDate
? new Date(announcement.publishDate)
: new Date(),
type: announcement.type || 'default',
extra: announcement.extra || ''
extra: announcement.extra || '',
});
setShowAnnouncementModal(true);
};
@@ -230,7 +256,9 @@ const SettingsAnnouncements = ({ options, refresh }) => {
const confirmDeleteAnnouncement = () => {
if (deletingAnnouncement) {
const newList = announcementsList.filter(item => item.id !== deletingAnnouncement.id);
const newList = announcementsList.filter(
(item) => item.id !== deletingAnnouncement.id,
);
setAnnouncementsList(newList);
setHasChanges(true);
showSuccess('公告已删除,请及时点击“保存设置”进行保存');
@@ -251,21 +279,20 @@ const SettingsAnnouncements = ({ options, refresh }) => {
// publishDateISO
const formData = {
...announcementForm,
publishDate: announcementForm.publishDate.toISOString()
publishDate: announcementForm.publishDate.toISOString(),
};
let newList;
if (editingAnnouncement) {
newList = announcementsList.map(item =>
item.id === editingAnnouncement.id
? { ...item, ...formData }
: item
newList = announcementsList.map((item) =>
item.id === editingAnnouncement.id ? { ...item, ...formData } : item,
);
} else {
const newId = Math.max(...announcementsList.map(item => item.id), 0) + 1;
const newId =
Math.max(...announcementsList.map((item) => item.id), 0) + 1;
const newAnnouncement = {
id: newId,
...formData
...formData,
};
newList = [...announcementsList, newAnnouncement];
}
@@ -273,7 +300,11 @@ const SettingsAnnouncements = ({ options, refresh }) => {
setAnnouncementsList(newList);
setHasChanges(true);
setShowAnnouncementModal(false);
showSuccess(editingAnnouncement ? '公告已更新,请及时点击“保存设置”进行保存' : '公告已添加,请及时点击“保存设置”进行保存');
showSuccess(
editingAnnouncement
? '公告已更新,请及时点击“保存设置”进行保存'
: '公告已添加,请及时点击“保存设置”进行保存',
);
} catch (error) {
showError('操作失败: ' + error.message);
} finally {
@@ -293,7 +324,7 @@ const SettingsAnnouncements = ({ options, refresh }) => {
// id
const listWithIds = list.map((item, index) => ({
...item,
id: item.id || index + 1
id: item.id || index + 1,
}));
setAnnouncementsList(listWithIds);
} catch (error) {
@@ -303,7 +334,8 @@ const SettingsAnnouncements = ({ options, refresh }) => {
};
useEffect(() => {
const annStr = options['console_setting.announcements'] ?? options.Announcements;
const annStr =
options['console_setting.announcements'] ?? options.Announcements;
if (annStr !== undefined) {
parseAnnouncements(annStr);
}
@@ -311,7 +343,11 @@ const SettingsAnnouncements = ({ options, refresh }) => {
useEffect(() => {
const enabledStr = options['console_setting.announcements_enabled'];
setPanelEnabled(enabledStr === undefined ? true : enabledStr === 'true' || enabledStr === true);
setPanelEnabled(
enabledStr === undefined
? true
: enabledStr === 'true' || enabledStr === true,
);
}, [options['console_setting.announcements_enabled']]);
const handleToggleEnabled = async (checked) => {
@@ -339,31 +375,39 @@ const SettingsAnnouncements = ({ options, refresh }) => {
return;
}
const newList = announcementsList.filter(item => !selectedRowKeys.includes(item.id));
const newList = announcementsList.filter(
(item) => !selectedRowKeys.includes(item.id),
);
setAnnouncementsList(newList);
setSelectedRowKeys([]);
setHasChanges(true);
showSuccess(`已删除 ${selectedRowKeys.length} 个系统公告,请及时点击“保存设置”进行保存`);
showSuccess(
`已删除 ${selectedRowKeys.length} 个系统公告,请及时点击“保存设置”进行保存`,
);
};
const renderHeader = () => (
<div className="flex flex-col w-full">
<div className="mb-2">
<div className="flex items-center text-blue-500">
<Bell size={16} className="mr-2" />
<Text>{t('系统公告管理可以发布系统通知和重要消息最多100个前端显示最新20条')}</Text>
<div className='flex flex-col w-full'>
<div className='mb-2'>
<div className='flex items-center text-blue-500'>
<Bell size={16} className='mr-2' />
<Text>
{t(
'系统公告管理可以发布系统通知和重要消息最多100个前端显示最新20条',
)}
</Text>
</div>
</div>
<Divider margin="12px" />
<Divider margin='12px' />
<div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full">
<div className="flex gap-2 w-full md:w-auto order-2 md:order-1">
<div className='flex flex-col md:flex-row justify-between items-center gap-4 w-full'>
<div className='flex gap-2 w-full md:w-auto order-2 md:order-1'>
<Button
theme='light'
type='primary'
icon={<Plus size={14} />}
className="w-full md:w-auto"
className='w-full md:w-auto'
onClick={handleAddAnnouncement}
>
{t('添加公告')}
@@ -374,9 +418,10 @@ const SettingsAnnouncements = ({ options, refresh }) => {
theme='light'
onClick={handleBatchDelete}
disabled={selectedRowKeys.length === 0}
className="w-full md:w-auto"
className='w-full md:w-auto'
>
{t('批量删除')} {selectedRowKeys.length > 0 && `(${selectedRowKeys.length})`}
{t('批量删除')}{' '}
{selectedRowKeys.length > 0 && `(${selectedRowKeys.length})`}
</Button>
<Button
icon={<Save size={14} />}
@@ -384,14 +429,14 @@ const SettingsAnnouncements = ({ options, refresh }) => {
loading={loading}
disabled={!hasChanges}
type='secondary'
className="w-full md:w-auto"
className='w-full md:w-auto'
>
{t('保存设置')}
</Button>
</div>
{/* 启用开关 */}
<div className="order-1 md:order-2 flex items-center gap-2">
<div className='order-1 md:order-2 flex items-center gap-2'>
<Switch checked={panelEnabled} onChange={handleToggleEnabled} />
<Text>{panelEnabled ? t('已启用') : t('已禁用')}</Text>
</div>
@@ -436,7 +481,7 @@ const SettingsAnnouncements = ({ options, refresh }) => {
columns={columns}
dataSource={getCurrentPageData()}
rowSelection={rowSelection}
rowKey="id"
rowKey='id'
scroll={{ x: 'max-content' }}
pagination={{
currentPage: currentPage,
@@ -444,11 +489,6 @@ const SettingsAnnouncements = ({ options, refresh }) => {
total: announcementsList.length,
showSizeChanger: true,
showQuickJumper: true,
formatPageText: (page) => t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
start: page.currentStart,
end: page.currentEnd,
total: announcementsList.length,
}),
pageSizeOptions: ['5', '10', '20', '50'],
onChange: (page, size) => {
setCurrentPage(page);
@@ -457,19 +497,23 @@ const SettingsAnnouncements = ({ options, refresh }) => {
onShowSizeChange: (current, size) => {
setCurrentPage(1);
setPageSize(size);
}
},
}}
size='middle'
loading={loading}
empty={
<Empty
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
image={
<IllustrationNoResult style={{ width: 150, height: 150 }} />
}
darkModeImage={
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
}
description={t('暂无系统公告')}
style={{ padding: 30 }}
/>
}
className="overflow-hidden"
className='overflow-hidden'
/>
</Form.Section>
@@ -495,7 +539,9 @@ const SettingsAnnouncements = ({ options, refresh }) => {
maxCount={500}
rows={3}
rules={[{ required: true, message: t('请输入公告内容') }]}
onChange={(value) => setAnnouncementForm({ ...announcementForm, content: value })}
onChange={(value) =>
setAnnouncementForm({ ...announcementForm, content: value })
}
/>
<Button
theme='light'
@@ -512,19 +558,25 @@ const SettingsAnnouncements = ({ options, refresh }) => {
label={t('发布日期')}
type='dateTime'
rules={[{ required: true, message: t('请选择发布日期') }]}
onChange={(value) => setAnnouncementForm({ ...announcementForm, publishDate: value })}
onChange={(value) =>
setAnnouncementForm({ ...announcementForm, publishDate: value })
}
/>
<Form.Select
field='type'
label={t('公告类型')}
optionList={typeOptions}
onChange={(value) => setAnnouncementForm({ ...announcementForm, type: value })}
onChange={(value) =>
setAnnouncementForm({ ...announcementForm, type: value })
}
/>
<Form.Input
field='extra'
label={t('说明信息')}
placeholder={t('可选,公告的补充说明')}
onChange={(value) => setAnnouncementForm({ ...announcementForm, extra: value })}
onChange={(value) =>
setAnnouncementForm({ ...announcementForm, extra: value })
}
/>
</Form>
</Modal>
@@ -539,10 +591,10 @@ const SettingsAnnouncements = ({ options, refresh }) => {
}}
okText={t('确认删除')}
cancelText={t('取消')}
type="warning"
type='warning'
okButtonProps={{
type: 'danger',
theme: 'solid'
theme: 'solid',
}}
>
<Text>{t('确定要删除此公告吗?')}</Text>
@@ -570,11 +622,13 @@ const SettingsAnnouncements = ({ options, refresh }) => {
maxCount={500}
rows={15}
style={{ width: '100%' }}
onChange={(value) => setAnnouncementForm({ ...announcementForm, content: value })}
onChange={(value) =>
setAnnouncementForm({ ...announcementForm, content: value })
}
/>
</Modal>
</>
);
};
export default SettingsAnnouncements;
export default SettingsAnnouncements;

View File

@@ -1,3 +1,22 @@
/*
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, useRef } from 'react';
import { Button, Col, Form, Row, Spin } from '@douyinfe/semi-ui';
import {

View File

@@ -1,3 +1,22 @@
/*
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 {
Button,
@@ -9,19 +28,13 @@ import {
Divider,
Modal,
Switch,
Tooltip
Tooltip,
} from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
IllustrationNoResultDark
IllustrationNoResultDark,
} from '@douyinfe/semi-illustrations';
import {
Plus,
Edit,
Trash2,
Save,
HelpCircle
} from 'lucide-react';
import { Plus, Edit, Trash2, Save, HelpCircle } from 'lucide-react';
import { API, showError, showSuccess } from '../../../helpers';
import { useTranslation } from 'react-i18next';
@@ -40,7 +53,7 @@ const SettingsFAQ = ({ options, refresh }) => {
const [hasChanges, setHasChanges] = useState(false);
const [faqForm, setFaqForm] = useState({
question: '',
answer: ''
answer: '',
});
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
@@ -56,17 +69,19 @@ const SettingsFAQ = ({ options, refresh }) => {
key: 'question',
render: (text) => (
<Tooltip content={text} showArrow>
<div style={{
maxWidth: '300px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontWeight: 'bold'
}}>
<div
style={{
maxWidth: '300px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontWeight: 'bold',
}}
>
{text}
</div>
</Tooltip>
)
),
},
{
title: t('回答内容'),
@@ -74,17 +89,19 @@ const SettingsFAQ = ({ options, refresh }) => {
key: 'answer',
render: (text) => (
<Tooltip content={text} showArrow>
<div style={{
maxWidth: '400px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
color: 'var(--semi-color-text-1)'
}}>
<div
style={{
maxWidth: '400px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
color: 'var(--semi-color-text-1)',
}}
>
{text}
</div>
</Tooltip>
)
),
},
{
title: t('操作'),
@@ -112,8 +129,8 @@ const SettingsFAQ = ({ options, refresh }) => {
{t('删除')}
</Button>
</Space>
)
}
),
},
];
const updateOption = async (key, value) => {
@@ -148,7 +165,7 @@ const SettingsFAQ = ({ options, refresh }) => {
setEditingFaq(null);
setFaqForm({
question: '',
answer: ''
answer: '',
});
setShowFaqModal(true);
};
@@ -157,7 +174,7 @@ const SettingsFAQ = ({ options, refresh }) => {
setEditingFaq(faq);
setFaqForm({
question: faq.question,
answer: faq.answer
answer: faq.answer,
});
setShowFaqModal(true);
};
@@ -169,7 +186,7 @@ const SettingsFAQ = ({ options, refresh }) => {
const confirmDeleteFaq = () => {
if (deletingFaq) {
const newList = faqList.filter(item => item.id !== deletingFaq.id);
const newList = faqList.filter((item) => item.id !== deletingFaq.id);
setFaqList(newList);
setHasChanges(true);
showSuccess('问答已删除,请及时点击“保存设置”进行保存');
@@ -189,16 +206,14 @@ const SettingsFAQ = ({ options, refresh }) => {
let newList;
if (editingFaq) {
newList = faqList.map(item =>
item.id === editingFaq.id
? { ...item, ...faqForm }
: item
newList = faqList.map((item) =>
item.id === editingFaq.id ? { ...item, ...faqForm } : item,
);
} else {
const newId = Math.max(...faqList.map(item => item.id), 0) + 1;
const newId = Math.max(...faqList.map((item) => item.id), 0) + 1;
const newFaq = {
id: newId,
...faqForm
...faqForm,
};
newList = [...faqList, newFaq];
}
@@ -206,7 +221,11 @@ const SettingsFAQ = ({ options, refresh }) => {
setFaqList(newList);
setHasChanges(true);
setShowFaqModal(false);
showSuccess(editingFaq ? '问答已更新,请及时点击“保存设置”进行保存' : '问答已添加,请及时点击“保存设置”进行保存');
showSuccess(
editingFaq
? '问答已更新,请及时点击“保存设置”进行保存'
: '问答已添加,请及时点击“保存设置”进行保存',
);
} catch (error) {
showError('操作失败: ' + error.message);
} finally {
@@ -226,7 +245,7 @@ const SettingsFAQ = ({ options, refresh }) => {
// id
const listWithIds = list.map((item, index) => ({
...item,
id: item.id || index + 1
id: item.id || index + 1,
}));
setFaqList(listWithIds);
} catch (error) {
@@ -243,7 +262,11 @@ const SettingsFAQ = ({ options, refresh }) => {
useEffect(() => {
const enabledStr = options['console_setting.faq_enabled'];
setPanelEnabled(enabledStr === undefined ? true : enabledStr === 'true' || enabledStr === true);
setPanelEnabled(
enabledStr === undefined
? true
: enabledStr === 'true' || enabledStr === true,
);
}, [options['console_setting.faq_enabled']]);
const handleToggleEnabled = async (checked) => {
@@ -271,31 +294,39 @@ const SettingsFAQ = ({ options, refresh }) => {
return;
}
const newList = faqList.filter(item => !selectedRowKeys.includes(item.id));
const newList = faqList.filter(
(item) => !selectedRowKeys.includes(item.id),
);
setFaqList(newList);
setSelectedRowKeys([]);
setHasChanges(true);
showSuccess(`已删除 ${selectedRowKeys.length} 个常见问答,请及时点击“保存设置”进行保存`);
showSuccess(
`已删除 ${selectedRowKeys.length} 个常见问答,请及时点击“保存设置”进行保存`,
);
};
const renderHeader = () => (
<div className="flex flex-col w-full">
<div className="mb-2">
<div className="flex items-center text-blue-500">
<HelpCircle size={16} className="mr-2" />
<Text>{t('常见问答管理为用户提供常见问题的答案最多50个前端显示最新20条')}</Text>
<div className='flex flex-col w-full'>
<div className='mb-2'>
<div className='flex items-center text-blue-500'>
<HelpCircle size={16} className='mr-2' />
<Text>
{t(
'常见问答管理为用户提供常见问题的答案最多50个前端显示最新20条',
)}
</Text>
</div>
</div>
<Divider margin="12px" />
<Divider margin='12px' />
<div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full">
<div className="flex gap-2 w-full md:w-auto order-2 md:order-1">
<div className='flex flex-col md:flex-row justify-between items-center gap-4 w-full'>
<div className='flex gap-2 w-full md:w-auto order-2 md:order-1'>
<Button
theme='light'
type='primary'
icon={<Plus size={14} />}
className="w-full md:w-auto"
className='w-full md:w-auto'
onClick={handleAddFaq}
>
{t('添加问答')}
@@ -306,9 +337,10 @@ const SettingsFAQ = ({ options, refresh }) => {
theme='light'
onClick={handleBatchDelete}
disabled={selectedRowKeys.length === 0}
className="w-full md:w-auto"
className='w-full md:w-auto'
>
{t('批量删除')} {selectedRowKeys.length > 0 && `(${selectedRowKeys.length})`}
{t('批量删除')}{' '}
{selectedRowKeys.length > 0 && `(${selectedRowKeys.length})`}
</Button>
<Button
icon={<Save size={14} />}
@@ -316,14 +348,14 @@ const SettingsFAQ = ({ options, refresh }) => {
loading={loading}
disabled={!hasChanges}
type='secondary'
className="w-full md:w-auto"
className='w-full md:w-auto'
>
{t('保存设置')}
</Button>
</div>
{/* 启用开关 */}
<div className="order-1 md:order-2 flex items-center gap-2">
<div className='order-1 md:order-2 flex items-center gap-2'>
<Switch checked={panelEnabled} onChange={handleToggleEnabled} />
<Text>{panelEnabled ? t('已启用') : t('已禁用')}</Text>
</div>
@@ -362,7 +394,7 @@ const SettingsFAQ = ({ options, refresh }) => {
columns={columns}
dataSource={getCurrentPageData()}
rowSelection={rowSelection}
rowKey="id"
rowKey='id'
scroll={{ x: 'max-content' }}
pagination={{
currentPage: currentPage,
@@ -370,11 +402,6 @@ const SettingsFAQ = ({ options, refresh }) => {
total: faqList.length,
showSizeChanger: true,
showQuickJumper: true,
formatPageText: (page) => t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
start: page.currentStart,
end: page.currentEnd,
total: faqList.length,
}),
pageSizeOptions: ['5', '10', '20', '50'],
onChange: (page, size) => {
setCurrentPage(page);
@@ -383,19 +410,23 @@ const SettingsFAQ = ({ options, refresh }) => {
onShowSizeChange: (current, size) => {
setCurrentPage(1);
setPageSize(size);
}
},
}}
size='middle'
loading={loading}
empty={
<Empty
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
image={
<IllustrationNoResult style={{ width: 150, height: 150 }} />
}
darkModeImage={
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
}
description={t('暂无常见问答')}
style={{ padding: 30 }}
/>
}
className="overflow-hidden"
className='overflow-hidden'
/>
</Form.Section>
@@ -409,7 +440,11 @@ const SettingsFAQ = ({ options, refresh }) => {
confirmLoading={modalLoading}
width={800}
>
<Form layout='vertical' initValues={faqForm} key={editingFaq ? editingFaq.id : 'new'}>
<Form
layout='vertical'
initValues={faqForm}
key={editingFaq ? editingFaq.id : 'new'}
>
<Form.Input
field='question'
label={t('问题标题')}
@@ -440,10 +475,10 @@ const SettingsFAQ = ({ options, refresh }) => {
}}
okText={t('确认删除')}
cancelText={t('取消')}
type="warning"
type='warning'
okButtonProps={{
type: 'danger',
theme: 'solid'
theme: 'solid',
}}
>
<Text>{t('确定要删除此问答吗?')}</Text>
@@ -452,4 +487,4 @@ const SettingsFAQ = ({ options, refresh }) => {
);
};
export default SettingsFAQ;
export default SettingsFAQ;

View File

@@ -1,3 +1,22 @@
/*
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 {
Button,
@@ -8,19 +27,13 @@ import {
Empty,
Divider,
Modal,
Switch
Switch,
} from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
IllustrationNoResultDark
IllustrationNoResultDark,
} from '@douyinfe/semi-illustrations';
import {
Plus,
Edit,
Trash2,
Save,
Activity
} from 'lucide-react';
import { Plus, Edit, Trash2, Save, Activity } from 'lucide-react';
import { API, showError, showSuccess } from '../../../helpers';
import { useTranslation } from 'react-i18next';
@@ -53,41 +66,47 @@ const SettingsUptimeKuma = ({ options, refresh }) => {
dataIndex: 'categoryName',
key: 'categoryName',
render: (text) => (
<div style={{
fontWeight: 'bold',
color: 'var(--semi-color-text-0)'
}}>
<div
style={{
fontWeight: 'bold',
color: 'var(--semi-color-text-0)',
}}
>
{text}
</div>
)
),
},
{
title: t('Uptime Kuma地址'),
dataIndex: 'url',
key: 'url',
render: (text) => (
<div style={{
maxWidth: '300px',
wordBreak: 'break-all',
fontFamily: 'monospace',
color: 'var(--semi-color-primary)'
}}>
<div
style={{
maxWidth: '300px',
wordBreak: 'break-all',
fontFamily: 'monospace',
color: 'var(--semi-color-primary)',
}}
>
{text}
</div>
)
),
},
{
title: t('状态页面Slug'),
dataIndex: 'slug',
key: 'slug',
render: (text) => (
<div style={{
fontFamily: 'monospace',
color: 'var(--semi-color-text-1)'
}}>
<div
style={{
fontFamily: 'monospace',
color: 'var(--semi-color-text-1)',
}}
>
{text}
</div>
)
),
},
{
title: t('操作'),
@@ -115,8 +134,8 @@ const SettingsUptimeKuma = ({ options, refresh }) => {
{t('删除')}
</Button>
</Space>
)
}
),
},
];
const updateOption = async (key, value) => {
@@ -174,7 +193,9 @@ const SettingsUptimeKuma = ({ options, refresh }) => {
const confirmDeleteGroup = () => {
if (deletingGroup) {
const newList = uptimeGroupsList.filter(item => item.id !== deletingGroup.id);
const newList = uptimeGroupsList.filter(
(item) => item.id !== deletingGroup.id,
);
setUptimeGroupsList(newList);
setHasChanges(true);
showSuccess('分类已删除,请及时点击“保存设置”进行保存');
@@ -206,16 +227,15 @@ const SettingsUptimeKuma = ({ options, refresh }) => {
let newList;
if (editingGroup) {
newList = uptimeGroupsList.map(item =>
item.id === editingGroup.id
? { ...item, ...uptimeForm }
: item
newList = uptimeGroupsList.map((item) =>
item.id === editingGroup.id ? { ...item, ...uptimeForm } : item,
);
} else {
const newId = Math.max(...uptimeGroupsList.map(item => item.id), 0) + 1;
const newId =
Math.max(...uptimeGroupsList.map((item) => item.id), 0) + 1;
const newGroup = {
id: newId,
...uptimeForm
...uptimeForm,
};
newList = [...uptimeGroupsList, newGroup];
}
@@ -223,7 +243,11 @@ const SettingsUptimeKuma = ({ options, refresh }) => {
setUptimeGroupsList(newList);
setHasChanges(true);
setShowUptimeModal(false);
showSuccess(editingGroup ? '分类已更新,请及时点击“保存设置”进行保存' : '分类已添加,请及时点击“保存设置”进行保存');
showSuccess(
editingGroup
? '分类已更新,请及时点击“保存设置”进行保存'
: '分类已添加,请及时点击“保存设置”进行保存',
);
} catch (error) {
showError('操作失败: ' + error.message);
} finally {
@@ -242,7 +266,7 @@ const SettingsUptimeKuma = ({ options, refresh }) => {
const list = Array.isArray(parsed) ? parsed : [];
const listWithIds = list.map((item, index) => ({
...item,
id: item.id || index + 1
id: item.id || index + 1,
}));
setUptimeGroupsList(listWithIds);
} catch (error) {
@@ -260,7 +284,11 @@ const SettingsUptimeKuma = ({ options, refresh }) => {
useEffect(() => {
const enabledStr = options['console_setting.uptime_kuma_enabled'];
setPanelEnabled(enabledStr === undefined ? true : enabledStr === 'true' || enabledStr === true);
setPanelEnabled(
enabledStr === undefined
? true
: enabledStr === 'true' || enabledStr === true,
);
}, [options['console_setting.uptime_kuma_enabled']]);
const handleToggleEnabled = async (checked) => {
@@ -288,31 +316,39 @@ const SettingsUptimeKuma = ({ options, refresh }) => {
return;
}
const newList = uptimeGroupsList.filter(item => !selectedRowKeys.includes(item.id));
const newList = uptimeGroupsList.filter(
(item) => !selectedRowKeys.includes(item.id),
);
setUptimeGroupsList(newList);
setSelectedRowKeys([]);
setHasChanges(true);
showSuccess(`已删除 ${selectedRowKeys.length} 个分类,请及时点击“保存设置”进行保存`);
showSuccess(
`已删除 ${selectedRowKeys.length} 个分类,请及时点击“保存设置”进行保存`,
);
};
const renderHeader = () => (
<div className="flex flex-col w-full">
<div className="mb-2">
<div className="flex items-center text-blue-500">
<Activity size={16} className="mr-2" />
<Text>{t('Uptime Kuma监控分类管理可以配置多个监控分类用于服务状态展示最多20个')}</Text>
<div className='flex flex-col w-full'>
<div className='mb-2'>
<div className='flex items-center text-blue-500'>
<Activity size={16} className='mr-2' />
<Text>
{t(
'Uptime Kuma监控分类管理可以配置多个监控分类用于服务状态展示最多20个',
)}
</Text>
</div>
</div>
<Divider margin="12px" />
<Divider margin='12px' />
<div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full">
<div className="flex gap-2 w-full md:w-auto order-2 md:order-1">
<div className='flex flex-col md:flex-row justify-between items-center gap-4 w-full'>
<div className='flex gap-2 w-full md:w-auto order-2 md:order-1'>
<Button
theme='light'
type='primary'
icon={<Plus size={14} />}
className="w-full md:w-auto"
className='w-full md:w-auto'
onClick={handleAddGroup}
>
{t('添加分类')}
@@ -323,9 +359,10 @@ const SettingsUptimeKuma = ({ options, refresh }) => {
theme='light'
onClick={handleBatchDelete}
disabled={selectedRowKeys.length === 0}
className="w-full md:w-auto"
className='w-full md:w-auto'
>
{t('批量删除')} {selectedRowKeys.length > 0 && `(${selectedRowKeys.length})`}
{t('批量删除')}{' '}
{selectedRowKeys.length > 0 && `(${selectedRowKeys.length})`}
</Button>
<Button
icon={<Save size={14} />}
@@ -333,14 +370,14 @@ const SettingsUptimeKuma = ({ options, refresh }) => {
loading={loading}
disabled={!hasChanges}
type='secondary'
className="w-full md:w-auto"
className='w-full md:w-auto'
>
{t('保存设置')}
</Button>
</div>
{/* 启用开关 */}
<div className="order-1 md:order-2 flex items-center gap-2">
<div className='order-1 md:order-2 flex items-center gap-2'>
<Switch checked={panelEnabled} onChange={handleToggleEnabled} />
<Text>{panelEnabled ? t('已启用') : t('已禁用')}</Text>
</div>
@@ -378,7 +415,7 @@ const SettingsUptimeKuma = ({ options, refresh }) => {
columns={columns}
dataSource={getCurrentPageData()}
rowSelection={rowSelection}
rowKey="id"
rowKey='id'
scroll={{ x: 'max-content' }}
pagination={{
currentPage: currentPage,
@@ -386,11 +423,6 @@ const SettingsUptimeKuma = ({ options, refresh }) => {
total: uptimeGroupsList.length,
showSizeChanger: true,
showQuickJumper: true,
formatPageText: (page) => t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
start: page.currentStart,
end: page.currentEnd,
total: uptimeGroupsList.length,
}),
pageSizeOptions: ['5', '10', '20', '50'],
onChange: (page, size) => {
setCurrentPage(page);
@@ -399,19 +431,23 @@ const SettingsUptimeKuma = ({ options, refresh }) => {
onShowSizeChange: (current, size) => {
setCurrentPage(1);
setPageSize(size);
}
},
}}
size='middle'
loading={loading}
empty={
<Empty
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
image={
<IllustrationNoResult style={{ width: 150, height: 150 }} />
}
darkModeImage={
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
}
description={t('暂无监控数据')}
style={{ padding: 30 }}
/>
}
className="overflow-hidden"
className='overflow-hidden'
/>
</Form.Section>
@@ -425,19 +461,27 @@ const SettingsUptimeKuma = ({ options, refresh }) => {
confirmLoading={modalLoading}
width={600}
>
<Form layout='vertical' initValues={uptimeForm} key={editingGroup ? editingGroup.id : 'new'}>
<Form
layout='vertical'
initValues={uptimeForm}
key={editingGroup ? editingGroup.id : 'new'}
>
<Form.Input
field='categoryName'
label={t('分类名称')}
placeholder={t('请输入分类名称OpenAI、Claude等')}
maxLength={50}
rules={[{ required: true, message: t('请输入分类名称') }]}
onChange={(value) => setUptimeForm({ ...uptimeForm, categoryName: value })}
onChange={(value) =>
setUptimeForm({ ...uptimeForm, categoryName: value })
}
/>
<Form.Input
field='url'
label={t('Uptime Kuma地址')}
placeholder={t('请输入Uptime Kuma服务地址https://status.example.com')}
placeholder={t(
'请输入Uptime Kuma服务地址https://status.example.com',
)}
maxLength={500}
rules={[{ required: true, message: t('请输入Uptime Kuma地址') }]}
onChange={(value) => setUptimeForm({ ...uptimeForm, url: value })}
@@ -463,10 +507,10 @@ const SettingsUptimeKuma = ({ options, refresh }) => {
}}
okText={t('确认删除')}
cancelText={t('取消')}
type="warning"
type='warning'
okButtonProps={{
type: 'danger',
theme: 'solid'
theme: 'solid',
}}
>
<Text>{t('确定要删除此分类吗?')}</Text>
@@ -475,4 +519,4 @@ const SettingsUptimeKuma = ({ options, refresh }) => {
);
};
export default SettingsUptimeKuma;
export default SettingsUptimeKuma;

View File

@@ -1,3 +1,22 @@
/*
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, useRef } from 'react';
import { Button, Col, Form, Row, Spin, Tag } from '@douyinfe/semi-ui';
import {

View File

@@ -1,3 +1,22 @@
/*
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, useRef } from 'react';
import { Button, Col, Form, Row, Spin } from '@douyinfe/semi-ui';
import {

View File

@@ -1,3 +1,22 @@
/*
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, useRef } from 'react';
import { Button, Col, Form, Row, Spin } from '@douyinfe/semi-ui';
import {
@@ -9,7 +28,7 @@ import {
verifyJSON,
} from '../../../helpers';
import { useTranslation } from 'react-i18next';
import Text from '@douyinfe/semi-ui/lib/es/typography/text.js';
import Text from '@douyinfe/semi-ui/lib/es/typography/text';
const GEMINI_SETTING_EXAMPLE = {
default: 'OFF',
@@ -152,8 +171,21 @@ export default function SettingGeminiModel(props) {
<Form.TextArea
field={'gemini.supported_imagine_models'}
label={t('支持的图像模型')}
placeholder={t('例如:') + '\n' + JSON.stringify(['gemini-2.0-flash-exp-image-generation'], null, 2)}
onChange={(value) => setInputs({ ...inputs, 'gemini.supported_imagine_models': value })}
placeholder={
t('例如:') +
'\n' +
JSON.stringify(
['gemini-2.0-flash-exp-image-generation'],
null,
2,
)
}
onChange={(value) =>
setInputs({
...inputs,
'gemini.supported_imagine_models': value,
})
}
trigger='blur'
stopValidateWithError
rules={[
@@ -172,9 +204,9 @@ export default function SettingGeminiModel(props) {
<Col span={16}>
<Text>
{t(
"和Claude不同默认情况下Gemini的思考模型会自动决定要不要思考就算不开启适配模型也可以正常使用" +
"如果您需要计费,推荐设置无后缀模型价格按思考价格设置。" +
"支持使用 gemini-2.5-pro-preview-06-05-thinking-128 格式来精确传递思考预算。"
'和Claude不同默认情况下Gemini的思考模型会自动决定要不要思考就算不开启适配模型也可以正常使用' +
'如果您需要计费,推荐设置无后缀模型价格按思考价格设置。' +
'支持使用 gemini-2.5-pro-preview-06-05-thinking-128 格式来精确传递思考预算。',
)}
</Text>
</Col>
@@ -184,7 +216,9 @@ export default function SettingGeminiModel(props) {
<Form.Switch
label={t('启用Gemini思考后缀适配')}
field={'gemini.thinking_adapter_enabled'}
extraText={t('适配 -thinking、-thinking-预算数字 和 -nothinking 后缀')}
extraText={t(
'适配 -thinking、-thinking-预算数字 和 -nothinking 后缀',
)}
onChange={(value) =>
setInputs({
...inputs,
@@ -209,7 +243,7 @@ export default function SettingGeminiModel(props) {
label={t('思考预算占比')}
field={'gemini.thinking_adapter_budget_tokens_percentage'}
initValue={''}
extraText={t('0.002-1之间的小数')}
extraText={t('0.002-1之间的小数')}
min={0.002}
max={1}
onChange={(value) =>

View File

@@ -1,3 +1,22 @@
/*
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, useRef } from 'react';
import { Button, Col, Form, Row, Spin, Banner } from '@douyinfe/semi-ui';
import {
@@ -91,22 +110,27 @@ export default function SettingGlobalModel(props) {
/>
</Col>
</Row>
<Form.Section text={t('连接保活设置')}>
<Row style={{ marginTop: 10 }}>
<Col span={24}>
<Banner
type="warning"
description="警告启用保活后如果已经写入保活数据后渠道出错系统无法重试如果必须开启推荐设置尽可能大的Ping间隔"
/>
</Col>
</Row>
<Row style={{ marginTop: 10 }}>
<Col span={24}>
<Banner
type='warning'
description='警告启用保活后如果已经写入保活数据后渠道出错系统无法重试如果必须开启推荐设置尽可能大的Ping间隔'
/>
</Col>
</Row>
<Row>
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.Switch
label={t('启用Ping间隔')}
field={'general_setting.ping_interval_enabled'}
onChange={(value) => setInputs({ ...inputs, 'general_setting.ping_interval_enabled': value })}
onChange={(value) =>
setInputs({
...inputs,
'general_setting.ping_interval_enabled': value,
})
}
extraText={'开启后将定期发送ping数据保持连接活跃'}
/>
</Col>
@@ -114,7 +138,12 @@ export default function SettingGlobalModel(props) {
<Form.InputNumber
label={t('Ping间隔')}
field={'general_setting.ping_interval_seconds'}
onChange={(value) => setInputs({ ...inputs, 'general_setting.ping_interval_seconds': value })}
onChange={(value) =>
setInputs({
...inputs,
'general_setting.ping_interval_seconds': value,
})
}
min={1}
disabled={!inputs['general_setting.ping_interval_enabled']}
/>

View File

@@ -1,3 +1,22 @@
/*
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, useRef } from 'react';
import { Button, Col, Form, Row, Spin } from '@douyinfe/semi-ui';
import { useTranslation } from 'react-i18next';

View File

@@ -1,13 +1,24 @@
/*
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, useRef } from 'react';
import {
Banner,
Button,
Col,
Form,
Row,
Spin,
Modal,
} from '@douyinfe/semi-ui';
import { Banner, Button, Col, Form, Row, Spin, Modal } from '@douyinfe/semi-ui';
import {
compareObjects,
API,
@@ -119,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'}
@@ -183,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'}

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

View File

@@ -1,3 +1,22 @@
/*
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, useRef } from 'react';
import { Button, Col, Form, Row, Spin, DatePicker } from '@douyinfe/semi-ui';
import dayjs from 'dayjs';

View File

@@ -1,3 +1,22 @@
/*
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, useRef } from 'react';
import { Button, Col, Form, Row, Spin } from '@douyinfe/semi-ui';
import {
@@ -19,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);
@@ -79,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

View File

@@ -1,3 +1,22 @@
/*
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, useRef } from 'react';
import { Button, Col, Form, Row, Spin, Tag } from '@douyinfe/semi-ui';
import {

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

View File

@@ -1,9 +1,24 @@
/*
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, useRef } from 'react';
import {
Button,
Form,
Spin,
} from '@douyinfe/semi-ui';
import { Button, Form, Spin } from '@douyinfe/semi-ui';
import {
API,
removeTrailingSlash,
@@ -22,7 +37,9 @@ export default function SettingsGeneralPayment(props) {
useEffect(() => {
if (props.options && formApiRef.current) {
const currentInputs = { ServerAddress: props.options.ServerAddress || '' };
const currentInputs = {
ServerAddress: props.options.ServerAddress || '',
};
setInputs(currentInputs);
formApiRef.current.setValues(currentInputs);
}
@@ -65,11 +82,13 @@ export default function SettingsGeneralPayment(props) {
label={t('服务器地址')}
placeholder={'https://yourdomain.com'}
style={{ width: '100%' }}
extraText={t('该服务器地址将影响支付回调地址以及默认首页展示的地址,请确保正确配置')}
extraText={t(
'该服务器地址将影响支付回调地址以及默认首页展示的地址,请确保正确配置',
)}
/>
<Button onClick={submitServerAddress}>{t('更新服务器地址')}</Button>
</Form.Section>
</Form>
</Spin>
);
}
}

View File

@@ -1,12 +1,24 @@
/*
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, useRef } from 'react';
import {
Button,
Form,
Row,
Col,
Typography,
Spin,
} from '@douyinfe/semi-ui';
import { Button, Form, Row, Col, Typography, Spin } from '@douyinfe/semi-ui';
const { Text } = Typography;
import {
API,
@@ -29,6 +41,8 @@ export default function SettingsPaymentGateway(props) {
TopupGroupRatio: '',
CustomCallbackAddress: '',
PayMethods: '',
AmountOptions: '',
AmountDiscount: '',
});
const [originInputs, setOriginInputs] = useState({});
const formApiRef = useRef(null);
@@ -39,12 +53,41 @@ export default function SettingsPaymentGateway(props) {
PayAddress: props.options.PayAddress || '',
EpayId: props.options.EpayId || '',
EpayKey: props.options.EpayKey || '',
Price: props.options.Price !== undefined ? parseFloat(props.options.Price) : 7.3,
MinTopUp: props.options.MinTopUp !== undefined ? parseFloat(props.options.MinTopUp) : 1,
Price:
props.options.Price !== undefined
? parseFloat(props.options.Price)
: 7.3,
MinTopUp:
props.options.MinTopUp !== undefined
? parseFloat(props.options.MinTopUp)
: 1,
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);
@@ -75,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 = [
@@ -105,21 +162,27 @@ 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 =>
const requestQueue = options.map((opt) =>
API.put('/api/option/', {
key: opt.key,
value: opt.value,
})
}),
);
const results = await Promise.all(requestQueue);
//
const errorResults = results.filter(res => !res.data.success);
const errorResults = results.filter((res) => !res.data.success);
if (errorResults.length > 0) {
errorResults.forEach(res => {
errorResults.forEach((res) => {
showError(res.data.message);
});
} else {
@@ -143,11 +206,11 @@ export default function SettingsPaymentGateway(props) {
>
<Form.Section text={t('支付设置')}>
<Text>
{t('(当前仅支持易支付接口,默认使用上方服务器地址作为回调地址!)')}
{t(
'(当前仅支持易支付接口,默认使用上方服务器地址作为回调地址!)',
)}
</Text>
<Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
>
<Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Input
field='PayAddress'
@@ -210,9 +273,40 @@ 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>
</Spin>
);
}
}

View File

@@ -1,3 +1,22 @@
/*
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, useRef } from 'react';
import {
Banner,
@@ -36,8 +55,14 @@ export default function SettingsPaymentGateway(props) {
StripeApiSecret: props.options.StripeApiSecret || '',
StripeWebhookSecret: props.options.StripeWebhookSecret || '',
StripePriceId: props.options.StripePriceId || '',
StripeUnitPrice: props.options.StripeUnitPrice !== undefined ? parseFloat(props.options.StripeUnitPrice) : 8.0,
StripeMinTopUp: props.options.StripeMinTopUp !== undefined ? parseFloat(props.options.StripeMinTopUp) : 1,
StripeUnitPrice:
props.options.StripeUnitPrice !== undefined
? parseFloat(props.options.StripeUnitPrice)
: 8.0,
StripeMinTopUp:
props.options.StripeMinTopUp !== undefined
? parseFloat(props.options.StripeMinTopUp)
: 1,
};
setInputs(currentInputs);
setOriginInputs({ ...currentInputs });
@@ -57,38 +82,53 @@ export default function SettingsPaymentGateway(props) {
setLoading(true);
try {
const options = []
const options = [];
if (inputs.StripeApiSecret && inputs.StripeApiSecret !== '') {
options.push({ key: 'StripeApiSecret', value: inputs.StripeApiSecret });
}
if (inputs.StripeWebhookSecret && inputs.StripeWebhookSecret !== '') {
options.push({ key: 'StripeWebhookSecret', value: inputs.StripeWebhookSecret });
options.push({
key: 'StripeWebhookSecret',
value: inputs.StripeWebhookSecret,
});
}
if (inputs.StripePriceId !== '') {
options.push({key: 'StripePriceId', value: inputs.StripePriceId,});
options.push({ key: 'StripePriceId', value: inputs.StripePriceId });
}
if (inputs.StripeUnitPrice !== undefined && inputs.StripeUnitPrice !== null) {
options.push({ key: 'StripeUnitPrice', value: inputs.StripeUnitPrice.toString() });
if (
inputs.StripeUnitPrice !== undefined &&
inputs.StripeUnitPrice !== null
) {
options.push({
key: 'StripeUnitPrice',
value: inputs.StripeUnitPrice.toString(),
});
}
if (inputs.StripeMinTopUp !== undefined && inputs.StripeMinTopUp !== null) {
options.push({ key: 'StripeMinTopUp', value: inputs.StripeMinTopUp.toString() });
if (
inputs.StripeMinTopUp !== undefined &&
inputs.StripeMinTopUp !== null
) {
options.push({
key: 'StripeMinTopUp',
value: inputs.StripeMinTopUp.toString(),
});
}
//
const requestQueue = options.map(opt =>
const requestQueue = options.map((opt) =>
API.put('/api/option/', {
key: opt.key,
value: opt.value,
})
}),
);
const results = await Promise.all(requestQueue);
//
const errorResults = results.filter(res => !res.data.success);
const errorResults = results.filter((res) => !res.data.success);
if (errorResults.length > 0) {
errorResults.forEach(res => {
errorResults.forEach((res) => {
showError(res.data.message);
});
} else {
@@ -114,40 +154,39 @@ export default function SettingsPaymentGateway(props) {
<Text>
Stripe 密钥Webhook 等设置请
<a
href='https://dashboard.stripe.com/developers'
target='_blank'
rel='noreferrer'
href='https://dashboard.stripe.com/developers'
target='_blank'
rel='noreferrer'
>
点击此处
</a>
进行设置最好先在
<a
href='https://dashboard.stripe.com/test/developers'
target='_blank'
rel='noreferrer'
href='https://dashboard.stripe.com/test/developers'
target='_blank'
rel='noreferrer'
>
测试环境
</a>
进行测试
<br />
</Text>
<Banner
type='info'
description={`Webhook 填:${props.options.ServerAddress ? removeTrailingSlash(props.options.ServerAddress) : t('网站地址')}/api/stripe/webhook`}
type='info'
description={`Webhook 填:${props.options.ServerAddress ? removeTrailingSlash(props.options.ServerAddress) : t('网站地址')}/api/stripe/webhook`}
/>
<Banner
type='warning'
description={`需要包含事件checkout.session.completed 和 checkout.session.expired`}
type='warning'
description={`需要包含事件checkout.session.completed 和 checkout.session.expired`}
/>
<Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
>
<Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Input
field='StripeApiSecret'
label={t('API 密钥')}
placeholder={t('sk_xxx 或 rk_xxx 的 Stripe 密钥,敏感信息不显示')}
placeholder={t(
'sk_xxx 或 rk_xxx 的 Stripe 密钥,敏感信息不显示',
)}
type='password'
/>
</Col>
@@ -192,4 +231,4 @@ export default function SettingsPaymentGateway(props) {
</Form>
</Spin>
);
}
}

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

View File

@@ -1,3 +1,22 @@
/*
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, useRef } from 'react';
import { Button, Col, Form, Row, Spin } from '@douyinfe/semi-ui';
import {
@@ -49,11 +68,11 @@ export default function RequestRateLimit(props) {
return showError(t('部分保存失败,请重试'));
}
for (let i = 0; i < res.length; i++) {
if (!res[i].data.success) {
return showError(res[i].data.message);
for (let i = 0; i < res.length; i++) {
if (!res[i].data.success) {
return showError(res[i].data.message);
}
}
}
showSuccess(t('保存成功'));
props.refresh();
@@ -128,6 +147,7 @@ export default function RequestRateLimit(props) {
label={t('用户每周期最多请求次数')}
step={1}
min={0}
max={100000000}
suffix={t('次')}
extraText={t('包括失败请求的次数0代表不限制')}
field={'ModelRequestRateLimitCount'}
@@ -144,6 +164,7 @@ export default function RequestRateLimit(props) {
label={t('用户每周期最多请求完成次数')}
step={1}
min={1}
max={100000000}
suffix={t('次')}
extraText={t('只包括请求成功的次数')}
field={'ModelRequestRateLimitSuccessCount'}
@@ -164,22 +185,39 @@ export default function RequestRateLimit(props) {
'{\n "default": [200, 100],\n "vip": [0, 1000]\n}',
)}
field={'ModelRequestRateLimitGroup'}
autosize={{ minRows: 5, maxRows: 15 }}
trigger='blur'
stopValidateWithError
rules={[
{
validator: (rule, value) => verifyJSON(value),
message: t('不是合法的 JSON 字符串'),
},
]}
autosize={{ minRows: 5, maxRows: 15 }}
trigger='blur'
stopValidateWithError
rules={[
{
validator: (rule, value) => verifyJSON(value),
message: t('不是合法的 JSON 字符串'),
},
]}
extraText={
<div>
<p>{t('说明:')}</p>
<ul>
<li>{t('使用 JSON 对象格式,格式为:{"组名": [最多请求次数, 最多请求完成次数]}')}</li>
<li>{t('示例:{"default": [200, 100], "vip": [0, 1000]}。')}</li>
<li>{t('[最多请求次数]必须大于等于0[最多请求完成次数]必须大于等于1。')}</li>
<li>
{t(
'使用 JSON 对象格式,格式为:{"组名": [最多请求次数, 最多请求完成次数]}',
)}
</li>
<li>
{t(
'示例:{"default": [200, 100], "vip": [0, 1000]}。',
)}
</li>
<li>
{t(
'[最多请求次数]必须大于等于0[最多请求完成次数]必须大于等于1。',
)}
</li>
<li>
{t(
'[最多请求次数]和[最多请求完成次数]的最大值为2147483647。',
)}
</li>
<li>{t('分组速率配置优先级高于全局速率限制。')}</li>
<li>{t('限制周期统一使用上方配置的“限制周期”值。')}</li>
</ul>

View File

@@ -1,3 +1,22 @@
/*
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, useRef } from 'react';
import { Button, Col, Form, Row, Spin } from '@douyinfe/semi-ui';
import {
@@ -114,9 +133,7 @@ export default function GroupRatioSettings(props) {
message: t('不是合法的 JSON 字符串'),
},
]}
onChange={(value) =>
setInputs({ ...inputs, GroupRatio: value })
}
onChange={(value) => setInputs({ ...inputs, GroupRatio: value })}
/>
</Col>
</Row>
@@ -194,7 +211,7 @@ export default function GroupRatioSettings(props) {
}
// Check if every element is a string
return parsed.every(item => typeof item === 'string');
return parsed.every((item) => typeof item === 'string');
} catch (error) {
return false;
}
@@ -202,9 +219,7 @@ export default function GroupRatioSettings(props) {
message: t('必须是有效的 JSON 字符串数组,例如:["g1","g2"]'),
},
]}
onChange={(value) =>
setInputs({ ...inputs, AutoGroups: value })
}
onChange={(value) => setInputs({ ...inputs, AutoGroups: value })}
/>
</Col>
</Row>

View File

@@ -1,3 +1,22 @@
/*
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, useRef } from 'react';
import {
Button,
@@ -25,6 +44,9 @@ export default function ModelRatioSettings(props) {
ModelRatio: '',
CacheRatio: '',
CompletionRatio: '',
ImageRatio: '',
AudioRatio: '',
AudioCompletionRatio: '',
ExposeRatioEnabled: false,
});
const refForm = useRef();
@@ -136,9 +158,7 @@ export default function ModelRatioSettings(props) {
message: '不是合法的 JSON 字符串',
},
]}
onChange={(value) =>
setInputs({ ...inputs, ModelPrice: value })
}
onChange={(value) => setInputs({ ...inputs, ModelPrice: value })}
/>
</Col>
</Row>
@@ -157,9 +177,7 @@ export default function ModelRatioSettings(props) {
message: '不是合法的 JSON 字符串',
},
]}
onChange={(value) =>
setInputs({ ...inputs, ModelRatio: value })
}
onChange={(value) => setInputs({ ...inputs, ModelRatio: value })}
/>
</Col>
</Row>
@@ -178,9 +196,7 @@ export default function ModelRatioSettings(props) {
message: '不是合法的 JSON 字符串',
},
]}
onChange={(value) =>
setInputs({ ...inputs, CacheRatio: value })
}
onChange={(value) => setInputs({ ...inputs, CacheRatio: value })}
/>
</Col>
</Row>
@@ -206,6 +222,72 @@ export default function ModelRatioSettings(props) {
/>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} sm={16}>
<Form.TextArea
label={t('图片输入倍率(仅部分模型支持该计费)')}
extraText={t('图片输入相关的倍率设置,键为模型名称,值为倍率,仅部分模型支持该计费')}
placeholder={t('为一个 JSON 文本,键为模型名称,值为倍率,例如:{"gpt-image-1": 2}')}
field={'ImageRatio'}
autosize={{ minRows: 6, maxRows: 12 }}
trigger='blur'
stopValidateWithError
rules={[
{
validator: (rule, value) => verifyJSON(value),
message: '不是合法的 JSON 字符串',
},
]}
onChange={(value) =>
setInputs({ ...inputs, ImageRatio: value })
}
/>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} sm={16}>
<Form.TextArea
label={t('音频倍率(仅部分模型支持该计费)')}
extraText={t('音频输入相关的倍率设置,键为模型名称,值为倍率')}
placeholder={t('为一个 JSON 文本,键为模型名称,值为倍率,例如:{"gpt-4o-audio-preview": 16}')}
field={'AudioRatio'}
autosize={{ minRows: 6, maxRows: 12 }}
trigger='blur'
stopValidateWithError
rules={[
{
validator: (rule, value) => verifyJSON(value),
message: '不是合法的 JSON 字符串',
},
]}
onChange={(value) =>
setInputs({ ...inputs, AudioRatio: value })
}
/>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} sm={16}>
<Form.TextArea
label={t('音频补全倍率(仅部分模型支持该计费)')}
extraText={t('音频输出补全相关的倍率设置,键为模型名称,值为倍率')}
placeholder={t('为一个 JSON 文本,键为模型名称,值为倍率,例如:{"gpt-4o-realtime": 2}')}
field={'AudioCompletionRatio'}
autosize={{ minRows: 6, maxRows: 12 }}
trigger='blur'
stopValidateWithError
rules={[
{
validator: (rule, value) => verifyJSON(value),
message: '不是合法的 JSON 字符串',
},
]}
onChange={(value) =>
setInputs({ ...inputs, AudioCompletionRatio: value })
}
/>
</Col>
</Row>
<Row gutter={16}>
<Col span={16}>
<Form.Switch

View File

@@ -1,3 +1,22 @@
/*
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 {
Table,
@@ -111,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,
);
//
@@ -420,12 +437,6 @@ export default function ModelRatioNotSetEditor(props) {
onPageChange: (page) => setCurrentPage(page),
onPageSizeChange: handlePageSizeChange,
pageSizeOptions: pageSizeOptions,
formatPageText: (page) =>
t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
start: page.currentStart,
end: page.currentEnd,
total: filteredModels.length,
}),
showTotal: true,
showSizeChanger: true,
}}

View File

@@ -1,4 +1,22 @@
// ModelSettingsVisualEditor.js
/*
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, useRef } from 'react';
import {
Table,
@@ -10,7 +28,7 @@ import {
RadioGroup,
Radio,
Checkbox,
Tag
Tag,
} from '@douyinfe/semi-ui';
import {
IconDelete,
@@ -26,6 +44,7 @@ export default function ModelSettingsVisualEditor(props) {
const { t } = useTranslation();
const [models, setModels] = useState([]);
const [visible, setVisible] = useState(false);
const [isEditMode, setIsEditMode] = useState(false);
const [currentModel, setCurrentModel] = useState(null);
const [searchText, setSearchText] = useState('');
const [currentPage, setCurrentPage] = useState(1);
@@ -53,7 +72,8 @@ export default function ModelSettingsVisualEditor(props) {
const modelData = Array.from(modelNames).map((name) => {
const price = modelPrice[name] === undefined ? '' : modelPrice[name];
const ratio = modelRatio[name] === undefined ? '' : modelRatio[name];
const comp = completionRatio[name] === undefined ? '' : completionRatio[name];
const comp =
completionRatio[name] === undefined ? '' : completionRatio[name];
return {
name,
@@ -79,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;
});
@@ -244,7 +262,8 @@ export default function ModelSettingsVisualEditor(props) {
if (model.name !== name) return model;
const updated = { ...model, [field]: value };
updated.hasConflict =
updated.price !== '' && (updated.ratio !== '' || updated.completionRatio !== '');
updated.price !== '' &&
(updated.ratio !== '' || updated.completionRatio !== '');
return updated;
}),
);
@@ -330,7 +349,8 @@ export default function ModelSettingsVisualEditor(props) {
completionRatio: values.completionRatio || '',
};
updated.hasConflict =
updated.price !== '' && (updated.ratio !== '' || updated.completionRatio !== '');
updated.price !== '' &&
(updated.ratio !== '' || updated.completionRatio !== '');
return updated;
}),
);
@@ -352,7 +372,8 @@ export default function ModelSettingsVisualEditor(props) {
completionRatio: values.completionRatio || '',
};
newModel.hasConflict =
newModel.price !== '' && (newModel.ratio !== '' || newModel.completionRatio !== '');
newModel.price !== '' &&
(newModel.ratio !== '' || newModel.completionRatio !== '');
return [newModel, ...prev];
});
setVisible(false);
@@ -368,9 +389,11 @@ export default function ModelSettingsVisualEditor(props) {
setCurrentModel(null);
setPricingMode('per-token');
setPricingSubMode('ratio');
setIsEditMode(false);
};
const editModel = (record) => {
setIsEditMode(true);
// Determine which pricing mode to use based on the model's current configuration
let initialPricingMode = 'per-token';
let initialPricingSubMode = 'ratio';
@@ -475,12 +498,6 @@ export default function ModelSettingsVisualEditor(props) {
pageSize: pageSize,
total: filteredModels.length,
onPageChange: (page) => setCurrentPage(page),
formatPageText: (page) =>
t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
start: page.currentStart,
end: page.currentEnd,
total: filteredModels.length,
}),
showTotal: true,
showSizeChanger: false,
}}
@@ -488,13 +505,7 @@ export default function ModelSettingsVisualEditor(props) {
</Space>
<Modal
title={
currentModel &&
currentModel.name &&
models.some((model) => model.name === currentModel.name)
? t('编辑模型')
: t('添加模型')
}
title={isEditMode ? t('编辑模型') : t('添加模型')}
visible={visible}
onCancel={() => {
resetModalState();
@@ -550,11 +561,7 @@ export default function ModelSettingsVisualEditor(props) {
label={t('模型名称')}
placeholder='strawberry'
required
disabled={
currentModel &&
currentModel.name &&
models.some((model) => model.name === currentModel.name)
}
disabled={isEditMode}
onChange={(value) =>
setCurrentModel((prev) => ({ ...prev, name: value }))
}

View File

@@ -1,3 +1,22 @@
/*
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, useCallback, useMemo, useEffect } from 'react';
import {
Button,
@@ -18,13 +37,19 @@ import {
AlertTriangle,
CheckCircle,
} from 'lucide-react';
import { API, showError, showSuccess, showWarning, stringToColor } from '../../../helpers';
import { useIsMobile } from '../../../hooks/useIsMobile.js';
import {
API,
showError,
showSuccess,
showWarning,
stringToColor,
} from '../../../helpers';
import { useIsMobile } from '../../../hooks/common/useIsMobile';
import { DEFAULT_ENDPOINT } from '../../../constants';
import { useTranslation } from 'react-i18next';
import {
IllustrationNoResult,
IllustrationNoResultDark
IllustrationNoResultDark,
} from '@douyinfe/semi-illustrations';
import ChannelSelectorModal from '../../../components/settings/ChannelSelectorModal';
@@ -53,7 +78,12 @@ function ConflictConfirmModal({ t, visible, items, onOk, onCancel }) {
onOk={onOk}
size={isMobile ? 'full-width' : 'large'}
>
<Table columns={columns} dataSource={items} pagination={false} size="small" />
<Table
columns={columns}
dataSource={items}
pagination={false}
size='small'
/>
</Modal>
);
}
@@ -107,7 +137,7 @@ export default function UpstreamRatioSync(props) {
if (res.data.success) {
const channels = res.data.data || [];
const transferData = channels.map(channel => ({
const transferData = channels.map((channel) => ({
key: channel.id,
label: channel.name,
value: channel.id,
@@ -118,11 +148,20 @@ export default function UpstreamRatioSync(props) {
setAllChannels(transferData);
// endpoints
setChannelEndpoints(prev => {
setChannelEndpoints((prev) => {
const merged = { ...prev };
transferData.forEach(channel => {
if (!merged[channel.key]) {
merged[channel.key] = DEFAULT_ENDPOINT;
transferData.forEach((channel) => {
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;
@@ -139,8 +178,8 @@ export default function UpstreamRatioSync(props) {
const confirmChannelSelection = () => {
const selected = allChannels
.filter(ch => selectedChannelIds.includes(ch.value))
.map(ch => ch._originalData);
.filter((ch) => selectedChannelIds.includes(ch.value))
.map((ch) => ch._originalData);
if (selected.length === 0) {
showWarning(t('请至少选择一个渠道'));
@@ -154,7 +193,7 @@ export default function UpstreamRatioSync(props) {
const fetchRatiosFromChannels = async (channelList) => {
setSyncLoading(true);
const upstreams = channelList.map(ch => ({
const upstreams = channelList.map((ch) => ({
id: ch.id,
name: ch.name,
base_url: ch.base_url,
@@ -177,9 +216,12 @@ export default function UpstreamRatioSync(props) {
const { differences = {}, test_results = [] } = res.data.data;
const errorResults = test_results.filter(r => r.status === 'error');
const errorResults = test_results.filter((r) => r.status === 'error');
if (errorResults.length > 0) {
showWarning(t('部分渠道测试失败:') + errorResults.map(r => `${r.name}: ${r.error}`).join(', '));
showWarning(
t('部分渠道测试失败:') +
errorResults.map((r) => `${r.name}: ${r.error}`).join(', '),
);
}
setDifferences(differences);
@@ -200,26 +242,29 @@ export default function UpstreamRatioSync(props) {
return ratioType === 'model_price' ? 'price' : 'ratio';
}
const selectValue = useCallback((model, ratioType, value) => {
const category = getBillingCategory(ratioType);
const selectValue = useCallback(
(model, ratioType, value) => {
const category = getBillingCategory(ratioType);
setResolutions(prev => {
const newModelRes = { ...(prev[model] || {}) };
setResolutions((prev) => {
const newModelRes = { ...(prev[model] || {}) };
Object.keys(newModelRes).forEach((rt) => {
if (getBillingCategory(rt) !== category) {
delete newModelRes[rt];
}
Object.keys(newModelRes).forEach((rt) => {
if (getBillingCategory(rt) !== category) {
delete newModelRes[rt];
}
});
newModelRes[ratioType] = value;
return {
...prev,
[model]: newModelRes,
};
});
newModelRes[ratioType] = value;
return {
...prev,
[model]: newModelRes,
};
});
}, [setResolutions]);
},
[setResolutions],
);
const applySync = async () => {
const currentRatios = {
@@ -233,9 +278,12 @@ export default function UpstreamRatioSync(props) {
const getLocalBillingCategory = (model) => {
if (currentRatios.ModelPrice[model] !== undefined) return 'price';
if (currentRatios.ModelRatio[model] !== undefined ||
if (
currentRatios.ModelRatio[model] !== undefined ||
currentRatios.CompletionRatio[model] !== undefined ||
currentRatios.CacheRatio[model] !== undefined) return 'ratio';
currentRatios.CacheRatio[model] !== undefined
)
return 'ratio';
return null;
};
@@ -253,9 +301,10 @@ export default function UpstreamRatioSync(props) {
const newCat = 'model_price' in ratios ? 'price' : 'ratio';
if (localCat && localCat !== newCat) {
const currentDesc = localCat === 'price'
? `${t('固定价格')} : ${currentRatios.ModelPrice[model]}`
: `${t('模型倍率')} : ${currentRatios.ModelRatio[model] ?? '-'}\n${t('补全倍率')} : ${currentRatios.CompletionRatio[model] ?? '-'}`;
const currentDesc =
localCat === 'price'
? `${t('固定价格')} : ${currentRatios.ModelPrice[model]}`
: `${t('模型倍率')} : ${currentRatios.ModelRatio[model] ?? '-'}\n${t('补全倍率')} : ${currentRatios.CompletionRatio[model] ?? '-'}`;
let newDesc = '';
if (newCat === 'price') {
@@ -289,80 +338,83 @@ export default function UpstreamRatioSync(props) {
await performSync(currentRatios);
};
const performSync = useCallback(async (currentRatios) => {
const finalRatios = {
ModelRatio: { ...currentRatios.ModelRatio },
CompletionRatio: { ...currentRatios.CompletionRatio },
CacheRatio: { ...currentRatios.CacheRatio },
ModelPrice: { ...currentRatios.ModelPrice },
};
const performSync = useCallback(
async (currentRatios) => {
const finalRatios = {
ModelRatio: { ...currentRatios.ModelRatio },
CompletionRatio: { ...currentRatios.CompletionRatio },
CacheRatio: { ...currentRatios.CacheRatio },
ModelPrice: { ...currentRatios.ModelPrice },
};
Object.entries(resolutions).forEach(([model, ratios]) => {
const selectedTypes = Object.keys(ratios);
const hasPrice = selectedTypes.includes('model_price');
const hasRatio = selectedTypes.some(rt => rt !== 'model_price');
Object.entries(resolutions).forEach(([model, ratios]) => {
const selectedTypes = Object.keys(ratios);
const hasPrice = selectedTypes.includes('model_price');
const hasRatio = selectedTypes.some((rt) => rt !== 'model_price');
if (hasPrice) {
delete finalRatios.ModelRatio[model];
delete finalRatios.CompletionRatio[model];
delete finalRatios.CacheRatio[model];
}
if (hasRatio) {
delete finalRatios.ModelPrice[model];
}
if (hasPrice) {
delete finalRatios.ModelRatio[model];
delete finalRatios.CompletionRatio[model];
delete finalRatios.CacheRatio[model];
}
if (hasRatio) {
delete finalRatios.ModelPrice[model];
}
Object.entries(ratios).forEach(([ratioType, value]) => {
const optionKey = ratioType
.split('_')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join('');
finalRatios[optionKey][model] = parseFloat(value);
Object.entries(ratios).forEach(([ratioType, value]) => {
const optionKey = ratioType
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join('');
finalRatios[optionKey][model] = parseFloat(value);
});
});
});
setLoading(true);
try {
const updates = Object.entries(finalRatios).map(([key, value]) =>
API.put('/api/option/', {
key,
value: JSON.stringify(value, null, 2),
})
);
setLoading(true);
try {
const updates = Object.entries(finalRatios).map(([key, value]) =>
API.put('/api/option/', {
key,
value: JSON.stringify(value, null, 2),
}),
);
const results = await Promise.all(updates);
const results = await Promise.all(updates);
if (results.every(res => res.data.success)) {
showSuccess(t('同步成功'));
props.refresh();
if (results.every((res) => res.data.success)) {
showSuccess(t('同步成功'));
props.refresh();
setDifferences(prevDifferences => {
const newDifferences = { ...prevDifferences };
setDifferences((prevDifferences) => {
const newDifferences = { ...prevDifferences };
Object.entries(resolutions).forEach(([model, ratios]) => {
Object.keys(ratios).forEach(ratioType => {
if (newDifferences[model] && newDifferences[model][ratioType]) {
delete newDifferences[model][ratioType];
Object.entries(resolutions).forEach(([model, ratios]) => {
Object.keys(ratios).forEach((ratioType) => {
if (newDifferences[model] && newDifferences[model][ratioType]) {
delete newDifferences[model][ratioType];
if (Object.keys(newDifferences[model]).length === 0) {
delete newDifferences[model];
if (Object.keys(newDifferences[model]).length === 0) {
delete newDifferences[model];
}
}
}
});
});
return newDifferences;
});
return newDifferences;
});
setResolutions({});
} else {
showError(t('部分保存失败'));
setResolutions({});
} else {
showError(t('部分保存失败'));
}
} catch (error) {
showError(t('保存失败'));
} finally {
setLoading(false);
}
} catch (error) {
showError(t('保存失败'));
} finally {
setLoading(false);
}
}, [resolutions, props.options, props.refresh]);
},
[resolutions, props.options, props.refresh],
);
const getCurrentPageData = (dataSource) => {
const startIndex = (currentPage - 1) * pageSize;
@@ -371,12 +423,12 @@ export default function UpstreamRatioSync(props) {
};
const renderHeader = () => (
<div className="flex flex-col w-full">
<div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full">
<div className="flex flex-col md:flex-row gap-2 w-full md:w-auto order-2 md:order-1">
<div className='flex flex-col w-full'>
<div className='flex flex-col md:flex-row justify-between items-center gap-4 w-full'>
<div className='flex flex-col md:flex-row gap-2 w-full md:w-auto order-2 md:order-1'>
<Button
icon={<RefreshCcw size={14} />}
className="w-full md:w-auto mt-2"
className='w-full md:w-auto mt-2'
onClick={() => {
setModalVisible(true);
if (allChannels.length === 0) {
@@ -396,20 +448,20 @@ export default function UpstreamRatioSync(props) {
type='secondary'
onClick={applySync}
disabled={!hasSelections}
className="w-full md:w-auto mt-2"
className='w-full md:w-auto mt-2'
>
{t('应用同步')}
</Button>
);
})()}
<div className="flex flex-col sm:flex-row gap-2 w-full md:w-auto mt-2">
<div className='flex flex-col sm:flex-row gap-2 w-full md:w-auto mt-2'>
<Input
prefix={<IconSearch size={14} />}
placeholder={t('搜索模型名称')}
value={searchKeyword}
onChange={setSearchKeyword}
className="w-full sm:w-64"
className='w-full sm:w-64'
showClear
/>
@@ -417,14 +469,16 @@ export default function UpstreamRatioSync(props) {
placeholder={t('按倍率类型筛选')}
value={ratioTypeFilter}
onChange={setRatioTypeFilter}
className="w-full sm:w-48"
className='w-full sm:w-48'
showClear
onClear={() => setRatioTypeFilter('')}
>
<Select.Option value="model_ratio">{t('模型倍率')}</Select.Option>
<Select.Option value="completion_ratio">{t('补全倍率')}</Select.Option>
<Select.Option value="cache_ratio">{t('缓存倍率')}</Select.Option>
<Select.Option value="model_price">{t('固定价格')}</Select.Option>
<Select.Option value='model_ratio'>{t('模型倍率')}</Select.Option>
<Select.Option value='completion_ratio'>
{t('补全倍率')}
</Select.Option>
<Select.Option value='cache_ratio'>{t('缓存倍率')}</Select.Option>
<Select.Option value='model_price'>{t('固定价格')}</Select.Option>
</Select>
</div>
</div>
@@ -438,7 +492,11 @@ export default function UpstreamRatioSync(props) {
Object.entries(differences).forEach(([model, ratioTypes]) => {
const hasPrice = 'model_price' in ratioTypes;
const hasOtherRatio = ['model_ratio', 'completion_ratio', 'cache_ratio'].some(rt => rt in ratioTypes);
const hasOtherRatio = [
'model_ratio',
'completion_ratio',
'cache_ratio',
].some((rt) => rt in ratioTypes);
const billingConflict = hasPrice && hasOtherRatio;
Object.entries(ratioTypes).forEach(([ratioType, diff]) => {
@@ -462,12 +520,13 @@ export default function UpstreamRatioSync(props) {
return dataSource;
}
return dataSource.filter(item => {
const matchesKeyword = !searchKeyword.trim() ||
return dataSource.filter((item) => {
const matchesKeyword =
!searchKeyword.trim() ||
item.model.toLowerCase().includes(searchKeyword.toLowerCase().trim());
const matchesRatioType = !ratioTypeFilter ||
item.ratioType === ratioTypeFilter;
const matchesRatioType =
!ratioTypeFilter || item.ratioType === ratioTypeFilter;
return matchesKeyword && matchesRatioType;
});
@@ -485,13 +544,17 @@ export default function UpstreamRatioSync(props) {
return (
<Empty
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
darkModeImage={
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
}
description={
searchKeyword.trim()
? t('未找到匹配的模型')
: (Object.keys(differences).length === 0 ?
(hasSynced ? t('暂无差异化倍率显示') : t('请先选择同步渠道'))
: t('请先选择同步渠道'))
: Object.keys(differences).length === 0
? hasSynced
? t('暂无差异化倍率显示')
: t('请先选择同步渠道')
: t('请先选择同步渠道')
}
style={{ padding: 30 }}
/>
@@ -514,13 +577,22 @@ export default function UpstreamRatioSync(props) {
cache_ratio: t('缓存倍率'),
model_price: t('固定价格'),
};
const baseTag = <Tag color={stringToColor(text)} shape="circle">{typeMap[text] || text}</Tag>;
const baseTag = (
<Tag color={stringToColor(text)} shape='circle'>
{typeMap[text] || text}
</Tag>
);
if (record?.billingConflict) {
return (
<div className="flex items-center gap-1">
<div className='flex items-center gap-1'>
{baseTag}
<Tooltip position="top" content={t('该模型存在固定价格与倍率计费方式冲突,请确认选择')}>
<AlertTriangle size={14} className="text-yellow-500" />
<Tooltip
position='top'
content={t(
'该模型存在固定价格与倍率计费方式冲突,请确认选择',
)}
>
<AlertTriangle size={14} className='text-yellow-500' />
</Tooltip>
</div>
);
@@ -532,12 +604,19 @@ export default function UpstreamRatioSync(props) {
title: t('置信度'),
dataIndex: 'confidence',
render: (_, record) => {
const allConfident = Object.values(record.confidence || {}).every(v => v !== false);
const allConfident = Object.values(record.confidence || {}).every(
(v) => v !== false,
);
if (allConfident) {
return (
<Tooltip content={t('所有上游数据均可信')}>
<Tag color="green" shape="circle" type="light" prefixIcon={<CheckCircle size={14} />}>
<Tag
color='green'
shape='circle'
type='light'
prefixIcon={<CheckCircle size={14} />}
>
{t('可信')}
</Tag>
</Tooltip>
@@ -549,8 +628,15 @@ export default function UpstreamRatioSync(props) {
.join(', ');
return (
<Tooltip content={t('以下上游数据可能不可信:') + untrustedSources}>
<Tag color="yellow" shape="circle" type="light" prefixIcon={<AlertTriangle size={14} />}>
<Tooltip
content={t('以下上游数据可能不可信:') + untrustedSources}
>
<Tag
color='yellow'
shape='circle'
type='light'
prefixIcon={<AlertTriangle size={14} />}
>
{t('谨慎')}
</Tag>
</Tooltip>
@@ -562,7 +648,10 @@ export default function UpstreamRatioSync(props) {
title: t('当前值'),
dataIndex: 'current',
render: (text) => (
<Tag color={text !== null && text !== undefined ? 'blue' : 'default'} shape="circle">
<Tag
color={text !== null && text !== undefined ? 'blue' : 'default'}
shape='circle'
>
{text !== null && text !== undefined ? text : t('未设置')}
</Tag>
),
@@ -574,9 +663,14 @@ export default function UpstreamRatioSync(props) {
filteredDataSource.forEach((row) => {
const upstreamVal = row.upstreams?.[upName];
if (upstreamVal !== null && upstreamVal !== undefined && upstreamVal !== 'same') {
if (
upstreamVal !== null &&
upstreamVal !== undefined &&
upstreamVal !== 'same'
) {
selectableCount++;
const isSelected = resolutions[row.model]?.[row.ratioType] === upstreamVal;
const isSelected =
resolutions[row.model]?.[row.ratioType] === upstreamVal;
if (isSelected) {
selectedCount++;
}
@@ -586,9 +680,11 @@ export default function UpstreamRatioSync(props) {
return {
selectableCount,
selectedCount,
allSelected: selectableCount > 0 && selectedCount === selectableCount,
partiallySelected: selectedCount > 0 && selectedCount < selectableCount,
hasSelectableItems: selectableCount > 0
allSelected:
selectableCount > 0 && selectedCount === selectableCount,
partiallySelected:
selectedCount > 0 && selectedCount < selectableCount,
hasSelectableItems: selectableCount > 0,
};
})();
@@ -596,7 +692,11 @@ export default function UpstreamRatioSync(props) {
if (checked) {
filteredDataSource.forEach((row) => {
const upstreamVal = row.upstreams?.[upName];
if (upstreamVal !== null && upstreamVal !== undefined && upstreamVal !== 'same') {
if (
upstreamVal !== null &&
upstreamVal !== undefined &&
upstreamVal !== 'same'
) {
selectValue(row.model, row.ratioType, upstreamVal);
}
});
@@ -634,17 +734,26 @@ export default function UpstreamRatioSync(props) {
const isConfident = record.confidence?.[upName] !== false;
if (upstreamVal === null || upstreamVal === undefined) {
return <Tag color="default" shape="circle">{t('未设置')}</Tag>;
return (
<Tag color='default' shape='circle'>
{t('未设置')}
</Tag>
);
}
if (upstreamVal === 'same') {
return <Tag color="blue" shape="circle">{t('与本地相同')}</Tag>;
return (
<Tag color='blue' shape='circle'>
{t('与本地相同')}
</Tag>
);
}
const isSelected = resolutions[record.model]?.[record.ratioType] === upstreamVal;
const isSelected =
resolutions[record.model]?.[record.ratioType] === upstreamVal;
return (
<div className="flex items-center gap-2">
<div className='flex items-center gap-2'>
<Checkbox
checked={isSelected}
onChange={(e) => {
@@ -668,8 +777,11 @@ export default function UpstreamRatioSync(props) {
{upstreamVal}
</Checkbox>
{!isConfident && (
<Tooltip position='left' content={t('该数据可能不可信,请谨慎使用')}>
<AlertTriangle size={16} className="text-yellow-500" />
<Tooltip
position='left'
content={t('该数据可能不可信,请谨慎使用')}
>
<AlertTriangle size={16} className='text-yellow-500' />
</Tooltip>
)}
</div>
@@ -689,11 +801,6 @@ export default function UpstreamRatioSync(props) {
total: filteredDataSource.length,
showSizeChanger: true,
showQuickJumper: true,
formatPageText: (page) => t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
start: page.currentStart,
end: page.currentEnd,
total: filteredDataSource.length,
}),
pageSizeOptions: ['5', '10', '20', '50'],
onChange: (page, size) => {
setCurrentPage(page);
@@ -702,7 +809,7 @@ export default function UpstreamRatioSync(props) {
onShowSizeChange: (current, size) => {
setCurrentPage(1);
setPageSize(size);
}
},
}}
scroll={{ x: 'max-content' }}
size='middle'
@@ -712,7 +819,7 @@ export default function UpstreamRatioSync(props) {
};
const updateChannelEndpoint = useCallback((channelId, endpoint) => {
setChannelEndpoints(prev => ({ ...prev, [channelId]: endpoint }));
setChannelEndpoints((prev) => ({ ...prev, [channelId]: endpoint }));
}, []);
const handleModalClose = () => {
@@ -759,4 +866,4 @@ export default function UpstreamRatioSync(props) {
/>
</>
);
}
}

View File

@@ -1,3 +1,22 @@
/*
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 { Layout, TabPane, Tabs } from '@douyinfe/semi-ui';
import { useNavigate, useLocation } from 'react-router-dom';
@@ -12,20 +31,20 @@ import {
LayoutDashboard,
MessageSquare,
Palette,
CreditCard
CreditCard,
} from 'lucide-react';
import SystemSetting from '../../components/settings/SystemSetting.js';
import SystemSetting from '../../components/settings/SystemSetting';
import { isRoot } from '../../helpers';
import OtherSetting from '../../components/settings/OtherSetting';
import OperationSetting from '../../components/settings/OperationSetting.js';
import RateLimitSetting from '../../components/settings/RateLimitSetting.js';
import ModelSetting from '../../components/settings/ModelSetting.js';
import DashboardSetting from '../../components/settings/DashboardSetting.js';
import RatioSetting from '../../components/settings/RatioSetting.js';
import ChatsSetting from '../../components/settings/ChatsSetting.js';
import DrawingSetting from '../../components/settings/DrawingSetting.js';
import PaymentSetting from '../../components/settings/PaymentSetting.js';
import OperationSetting from '../../components/settings/OperationSetting';
import RateLimitSetting from '../../components/settings/RateLimitSetting';
import ModelSetting from '../../components/settings/ModelSetting';
import DashboardSetting from '../../components/settings/DashboardSetting';
import RatioSetting from '../../components/settings/RatioSetting';
import ChatsSetting from '../../components/settings/ChatsSetting';
import DrawingSetting from '../../components/settings/DrawingSetting';
import PaymentSetting from '../../components/settings/PaymentSetting';
const Setting = () => {
const { t } = useTranslation();
@@ -150,7 +169,7 @@ const Setting = () => {
}
}, [location.search]);
return (
<div className="mt-[64px] px-2">
<div className='mt-[60px] px-2'>
<Layout>
<Layout.Content>
<Tabs