Merge branch 'main' into feat_subscribe_sp1
This commit is contained in:
@@ -1,139 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { API, showError } from '../../helpers';
|
||||
import { marked } from 'marked';
|
||||
import { Empty } from '@douyinfe/semi-ui';
|
||||
import { IllustrationConstruction, IllustrationConstructionDark } from '@douyinfe/semi-illustrations';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const About = () => {
|
||||
const { t } = useTranslation();
|
||||
const [about, setAbout] = useState('');
|
||||
const [aboutLoaded, setAboutLoaded] = useState(false);
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
const displayAbout = async () => {
|
||||
setAbout(localStorage.getItem('about') || '');
|
||||
const res = await API.get('/api/about');
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
let aboutContent = data;
|
||||
if (!data.startsWith('https://')) {
|
||||
aboutContent = marked.parse(data);
|
||||
}
|
||||
setAbout(aboutContent);
|
||||
localStorage.setItem('about', aboutContent);
|
||||
} else {
|
||||
showError(message);
|
||||
setAbout(t('加载关于内容失败...'));
|
||||
}
|
||||
setAboutLoaded(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
displayAbout().then();
|
||||
}, []);
|
||||
|
||||
const emptyStyle = {
|
||||
padding: '24px'
|
||||
};
|
||||
|
||||
const customDescription = (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<p>{t('可在设置页面设置关于内容,支持 HTML & Markdown')}</p>
|
||||
{t('New API项目仓库地址:')}
|
||||
<a
|
||||
href='https://github.com/QuantumNous/new-api'
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="!text-semi-color-primary"
|
||||
>
|
||||
https://github.com/QuantumNous/new-api
|
||||
</a>
|
||||
<p>
|
||||
<a
|
||||
href="https://github.com/QuantumNous/new-api"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="!text-semi-color-primary"
|
||||
>
|
||||
NewAPI
|
||||
</a> {t('© {{currentYear}}', { currentYear })} <a
|
||||
href="https://github.com/QuantumNous"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="!text-semi-color-primary"
|
||||
>
|
||||
QuantumNous
|
||||
</a> {t('| 基于')} <a
|
||||
href="https://github.com/songquanpeng/one-api/releases/tag/v0.5.4"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="!text-semi-color-primary"
|
||||
>
|
||||
One API v0.5.4
|
||||
</a> © 2023 <a
|
||||
href="https://github.com/songquanpeng"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="!text-semi-color-primary"
|
||||
>
|
||||
JustSong
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
{t('本项目根据')}
|
||||
<a
|
||||
href="https://github.com/songquanpeng/one-api/blob/v0.5.4/LICENSE"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="!text-semi-color-primary"
|
||||
>
|
||||
{t('MIT许可证')}
|
||||
</a>
|
||||
{t('授权,需在遵守')}
|
||||
<a
|
||||
href="https://github.com/QuantumNous/new-api/blob/main/LICENSE"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="!text-semi-color-primary"
|
||||
>
|
||||
{t('Apache-2.0协议')}
|
||||
</a>
|
||||
{t('的前提下使用。')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mt-[64px] px-2">
|
||||
{aboutLoaded && about === '' ? (
|
||||
<div className="flex justify-center items-center h-screen p-8">
|
||||
<Empty
|
||||
image={<IllustrationConstruction style={{ width: 150, height: 150 }} />}
|
||||
darkModeImage={<IllustrationConstructionDark style={{ width: 150, height: 150 }} />}
|
||||
description={t('管理员暂时未设置任何关于内容')}
|
||||
style={emptyStyle}
|
||||
>
|
||||
{customDescription}
|
||||
</Empty>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{about.startsWith('https://') ? (
|
||||
<iframe
|
||||
src={about}
|
||||
style={{ width: '100%', height: '100vh', border: 'none' }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{ fontSize: 'larger' }}
|
||||
dangerouslySetInnerHTML={{ __html: about }}
|
||||
></div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default About;
|
||||
173
web/src/pages/About/index.jsx
Normal file
173
web/src/pages/About/index.jsx
Normal file
@@ -0,0 +1,173 @@
|
||||
/*
|
||||
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 { API, showError } from '../../helpers';
|
||||
import { marked } from 'marked';
|
||||
import { Empty } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IllustrationConstruction,
|
||||
IllustrationConstructionDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const About = () => {
|
||||
const { t } = useTranslation();
|
||||
const [about, setAbout] = useState('');
|
||||
const [aboutLoaded, setAboutLoaded] = useState(false);
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
const displayAbout = async () => {
|
||||
setAbout(localStorage.getItem('about') || '');
|
||||
const res = await API.get('/api/about');
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
let aboutContent = data;
|
||||
if (!data.startsWith('https://')) {
|
||||
aboutContent = marked.parse(data);
|
||||
}
|
||||
setAbout(aboutContent);
|
||||
localStorage.setItem('about', aboutContent);
|
||||
} else {
|
||||
showError(message);
|
||||
setAbout(t('加载关于内容失败...'));
|
||||
}
|
||||
setAboutLoaded(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
displayAbout().then();
|
||||
}, []);
|
||||
|
||||
const emptyStyle = {
|
||||
padding: '24px',
|
||||
};
|
||||
|
||||
const customDescription = (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<p>{t('可在设置页面设置关于内容,支持 HTML & Markdown')}</p>
|
||||
{t('New API项目仓库地址:')}
|
||||
<a
|
||||
href='https://github.com/QuantumNous/new-api'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='!text-semi-color-primary'
|
||||
>
|
||||
https://github.com/QuantumNous/new-api
|
||||
</a>
|
||||
<p>
|
||||
<a
|
||||
href='https://github.com/QuantumNous/new-api'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='!text-semi-color-primary'
|
||||
>
|
||||
NewAPI
|
||||
</a>{' '}
|
||||
{t('© {{currentYear}}', { currentYear })}{' '}
|
||||
<a
|
||||
href='https://github.com/QuantumNous'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='!text-semi-color-primary'
|
||||
>
|
||||
QuantumNous
|
||||
</a>{' '}
|
||||
{t('| 基于')}{' '}
|
||||
<a
|
||||
href='https://github.com/songquanpeng/one-api/releases/tag/v0.5.4'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='!text-semi-color-primary'
|
||||
>
|
||||
One API v0.5.4
|
||||
</a>{' '}
|
||||
© 2023{' '}
|
||||
<a
|
||||
href='https://github.com/songquanpeng'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='!text-semi-color-primary'
|
||||
>
|
||||
JustSong
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
{t('本项目根据')}
|
||||
<a
|
||||
href='https://github.com/songquanpeng/one-api/blob/v0.5.4/LICENSE'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='!text-semi-color-primary'
|
||||
>
|
||||
{t('MIT许可证')}
|
||||
</a>
|
||||
{t('授权,需在遵守')}
|
||||
<a
|
||||
href='https://www.gnu.org/licenses/agpl-3.0.html'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='!text-semi-color-primary'
|
||||
>
|
||||
{t('AGPL v3.0协议')}
|
||||
</a>
|
||||
{t('的前提下使用。')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='mt-[60px] px-2'>
|
||||
{aboutLoaded && about === '' ? (
|
||||
<div className='flex justify-center items-center h-screen p-8'>
|
||||
<Empty
|
||||
image={
|
||||
<IllustrationConstruction style={{ width: 150, height: 150 }} />
|
||||
}
|
||||
darkModeImage={
|
||||
<IllustrationConstructionDark
|
||||
style={{ width: 150, height: 150 }}
|
||||
/>
|
||||
}
|
||||
description={t('管理员暂时未设置任何关于内容')}
|
||||
style={emptyStyle}
|
||||
>
|
||||
{customDescription}
|
||||
</Empty>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{about.startsWith('https://') ? (
|
||||
<iframe
|
||||
src={about}
|
||||
style={{ width: '100%', height: '100vh', border: 'none' }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{ fontSize: 'larger' }}
|
||||
dangerouslySetInnerHTML={{ __html: about }}
|
||||
></div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default About;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,444 +0,0 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
API,
|
||||
showError,
|
||||
showInfo,
|
||||
showSuccess,
|
||||
showWarning,
|
||||
verifyJSON,
|
||||
} from '../../helpers';
|
||||
import {
|
||||
SideSheet,
|
||||
Space,
|
||||
Button,
|
||||
Typography,
|
||||
Spin,
|
||||
Banner,
|
||||
Card,
|
||||
Tag,
|
||||
Avatar,
|
||||
Form,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconSave,
|
||||
IconClose,
|
||||
IconBookmark,
|
||||
IconUser,
|
||||
IconCode,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { getChannelModels } from '../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
const MODEL_MAPPING_EXAMPLE = {
|
||||
'gpt-3.5-turbo': 'gpt-3.5-turbo-0125',
|
||||
};
|
||||
|
||||
const EditTagModal = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const { visible, tag, handleClose, refresh } = props;
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [originModelOptions, setOriginModelOptions] = useState([]);
|
||||
const [modelOptions, setModelOptions] = useState([]);
|
||||
const [groupOptions, setGroupOptions] = useState([]);
|
||||
const [customModel, setCustomModel] = useState('');
|
||||
const originInputs = {
|
||||
tag: '',
|
||||
new_tag: null,
|
||||
model_mapping: null,
|
||||
groups: [],
|
||||
models: [],
|
||||
};
|
||||
const [inputs, setInputs] = useState(originInputs);
|
||||
const formApiRef = useRef(null);
|
||||
const getInitValues = () => ({ ...originInputs });
|
||||
|
||||
const handleInputChange = (name, value) => {
|
||||
setInputs((inputs) => ({ ...inputs, [name]: value }));
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValue(name, value);
|
||||
}
|
||||
if (name === 'type') {
|
||||
let localModels = [];
|
||||
switch (value) {
|
||||
case 2:
|
||||
localModels = [
|
||||
'mj_imagine',
|
||||
'mj_variation',
|
||||
'mj_reroll',
|
||||
'mj_blend',
|
||||
'mj_upscale',
|
||||
'mj_describe',
|
||||
'mj_uploads',
|
||||
];
|
||||
break;
|
||||
case 5:
|
||||
localModels = [
|
||||
'swap_face',
|
||||
'mj_imagine',
|
||||
'mj_video',
|
||||
'mj_edits',
|
||||
'mj_variation',
|
||||
'mj_reroll',
|
||||
'mj_blend',
|
||||
'mj_upscale',
|
||||
'mj_describe',
|
||||
'mj_zoom',
|
||||
'mj_shorten',
|
||||
'mj_modal',
|
||||
'mj_inpaint',
|
||||
'mj_custom_zoom',
|
||||
'mj_high_variation',
|
||||
'mj_low_variation',
|
||||
'mj_pan',
|
||||
'mj_uploads',
|
||||
];
|
||||
break;
|
||||
case 36:
|
||||
localModels = ['suno_music', 'suno_lyrics'];
|
||||
break;
|
||||
default:
|
||||
localModels = getChannelModels(value);
|
||||
break;
|
||||
}
|
||||
if (inputs.models.length === 0) {
|
||||
setInputs((inputs) => ({ ...inputs, models: localModels }));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const fetchModels = async () => {
|
||||
try {
|
||||
let res = await API.get(`/api/channel/models`);
|
||||
let localModelOptions = res.data.data.map((model) => ({
|
||||
label: model.id,
|
||||
value: model.id,
|
||||
}));
|
||||
setOriginModelOptions(localModelOptions);
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchGroups = async () => {
|
||||
try {
|
||||
let res = await API.get(`/api/group/`);
|
||||
if (res === undefined) {
|
||||
return;
|
||||
}
|
||||
setGroupOptions(
|
||||
res.data.data.map((group) => ({
|
||||
label: group,
|
||||
value: group,
|
||||
})),
|
||||
);
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async (values) => {
|
||||
setLoading(true);
|
||||
const formVals = values || formApiRef.current?.getValues() || {};
|
||||
let data = { tag };
|
||||
if (formVals.model_mapping) {
|
||||
if (!verifyJSON(formVals.model_mapping)) {
|
||||
showInfo('模型映射必须是合法的 JSON 格式!');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
data.model_mapping = formVals.model_mapping;
|
||||
}
|
||||
if (formVals.groups && formVals.groups.length > 0) {
|
||||
data.groups = formVals.groups.join(',');
|
||||
}
|
||||
if (formVals.models && formVals.models.length > 0) {
|
||||
data.models = formVals.models.join(',');
|
||||
}
|
||||
data.new_tag = formVals.new_tag;
|
||||
if (
|
||||
data.model_mapping === undefined &&
|
||||
data.groups === undefined &&
|
||||
data.models === undefined &&
|
||||
data.new_tag === undefined
|
||||
) {
|
||||
showWarning('没有任何修改!');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
await submit(data);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const submit = async (data) => {
|
||||
try {
|
||||
const res = await API.put('/api/channel/tag', data);
|
||||
if (res?.data?.success) {
|
||||
showSuccess('标签更新成功!');
|
||||
refresh();
|
||||
handleClose();
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let localModelOptions = [...originModelOptions];
|
||||
inputs.models.forEach((model) => {
|
||||
if (!localModelOptions.find((option) => option.label === model)) {
|
||||
localModelOptions.push({
|
||||
label: model,
|
||||
value: model,
|
||||
});
|
||||
}
|
||||
});
|
||||
setModelOptions(localModelOptions);
|
||||
}, [originModelOptions, inputs.models]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTagModels = async () => {
|
||||
if (!tag) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.get(`/api/channel/tag/models?tag=${tag}`);
|
||||
if (res?.data?.success) {
|
||||
const models = res.data.data ? res.data.data.split(',') : [];
|
||||
handleInputChange('models', models);
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchModels().then();
|
||||
fetchGroups().then();
|
||||
fetchTagModels().then();
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValues({
|
||||
...getInitValues(),
|
||||
tag: tag,
|
||||
new_tag: tag,
|
||||
});
|
||||
}
|
||||
|
||||
setInputs({
|
||||
...originInputs,
|
||||
tag: tag,
|
||||
new_tag: tag,
|
||||
});
|
||||
}, [visible, tag]);
|
||||
|
||||
useEffect(() => {
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValues(inputs);
|
||||
}
|
||||
}, [inputs]);
|
||||
|
||||
const addCustomModels = () => {
|
||||
if (customModel.trim() === '') return;
|
||||
const modelArray = customModel.split(',').map((model) => model.trim());
|
||||
|
||||
let localModels = [...inputs.models];
|
||||
let localModelOptions = [...modelOptions];
|
||||
const addedModels = [];
|
||||
|
||||
modelArray.forEach((model) => {
|
||||
if (model && !localModels.includes(model)) {
|
||||
localModels.push(model);
|
||||
localModelOptions.push({
|
||||
key: model,
|
||||
text: model,
|
||||
value: model,
|
||||
});
|
||||
addedModels.push(model);
|
||||
}
|
||||
});
|
||||
|
||||
setModelOptions(localModelOptions);
|
||||
setCustomModel('');
|
||||
handleInputChange('models', localModels);
|
||||
|
||||
if (addedModels.length > 0) {
|
||||
showSuccess(
|
||||
t('已新增 {{count}} 个模型:{{list}}', {
|
||||
count: addedModels.length,
|
||||
list: addedModels.join(', '),
|
||||
})
|
||||
);
|
||||
} else {
|
||||
showInfo(t('未发现新增模型'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SideSheet
|
||||
placement='right'
|
||||
title={
|
||||
<Space>
|
||||
<Tag color="blue" shape="circle">{t('编辑')}</Tag>
|
||||
<Title heading={4} className="m-0">
|
||||
{t('编辑标签')}
|
||||
</Title>
|
||||
</Space>
|
||||
}
|
||||
bodyStyle={{ padding: '0' }}
|
||||
visible={visible}
|
||||
width={600}
|
||||
onCancel={handleClose}
|
||||
footer={
|
||||
<div className="flex justify-end bg-white">
|
||||
<Space>
|
||||
<Button
|
||||
theme="solid"
|
||||
onClick={() => formApiRef.current?.submitForm()}
|
||||
loading={loading}
|
||||
icon={<IconSave />}
|
||||
>
|
||||
{t('保存')}
|
||||
</Button>
|
||||
<Button
|
||||
theme="light"
|
||||
type="primary"
|
||||
onClick={handleClose}
|
||||
icon={<IconClose />}
|
||||
>
|
||||
{t('取消')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
closeIcon={null}
|
||||
>
|
||||
<Form
|
||||
key={tag || 'edit'}
|
||||
initValues={getInitValues()}
|
||||
getFormApi={(api) => (formApiRef.current = api)}
|
||||
onSubmit={handleSave}
|
||||
>
|
||||
{() => (
|
||||
<Spin spinning={loading}>
|
||||
<div className="p-2">
|
||||
<Card className="!rounded-2xl shadow-sm border-0 mb-6">
|
||||
{/* Header: Tag Info */}
|
||||
<div className="flex items-center mb-2">
|
||||
<Avatar size="small" color="blue" className="mr-2 shadow-md">
|
||||
<IconBookmark size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className="text-lg font-medium">{t('标签信息')}</Text>
|
||||
<div className="text-xs text-gray-600">{t('标签的基本配置')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Banner
|
||||
type="warning"
|
||||
description={t('所有编辑均为覆盖操作,留空则不更改')}
|
||||
className="!rounded-lg mb-4"
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Form.Input
|
||||
field='new_tag'
|
||||
label={t('标签名称')}
|
||||
placeholder={t('请输入新标签,留空则解散标签')}
|
||||
onChange={(value) => handleInputChange('new_tag', value)}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="!rounded-2xl shadow-sm border-0 mb-6">
|
||||
{/* Header: Model Config */}
|
||||
<div className="flex items-center mb-2">
|
||||
<Avatar size="small" color="purple" className="mr-2 shadow-md">
|
||||
<IconCode size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className="text-lg font-medium">{t('模型配置')}</Text>
|
||||
<div className="text-xs text-gray-600">{t('模型选择和映射设置')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Banner
|
||||
type="info"
|
||||
description={t('当前模型列表为该标签下所有渠道模型列表最长的一个,并非所有渠道的并集,请注意可能导致某些渠道模型丢失。')}
|
||||
className="!rounded-lg mb-4"
|
||||
/>
|
||||
<Form.Select
|
||||
field='models'
|
||||
label={t('模型')}
|
||||
placeholder={t('请选择该渠道所支持的模型,留空则不更改')}
|
||||
multiple
|
||||
filter
|
||||
searchPosition='dropdown'
|
||||
optionList={modelOptions}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(value) => handleInputChange('models', value)}
|
||||
/>
|
||||
|
||||
<Form.Input
|
||||
field='custom_model'
|
||||
label={t('自定义模型名称')}
|
||||
placeholder={t('输入自定义模型名称')}
|
||||
onChange={(value) => setCustomModel(value.trim())}
|
||||
suffix={<Button size='small' type='primary' onClick={addCustomModels}>{t('填入')}</Button>}
|
||||
/>
|
||||
|
||||
<Form.TextArea
|
||||
field='model_mapping'
|
||||
label={t('模型重定向')}
|
||||
placeholder={t('此项可选,用于修改请求体中的模型名称,为一个 JSON 字符串,键为请求中模型名称,值为要替换的模型名称,留空则不更改')}
|
||||
autosize
|
||||
onChange={(value) => handleInputChange('model_mapping', value)}
|
||||
extraText={(
|
||||
<Space>
|
||||
<Text className="!text-semi-color-primary cursor-pointer" onClick={() => handleInputChange('model_mapping', JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2))}>{t('填入模板')}</Text>
|
||||
<Text className="!text-semi-color-primary cursor-pointer" onClick={() => handleInputChange('model_mapping', JSON.stringify({}, null, 2))}>{t('清空重定向')}</Text>
|
||||
<Text className="!text-semi-color-primary cursor-pointer" onClick={() => handleInputChange('model_mapping', '')}>{t('不更改')}</Text>
|
||||
</Space>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="!rounded-2xl shadow-sm border-0">
|
||||
{/* Header: Group Settings */}
|
||||
<div className="flex items-center mb-2">
|
||||
<Avatar size="small" color="green" className="mr-2 shadow-md">
|
||||
<IconUser size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className="text-lg font-medium">{t('分组设置')}</Text>
|
||||
<div className="text-xs text-gray-600">{t('用户分组配置')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Form.Select
|
||||
field='groups'
|
||||
label={t('分组')}
|
||||
placeholder={t('请选择可以使用该渠道的分组,留空则不更改')}
|
||||
multiple
|
||||
allowAdditions
|
||||
additionLabel={t('请在系统设置页面编辑分组倍率以添加新的分组:')}
|
||||
optionList={groupOptions}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(value) => handleInputChange('groups', value)}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Spin>
|
||||
)}
|
||||
</Form>
|
||||
</SideSheet>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditTagModal;
|
||||
@@ -1,12 +0,0 @@
|
||||
import React from 'react';
|
||||
import ChannelsTable from '../../components/table/ChannelsTable';
|
||||
|
||||
const File = () => {
|
||||
return (
|
||||
<div className="mt-[64px] px-2">
|
||||
<ChannelsTable />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default File;
|
||||
31
web/src/pages/Channel/index.jsx
Normal file
31
web/src/pages/Channel/index.jsx
Normal file
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import ChannelsTable from '../../components/table/channels';
|
||||
|
||||
const File = () => {
|
||||
return (
|
||||
<div className='mt-[60px] px-2'>
|
||||
<ChannelsTable />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default File;
|
||||
@@ -1,60 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useTokenKeys } from '../../hooks/useTokenKeys';
|
||||
import { Spin } from '@douyinfe/semi-ui';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const ChatPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const { id } = useParams();
|
||||
const { keys, serverAddress, isLoading } = useTokenKeys(id);
|
||||
|
||||
const comLink = (key) => {
|
||||
// console.log('chatLink:', chatLink);
|
||||
if (!serverAddress || !key) return '';
|
||||
let link = '';
|
||||
if (id) {
|
||||
let chats = localStorage.getItem('chats');
|
||||
if (chats) {
|
||||
chats = JSON.parse(chats);
|
||||
if (Array.isArray(chats) && chats.length > 0) {
|
||||
for (let k in chats[id]) {
|
||||
link = chats[id][k];
|
||||
link = link.replaceAll(
|
||||
'{address}',
|
||||
encodeURIComponent(serverAddress),
|
||||
);
|
||||
link = link.replaceAll('{key}', 'sk-' + key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return link;
|
||||
};
|
||||
|
||||
const iframeSrc = keys.length > 0 ? comLink(keys[0]) : '';
|
||||
|
||||
return !isLoading && iframeSrc ? (
|
||||
<iframe
|
||||
src={iframeSrc}
|
||||
style={{ width: '100%', height: 'calc(100vh - 64px)', border: 'none', marginTop: '64px' }}
|
||||
title='Token Frame'
|
||||
allow='camera;microphone'
|
||||
/>
|
||||
) : (
|
||||
<div className="fixed inset-0 w-screen h-screen flex items-center justify-center bg-white/80 z-[1000] mt-[64px]">
|
||||
<div className="flex flex-col items-center">
|
||||
<Spin
|
||||
size="large"
|
||||
spinning={true}
|
||||
tip={null}
|
||||
/>
|
||||
<span className="whitespace-nowrap mt-2 text-center" style={{ color: 'var(--semi-color-primary)' }}>
|
||||
{t('正在跳转...')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatPage;
|
||||
83
web/src/pages/Chat/index.jsx
Normal file
83
web/src/pages/Chat/index.jsx
Normal file
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useTokenKeys } from '../../hooks/chat/useTokenKeys';
|
||||
import { Spin } from '@douyinfe/semi-ui';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const ChatPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const { id } = useParams();
|
||||
const { keys, serverAddress, isLoading } = useTokenKeys(id);
|
||||
|
||||
const comLink = (key) => {
|
||||
// console.log('chatLink:', chatLink);
|
||||
if (!serverAddress || !key) return '';
|
||||
let link = '';
|
||||
if (id) {
|
||||
let chats = localStorage.getItem('chats');
|
||||
if (chats) {
|
||||
chats = JSON.parse(chats);
|
||||
if (Array.isArray(chats) && chats.length > 0) {
|
||||
for (let k in chats[id]) {
|
||||
link = chats[id][k];
|
||||
link = link.replaceAll(
|
||||
'{address}',
|
||||
encodeURIComponent(serverAddress),
|
||||
);
|
||||
link = link.replaceAll('{key}', 'sk-' + key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return link;
|
||||
};
|
||||
|
||||
const iframeSrc = keys.length > 0 ? comLink(keys[0]) : '';
|
||||
|
||||
return !isLoading && iframeSrc ? (
|
||||
<iframe
|
||||
src={iframeSrc}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'calc(100vh - 64px)',
|
||||
border: 'none',
|
||||
marginTop: '64px',
|
||||
}}
|
||||
title='Token Frame'
|
||||
allow='camera;microphone'
|
||||
/>
|
||||
) : (
|
||||
<div className='fixed inset-0 w-screen h-screen flex items-center justify-center bg-white/80 z-[1000] mt-[60px]'>
|
||||
<div className='flex flex-col items-center'>
|
||||
<Spin size='large' spinning={true} tip={null} />
|
||||
<span
|
||||
className='whitespace-nowrap mt-2 text-center'
|
||||
style={{ color: 'var(--semi-color-primary)' }}
|
||||
>
|
||||
{t('正在跳转...')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatPage;
|
||||
@@ -1,26 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useTokenKeys } from '../../hooks/useTokenKeys';
|
||||
|
||||
const chat2page = () => {
|
||||
const { keys, chatLink, serverAddress, isLoading } = useTokenKeys();
|
||||
|
||||
const comLink = (key) => {
|
||||
if (!chatLink || !serverAddress || !key) return '';
|
||||
return `${chatLink}/#/?settings={"key":"sk-${key}","url":"${encodeURIComponent(serverAddress)}"}`;
|
||||
};
|
||||
|
||||
if (keys.length > 0) {
|
||||
const redirectLink = comLink(keys[0]);
|
||||
if (redirectLink) {
|
||||
window.location.href = redirectLink;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-[64px] px-2">
|
||||
<h3>正在加载,请稍候...</h3>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default chat2page;
|
||||
45
web/src/pages/Chat2Link/index.jsx
Normal file
45
web/src/pages/Chat2Link/index.jsx
Normal file
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useTokenKeys } from '../../hooks/chat/useTokenKeys';
|
||||
|
||||
const chat2page = () => {
|
||||
const { keys, chatLink, serverAddress, isLoading } = useTokenKeys();
|
||||
|
||||
const comLink = (key) => {
|
||||
if (!chatLink || !serverAddress || !key) return '';
|
||||
return `${chatLink}/#/?settings={"key":"sk-${key}","url":"${encodeURIComponent(serverAddress)}"}`;
|
||||
};
|
||||
|
||||
if (keys.length > 0) {
|
||||
const redirectLink = comLink(keys[0]);
|
||||
if (redirectLink) {
|
||||
window.location.href = redirectLink;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='mt-[60px] px-2'>
|
||||
<h3>正在加载,请稍候...</h3>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default chat2page;
|
||||
29
web/src/pages/Dashboard/index.jsx
Normal file
29
web/src/pages/Dashboard/index.jsx
Normal file
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import Dashboard from '../../components/dashboard';
|
||||
|
||||
const Detail = () => (
|
||||
<div className='mt-[60px] px-2'>
|
||||
<Dashboard />
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Detail;
|
||||
File diff suppressed because it is too large
Load Diff
43
web/src/pages/Forbidden/index.jsx
Normal file
43
web/src/pages/Forbidden/index.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Empty } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IllustrationNoAccess,
|
||||
IllustrationNoAccessDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const Forbidden = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className='flex justify-center items-center h-screen p-8'>
|
||||
<Empty
|
||||
image={<IllustrationNoAccess style={{ width: 250, height: 250 }} />}
|
||||
darkModeImage={
|
||||
<IllustrationNoAccessDark style={{ width: 250, height: 250 }} />
|
||||
}
|
||||
description={t('您无权访问此页面,请联系管理员')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Forbidden;
|
||||
@@ -1,28 +1,82 @@
|
||||
/*
|
||||
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, { useContext, useEffect, useState } from 'react';
|
||||
import { Button, Typography, Tag, Input, ScrollList, ScrollItem } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
Button,
|
||||
Typography,
|
||||
Input,
|
||||
ScrollList,
|
||||
ScrollItem,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { API, showError, copy, showSuccess } from '../../helpers';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile.js';
|
||||
import { useIsMobile } from '../../hooks/common/useIsMobile';
|
||||
import { API_ENDPOINTS } from '../../constants/common.constant';
|
||||
import { StatusContext } from '../../context/Status';
|
||||
import { useActualTheme } from '../../context/Theme';
|
||||
import { marked } from 'marked';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IconGithubLogo, IconPlay, IconFile, IconCopy } from '@douyinfe/semi-icons';
|
||||
import {
|
||||
IconGithubLogo,
|
||||
IconPlay,
|
||||
IconFile,
|
||||
IconCopy,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { Link } from 'react-router-dom';
|
||||
import NoticeModal from '../../components/layout/NoticeModal';
|
||||
import { Moonshot, OpenAI, XAI, Zhipu, Volcengine, Cohere, Claude, Gemini, Suno, Minimax, Wenxin, Spark, Qingyan, DeepSeek, Qwen, Midjourney, Grok, AzureAI, Hunyuan, Xinference } from '@lobehub/icons';
|
||||
import {
|
||||
Moonshot,
|
||||
OpenAI,
|
||||
XAI,
|
||||
Zhipu,
|
||||
Volcengine,
|
||||
Cohere,
|
||||
Claude,
|
||||
Gemini,
|
||||
Suno,
|
||||
Minimax,
|
||||
Wenxin,
|
||||
Spark,
|
||||
Qingyan,
|
||||
DeepSeek,
|
||||
Qwen,
|
||||
Midjourney,
|
||||
Grok,
|
||||
AzureAI,
|
||||
Hunyuan,
|
||||
Xinference,
|
||||
} from '@lobehub/icons';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const Home = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [statusState] = useContext(StatusContext);
|
||||
const actualTheme = useActualTheme();
|
||||
const [homePageContentLoaded, setHomePageContentLoaded] = useState(false);
|
||||
const [homePageContent, setHomePageContent] = useState('');
|
||||
const [noticeVisible, setNoticeVisible] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
const isDemoSiteMode = statusState?.status?.demo_site_enabled || false;
|
||||
const docsLink = statusState?.status?.docs_link || '';
|
||||
const serverAddress = statusState?.status?.server_address || window.location.origin;
|
||||
const serverAddress =
|
||||
statusState?.status?.server_address || `${window.location.origin}`;
|
||||
const endpointItems = API_ENDPOINTS.map((e) => ({ value: e }));
|
||||
const [endpointIndex, setEndpointIndex] = useState(0);
|
||||
const isChinese = i18n.language.startsWith('zh');
|
||||
@@ -43,9 +97,8 @@ const Home = () => {
|
||||
if (data.startsWith('https://')) {
|
||||
const iframe = document.querySelector('iframe');
|
||||
if (iframe) {
|
||||
const theme = localStorage.getItem('theme-mode') || 'light';
|
||||
iframe.onload = () => {
|
||||
iframe.contentWindow.postMessage({ themeMode: theme }, '*');
|
||||
iframe.contentWindow.postMessage({ themeMode: actualTheme }, '*');
|
||||
iframe.contentWindow.postMessage({ lang: i18n.language }, '*');
|
||||
};
|
||||
}
|
||||
@@ -96,51 +149,58 @@ const Home = () => {
|
||||
}, [endpointItems.length]);
|
||||
|
||||
return (
|
||||
<div className="w-full overflow-x-hidden">
|
||||
<div className='w-full overflow-x-hidden'>
|
||||
<NoticeModal
|
||||
visible={noticeVisible}
|
||||
onClose={() => setNoticeVisible(false)}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
{homePageContentLoaded && homePageContent === '' ? (
|
||||
<div className="w-full overflow-x-hidden">
|
||||
<div className='w-full overflow-x-hidden'>
|
||||
{/* Banner 部分 */}
|
||||
<div className="w-full border-b border-semi-color-border min-h-[500px] md:min-h-[600px] lg:min-h-[700px] relative overflow-x-hidden">
|
||||
<div className='w-full border-b border-semi-color-border min-h-[500px] md:min-h-[600px] lg:min-h-[700px] relative overflow-x-hidden'>
|
||||
{/* 背景模糊晕染球 */}
|
||||
<div className="blur-ball blur-ball-indigo" />
|
||||
<div className="blur-ball blur-ball-teal" />
|
||||
<div className="flex items-center justify-center h-full px-4 py-20 md:py-24 lg:py-32 mt-10">
|
||||
<div className='blur-ball blur-ball-indigo' />
|
||||
<div className='blur-ball blur-ball-teal' />
|
||||
<div className='flex items-center justify-center h-full px-4 py-20 md:py-24 lg:py-32 mt-10'>
|
||||
{/* 居中内容区 */}
|
||||
<div className="flex flex-col items-center justify-center text-center max-w-4xl mx-auto">
|
||||
<div className="flex flex-col items-center justify-center mb-6 md:mb-8">
|
||||
<h1 className={`text-4xl md:text-5xl lg:text-6xl xl:text-7xl font-bold text-semi-color-text-0 leading-tight ${isChinese ? 'tracking-wide md:tracking-wider' : ''}`}>
|
||||
<div className='flex flex-col items-center justify-center text-center max-w-4xl mx-auto'>
|
||||
<div className='flex flex-col items-center justify-center mb-6 md:mb-8'>
|
||||
<h1
|
||||
className={`text-4xl md:text-5xl lg:text-6xl xl:text-7xl font-bold text-semi-color-text-0 leading-tight ${isChinese ? 'tracking-wide md:tracking-wider' : ''}`}
|
||||
>
|
||||
{i18n.language === 'en' ? (
|
||||
<>
|
||||
The Unified<br />
|
||||
<span className="shine-text">LLMs API Gateway</span>
|
||||
The Unified
|
||||
<br />
|
||||
<span className='shine-text'>LLMs API Gateway</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
统一的<br />
|
||||
<span className="shine-text">大模型接口网关</span>
|
||||
统一的
|
||||
<br />
|
||||
<span className='shine-text'>大模型接口网关</span>
|
||||
</>
|
||||
)}
|
||||
</h1>
|
||||
<p className="text-base md:text-lg lg:text-xl text-semi-color-text-1 mt-4 md:mt-6 max-w-xl">
|
||||
<p className='text-base md:text-lg lg:text-xl text-semi-color-text-1 mt-4 md:mt-6 max-w-xl'>
|
||||
{t('更好的价格,更好的稳定性,只需要将模型基址替换为:')}
|
||||
</p>
|
||||
{/* BASE URL 与端点选择 */}
|
||||
<div className="flex flex-col md:flex-row items-center justify-center gap-4 w-full mt-4 md:mt-6 max-w-md">
|
||||
<div className='flex flex-col md:flex-row items-center justify-center gap-4 w-full mt-4 md:mt-6 max-w-md'>
|
||||
<Input
|
||||
readonly
|
||||
value={serverAddress}
|
||||
className="flex-1 !rounded-full"
|
||||
className='flex-1 !rounded-full'
|
||||
size={isMobile ? 'default' : 'large'}
|
||||
suffix={
|
||||
<div className="flex items-center gap-2">
|
||||
<ScrollList bodyHeight={32} style={{ border: 'unset', boxShadow: 'unset' }}>
|
||||
<div className='flex items-center gap-2'>
|
||||
<ScrollList
|
||||
bodyHeight={32}
|
||||
style={{ border: 'unset', boxShadow: 'unset' }}
|
||||
>
|
||||
<ScrollItem
|
||||
mode="wheel"
|
||||
mode='wheel'
|
||||
cycled={true}
|
||||
list={endpointItems}
|
||||
selectedIndex={endpointIndex}
|
||||
@@ -148,10 +208,10 @@ const Home = () => {
|
||||
/>
|
||||
</ScrollList>
|
||||
<Button
|
||||
type="primary"
|
||||
type='primary'
|
||||
onClick={handleCopyBaseURL}
|
||||
icon={<IconCopy />}
|
||||
className="!rounded-full"
|
||||
className='!rounded-full'
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
@@ -160,26 +220,37 @@ const Home = () => {
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex flex-row gap-4 justify-center items-center">
|
||||
<Link to="/console">
|
||||
<Button theme="solid" type="primary" size={isMobile ? "default" : "large"} className="!rounded-3xl px-8 py-2" icon={<IconPlay />}>
|
||||
<div className='flex flex-row gap-4 justify-center items-center'>
|
||||
<Link to='/console'>
|
||||
<Button
|
||||
theme='solid'
|
||||
type='primary'
|
||||
size={isMobile ? 'default' : 'large'}
|
||||
className='!rounded-3xl px-8 py-2'
|
||||
icon={<IconPlay />}
|
||||
>
|
||||
{t('获取密钥')}
|
||||
</Button>
|
||||
</Link>
|
||||
{isDemoSiteMode && statusState?.status?.version ? (
|
||||
<Button
|
||||
size={isMobile ? "default" : "large"}
|
||||
className="flex items-center !rounded-3xl px-6 py-2"
|
||||
size={isMobile ? 'default' : 'large'}
|
||||
className='flex items-center !rounded-3xl px-6 py-2'
|
||||
icon={<IconGithubLogo />}
|
||||
onClick={() => window.open('https://github.com/QuantumNous/new-api', '_blank')}
|
||||
onClick={() =>
|
||||
window.open(
|
||||
'https://github.com/QuantumNous/new-api',
|
||||
'_blank',
|
||||
)
|
||||
}
|
||||
>
|
||||
{statusState.status.version}
|
||||
</Button>
|
||||
) : (
|
||||
docsLink && (
|
||||
<Button
|
||||
size={isMobile ? "default" : "large"}
|
||||
className="flex items-center !rounded-3xl px-6 py-2"
|
||||
size={isMobile ? 'default' : 'large'}
|
||||
className='flex items-center !rounded-3xl px-6 py-2'
|
||||
icon={<IconFile />}
|
||||
onClick={() => window.open(docsLink, '_blank')}
|
||||
>
|
||||
@@ -190,75 +261,80 @@ const Home = () => {
|
||||
</div>
|
||||
|
||||
{/* 框架兼容性图标 */}
|
||||
<div className="mt-12 md:mt-16 lg:mt-20 w-full">
|
||||
<div className="flex items-center mb-6 md:mb-8 justify-center">
|
||||
<Text type="tertiary" className="text-lg md:text-xl lg:text-2xl font-light">
|
||||
<div className='mt-12 md:mt-16 lg:mt-20 w-full'>
|
||||
<div className='flex items-center mb-6 md:mb-8 justify-center'>
|
||||
<Text
|
||||
type='tertiary'
|
||||
className='text-lg md:text-xl lg:text-2xl font-light'
|
||||
>
|
||||
{t('支持众多的大模型供应商')}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-center gap-3 sm:gap-4 md:gap-6 lg:gap-8 max-w-5xl mx-auto px-4">
|
||||
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
|
||||
<div className='flex flex-wrap items-center justify-center gap-3 sm:gap-4 md:gap-6 lg:gap-8 max-w-5xl mx-auto px-4'>
|
||||
<div className='w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center'>
|
||||
<Moonshot size={40} />
|
||||
</div>
|
||||
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
|
||||
<div className='w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center'>
|
||||
<OpenAI size={40} />
|
||||
</div>
|
||||
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
|
||||
<div className='w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center'>
|
||||
<XAI size={40} />
|
||||
</div>
|
||||
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
|
||||
<div className='w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center'>
|
||||
<Zhipu.Color size={40} />
|
||||
</div>
|
||||
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
|
||||
<div className='w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center'>
|
||||
<Volcengine.Color size={40} />
|
||||
</div>
|
||||
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
|
||||
<div className='w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center'>
|
||||
<Cohere.Color size={40} />
|
||||
</div>
|
||||
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
|
||||
<div className='w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center'>
|
||||
<Claude.Color size={40} />
|
||||
</div>
|
||||
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
|
||||
<div className='w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center'>
|
||||
<Gemini.Color size={40} />
|
||||
</div>
|
||||
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
|
||||
<div className='w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center'>
|
||||
<Suno size={40} />
|
||||
</div>
|
||||
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
|
||||
<div className='w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center'>
|
||||
<Minimax.Color size={40} />
|
||||
</div>
|
||||
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
|
||||
<div className='w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center'>
|
||||
<Wenxin.Color size={40} />
|
||||
</div>
|
||||
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
|
||||
<div className='w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center'>
|
||||
<Spark.Color size={40} />
|
||||
</div>
|
||||
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
|
||||
<div className='w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center'>
|
||||
<Qingyan.Color size={40} />
|
||||
</div>
|
||||
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
|
||||
<div className='w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center'>
|
||||
<DeepSeek.Color size={40} />
|
||||
</div>
|
||||
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
|
||||
<div className='w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center'>
|
||||
<Qwen.Color size={40} />
|
||||
</div>
|
||||
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
|
||||
<div className='w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center'>
|
||||
<Midjourney size={40} />
|
||||
</div>
|
||||
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
|
||||
<div className='w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center'>
|
||||
<Grok size={40} />
|
||||
</div>
|
||||
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
|
||||
<div className='w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center'>
|
||||
<AzureAI.Color size={40} />
|
||||
</div>
|
||||
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
|
||||
<div className='w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center'>
|
||||
<Hunyuan.Color size={40} />
|
||||
</div>
|
||||
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
|
||||
<div className='w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center'>
|
||||
<Xinference.Color size={40} />
|
||||
</div>
|
||||
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
|
||||
<Typography.Text className="!text-lg sm:!text-xl md:!text-2xl lg:!text-3xl font-bold">30+</Typography.Text>
|
||||
<div className='w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center'>
|
||||
<Typography.Text className='!text-lg sm:!text-xl md:!text-2xl lg:!text-3xl font-bold'>
|
||||
30+
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -267,14 +343,17 @@ const Home = () => {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-hidden w-full">
|
||||
<div className='overflow-x-hidden w-full'>
|
||||
{homePageContent.startsWith('https://') ? (
|
||||
<iframe
|
||||
src={homePageContent}
|
||||
className="w-full h-screen border-none"
|
||||
className='w-full h-screen border-none'
|
||||
/>
|
||||
) : (
|
||||
<div className="mt-[64px]" dangerouslySetInnerHTML={{ __html: homePageContent }} />
|
||||
<div
|
||||
className='mt-[60px]'
|
||||
dangerouslySetInnerHTML={{ __html: homePageContent }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -283,4 +362,3 @@ const Home = () => {
|
||||
};
|
||||
|
||||
export default Home;
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import React from 'react';
|
||||
import LogsTable from '../../components/table/LogsTable';
|
||||
|
||||
const Token = () => (
|
||||
<div className="mt-[64px] px-2">
|
||||
<LogsTable />
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Token;
|
||||
29
web/src/pages/Log/index.jsx
Normal file
29
web/src/pages/Log/index.jsx
Normal file
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import UsageLogsTable from '../../components/table/usage-logs';
|
||||
|
||||
const Token = () => (
|
||||
<div className='mt-[60px] px-2'>
|
||||
<UsageLogsTable />
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Token;
|
||||
@@ -1,10 +0,0 @@
|
||||
import React from 'react';
|
||||
import MjLogsTable from '../../components/table/MjLogsTable';
|
||||
|
||||
const Midjourney = () => (
|
||||
<div className="mt-[64px] px-2">
|
||||
<MjLogsTable />
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Midjourney;
|
||||
29
web/src/pages/Midjourney/index.jsx
Normal file
29
web/src/pages/Midjourney/index.jsx
Normal file
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import MjLogsTable from '../../components/table/mj-logs';
|
||||
|
||||
const Midjourney = () => (
|
||||
<div className='mt-[60px] px-2'>
|
||||
<MjLogsTable />
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Midjourney;
|
||||
30
web/src/pages/Model/index.jsx
Normal file
30
web/src/pages/Model/index.jsx
Normal file
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import React from 'react';
|
||||
import ModelsTable from '../../components/table/models';
|
||||
|
||||
const ModelPage = () => {
|
||||
return (
|
||||
<div className='mt-[60px] px-2'>
|
||||
<ModelsTable />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelPage;
|
||||
@@ -1,19 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Empty } from '@douyinfe/semi-ui';
|
||||
import { IllustrationNotFound, IllustrationNotFoundDark } from '@douyinfe/semi-illustrations';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const NotFound = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex justify-center items-center h-screen p-8 mt-[64px]">
|
||||
<Empty
|
||||
image={<IllustrationNotFound style={{ width: 250, height: 250 }} />}
|
||||
darkModeImage={<IllustrationNotFoundDark style={{ width: 250, height: 250 }} />}
|
||||
description={t('页面未找到,请检查您的浏览器地址是否正确')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotFound;
|
||||
43
web/src/pages/NotFound/index.jsx
Normal file
43
web/src/pages/NotFound/index.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Empty } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IllustrationNotFound,
|
||||
IllustrationNotFoundDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const NotFound = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className='flex justify-center items-center h-screen p-8'>
|
||||
<Empty
|
||||
image={<IllustrationNotFound style={{ width: 250, height: 250 }} />}
|
||||
darkModeImage={
|
||||
<IllustrationNotFoundDark style={{ width: 250, height: 250 }} />
|
||||
}
|
||||
description={t('页面未找到,请检查您的浏览器地址是否正确')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotFound;
|
||||
@@ -1,25 +1,44 @@
|
||||
/*
|
||||
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, { useContext, useEffect, useCallback, useRef } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Layout, Toast, Modal } from '@douyinfe/semi-ui';
|
||||
|
||||
// Context
|
||||
import { UserContext } from '../../context/User/index.js';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile.js';
|
||||
import { UserContext } from '../../context/User';
|
||||
import { useIsMobile } from '../../hooks/common/useIsMobile';
|
||||
|
||||
// hooks
|
||||
import { usePlaygroundState } from '../../hooks/usePlaygroundState.js';
|
||||
import { useMessageActions } from '../../hooks/useMessageActions.js';
|
||||
import { useApiRequest } from '../../hooks/useApiRequest.js';
|
||||
import { useSyncMessageAndCustomBody } from '../../hooks/useSyncMessageAndCustomBody.js';
|
||||
import { useMessageEdit } from '../../hooks/useMessageEdit.js';
|
||||
import { useDataLoader } from '../../hooks/useDataLoader.js';
|
||||
import { usePlaygroundState } from '../../hooks/playground/usePlaygroundState';
|
||||
import { useMessageActions } from '../../hooks/playground/useMessageActions';
|
||||
import { useApiRequest } from '../../hooks/playground/useApiRequest';
|
||||
import { useSyncMessageAndCustomBody } from '../../hooks/playground/useSyncMessageAndCustomBody';
|
||||
import { useMessageEdit } from '../../hooks/playground/useMessageEdit';
|
||||
import { useDataLoader } from '../../hooks/playground/useDataLoader';
|
||||
|
||||
// Constants and utils
|
||||
import {
|
||||
MESSAGE_ROLES,
|
||||
ERROR_MESSAGES
|
||||
} from '../../constants/playground.constants.js';
|
||||
ERROR_MESSAGES,
|
||||
} from '../../constants/playground.constants';
|
||||
import {
|
||||
getLogo,
|
||||
stringToColor,
|
||||
@@ -27,7 +46,7 @@ import {
|
||||
createMessage,
|
||||
createLoadingAssistantMessage,
|
||||
getTextContent,
|
||||
buildApiPayload
|
||||
buildApiPayload,
|
||||
} from '../../helpers';
|
||||
|
||||
// Components
|
||||
@@ -35,10 +54,10 @@ import {
|
||||
OptimizedSettingsPanel,
|
||||
OptimizedDebugPanel,
|
||||
OptimizedMessageContent,
|
||||
OptimizedMessageActions
|
||||
} from '../../components/playground/OptimizedComponents.js';
|
||||
import ChatArea from '../../components/playground/ChatArea.js';
|
||||
import FloatingButtons from '../../components/playground/FloatingButtons.js';
|
||||
OptimizedMessageActions,
|
||||
} from '../../components/playground/OptimizedComponents';
|
||||
import ChatArea from '../../components/playground/ChatArea';
|
||||
import FloatingButtons from '../../components/playground/FloatingButtons';
|
||||
|
||||
// 生成头像
|
||||
const generateAvatarDataUrl = (username) => {
|
||||
@@ -105,7 +124,7 @@ const Playground = () => {
|
||||
setDebugData,
|
||||
setActiveDebugTab,
|
||||
sseSourceRef,
|
||||
saveMessagesImmediately
|
||||
saveMessagesImmediately,
|
||||
);
|
||||
|
||||
// 数据加载
|
||||
@@ -118,19 +137,26 @@ const Playground = () => {
|
||||
setEditValue,
|
||||
handleMessageEdit,
|
||||
handleEditSave,
|
||||
handleEditCancel
|
||||
} = useMessageEdit(setMessage, inputs, parameterEnabled, sendRequest, saveMessagesImmediately);
|
||||
handleEditCancel,
|
||||
} = useMessageEdit(
|
||||
setMessage,
|
||||
inputs,
|
||||
parameterEnabled,
|
||||
sendRequest,
|
||||
saveMessagesImmediately,
|
||||
);
|
||||
|
||||
// 消息和自定义请求体同步
|
||||
const { syncMessageToCustomBody, syncCustomBodyToMessage } = useSyncMessageAndCustomBody(
|
||||
customRequestMode,
|
||||
customRequestBody,
|
||||
message,
|
||||
inputs,
|
||||
setCustomRequestBody,
|
||||
setMessage,
|
||||
debouncedSaveConfig
|
||||
);
|
||||
const { syncMessageToCustomBody, syncCustomBodyToMessage } =
|
||||
useSyncMessageAndCustomBody(
|
||||
customRequestMode,
|
||||
customRequestBody,
|
||||
message,
|
||||
inputs,
|
||||
setCustomRequestBody,
|
||||
setMessage,
|
||||
debouncedSaveConfig,
|
||||
);
|
||||
|
||||
// 角色信息
|
||||
const roleInfo = {
|
||||
@@ -149,7 +175,12 @@ const Playground = () => {
|
||||
};
|
||||
|
||||
// 消息操作
|
||||
const messageActions = useMessageActions(message, setMessage, onMessageSend, saveMessagesImmediately);
|
||||
const messageActions = useMessageActions(
|
||||
message,
|
||||
setMessage,
|
||||
onMessageSend,
|
||||
saveMessagesImmediately,
|
||||
);
|
||||
|
||||
// 构建预览请求体
|
||||
const constructPreviewPayload = useCallback(() => {
|
||||
@@ -167,15 +198,26 @@ const Playground = () => {
|
||||
let messages = [...message];
|
||||
|
||||
// 如果存在用户消息
|
||||
if (!(messages.length === 0 || messages.every(msg => msg.role !== MESSAGE_ROLES.USER))) {
|
||||
if (
|
||||
!(
|
||||
messages.length === 0 ||
|
||||
messages.every((msg) => msg.role !== MESSAGE_ROLES.USER)
|
||||
)
|
||||
) {
|
||||
// 处理最后一个用户消息的图片
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].role === MESSAGE_ROLES.USER) {
|
||||
if (inputs.imageEnabled && inputs.imageUrls) {
|
||||
const validImageUrls = inputs.imageUrls.filter(url => url.trim() !== '');
|
||||
const validImageUrls = inputs.imageUrls.filter(
|
||||
(url) => url.trim() !== '',
|
||||
);
|
||||
if (validImageUrls.length > 0) {
|
||||
const textContent = getTextContent(messages[i]) || '示例消息';
|
||||
const content = buildMessageContent(textContent, validImageUrls, true);
|
||||
const content = buildMessageContent(
|
||||
textContent,
|
||||
validImageUrls,
|
||||
true,
|
||||
);
|
||||
messages[i] = { ...messages[i], content };
|
||||
}
|
||||
}
|
||||
@@ -204,7 +246,7 @@ const Playground = () => {
|
||||
try {
|
||||
const customPayload = JSON.parse(customRequestBody);
|
||||
|
||||
setMessage(prevMessage => {
|
||||
setMessage((prevMessage) => {
|
||||
const newMessages = [...prevMessage, userMessage, loadingMessage];
|
||||
|
||||
// 发送自定义请求体
|
||||
@@ -224,14 +266,26 @@ const Playground = () => {
|
||||
}
|
||||
|
||||
// 默认模式
|
||||
const validImageUrls = inputs.imageUrls.filter(url => url.trim() !== '');
|
||||
const messageContent = buildMessageContent(content, validImageUrls, inputs.imageEnabled);
|
||||
const userMessageWithImages = createMessage(MESSAGE_ROLES.USER, messageContent);
|
||||
const validImageUrls = inputs.imageUrls.filter((url) => url.trim() !== '');
|
||||
const messageContent = buildMessageContent(
|
||||
content,
|
||||
validImageUrls,
|
||||
inputs.imageEnabled,
|
||||
);
|
||||
const userMessageWithImages = createMessage(
|
||||
MESSAGE_ROLES.USER,
|
||||
messageContent,
|
||||
);
|
||||
|
||||
setMessage(prevMessage => {
|
||||
setMessage((prevMessage) => {
|
||||
const newMessages = [...prevMessage, userMessageWithImages];
|
||||
|
||||
const payload = buildApiPayload(newMessages, null, inputs, parameterEnabled);
|
||||
const payload = buildApiPayload(
|
||||
newMessages,
|
||||
null,
|
||||
inputs,
|
||||
parameterEnabled,
|
||||
);
|
||||
sendRequest(payload, inputs.stream);
|
||||
|
||||
// 禁用图片模式
|
||||
@@ -250,15 +304,18 @@ const Playground = () => {
|
||||
}
|
||||
|
||||
// 切换推理展开状态
|
||||
const toggleReasoningExpansion = useCallback((messageId) => {
|
||||
setMessage(prevMessages =>
|
||||
prevMessages.map(msg =>
|
||||
msg.id === messageId && msg.role === MESSAGE_ROLES.ASSISTANT
|
||||
? { ...msg, isReasoningExpanded: !msg.isReasoningExpanded }
|
||||
: msg
|
||||
)
|
||||
);
|
||||
}, [setMessage]);
|
||||
const toggleReasoningExpansion = useCallback(
|
||||
(messageId) => {
|
||||
setMessage((prevMessages) =>
|
||||
prevMessages.map((msg) =>
|
||||
msg.id === messageId && msg.role === MESSAGE_ROLES.ASSISTANT
|
||||
? { ...msg, isReasoningExpanded: !msg.isReasoningExpanded }
|
||||
: msg,
|
||||
),
|
||||
);
|
||||
},
|
||||
[setMessage],
|
||||
);
|
||||
|
||||
// 渲染函数
|
||||
const renderCustomChatContent = useCallback(
|
||||
@@ -279,30 +336,41 @@ const Playground = () => {
|
||||
/>
|
||||
);
|
||||
},
|
||||
[styleState, editingMessageId, editValue, handleEditSave, handleEditCancel, setEditValue, toggleReasoningExpansion],
|
||||
[
|
||||
styleState,
|
||||
editingMessageId,
|
||||
editValue,
|
||||
handleEditSave,
|
||||
handleEditCancel,
|
||||
setEditValue,
|
||||
toggleReasoningExpansion,
|
||||
],
|
||||
);
|
||||
|
||||
const renderChatBoxAction = useCallback((props) => {
|
||||
const { message: currentMessage } = props;
|
||||
const isAnyMessageGenerating = message.some(msg =>
|
||||
msg.status === 'loading' || msg.status === 'incomplete'
|
||||
);
|
||||
const isCurrentlyEditing = editingMessageId === currentMessage.id;
|
||||
const renderChatBoxAction = useCallback(
|
||||
(props) => {
|
||||
const { message: currentMessage } = props;
|
||||
const isAnyMessageGenerating = message.some(
|
||||
(msg) => msg.status === 'loading' || msg.status === 'incomplete',
|
||||
);
|
||||
const isCurrentlyEditing = editingMessageId === currentMessage.id;
|
||||
|
||||
return (
|
||||
<OptimizedMessageActions
|
||||
message={currentMessage}
|
||||
styleState={styleState}
|
||||
onMessageReset={messageActions.handleMessageReset}
|
||||
onMessageCopy={messageActions.handleMessageCopy}
|
||||
onMessageDelete={messageActions.handleMessageDelete}
|
||||
onRoleToggle={messageActions.handleRoleToggle}
|
||||
onMessageEdit={handleMessageEdit}
|
||||
isAnyMessageGenerating={isAnyMessageGenerating}
|
||||
isEditing={isCurrentlyEditing}
|
||||
/>
|
||||
);
|
||||
}, [messageActions, styleState, message, editingMessageId, handleMessageEdit]);
|
||||
return (
|
||||
<OptimizedMessageActions
|
||||
message={currentMessage}
|
||||
styleState={styleState}
|
||||
onMessageReset={messageActions.handleMessageReset}
|
||||
onMessageCopy={messageActions.handleMessageCopy}
|
||||
onMessageDelete={messageActions.handleMessageDelete}
|
||||
onRoleToggle={messageActions.handleRoleToggle}
|
||||
onMessageEdit={handleMessageEdit}
|
||||
isAnyMessageGenerating={isAnyMessageGenerating}
|
||||
isEditing={isCurrentlyEditing}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[messageActions, styleState, message, editingMessageId, handleMessageEdit],
|
||||
);
|
||||
|
||||
// Effects
|
||||
|
||||
@@ -329,20 +397,36 @@ const Playground = () => {
|
||||
const timer = setTimeout(() => {
|
||||
const preview = constructPreviewPayload();
|
||||
setPreviewPayload(preview);
|
||||
setDebugData(prev => ({
|
||||
setDebugData((prev) => ({
|
||||
...prev,
|
||||
previewRequest: preview ? JSON.stringify(preview, null, 2) : null,
|
||||
previewTimestamp: preview ? new Date().toISOString() : null
|
||||
previewTimestamp: preview ? new Date().toISOString() : null,
|
||||
}));
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [message, inputs, parameterEnabled, customRequestMode, customRequestBody, constructPreviewPayload, setPreviewPayload, setDebugData]);
|
||||
}, [
|
||||
message,
|
||||
inputs,
|
||||
parameterEnabled,
|
||||
customRequestMode,
|
||||
customRequestBody,
|
||||
constructPreviewPayload,
|
||||
setPreviewPayload,
|
||||
setDebugData,
|
||||
]);
|
||||
|
||||
// 自动保存配置
|
||||
useEffect(() => {
|
||||
debouncedSaveConfig();
|
||||
}, [inputs, parameterEnabled, showDebugPanel, customRequestMode, customRequestBody, debouncedSaveConfig]);
|
||||
}, [
|
||||
inputs,
|
||||
parameterEnabled,
|
||||
showDebugPanel,
|
||||
customRequestMode,
|
||||
customRequestBody,
|
||||
debouncedSaveConfig,
|
||||
]);
|
||||
|
||||
// 清空对话的处理函数
|
||||
const handleClearMessages = useCallback(() => {
|
||||
@@ -352,28 +436,19 @@ const Playground = () => {
|
||||
}, [setMessage, saveMessagesImmediately]);
|
||||
|
||||
return (
|
||||
<div className="h-full bg-gray-50 mt-[64px]">
|
||||
<Layout style={{ height: '100%', background: 'transparent' }} className="flex flex-col md:flex-row">
|
||||
<div className='h-full'>
|
||||
<Layout className='h-full bg-transparent flex flex-col md:flex-row'>
|
||||
{(showSettings || !isMobile) && (
|
||||
<Layout.Sider
|
||||
style={{
|
||||
background: 'transparent',
|
||||
borderRight: 'none',
|
||||
flexShrink: 0,
|
||||
minWidth: isMobile ? '100%' : 320,
|
||||
maxWidth: isMobile ? '100%' : 320,
|
||||
height: isMobile ? 'auto' : 'calc(100vh - 66px)',
|
||||
overflow: 'auto',
|
||||
position: isMobile ? 'fixed' : 'relative',
|
||||
zIndex: isMobile ? 1000 : 1,
|
||||
width: '100%',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
}}
|
||||
className={`
|
||||
bg-transparent border-r-0 flex-shrink-0 overflow-auto mt-[60px]
|
||||
${
|
||||
isMobile
|
||||
? 'fixed top-0 left-0 right-0 bottom-0 z-[1000] w-full h-auto bg-white shadow-lg'
|
||||
: 'relative z-[1] w-80 h-[calc(100vh-66px)]'
|
||||
}
|
||||
`}
|
||||
width={isMobile ? '100%' : 320}
|
||||
className={isMobile ? 'bg-white shadow-lg' : ''}
|
||||
>
|
||||
<OptimizedSettingsPanel
|
||||
inputs={inputs}
|
||||
@@ -398,9 +473,9 @@ const Playground = () => {
|
||||
</Layout.Sider>
|
||||
)}
|
||||
|
||||
<Layout.Content className="relative flex-1 overflow-hidden">
|
||||
<div className="overflow-hidden flex flex-col lg:flex-row h-[calc(100vh-66px)]">
|
||||
<div className="flex-1 flex flex-col">
|
||||
<Layout.Content className='relative flex-1 overflow-hidden'>
|
||||
<div className='overflow-hidden flex flex-col lg:flex-row h-[calc(100vh-66px)] mt-[60px]'>
|
||||
<div className='flex-1 flex flex-col'>
|
||||
<ChatArea
|
||||
chatRef={chatRef}
|
||||
message={message}
|
||||
@@ -422,7 +497,7 @@ const Playground = () => {
|
||||
|
||||
{/* 调试面板 - 桌面端 */}
|
||||
{showDebugPanel && !isMobile && (
|
||||
<div className="w-96 flex-shrink-0 h-full">
|
||||
<div className='w-96 flex-shrink-0 h-full'>
|
||||
<OptimizedDebugPanel
|
||||
debugData={debugData}
|
||||
activeDebugTab={activeDebugTab}
|
||||
@@ -436,19 +511,7 @@ const Playground = () => {
|
||||
|
||||
{/* 调试面板 - 移动端覆盖层 */}
|
||||
{showDebugPanel && isMobile && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: 1000,
|
||||
backgroundColor: 'white',
|
||||
overflow: 'auto',
|
||||
}}
|
||||
className="shadow-lg"
|
||||
>
|
||||
<div className='fixed top-0 left-0 right-0 bottom-0 z-[1000] bg-white overflow-auto shadow-lg'>
|
||||
<OptimizedDebugPanel
|
||||
debugData={debugData}
|
||||
activeDebugTab={activeDebugTab}
|
||||
@@ -1,10 +0,0 @@
|
||||
import React from 'react';
|
||||
import ModelPricing from '../../components/table/ModelPricing.js';
|
||||
|
||||
const Pricing = () => (
|
||||
<div className="mt-[64px] px-2">
|
||||
<ModelPricing />
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Pricing;
|
||||
29
web/src/pages/Pricing/index.jsx
Normal file
29
web/src/pages/Pricing/index.jsx
Normal file
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import ModelPricingPage from '../../components/table/model-pricing/layout/PricingPage';
|
||||
|
||||
const Pricing = () => (
|
||||
<>
|
||||
<ModelPricingPage />
|
||||
</>
|
||||
);
|
||||
|
||||
export default Pricing;
|
||||
@@ -1,305 +0,0 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
API,
|
||||
downloadTextAsFile,
|
||||
showError,
|
||||
showSuccess,
|
||||
renderQuota,
|
||||
renderQuotaWithPrompt,
|
||||
} from '../../helpers';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile.js';
|
||||
import {
|
||||
Button,
|
||||
Modal,
|
||||
SideSheet,
|
||||
Space,
|
||||
Spin,
|
||||
Typography,
|
||||
Card,
|
||||
Tag,
|
||||
Form,
|
||||
Avatar,
|
||||
Row,
|
||||
Col,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconCreditCard,
|
||||
IconSave,
|
||||
IconClose,
|
||||
IconGift,
|
||||
} from '@douyinfe/semi-icons';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
const EditRedemption = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const isEdit = props.editingRedemption.id !== undefined;
|
||||
const [loading, setLoading] = useState(isEdit);
|
||||
const isMobile = useIsMobile();
|
||||
const formApiRef = useRef(null);
|
||||
|
||||
const getInitValues = () => ({
|
||||
name: '',
|
||||
quota: 100000,
|
||||
count: 1,
|
||||
expired_time: null,
|
||||
});
|
||||
|
||||
const handleCancel = () => {
|
||||
props.handleClose();
|
||||
};
|
||||
|
||||
const loadRedemption = async () => {
|
||||
setLoading(true);
|
||||
let res = await API.get(`/api/redemption/${props.editingRedemption.id}`);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
if (data.expired_time === 0) {
|
||||
data.expired_time = null;
|
||||
} else {
|
||||
data.expired_time = new Date(data.expired_time * 1000);
|
||||
}
|
||||
formApiRef.current?.setValues({ ...getInitValues(), ...data });
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (formApiRef.current) {
|
||||
if (isEdit) {
|
||||
loadRedemption();
|
||||
} else {
|
||||
formApiRef.current.setValues(getInitValues());
|
||||
}
|
||||
}
|
||||
}, [props.editingRedemption.id]);
|
||||
|
||||
const submit = async (values) => {
|
||||
let name = values.name;
|
||||
if (!isEdit && (!name || name === '')) {
|
||||
name = renderQuota(values.quota);
|
||||
}
|
||||
setLoading(true);
|
||||
let localInputs = { ...values };
|
||||
localInputs.count = parseInt(localInputs.count) || 0;
|
||||
localInputs.quota = parseInt(localInputs.quota) || 0;
|
||||
localInputs.name = name;
|
||||
if (!localInputs.expired_time) {
|
||||
localInputs.expired_time = 0;
|
||||
} else {
|
||||
localInputs.expired_time = Math.floor(localInputs.expired_time.getTime() / 1000);
|
||||
}
|
||||
let res;
|
||||
if (isEdit) {
|
||||
res = await API.put(`/api/redemption/`, {
|
||||
...localInputs,
|
||||
id: parseInt(props.editingRedemption.id),
|
||||
});
|
||||
} else {
|
||||
res = await API.post(`/api/redemption/`, {
|
||||
...localInputs,
|
||||
});
|
||||
}
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
if (isEdit) {
|
||||
showSuccess(t('兑换码更新成功!'));
|
||||
props.refresh();
|
||||
props.handleClose();
|
||||
} else {
|
||||
showSuccess(t('兑换码创建成功!'));
|
||||
props.refresh();
|
||||
formApiRef.current?.setValues(getInitValues());
|
||||
props.handleClose();
|
||||
}
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
if (!isEdit && data) {
|
||||
let text = '';
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
text += data[i] + '\n';
|
||||
}
|
||||
Modal.confirm({
|
||||
title: t('兑换码创建成功'),
|
||||
content: (
|
||||
<div>
|
||||
<p>{t('兑换码创建成功,是否下载兑换码?')}</p>
|
||||
<p>{t('兑换码将以文本文件的形式下载,文件名为兑换码的名称。')}</p>
|
||||
</div>
|
||||
),
|
||||
onOk: () => {
|
||||
downloadTextAsFile(text, `${localInputs.name}.txt`);
|
||||
},
|
||||
});
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SideSheet
|
||||
placement={isEdit ? 'right' : 'left'}
|
||||
title={
|
||||
<Space>
|
||||
{isEdit ?
|
||||
<Tag color="blue" shape="circle">{t('更新')}</Tag> :
|
||||
<Tag color="green" shape="circle">{t('新建')}</Tag>
|
||||
}
|
||||
<Title heading={4} className="m-0">
|
||||
{isEdit ? t('更新兑换码信息') : t('创建新的兑换码')}
|
||||
</Title>
|
||||
</Space>
|
||||
}
|
||||
bodyStyle={{ padding: '0' }}
|
||||
visible={props.visiable}
|
||||
width={isMobile ? '100%' : 600}
|
||||
footer={
|
||||
<div className="flex justify-end bg-white">
|
||||
<Space>
|
||||
<Button
|
||||
theme="solid"
|
||||
onClick={() => formApiRef.current?.submitForm()}
|
||||
icon={<IconSave />}
|
||||
loading={loading}
|
||||
>
|
||||
{t('提交')}
|
||||
</Button>
|
||||
<Button
|
||||
theme="light"
|
||||
type="primary"
|
||||
onClick={handleCancel}
|
||||
icon={<IconClose />}
|
||||
>
|
||||
{t('取消')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
closeIcon={null}
|
||||
onCancel={() => handleCancel()}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
<Form
|
||||
initValues={getInitValues()}
|
||||
getFormApi={(api) => formApiRef.current = api}
|
||||
onSubmit={submit}
|
||||
>
|
||||
{({ values }) => (
|
||||
<div className="p-2">
|
||||
<Card className="!rounded-2xl shadow-sm border-0 mb-6">
|
||||
{/* Header: Basic Info */}
|
||||
<div className="flex items-center mb-2">
|
||||
<Avatar size="small" color="blue" className="mr-2 shadow-md">
|
||||
<IconGift size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className="text-lg font-medium">{t('基本信息')}</Text>
|
||||
<div className="text-xs text-gray-600">{t('设置兑换码的基本信息')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Row gutter={12}>
|
||||
<Col span={24}>
|
||||
<Form.Input
|
||||
field='name'
|
||||
label={t('名称')}
|
||||
placeholder={t('请输入名称')}
|
||||
style={{ width: '100%' }}
|
||||
rules={!isEdit ? [] : [{ required: true, message: t('请输入名称') }]}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.DatePicker
|
||||
field='expired_time'
|
||||
label={t('过期时间')}
|
||||
type='dateTime'
|
||||
placeholder={t('选择过期时间(可选,留空为永久)')}
|
||||
style={{ width: '100%' }}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<Card className="!rounded-2xl shadow-sm border-0">
|
||||
{/* Header: Quota Settings */}
|
||||
<div className="flex items-center mb-2">
|
||||
<Avatar size="small" color="green" className="mr-2 shadow-md">
|
||||
<IconCreditCard size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className="text-lg font-medium">{t('额度设置')}</Text>
|
||||
<div className="text-xs text-gray-600">{t('设置兑换码的额度和数量')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Row gutter={12}>
|
||||
<Col span={12}>
|
||||
<Form.AutoComplete
|
||||
field='quota'
|
||||
label={t('额度')}
|
||||
placeholder={t('请输入额度')}
|
||||
style={{ width: '100%' }}
|
||||
type='number'
|
||||
rules={[
|
||||
{ required: true, message: t('请输入额度') },
|
||||
{
|
||||
validator: (rule, v) => {
|
||||
const num = parseInt(v, 10);
|
||||
return num > 0
|
||||
? Promise.resolve()
|
||||
: Promise.reject(t('额度必须大于0'));
|
||||
},
|
||||
},
|
||||
]}
|
||||
extraText={renderQuotaWithPrompt(Number(values.quota) || 0)}
|
||||
data={[
|
||||
{ value: 500000, label: '1$' },
|
||||
{ value: 5000000, label: '10$' },
|
||||
{ value: 25000000, label: '50$' },
|
||||
{ value: 50000000, label: '100$' },
|
||||
{ value: 250000000, label: '500$' },
|
||||
{ value: 500000000, label: '1000$' },
|
||||
]}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
{!isEdit && (
|
||||
<Col span={12}>
|
||||
<Form.InputNumber
|
||||
field='count'
|
||||
label={t('生成数量')}
|
||||
min={1}
|
||||
rules={[
|
||||
{ required: true, message: t('请输入生成数量') },
|
||||
{
|
||||
validator: (rule, v) => {
|
||||
const num = parseInt(v, 10);
|
||||
return num > 0
|
||||
? Promise.resolve()
|
||||
: Promise.reject(t('生成数量必须大于0'));
|
||||
},
|
||||
},
|
||||
]}
|
||||
style={{ width: '100%' }}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
</Spin>
|
||||
</SideSheet>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditRedemption;
|
||||
@@ -1,12 +0,0 @@
|
||||
import React from 'react';
|
||||
import RedemptionsTable from '../../components/table/RedemptionsTable';
|
||||
|
||||
const Redemption = () => {
|
||||
return (
|
||||
<div className="mt-[64px] px-2">
|
||||
<RedemptionsTable />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Redemption;
|
||||
31
web/src/pages/Redemption/index.jsx
Normal file
31
web/src/pages/Redemption/index.jsx
Normal file
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import RedemptionsTable from '../../components/table/redemptions';
|
||||
|
||||
const Redemption = () => {
|
||||
return (
|
||||
<div className='mt-[60px] px-2'>
|
||||
<RedemptionsTable />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Redemption;
|
||||
@@ -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,
|
||||
@@ -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;
|
||||
@@ -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 }) => {
|
||||
// 将publishDate转换为ISO字符串保存
|
||||
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;
|
||||
@@ -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 {
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 {
|
||||
@@ -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 {
|
||||
@@ -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) =>
|
||||
@@ -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']}
|
||||
/>
|
||||
@@ -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';
|
||||
@@ -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'}
|
||||
356
web/src/pages/Setting/Operation/SettingsHeaderNavModules.jsx
Normal file
356
web/src/pages/Setting/Operation/SettingsHeaderNavModules.jsx
Normal file
@@ -0,0 +1,356 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useContext } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Form,
|
||||
Row,
|
||||
Switch,
|
||||
Typography,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { API, showError, showSuccess } from '../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { StatusContext } from '../../../context/Status';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export default function SettingsHeaderNavModules(props) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [statusState, statusDispatch] = useContext(StatusContext);
|
||||
|
||||
// 顶栏模块管理状态
|
||||
const [headerNavModules, setHeaderNavModules] = useState({
|
||||
home: true,
|
||||
console: true,
|
||||
pricing: {
|
||||
enabled: true,
|
||||
requireAuth: false, // 默认不需要登录鉴权
|
||||
},
|
||||
docs: true,
|
||||
about: true,
|
||||
});
|
||||
|
||||
// 处理顶栏模块配置变更
|
||||
function handleHeaderNavModuleChange(moduleKey) {
|
||||
return (checked) => {
|
||||
const newModules = { ...headerNavModules };
|
||||
if (moduleKey === 'pricing') {
|
||||
// 对于pricing模块,只更新enabled属性
|
||||
newModules[moduleKey] = {
|
||||
...newModules[moduleKey],
|
||||
enabled: checked,
|
||||
};
|
||||
} else {
|
||||
newModules[moduleKey] = checked;
|
||||
}
|
||||
setHeaderNavModules(newModules);
|
||||
};
|
||||
}
|
||||
|
||||
// 处理模型广场权限控制变更
|
||||
function handlePricingAuthChange(checked) {
|
||||
const newModules = { ...headerNavModules };
|
||||
newModules.pricing = {
|
||||
...newModules.pricing,
|
||||
requireAuth: checked,
|
||||
};
|
||||
setHeaderNavModules(newModules);
|
||||
}
|
||||
|
||||
// 重置顶栏模块为默认配置
|
||||
function resetHeaderNavModules() {
|
||||
const defaultModules = {
|
||||
home: true,
|
||||
console: true,
|
||||
pricing: {
|
||||
enabled: true,
|
||||
requireAuth: false,
|
||||
},
|
||||
docs: true,
|
||||
about: true,
|
||||
};
|
||||
setHeaderNavModules(defaultModules);
|
||||
showSuccess(t('已重置为默认配置'));
|
||||
}
|
||||
|
||||
// 保存配置
|
||||
async function onSubmit() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.put('/api/option/', {
|
||||
key: 'HeaderNavModules',
|
||||
value: JSON.stringify(headerNavModules),
|
||||
});
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess(t('保存成功'));
|
||||
|
||||
// 立即更新StatusContext中的状态
|
||||
statusDispatch({
|
||||
type: 'set',
|
||||
payload: {
|
||||
...statusState.status,
|
||||
HeaderNavModules: JSON.stringify(headerNavModules),
|
||||
},
|
||||
});
|
||||
|
||||
// 刷新父组件状态
|
||||
if (props.refresh) {
|
||||
await props.refresh();
|
||||
}
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('保存失败,请重试'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// 从 props.options 中获取配置
|
||||
if (props.options && props.options.HeaderNavModules) {
|
||||
try {
|
||||
const modules = JSON.parse(props.options.HeaderNavModules);
|
||||
|
||||
// 处理向后兼容性:如果pricing是boolean,转换为对象格式
|
||||
if (typeof modules.pricing === 'boolean') {
|
||||
modules.pricing = {
|
||||
enabled: modules.pricing,
|
||||
requireAuth: false, // 默认不需要登录鉴权
|
||||
};
|
||||
}
|
||||
|
||||
setHeaderNavModules(modules);
|
||||
} catch (error) {
|
||||
// 使用默认配置
|
||||
const defaultModules = {
|
||||
home: true,
|
||||
console: true,
|
||||
pricing: {
|
||||
enabled: true,
|
||||
requireAuth: false,
|
||||
},
|
||||
docs: true,
|
||||
about: true,
|
||||
};
|
||||
setHeaderNavModules(defaultModules);
|
||||
}
|
||||
}
|
||||
}, [props.options]);
|
||||
|
||||
// 模块配置数据
|
||||
const moduleConfigs = [
|
||||
{
|
||||
key: 'home',
|
||||
title: t('首页'),
|
||||
description: t('用户主页,展示系统信息'),
|
||||
},
|
||||
{
|
||||
key: 'console',
|
||||
title: t('控制台'),
|
||||
description: t('用户控制面板,管理账户'),
|
||||
},
|
||||
{
|
||||
key: 'pricing',
|
||||
title: t('模型广场'),
|
||||
description: t('模型定价,需要登录访问'),
|
||||
hasSubConfig: true, // 标识该模块有子配置
|
||||
},
|
||||
{
|
||||
key: 'docs',
|
||||
title: t('文档'),
|
||||
description: t('系统文档和帮助信息'),
|
||||
},
|
||||
{
|
||||
key: 'about',
|
||||
title: t('关于'),
|
||||
description: t('关于系统的详细信息'),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Form.Section
|
||||
text={t('顶栏管理')}
|
||||
extraText={t('控制顶栏模块显示状态,全局生效')}
|
||||
>
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: '24px' }}>
|
||||
{moduleConfigs.map((module) => (
|
||||
<Col key={module.key} xs={24} sm={12} md={6} lg={6} xl={6}>
|
||||
<Card
|
||||
style={{
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--semi-color-border)',
|
||||
transition: 'all 0.2s ease',
|
||||
background: 'var(--semi-color-bg-1)',
|
||||
minHeight: '80px',
|
||||
}}
|
||||
bodyStyle={{ padding: '16px' }}
|
||||
hoverable
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, textAlign: 'left' }}>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: '600',
|
||||
fontSize: '14px',
|
||||
color: 'var(--semi-color-text-0)',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
{module.title}
|
||||
</div>
|
||||
<Text
|
||||
type='secondary'
|
||||
size='small'
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: 'var(--semi-color-text-2)',
|
||||
lineHeight: '1.4',
|
||||
display: 'block',
|
||||
}}
|
||||
>
|
||||
{module.description}
|
||||
</Text>
|
||||
</div>
|
||||
<div style={{ marginLeft: '16px' }}>
|
||||
<Switch
|
||||
checked={
|
||||
module.key === 'pricing'
|
||||
? headerNavModules[module.key]?.enabled
|
||||
: headerNavModules[module.key]
|
||||
}
|
||||
onChange={handleHeaderNavModuleChange(module.key)}
|
||||
size='default'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 为模型广场添加权限控制子开关 */}
|
||||
{module.key === 'pricing' &&
|
||||
(module.key === 'pricing'
|
||||
? headerNavModules[module.key]?.enabled
|
||||
: headerNavModules[module.key]) && (
|
||||
<div
|
||||
style={{
|
||||
borderTop: '1px solid var(--semi-color-border)',
|
||||
marginTop: '12px',
|
||||
paddingTop: '12px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, textAlign: 'left' }}>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: '500',
|
||||
fontSize: '12px',
|
||||
color: 'var(--semi-color-text-1)',
|
||||
marginBottom: '2px',
|
||||
}}
|
||||
>
|
||||
{t('需要登录访问')}
|
||||
</div>
|
||||
<Text
|
||||
type='secondary'
|
||||
size='small'
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: 'var(--semi-color-text-2)',
|
||||
lineHeight: '1.4',
|
||||
display: 'block',
|
||||
}}
|
||||
>
|
||||
{t('开启后未登录用户无法访问模型广场')}
|
||||
</Text>
|
||||
</div>
|
||||
<div style={{ marginLeft: '16px' }}>
|
||||
<Switch
|
||||
checked={
|
||||
headerNavModules.pricing?.requireAuth || false
|
||||
}
|
||||
onChange={handlePricingAuthChange}
|
||||
size='default'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '12px',
|
||||
justifyContent: 'flex-start',
|
||||
alignItems: 'center',
|
||||
paddingTop: '8px',
|
||||
borderTop: '1px solid var(--semi-color-border)',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
size='default'
|
||||
type='tertiary'
|
||||
onClick={resetHeaderNavModules}
|
||||
style={{
|
||||
borderRadius: '6px',
|
||||
fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
{t('重置为默认')}
|
||||
</Button>
|
||||
<Button
|
||||
size='default'
|
||||
type='primary'
|
||||
onClick={onSubmit}
|
||||
loading={loading}
|
||||
style={{
|
||||
borderRadius: '6px',
|
||||
fontWeight: '500',
|
||||
minWidth: '100px',
|
||||
}}
|
||||
>
|
||||
{t('保存设置')}
|
||||
</Button>
|
||||
</div>
|
||||
</Form.Section>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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
|
||||
@@ -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 {
|
||||
422
web/src/pages/Setting/Operation/SettingsSidebarModulesAdmin.jsx
Normal file
422
web/src/pages/Setting/Operation/SettingsSidebarModulesAdmin.jsx
Normal file
@@ -0,0 +1,422 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useContext } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Card,
|
||||
Form,
|
||||
Button,
|
||||
Switch,
|
||||
Row,
|
||||
Col,
|
||||
Typography,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { API, showSuccess, showError } from '../../../helpers';
|
||||
import { StatusContext } from '../../../context/Status';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export default function SettingsSidebarModulesAdmin(props) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [statusState, statusDispatch] = useContext(StatusContext);
|
||||
|
||||
// 左侧边栏模块管理状态(管理员全局控制)
|
||||
const [sidebarModulesAdmin, setSidebarModulesAdmin] = useState({
|
||||
chat: {
|
||||
enabled: true,
|
||||
playground: true,
|
||||
chat: true,
|
||||
},
|
||||
console: {
|
||||
enabled: true,
|
||||
detail: true,
|
||||
token: true,
|
||||
log: true,
|
||||
midjourney: true,
|
||||
task: true,
|
||||
},
|
||||
personal: {
|
||||
enabled: true,
|
||||
topup: true,
|
||||
personal: true,
|
||||
},
|
||||
admin: {
|
||||
enabled: true,
|
||||
channel: true,
|
||||
models: true,
|
||||
redemption: true,
|
||||
user: true,
|
||||
setting: true,
|
||||
},
|
||||
});
|
||||
|
||||
// 处理区域级别开关变更
|
||||
function handleSectionChange(sectionKey) {
|
||||
return (checked) => {
|
||||
const newModules = {
|
||||
...sidebarModulesAdmin,
|
||||
[sectionKey]: {
|
||||
...sidebarModulesAdmin[sectionKey],
|
||||
enabled: checked,
|
||||
},
|
||||
};
|
||||
setSidebarModulesAdmin(newModules);
|
||||
};
|
||||
}
|
||||
|
||||
// 处理功能级别开关变更
|
||||
function handleModuleChange(sectionKey, moduleKey) {
|
||||
return (checked) => {
|
||||
const newModules = {
|
||||
...sidebarModulesAdmin,
|
||||
[sectionKey]: {
|
||||
...sidebarModulesAdmin[sectionKey],
|
||||
[moduleKey]: checked,
|
||||
},
|
||||
};
|
||||
setSidebarModulesAdmin(newModules);
|
||||
};
|
||||
}
|
||||
|
||||
// 重置为默认配置
|
||||
function resetSidebarModules() {
|
||||
const defaultModules = {
|
||||
chat: {
|
||||
enabled: true,
|
||||
playground: true,
|
||||
chat: true,
|
||||
},
|
||||
console: {
|
||||
enabled: true,
|
||||
detail: true,
|
||||
token: true,
|
||||
log: true,
|
||||
midjourney: true,
|
||||
task: true,
|
||||
},
|
||||
personal: {
|
||||
enabled: true,
|
||||
topup: true,
|
||||
personal: true,
|
||||
},
|
||||
admin: {
|
||||
enabled: true,
|
||||
channel: true,
|
||||
models: true,
|
||||
redemption: true,
|
||||
user: true,
|
||||
setting: true,
|
||||
},
|
||||
};
|
||||
setSidebarModulesAdmin(defaultModules);
|
||||
showSuccess(t('已重置为默认配置'));
|
||||
}
|
||||
|
||||
// 保存配置
|
||||
async function onSubmit() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.put('/api/option/', {
|
||||
key: 'SidebarModulesAdmin',
|
||||
value: JSON.stringify(sidebarModulesAdmin),
|
||||
});
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess(t('保存成功'));
|
||||
|
||||
// 立即更新StatusContext中的状态
|
||||
statusDispatch({
|
||||
type: 'set',
|
||||
payload: {
|
||||
...statusState.status,
|
||||
SidebarModulesAdmin: JSON.stringify(sidebarModulesAdmin),
|
||||
},
|
||||
});
|
||||
|
||||
// 刷新父组件状态
|
||||
if (props.refresh) {
|
||||
await props.refresh();
|
||||
}
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('保存失败,请重试'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// 从 props.options 中获取配置
|
||||
if (props.options && props.options.SidebarModulesAdmin) {
|
||||
try {
|
||||
const modules = JSON.parse(props.options.SidebarModulesAdmin);
|
||||
setSidebarModulesAdmin(modules);
|
||||
} catch (error) {
|
||||
// 使用默认配置
|
||||
const defaultModules = {
|
||||
chat: { enabled: true, playground: true, chat: true },
|
||||
console: {
|
||||
enabled: true,
|
||||
detail: true,
|
||||
token: true,
|
||||
log: true,
|
||||
midjourney: true,
|
||||
task: true,
|
||||
},
|
||||
personal: { enabled: true, topup: true, personal: true },
|
||||
admin: {
|
||||
enabled: true,
|
||||
channel: true,
|
||||
models: true,
|
||||
redemption: true,
|
||||
user: true,
|
||||
setting: true,
|
||||
},
|
||||
};
|
||||
setSidebarModulesAdmin(defaultModules);
|
||||
}
|
||||
}
|
||||
}, [props.options]);
|
||||
|
||||
// 区域配置数据
|
||||
const sectionConfigs = [
|
||||
{
|
||||
key: 'chat',
|
||||
title: t('聊天区域'),
|
||||
description: t('操练场和聊天功能'),
|
||||
modules: [
|
||||
{
|
||||
key: 'playground',
|
||||
title: t('操练场'),
|
||||
description: t('AI模型测试环境'),
|
||||
},
|
||||
{ key: 'chat', title: t('聊天'), description: t('聊天会话管理') },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'console',
|
||||
title: t('控制台区域'),
|
||||
description: t('数据管理和日志查看'),
|
||||
modules: [
|
||||
{ key: 'detail', title: t('数据看板'), description: t('系统数据统计') },
|
||||
{ key: 'token', title: t('令牌管理'), description: t('API令牌管理') },
|
||||
{ key: 'log', title: t('使用日志'), description: t('API使用记录') },
|
||||
{
|
||||
key: 'midjourney',
|
||||
title: t('绘图日志'),
|
||||
description: t('绘图任务记录'),
|
||||
},
|
||||
{ key: 'task', title: t('任务日志'), description: t('系统任务记录') },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'personal',
|
||||
title: t('个人中心区域'),
|
||||
description: t('用户个人功能'),
|
||||
modules: [
|
||||
{ key: 'topup', title: t('钱包管理'), description: t('余额充值管理') },
|
||||
{
|
||||
key: 'personal',
|
||||
title: t('个人设置'),
|
||||
description: t('个人信息设置'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'admin',
|
||||
title: t('管理员区域'),
|
||||
description: t('系统管理功能'),
|
||||
modules: [
|
||||
{ key: 'channel', title: t('渠道管理'), description: t('API渠道配置') },
|
||||
{ key: 'models', title: t('模型管理'), description: t('AI模型配置') },
|
||||
{
|
||||
key: 'redemption',
|
||||
title: t('兑换码管理'),
|
||||
description: t('兑换码生成管理'),
|
||||
},
|
||||
{ key: 'user', title: t('用户管理'), description: t('用户账户管理') },
|
||||
{
|
||||
key: 'setting',
|
||||
title: t('系统设置'),
|
||||
description: t('系统参数配置'),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Form.Section
|
||||
text={t('侧边栏管理(全局控制)')}
|
||||
extraText={t(
|
||||
'全局控制侧边栏区域和功能显示,管理员隐藏的功能用户无法启用',
|
||||
)}
|
||||
>
|
||||
{sectionConfigs.map((section) => (
|
||||
<div key={section.key} style={{ marginBottom: '32px' }}>
|
||||
{/* 区域标题和总开关 */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '16px',
|
||||
padding: '12px 16px',
|
||||
backgroundColor: 'var(--semi-color-fill-0)',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--semi-color-border)',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: '600',
|
||||
fontSize: '16px',
|
||||
color: 'var(--semi-color-text-0)',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
{section.title}
|
||||
</div>
|
||||
<Text
|
||||
type='secondary'
|
||||
size='small'
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: 'var(--semi-color-text-2)',
|
||||
lineHeight: '1.4',
|
||||
}}
|
||||
>
|
||||
{section.description}
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={sidebarModulesAdmin[section.key]?.enabled}
|
||||
onChange={handleSectionChange(section.key)}
|
||||
size='default'
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 功能模块网格 */}
|
||||
<Row gutter={[16, 16]}>
|
||||
{section.modules.map((module) => (
|
||||
<Col key={module.key} xs={24} sm={12} md={8} lg={6} xl={6}>
|
||||
<Card
|
||||
bodyStyle={{ padding: '16px' }}
|
||||
hoverable
|
||||
style={{
|
||||
opacity: sidebarModulesAdmin[section.key]?.enabled
|
||||
? 1
|
||||
: 0.5,
|
||||
transition: 'opacity 0.2s',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, textAlign: 'left' }}>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: '600',
|
||||
fontSize: '14px',
|
||||
color: 'var(--semi-color-text-0)',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
{module.title}
|
||||
</div>
|
||||
<Text
|
||||
type='secondary'
|
||||
size='small'
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: 'var(--semi-color-text-2)',
|
||||
lineHeight: '1.4',
|
||||
display: 'block',
|
||||
}}
|
||||
>
|
||||
{module.description}
|
||||
</Text>
|
||||
</div>
|
||||
<div style={{ marginLeft: '16px' }}>
|
||||
<Switch
|
||||
checked={
|
||||
sidebarModulesAdmin[section.key]?.[module.key]
|
||||
}
|
||||
onChange={handleModuleChange(section.key, module.key)}
|
||||
size='default'
|
||||
disabled={!sidebarModulesAdmin[section.key]?.enabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '12px',
|
||||
justifyContent: 'flex-start',
|
||||
alignItems: 'center',
|
||||
paddingTop: '8px',
|
||||
borderTop: '1px solid var(--semi-color-border)',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
size='default'
|
||||
type='tertiary'
|
||||
onClick={resetSidebarModules}
|
||||
style={{
|
||||
borderRadius: '6px',
|
||||
fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
{t('重置为默认')}
|
||||
</Button>
|
||||
<Button
|
||||
size='default'
|
||||
type='primary'
|
||||
onClick={onSubmit}
|
||||
loading={loading}
|
||||
style={{
|
||||
borderRadius: '6px',
|
||||
fontWeight: '500',
|
||||
minWidth: '100px',
|
||||
}}
|
||||
>
|
||||
{t('保存设置')}
|
||||
</Button>
|
||||
</div>
|
||||
</Form.Section>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
453
web/src/pages/Setting/Personal/SettingsSidebarModulesUser.jsx
Normal file
453
web/src/pages/Setting/Personal/SettingsSidebarModulesUser.jsx
Normal file
@@ -0,0 +1,453 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useContext } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
Switch,
|
||||
Typography,
|
||||
Row,
|
||||
Col,
|
||||
Avatar,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { API, showSuccess, showError } from '../../../helpers';
|
||||
import { StatusContext } from '../../../context/Status';
|
||||
import { UserContext } from '../../../context/User';
|
||||
import { useUserPermissions } from '../../../hooks/common/useUserPermissions';
|
||||
import { useSidebar } from '../../../hooks/common/useSidebar';
|
||||
import { Settings } from 'lucide-react';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export default function SettingsSidebarModulesUser() {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [statusState] = useContext(StatusContext);
|
||||
|
||||
// 使用后端权限验证替代前端角色判断
|
||||
const {
|
||||
permissions,
|
||||
loading: permissionsLoading,
|
||||
hasSidebarSettingsPermission,
|
||||
isSidebarSectionAllowed,
|
||||
isSidebarModuleAllowed,
|
||||
} = useUserPermissions();
|
||||
|
||||
// 使用useSidebar钩子获取刷新方法
|
||||
const { refreshUserConfig } = useSidebar();
|
||||
|
||||
// 如果没有边栏设置权限,不显示此组件
|
||||
if (!permissionsLoading && !hasSidebarSettingsPermission()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 权限加载中,显示加载状态
|
||||
if (permissionsLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 根据用户权限生成默认配置
|
||||
const generateDefaultConfig = () => {
|
||||
const defaultConfig = {};
|
||||
|
||||
// 聊天区域 - 所有用户都可以访问
|
||||
if (isSidebarSectionAllowed('chat')) {
|
||||
defaultConfig.chat = {
|
||||
enabled: true,
|
||||
playground: isSidebarModuleAllowed('chat', 'playground'),
|
||||
chat: isSidebarModuleAllowed('chat', 'chat'),
|
||||
};
|
||||
}
|
||||
|
||||
// 控制台区域 - 所有用户都可以访问
|
||||
if (isSidebarSectionAllowed('console')) {
|
||||
defaultConfig.console = {
|
||||
enabled: true,
|
||||
detail: isSidebarModuleAllowed('console', 'detail'),
|
||||
token: isSidebarModuleAllowed('console', 'token'),
|
||||
log: isSidebarModuleAllowed('console', 'log'),
|
||||
midjourney: isSidebarModuleAllowed('console', 'midjourney'),
|
||||
task: isSidebarModuleAllowed('console', 'task'),
|
||||
};
|
||||
}
|
||||
|
||||
// 个人中心区域 - 所有用户都可以访问
|
||||
if (isSidebarSectionAllowed('personal')) {
|
||||
defaultConfig.personal = {
|
||||
enabled: true,
|
||||
topup: isSidebarModuleAllowed('personal', 'topup'),
|
||||
personal: isSidebarModuleAllowed('personal', 'personal'),
|
||||
};
|
||||
}
|
||||
|
||||
// 管理员区域 - 只有管理员可以访问
|
||||
if (isSidebarSectionAllowed('admin')) {
|
||||
defaultConfig.admin = {
|
||||
enabled: true,
|
||||
channel: isSidebarModuleAllowed('admin', 'channel'),
|
||||
models: isSidebarModuleAllowed('admin', 'models'),
|
||||
redemption: isSidebarModuleAllowed('admin', 'redemption'),
|
||||
user: isSidebarModuleAllowed('admin', 'user'),
|
||||
setting: isSidebarModuleAllowed('admin', 'setting'),
|
||||
};
|
||||
}
|
||||
|
||||
return defaultConfig;
|
||||
};
|
||||
|
||||
// 用户个人左侧边栏模块设置
|
||||
const [sidebarModulesUser, setSidebarModulesUser] = useState({});
|
||||
|
||||
// 管理员全局配置
|
||||
const [adminConfig, setAdminConfig] = useState(null);
|
||||
|
||||
// 处理区域级别开关变更
|
||||
function handleSectionChange(sectionKey) {
|
||||
return (checked) => {
|
||||
const newModules = {
|
||||
...sidebarModulesUser,
|
||||
[sectionKey]: {
|
||||
...sidebarModulesUser[sectionKey],
|
||||
enabled: checked,
|
||||
},
|
||||
};
|
||||
setSidebarModulesUser(newModules);
|
||||
console.log('用户边栏区域配置变更:', sectionKey, checked, newModules);
|
||||
};
|
||||
}
|
||||
|
||||
// 处理功能级别开关变更
|
||||
function handleModuleChange(sectionKey, moduleKey) {
|
||||
return (checked) => {
|
||||
const newModules = {
|
||||
...sidebarModulesUser,
|
||||
[sectionKey]: {
|
||||
...sidebarModulesUser[sectionKey],
|
||||
[moduleKey]: checked,
|
||||
},
|
||||
};
|
||||
setSidebarModulesUser(newModules);
|
||||
console.log(
|
||||
'用户边栏功能配置变更:',
|
||||
sectionKey,
|
||||
moduleKey,
|
||||
checked,
|
||||
newModules,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
// 重置为默认配置(基于权限过滤)
|
||||
function resetSidebarModules() {
|
||||
const defaultConfig = generateDefaultConfig();
|
||||
setSidebarModulesUser(defaultConfig);
|
||||
showSuccess(t('已重置为默认配置'));
|
||||
console.log('用户边栏配置重置为默认:', defaultConfig);
|
||||
}
|
||||
|
||||
// 保存配置
|
||||
async function onSubmit() {
|
||||
setLoading(true);
|
||||
try {
|
||||
console.log('保存用户边栏配置:', sidebarModulesUser);
|
||||
const res = await API.put('/api/user/self', {
|
||||
sidebar_modules: JSON.stringify(sidebarModulesUser),
|
||||
});
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess(t('保存成功'));
|
||||
console.log('用户边栏配置保存成功');
|
||||
|
||||
// 刷新useSidebar钩子中的用户配置,实现实时更新
|
||||
await refreshUserConfig();
|
||||
console.log('用户边栏配置已刷新,边栏将立即更新');
|
||||
} else {
|
||||
showError(message);
|
||||
console.error('用户边栏配置保存失败:', message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('保存失败,请重试'));
|
||||
console.error('用户边栏配置保存异常:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// 统一的配置加载逻辑
|
||||
useEffect(() => {
|
||||
const loadConfigs = async () => {
|
||||
try {
|
||||
// 获取管理员全局配置
|
||||
if (statusState?.status?.SidebarModulesAdmin) {
|
||||
const adminConf = JSON.parse(statusState.status.SidebarModulesAdmin);
|
||||
setAdminConfig(adminConf);
|
||||
console.log('加载管理员边栏配置:', adminConf);
|
||||
}
|
||||
|
||||
// 获取用户个人配置
|
||||
const userRes = await API.get('/api/user/self');
|
||||
if (userRes.data.success && userRes.data.data.sidebar_modules) {
|
||||
let userConf;
|
||||
// 检查sidebar_modules是字符串还是对象
|
||||
if (typeof userRes.data.data.sidebar_modules === 'string') {
|
||||
userConf = JSON.parse(userRes.data.data.sidebar_modules);
|
||||
} else {
|
||||
userConf = userRes.data.data.sidebar_modules;
|
||||
}
|
||||
console.log('从API加载的用户配置:', userConf);
|
||||
|
||||
// 确保用户配置也经过权限过滤
|
||||
const filteredUserConf = {};
|
||||
Object.keys(userConf).forEach((sectionKey) => {
|
||||
if (isSidebarSectionAllowed(sectionKey)) {
|
||||
filteredUserConf[sectionKey] = { ...userConf[sectionKey] };
|
||||
// 过滤不允许的模块
|
||||
Object.keys(userConf[sectionKey]).forEach((moduleKey) => {
|
||||
if (
|
||||
moduleKey !== 'enabled' &&
|
||||
!isSidebarModuleAllowed(sectionKey, moduleKey)
|
||||
) {
|
||||
delete filteredUserConf[sectionKey][moduleKey];
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
setSidebarModulesUser(filteredUserConf);
|
||||
console.log('权限过滤后的用户配置:', filteredUserConf);
|
||||
} else {
|
||||
// 如果用户没有配置,使用权限过滤后的默认配置
|
||||
const defaultConfig = generateDefaultConfig();
|
||||
setSidebarModulesUser(defaultConfig);
|
||||
console.log('用户无配置,使用默认配置:', defaultConfig);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载边栏配置失败:', error);
|
||||
// 出错时也使用默认配置
|
||||
const defaultConfig = generateDefaultConfig();
|
||||
setSidebarModulesUser(defaultConfig);
|
||||
}
|
||||
};
|
||||
|
||||
// 只有权限加载完成且有边栏设置权限时才加载配置
|
||||
if (!permissionsLoading && hasSidebarSettingsPermission()) {
|
||||
loadConfigs();
|
||||
}
|
||||
}, [
|
||||
statusState,
|
||||
permissionsLoading,
|
||||
hasSidebarSettingsPermission,
|
||||
isSidebarSectionAllowed,
|
||||
isSidebarModuleAllowed,
|
||||
]);
|
||||
|
||||
// 检查功能是否被管理员允许
|
||||
const isAllowedByAdmin = (sectionKey, moduleKey = null) => {
|
||||
if (!adminConfig) return true;
|
||||
|
||||
if (moduleKey) {
|
||||
return (
|
||||
adminConfig[sectionKey]?.enabled && adminConfig[sectionKey]?.[moduleKey]
|
||||
);
|
||||
} else {
|
||||
return adminConfig[sectionKey]?.enabled;
|
||||
}
|
||||
};
|
||||
|
||||
// 区域配置数据(根据后端权限过滤)
|
||||
const sectionConfigs = [
|
||||
{
|
||||
key: 'chat',
|
||||
title: t('聊天区域'),
|
||||
description: t('操练场和聊天功能'),
|
||||
modules: [
|
||||
{
|
||||
key: 'playground',
|
||||
title: t('操练场'),
|
||||
description: t('AI模型测试环境'),
|
||||
},
|
||||
{ key: 'chat', title: t('聊天'), description: t('聊天会话管理') },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'console',
|
||||
title: t('控制台区域'),
|
||||
description: t('数据管理和日志查看'),
|
||||
modules: [
|
||||
{ key: 'detail', title: t('数据看板'), description: t('系统数据统计') },
|
||||
{ key: 'token', title: t('令牌管理'), description: t('API令牌管理') },
|
||||
{ key: 'log', title: t('使用日志'), description: t('API使用记录') },
|
||||
{
|
||||
key: 'midjourney',
|
||||
title: t('绘图日志'),
|
||||
description: t('绘图任务记录'),
|
||||
},
|
||||
{ key: 'task', title: t('任务日志'), description: t('系统任务记录') },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'personal',
|
||||
title: t('个人中心区域'),
|
||||
description: t('用户个人功能'),
|
||||
modules: [
|
||||
{ key: 'topup', title: t('钱包管理'), description: t('余额充值管理') },
|
||||
{
|
||||
key: 'personal',
|
||||
title: t('个人设置'),
|
||||
description: t('个人信息设置'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'admin',
|
||||
title: t('管理员区域'),
|
||||
description: t('系统管理功能'),
|
||||
modules: [
|
||||
{ key: 'channel', title: t('渠道管理'), description: t('API渠道配置') },
|
||||
{ key: 'models', title: t('模型管理'), description: t('AI模型配置') },
|
||||
{
|
||||
key: 'redemption',
|
||||
title: t('兑换码管理'),
|
||||
description: t('兑换码生成管理'),
|
||||
},
|
||||
{ key: 'user', title: t('用户管理'), description: t('用户账户管理') },
|
||||
{
|
||||
key: 'setting',
|
||||
title: t('系统设置'),
|
||||
description: t('系统参数配置'),
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
.filter((section) => {
|
||||
// 使用后端权限验证替代前端角色判断
|
||||
return isSidebarSectionAllowed(section.key);
|
||||
})
|
||||
.map((section) => ({
|
||||
...section,
|
||||
modules: section.modules.filter((module) =>
|
||||
isSidebarModuleAllowed(section.key, module.key),
|
||||
),
|
||||
}))
|
||||
.filter(
|
||||
(section) =>
|
||||
// 过滤掉没有可用模块的区域
|
||||
section.modules.length > 0 && isAllowedByAdmin(section.key),
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className='!rounded-2xl shadow-sm border-0'>
|
||||
{/* 卡片头部 */}
|
||||
<div className='flex items-center mb-4'>
|
||||
<Avatar size='small' color='purple' className='mr-3 shadow-md'>
|
||||
<Settings size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Typography.Text className='text-lg font-medium'>
|
||||
{t('左侧边栏个人设置')}
|
||||
</Typography.Text>
|
||||
<div className='text-xs text-gray-600'>
|
||||
{t('个性化设置左侧边栏的显示内容')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mb-4'>
|
||||
<Text type='secondary' className='text-sm text-gray-600'>
|
||||
{t('您可以个性化设置侧边栏的要显示功能')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{sectionConfigs.map((section) => (
|
||||
<div key={section.key} className='mb-6'>
|
||||
{/* 区域标题和总开关 */}
|
||||
<div className='flex justify-between items-center mb-4 p-4 bg-gray-50 rounded-xl border border-gray-200'>
|
||||
<div>
|
||||
<div className='font-semibold text-base text-gray-900 mb-1'>
|
||||
{section.title}
|
||||
</div>
|
||||
<Text className='text-xs text-gray-600'>
|
||||
{section.description}
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={sidebarModulesUser[section.key]?.enabled}
|
||||
onChange={handleSectionChange(section.key)}
|
||||
size='default'
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 功能模块网格 */}
|
||||
<Row gutter={[12, 12]}>
|
||||
{section.modules.map((module) => (
|
||||
<Col key={module.key} xs={24} sm={12} md={8} lg={6} xl={6}>
|
||||
<Card
|
||||
className={`!rounded-xl border border-gray-200 hover:border-blue-300 transition-all duration-200 ${
|
||||
sidebarModulesUser[section.key]?.enabled ? '' : 'opacity-50'
|
||||
}`}
|
||||
bodyStyle={{ padding: '16px' }}
|
||||
hoverable
|
||||
>
|
||||
<div className='flex justify-between items-center h-full'>
|
||||
<div className='flex-1 text-left'>
|
||||
<div className='font-semibold text-sm text-gray-900 mb-1'>
|
||||
{module.title}
|
||||
</div>
|
||||
<Text className='text-xs text-gray-600 leading-relaxed block'>
|
||||
{module.description}
|
||||
</Text>
|
||||
</div>
|
||||
<div className='ml-4'>
|
||||
<Switch
|
||||
checked={sidebarModulesUser[section.key]?.[module.key]}
|
||||
onChange={handleModuleChange(section.key, module.key)}
|
||||
size='default'
|
||||
disabled={!sidebarModulesUser[section.key]?.enabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 底部按钮 */}
|
||||
<div className='flex justify-end gap-3 mt-6 pt-4 border-t border-gray-200'>
|
||||
<Button
|
||||
type='tertiary'
|
||||
onClick={resetSidebarModules}
|
||||
className='!rounded-lg'
|
||||
>
|
||||
{t('重置为默认')}
|
||||
</Button>
|
||||
<Button
|
||||
type='primary'
|
||||
onClick={onSubmit}
|
||||
loading={loading}
|
||||
className='!rounded-lg'
|
||||
>
|
||||
{t('保存设置')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}}
|
||||
@@ -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 }))
|
||||
}
|
||||
@@ -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) {
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -1,578 +0,0 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import {
|
||||
Card,
|
||||
Form,
|
||||
Button,
|
||||
Typography,
|
||||
Modal,
|
||||
Banner,
|
||||
Layout,
|
||||
Tag,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { API, showError, showNotice } from '../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
IconHelpCircle,
|
||||
IconInfoCircle,
|
||||
IconUser,
|
||||
IconLock,
|
||||
IconSetting,
|
||||
IconCheckCircleStroked,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { Shield, Rocket, FlaskConical, Database, Layers } from 'lucide-react';
|
||||
|
||||
const Setup = () => {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selfUseModeInfoVisible, setUsageModeInfoVisible] = useState(false);
|
||||
const [setupStatus, setSetupStatus] = useState({
|
||||
status: false,
|
||||
root_init: false,
|
||||
database_type: '',
|
||||
});
|
||||
const { Text, Title } = Typography;
|
||||
const formRef = useRef(null);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
usageMode: 'external',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchSetupStatus();
|
||||
}, []);
|
||||
|
||||
const fetchSetupStatus = async () => {
|
||||
try {
|
||||
const res = await API.get('/api/setup');
|
||||
const { success, data } = res.data;
|
||||
if (success) {
|
||||
setSetupStatus(data);
|
||||
|
||||
// If setup is already completed, redirect to home
|
||||
if (data.status) {
|
||||
window.location.href = '/';
|
||||
}
|
||||
} else {
|
||||
showError(t('获取初始化状态失败'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch setup status:', error);
|
||||
showError(t('获取初始化状态失败'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleUsageModeChange = (val) => {
|
||||
setFormData({ ...formData, usageMode: val });
|
||||
};
|
||||
|
||||
const onSubmit = () => {
|
||||
if (!formRef.current) {
|
||||
console.error('Form reference is null');
|
||||
showError(t('表单引用错误,请刷新页面重试'));
|
||||
return;
|
||||
}
|
||||
|
||||
const values = formRef.current.getValues();
|
||||
console.log('Form values:', values);
|
||||
|
||||
// For root_init=false, validate admin username and password
|
||||
if (!setupStatus.root_init) {
|
||||
if (!values.username || !values.username.trim()) {
|
||||
showError(t('请输入管理员用户名'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!values.password || values.password.length < 8) {
|
||||
showError(t('密码长度至少为8个字符'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (values.password !== values.confirmPassword) {
|
||||
showError(t('两次输入的密码不一致'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare submission data
|
||||
const formValues = { ...values };
|
||||
formValues.SelfUseModeEnabled = values.usageMode === 'self';
|
||||
formValues.DemoSiteEnabled = values.usageMode === 'demo';
|
||||
|
||||
// Remove usageMode as it's not needed by the backend
|
||||
delete formValues.usageMode;
|
||||
|
||||
console.log('Submitting data to backend:', formValues);
|
||||
setLoading(true);
|
||||
|
||||
// Submit to backend
|
||||
API.post('/api/setup', formValues)
|
||||
.then((res) => {
|
||||
const { success, message } = res.data;
|
||||
console.log('API response:', res.data);
|
||||
|
||||
if (success) {
|
||||
showNotice(t('系统初始化成功,正在跳转...'));
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1500);
|
||||
} else {
|
||||
showError(message || t('初始化失败,请重试'));
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('API error:', error);
|
||||
showError(t('系统初始化失败,请重试'));
|
||||
setLoading(false);
|
||||
})
|
||||
.finally(() => {
|
||||
// setLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-gray-50 mt-[64px]">
|
||||
<Layout>
|
||||
<Layout.Content>
|
||||
<div className="flex justify-center px-4 py-8">
|
||||
<div className="w-full max-w-3xl">
|
||||
{/* 主卡片容器 */}
|
||||
<Card className="!rounded-2xl shadow-lg border-0">
|
||||
{/* 顶部装饰性区域 */}
|
||||
<Card
|
||||
className="!rounded-2xl !border-0 !shadow-2xl overflow-hidden mb-6"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #f97316 0%, #f59e0b 25%, #f43f5e 50%, #ec4899 75%, #e879f9 100%)',
|
||||
position: 'relative'
|
||||
}}
|
||||
bodyStyle={{ padding: 0 }}
|
||||
>
|
||||
{/* 装饰性背景元素 */}
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="absolute -top-10 -right-10 w-40 h-40 bg-white opacity-10 rounded-full"></div>
|
||||
<div className="absolute -bottom-16 -left-16 w-48 h-48 bg-white opacity-5 rounded-full"></div>
|
||||
<div className="absolute top-1/2 right-1/4 w-24 h-24 bg-yellow-400 opacity-10 rounded-full"></div>
|
||||
</div>
|
||||
|
||||
<div className="relative py-5 px-6 flex items-center" style={{ color: 'white' }}>
|
||||
<div className="w-14 h-14 rounded-full bg-white bg-opacity-20 flex items-center justify-center mr-5 shadow-lg flex-shrink-0">
|
||||
<IconSetting size="large" style={{ color: 'white' }} />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<Title heading={3} style={{ color: 'white', marginBottom: '2px' }}>
|
||||
{t('系统初始化')}
|
||||
</Title>
|
||||
<Text style={{ color: 'rgba(255, 255, 255, 0.9)', fontSize: '15px' }}>
|
||||
{t('欢迎使用,请完成以下设置以开始使用系统')}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
{/* 数据库警告 */}
|
||||
{setupStatus.database_type === 'sqlite' && (
|
||||
<div className="px-4">
|
||||
<Banner
|
||||
type='warning'
|
||||
icon={
|
||||
<div className="w-12 h-12 rounded-lg bg-orange-50 flex items-center justify-center">
|
||||
<Database size={22} className="text-orange-500" />
|
||||
</div>
|
||||
}
|
||||
closeIcon={null}
|
||||
title={
|
||||
<div className="flex items-center">
|
||||
<span className="font-medium">{t('数据库警告')}</span>
|
||||
<Tag color='orange' shape='circle' className="ml-2">
|
||||
SQLite
|
||||
</Tag>
|
||||
</div>
|
||||
}
|
||||
description={
|
||||
<div>
|
||||
<p>
|
||||
{t(
|
||||
'您正在使用 SQLite 数据库。如果您在容器环境中运行,请确保已正确设置数据库文件的持久化映射,否则容器重启后所有数据将丢失!',
|
||||
)}
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
<strong>{t(
|
||||
'建议在生产环境中使用 MySQL 或 PostgreSQL 数据库,或确保 SQLite 数据库文件已映射到宿主机的持久化存储。',
|
||||
)}</strong>
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
className="!rounded-xl mb-6"
|
||||
fullMode={false}
|
||||
bordered
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* MySQL数据库提示 */}
|
||||
{setupStatus.database_type === 'mysql' && (
|
||||
<div className="px-4">
|
||||
<Banner
|
||||
type='info'
|
||||
icon={
|
||||
<div className="w-12 h-12 rounded-lg bg-blue-50 flex items-center justify-center">
|
||||
<Database size={22} className="text-blue-500" />
|
||||
</div>
|
||||
}
|
||||
closeIcon={null}
|
||||
title={
|
||||
<div className="flex items-center">
|
||||
<span className="font-medium">{t('数据库信息')}</span>
|
||||
<Tag color='blue' shape='circle' className="ml-2">
|
||||
MySQL
|
||||
</Tag>
|
||||
</div>
|
||||
}
|
||||
description={
|
||||
<div>
|
||||
<p>
|
||||
{t(
|
||||
'您正在使用 MySQL 数据库。MySQL 是一个可靠的关系型数据库管理系统,适合生产环境使用。',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
className="!rounded-xl mb-6"
|
||||
fullMode={false}
|
||||
bordered
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* PostgreSQL数据库提示 */}
|
||||
{setupStatus.database_type === 'postgres' && (
|
||||
<div className="px-4">
|
||||
<Banner
|
||||
type='success'
|
||||
icon={
|
||||
<div className="w-12 h-12 rounded-lg bg-green-50 flex items-center justify-center">
|
||||
<Database size={22} className="text-green-500" />
|
||||
</div>
|
||||
}
|
||||
closeIcon={null}
|
||||
title={
|
||||
<div className="flex items-center">
|
||||
<span className="font-medium">{t('数据库信息')}</span>
|
||||
<Tag color='green' shape='circle' className="ml-2">
|
||||
PostgreSQL
|
||||
</Tag>
|
||||
</div>
|
||||
}
|
||||
description={
|
||||
<div>
|
||||
<p>
|
||||
{t(
|
||||
'您正在使用 PostgreSQL 数据库。PostgreSQL 是一个功能强大的开源关系型数据库系统,提供了出色的可靠性和数据完整性,适合生产环境使用。',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
className="!rounded-xl mb-6"
|
||||
fullMode={false}
|
||||
bordered
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 主内容区域 */}
|
||||
<Form
|
||||
getFormApi={(formApi) => {
|
||||
formRef.current = formApi;
|
||||
console.log('Form API set:', formApi);
|
||||
}}
|
||||
initValues={formData}
|
||||
>
|
||||
{/* 管理员账号设置 */}
|
||||
<Card className="!rounded-2xl shadow-sm border-0 mb-6">
|
||||
<div className="flex items-center mb-4 p-6 rounded-xl" style={{
|
||||
background: 'linear-gradient(135deg, #1e3a8a 0%, #2563eb 50%, #3b82f6 100%)',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="absolute -top-10 -right-10 w-40 h-40 bg-white opacity-5 rounded-full"></div>
|
||||
<div className="absolute -bottom-8 -left-8 w-24 h-24 bg-white opacity-10 rounded-full"></div>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center mr-4 relative">
|
||||
<IconUser size="large" style={{ color: '#ffffff' }} />
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Text style={{ color: '#ffffff' }} className="text-lg font-medium">{t('管理员账号')}</Text>
|
||||
<div style={{ color: '#ffffff' }} className="text-sm opacity-80">{t('设置系统管理员的登录信息')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{setupStatus.root_init ? (
|
||||
<>
|
||||
<Banner
|
||||
type='info'
|
||||
icon={
|
||||
<div className="w-10 h-10 rounded-full bg-blue-50 flex items-center justify-center">
|
||||
<IconCheckCircleStroked size="large" className="text-blue-500" />
|
||||
</div>
|
||||
}
|
||||
closeIcon={null}
|
||||
description={
|
||||
<div className="flex items-center">
|
||||
<span>{t('管理员账号已经初始化过,请继续设置其他参数')}</span>
|
||||
</div>
|
||||
}
|
||||
className="!rounded-lg"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Form.Input
|
||||
field='username'
|
||||
label={t('用户名')}
|
||||
placeholder={t('请输入管理员用户名')}
|
||||
prefix={<IconUser />}
|
||||
showClear
|
||||
size='large'
|
||||
className="mb-4 !rounded-lg"
|
||||
noLabel={false}
|
||||
validateStatus="default"
|
||||
onChange={(value) =>
|
||||
setFormData({ ...formData, username: value })
|
||||
}
|
||||
/>
|
||||
<Form.Input
|
||||
field='password'
|
||||
label={t('密码')}
|
||||
placeholder={t('请输入管理员密码')}
|
||||
type='password'
|
||||
prefix={<IconLock />}
|
||||
showClear
|
||||
size='large'
|
||||
className="mb-4 !rounded-lg"
|
||||
noLabel={false}
|
||||
mode="password"
|
||||
validateStatus="default"
|
||||
onChange={(value) =>
|
||||
setFormData({ ...formData, password: value })
|
||||
}
|
||||
/>
|
||||
<Form.Input
|
||||
field='confirmPassword'
|
||||
label={t('确认密码')}
|
||||
placeholder={t('请确认管理员密码')}
|
||||
type='password'
|
||||
prefix={<IconLock />}
|
||||
showClear
|
||||
size='large'
|
||||
className="!rounded-lg"
|
||||
noLabel={false}
|
||||
mode="password"
|
||||
validateStatus="default"
|
||||
onChange={(value) =>
|
||||
setFormData({ ...formData, confirmPassword: value })
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 使用模式 */}
|
||||
<Card className="!rounded-2xl shadow-sm border-0 mb-6">
|
||||
<div className="flex items-center mb-4 p-6 rounded-xl" style={{
|
||||
background: 'linear-gradient(135deg, #4c1d95 0%, #6d28d9 50%, #7c3aed 100%)',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="absolute -top-10 -right-10 w-40 h-40 bg-white opacity-5 rounded-full"></div>
|
||||
<div className="absolute -bottom-8 -left-8 w-24 h-24 bg-white opacity-10 rounded-full"></div>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center mr-4 relative">
|
||||
<Layers size={22} style={{ color: '#ffffff' }} />
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="flex items-center">
|
||||
<Text style={{ color: '#ffffff' }} className="text-lg font-medium">{t('使用模式')}</Text>
|
||||
<Button
|
||||
theme='borderless'
|
||||
type='tertiary'
|
||||
icon={<IconHelpCircle size="small" style={{ color: '#ffffff' }} />}
|
||||
size='small'
|
||||
onClick={() => setUsageModeInfoVisible(true)}
|
||||
className="!rounded-full"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ color: '#ffffff' }} className="text-sm opacity-80">{t('选择适合您使用场景的模式')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Form.RadioGroup
|
||||
field='usageMode'
|
||||
noLabel={true}
|
||||
initValue='external'
|
||||
onChange={handleUsageModeChange}
|
||||
type='pureCard'
|
||||
className="[&_.semi-radio-addon-buttonRadio-wrapper]:!rounded-xl"
|
||||
validateStatus="default"
|
||||
>
|
||||
<div className="space-y-3 mt-2">
|
||||
<Form.Radio
|
||||
value='external'
|
||||
className="!p-4 !rounded-xl hover:!bg-blue-50 transition-colors w-full"
|
||||
extra={
|
||||
<div className="flex items-start">
|
||||
<div className="w-10 h-10 rounded-full bg-blue-50 flex items-center justify-center mr-3 flex-shrink-0">
|
||||
<Rocket size={20} className="text-blue-500" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-gray-900 mb-1">{t('对外运营模式')}</div>
|
||||
<div className="text-sm text-gray-500">{t('适用于为多个用户提供服务的场景')}</div>
|
||||
<Tag color='blue' shape='circle' className="mt-2">
|
||||
{t('默认模式')}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<Form.Radio
|
||||
value='self'
|
||||
className="!p-4 !rounded-xl hover:!bg-green-50 transition-colors w-full"
|
||||
extra={
|
||||
<div className="flex items-start">
|
||||
<div className="w-10 h-10 rounded-full bg-green-50 flex items-center justify-center mr-3 flex-shrink-0">
|
||||
<Shield size={20} className="text-green-500" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-gray-900 mb-1">{t('自用模式')}</div>
|
||||
<div className="text-sm text-gray-500">{t('适用于个人使用的场景,不需要设置模型价格')}</div>
|
||||
<Tag color='green' shape='circle' className="mt-2">
|
||||
{t('无需计费')}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<Form.Radio
|
||||
value='demo'
|
||||
className="!p-4 !rounded-xl hover:!bg-purple-50 transition-colors w-full"
|
||||
extra={
|
||||
<div className="flex items-start">
|
||||
<div className="w-10 h-10 rounded-full bg-purple-50 flex items-center justify-center mr-3 flex-shrink-0">
|
||||
<FlaskConical size={20} className="text-purple-500" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-gray-900 mb-1">{t('演示站点模式')}</div>
|
||||
<div className="text-sm text-gray-500">{t('适用于展示系统功能的场景,提供基础功能演示')}</div>
|
||||
<Tag color='purple' shape='circle' className="mt-2">
|
||||
{t('演示体验')}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Form.RadioGroup>
|
||||
</Card>
|
||||
</Form>
|
||||
|
||||
<div className="flex justify-center mt-6">
|
||||
<Button
|
||||
type='primary'
|
||||
onClick={onSubmit}
|
||||
loading={loading}
|
||||
size='large'
|
||||
className="!rounded-lg !bg-gradient-to-r !from-orange-500 !to-pink-500 hover:!from-orange-600 hover:!to-pink-600 !border-0 !px-8"
|
||||
icon={<IconCheckCircleStroked />}
|
||||
>
|
||||
{t('初始化系统')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
|
||||
{/* 使用模式说明模态框 */}
|
||||
<Modal
|
||||
title={
|
||||
<div className="flex items-center">
|
||||
<IconInfoCircle className="mr-2 text-blue-500" />
|
||||
{t('使用模式说明')}
|
||||
</div>
|
||||
}
|
||||
visible={selfUseModeInfoVisible}
|
||||
onOk={() => setUsageModeInfoVisible(false)}
|
||||
onCancel={() => setUsageModeInfoVisible(false)}
|
||||
closeOnEsc={true}
|
||||
okText={t('我已了解')}
|
||||
cancelText={null}
|
||||
centered={true}
|
||||
size='medium'
|
||||
className="[&_.semi-modal-body]:!p-6"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{/* 对外运营模式 */}
|
||||
<div className="bg-blue-50 rounded-xl p-4">
|
||||
<div className="flex items-start">
|
||||
<div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center mr-3 flex-shrink-0">
|
||||
<Rocket size={20} className="text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<Title heading={6} className="text-blue-900 mb-2">{t('对外运营模式')}</Title>
|
||||
<div className="space-y-2 text-sm text-gray-700">
|
||||
<p>{t('默认模式,适用于为多个用户提供服务的场景。')}</p>
|
||||
<p>{t('此模式下,系统将计算每次调用的用量,您需要对每个模型都设置价格,如果没有设置价格,用户将无法使用该模型。')}</p>
|
||||
<div className="mt-3">
|
||||
<Tag color='blue' shape='circle' className="mr-2">{t('计费模式')}</Tag>
|
||||
<Tag color='blue' shape='circle'>{t('多用户支持')}</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 自用模式 */}
|
||||
<div className="bg-green-50 rounded-xl p-4">
|
||||
<div className="flex items-start">
|
||||
<div className="w-10 h-10 rounded-full bg-green-100 flex items-center justify-center mr-3 flex-shrink-0">
|
||||
<Shield size={20} className="text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<Title heading={6} className="text-green-900 mb-2">{t('自用模式')}</Title>
|
||||
<div className="space-y-2 text-sm text-gray-700">
|
||||
<p>{t('适用于个人使用的场景。')}</p>
|
||||
<p>{t('不需要设置模型价格,系统将弱化用量计算,您可专注于使用模型。')}</p>
|
||||
<div className="mt-3">
|
||||
<Tag color='green' shape='circle' className="mr-2">{t('无需计费')}</Tag>
|
||||
<Tag color='green' shape='circle'>{t('个人使用')}</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 演示站点模式 */}
|
||||
<div className="bg-purple-50 rounded-xl p-4">
|
||||
<div className="flex items-start">
|
||||
<div className="w-10 h-10 rounded-full bg-purple-100 flex items-center justify-center mr-3 flex-shrink-0">
|
||||
<FlaskConical size={20} className="text-purple-600" />
|
||||
</div>
|
||||
<div>
|
||||
<Title heading={6} className="text-purple-900 mb-2">{t('演示站点模式')}</Title>
|
||||
<div className="space-y-2 text-sm text-gray-700">
|
||||
<p>{t('适用于展示系统功能的场景。')}</p>
|
||||
<p>{t('提供基础功能演示,方便用户了解系统特性。')}</p>
|
||||
<div className="mt-3">
|
||||
<Tag color='purple' shape='circle' className="mr-2">{t('功能演示')}</Tag>
|
||||
<Tag color='purple' shape='circle'>{t('体验试用')}</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Setup;
|
||||
31
web/src/pages/Setup/index.jsx
Normal file
31
web/src/pages/Setup/index.jsx
Normal file
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { SetupWizard } from '../../components/setup';
|
||||
|
||||
/**
|
||||
* Setup页面组件
|
||||
* 使用新的组件化结构进行系统初始化
|
||||
*/
|
||||
const Setup = () => {
|
||||
return <SetupWizard />;
|
||||
};
|
||||
|
||||
export default Setup;
|
||||
@@ -1,10 +0,0 @@
|
||||
import React from 'react';
|
||||
import TaskLogsTable from '../../components/table/TaskLogsTable.js';
|
||||
|
||||
const Task = () => (
|
||||
<div className="mt-[64px] px-2">
|
||||
<TaskLogsTable />
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Task;
|
||||
29
web/src/pages/Task/index.jsx
Normal file
29
web/src/pages/Task/index.jsx
Normal file
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import TaskLogsTable from '../../components/table/task-logs';
|
||||
|
||||
const Task = () => (
|
||||
<div className='mt-[60px] px-2'>
|
||||
<TaskLogsTable />
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Task;
|
||||
@@ -1,525 +0,0 @@
|
||||
import React, { useEffect, useState, useContext, useRef } from 'react';
|
||||
import {
|
||||
API,
|
||||
showError,
|
||||
showSuccess,
|
||||
timestamp2string,
|
||||
renderGroupOption,
|
||||
renderQuotaWithPrompt,
|
||||
getModelCategories,
|
||||
} from '../../helpers';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile.js';
|
||||
import {
|
||||
Button,
|
||||
SideSheet,
|
||||
Space,
|
||||
Spin,
|
||||
Typography,
|
||||
Card,
|
||||
Tag,
|
||||
Avatar,
|
||||
Form,
|
||||
Col,
|
||||
Row,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconCreditCard,
|
||||
IconLink,
|
||||
IconSave,
|
||||
IconClose,
|
||||
IconKey,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { StatusContext } from '../../context/Status';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
const EditToken = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const [statusState, statusDispatch] = useContext(StatusContext);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
const formApiRef = useRef(null);
|
||||
const [models, setModels] = useState([]);
|
||||
const [groups, setGroups] = useState([]);
|
||||
const isEdit = props.editingToken.id !== undefined;
|
||||
|
||||
const getInitValues = () => ({
|
||||
name: '',
|
||||
remain_quota: 500000,
|
||||
expired_time: -1,
|
||||
unlimited_quota: false,
|
||||
model_limits_enabled: false,
|
||||
model_limits: [],
|
||||
allow_ips: '',
|
||||
group: '',
|
||||
tokenCount: 1,
|
||||
});
|
||||
|
||||
const handleCancel = () => {
|
||||
props.handleClose();
|
||||
};
|
||||
|
||||
const setExpiredTime = (month, day, hour, minute) => {
|
||||
let now = new Date();
|
||||
let timestamp = now.getTime() / 1000;
|
||||
let seconds = month * 30 * 24 * 60 * 60;
|
||||
seconds += day * 24 * 60 * 60;
|
||||
seconds += hour * 60 * 60;
|
||||
seconds += minute * 60;
|
||||
if (!formApiRef.current) return;
|
||||
if (seconds !== 0) {
|
||||
timestamp += seconds;
|
||||
formApiRef.current.setValue('expired_time', timestamp2string(timestamp));
|
||||
} else {
|
||||
formApiRef.current.setValue('expired_time', -1);
|
||||
}
|
||||
};
|
||||
|
||||
const loadModels = async () => {
|
||||
let res = await API.get(`/api/user/models`);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
const categories = getModelCategories(t);
|
||||
let localModelOptions = data.map((model) => {
|
||||
let icon = null;
|
||||
for (const [key, category] of Object.entries(categories)) {
|
||||
if (key !== 'all' && category.filter({ model_name: model })) {
|
||||
icon = category.icon;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return {
|
||||
label: (
|
||||
<span className="flex items-center gap-1">
|
||||
{icon}
|
||||
{model}
|
||||
</span>
|
||||
),
|
||||
value: model,
|
||||
};
|
||||
});
|
||||
setModels(localModelOptions);
|
||||
} else {
|
||||
showError(t(message));
|
||||
}
|
||||
};
|
||||
|
||||
const loadGroups = async () => {
|
||||
let res = await API.get(`/api/user/self/groups`);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
let localGroupOptions = Object.entries(data).map(([group, info]) => ({
|
||||
label: info.desc,
|
||||
value: group,
|
||||
ratio: info.ratio,
|
||||
}));
|
||||
if (statusState?.status?.default_use_auto_group) {
|
||||
if (localGroupOptions.some((group) => group.value === 'auto')) {
|
||||
localGroupOptions.sort((a, b) => (a.value === 'auto' ? -1 : 1));
|
||||
} else {
|
||||
localGroupOptions.unshift({ label: t('自动选择'), value: 'auto' });
|
||||
}
|
||||
}
|
||||
setGroups(localGroupOptions);
|
||||
if (statusState?.status?.default_use_auto_group && formApiRef.current) {
|
||||
formApiRef.current.setValue('group', 'auto');
|
||||
}
|
||||
} else {
|
||||
showError(t(message));
|
||||
}
|
||||
};
|
||||
|
||||
const loadToken = async () => {
|
||||
setLoading(true);
|
||||
let res = await API.get(`/api/token/${props.editingToken.id}`);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
if (data.expired_time !== -1) {
|
||||
data.expired_time = timestamp2string(data.expired_time);
|
||||
}
|
||||
if (data.model_limits !== '') {
|
||||
data.model_limits = data.model_limits.split(',');
|
||||
} else {
|
||||
data.model_limits = [];
|
||||
}
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValues({ ...getInitValues(), ...data });
|
||||
}
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (formApiRef.current) {
|
||||
if (!isEdit) {
|
||||
formApiRef.current.setValues(getInitValues());
|
||||
}
|
||||
}
|
||||
loadModels();
|
||||
loadGroups();
|
||||
}, [props.editingToken.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.visiable) {
|
||||
if (isEdit) {
|
||||
loadToken();
|
||||
} else {
|
||||
formApiRef.current?.setValues(getInitValues());
|
||||
}
|
||||
} else {
|
||||
formApiRef.current?.reset();
|
||||
}
|
||||
}, [props.visiable, props.editingToken.id]);
|
||||
|
||||
const generateRandomSuffix = () => {
|
||||
const characters =
|
||||
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let result = '';
|
||||
for (let i = 0; i < 6; i++) {
|
||||
result += characters.charAt(
|
||||
Math.floor(Math.random() * characters.length),
|
||||
);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const submit = async (values) => {
|
||||
setLoading(true);
|
||||
if (isEdit) {
|
||||
let { tokenCount: _tc, ...localInputs } = values;
|
||||
localInputs.remain_quota = parseInt(localInputs.remain_quota);
|
||||
if (localInputs.expired_time !== -1) {
|
||||
let time = Date.parse(localInputs.expired_time);
|
||||
if (isNaN(time)) {
|
||||
showError(t('过期时间格式错误!'));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
localInputs.expired_time = Math.ceil(time / 1000);
|
||||
}
|
||||
localInputs.model_limits = localInputs.model_limits.join(',');
|
||||
localInputs.model_limits_enabled = localInputs.model_limits.length > 0;
|
||||
let res = await API.put(`/api/token/`, {
|
||||
...localInputs,
|
||||
id: parseInt(props.editingToken.id),
|
||||
});
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess(t('令牌更新成功!'));
|
||||
props.refresh();
|
||||
props.handleClose();
|
||||
} else {
|
||||
showError(t(message));
|
||||
}
|
||||
} else {
|
||||
const count = parseInt(values.tokenCount, 10) || 1;
|
||||
let successCount = 0;
|
||||
for (let i = 0; i < count; i++) {
|
||||
let { tokenCount: _tc, ...localInputs } = values;
|
||||
const baseName = values.name.trim() === '' ? 'default' : values.name.trim();
|
||||
if (i !== 0 || values.name.trim() === '') {
|
||||
localInputs.name = `${baseName}-${generateRandomSuffix()}`;
|
||||
} else {
|
||||
localInputs.name = baseName;
|
||||
}
|
||||
localInputs.remain_quota = parseInt(localInputs.remain_quota);
|
||||
|
||||
if (localInputs.expired_time !== -1) {
|
||||
let time = Date.parse(localInputs.expired_time);
|
||||
if (isNaN(time)) {
|
||||
showError(t('过期时间格式错误!'));
|
||||
setLoading(false);
|
||||
break;
|
||||
}
|
||||
localInputs.expired_time = Math.ceil(time / 1000);
|
||||
}
|
||||
localInputs.model_limits = localInputs.model_limits.join(',');
|
||||
localInputs.model_limits_enabled = localInputs.model_limits.length > 0;
|
||||
let res = await API.post(`/api/token/`, localInputs);
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
successCount++;
|
||||
} else {
|
||||
showError(t(message));
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (successCount > 0) {
|
||||
showSuccess(t('令牌创建成功,请在列表页面点击复制获取令牌!'));
|
||||
props.refresh();
|
||||
props.handleClose();
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
formApiRef.current?.setValues(getInitValues());
|
||||
};
|
||||
|
||||
return (
|
||||
<SideSheet
|
||||
placement={isEdit ? 'right' : 'left'}
|
||||
title={
|
||||
<Space>
|
||||
{isEdit ? (
|
||||
<Tag color='blue' shape='circle'>
|
||||
{t('更新')}
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag color='green' shape='circle'>
|
||||
{t('新建')}
|
||||
</Tag>
|
||||
)}
|
||||
<Title heading={4} className='m-0'>
|
||||
{isEdit ? t('更新令牌信息') : t('创建新的令牌')}
|
||||
</Title>
|
||||
</Space>
|
||||
}
|
||||
bodyStyle={{ padding: '0' }}
|
||||
visible={props.visiable}
|
||||
width={isMobile ? '100%' : 600}
|
||||
footer={
|
||||
<div className='flex justify-end bg-white'>
|
||||
<Space>
|
||||
<Button
|
||||
theme='solid'
|
||||
className='!rounded-lg'
|
||||
onClick={() => formApiRef.current?.submitForm()}
|
||||
icon={<IconSave />}
|
||||
loading={loading}
|
||||
>
|
||||
{t('提交')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='light'
|
||||
className='!rounded-lg'
|
||||
type='primary'
|
||||
onClick={handleCancel}
|
||||
icon={<IconClose />}
|
||||
>
|
||||
{t('取消')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
closeIcon={null}
|
||||
onCancel={() => handleCancel()}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
<Form
|
||||
key={isEdit ? 'edit' : 'new'}
|
||||
initValues={getInitValues()}
|
||||
getFormApi={(api) => (formApiRef.current = api)}
|
||||
onSubmit={submit}
|
||||
>
|
||||
{({ values }) => (
|
||||
<div className='p-2'>
|
||||
{/* 基本信息 */}
|
||||
<Card className='!rounded-2xl shadow-sm border-0'>
|
||||
<div className='flex items-center mb-2'>
|
||||
<Avatar size='small' color='blue' className='mr-2 shadow-md'>
|
||||
<IconKey size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className='text-lg font-medium'>{t('基本信息')}</Text>
|
||||
<div className='text-xs text-gray-600'>{t('设置令牌的基本信息')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<Row gutter={12}>
|
||||
<Col span={24}>
|
||||
<Form.Input
|
||||
field='name'
|
||||
label={t('名称')}
|
||||
placeholder={t('请输入名称')}
|
||||
rules={[{ required: true, message: t('请输入名称') }]}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
{groups.length > 0 ? (
|
||||
<Form.Select
|
||||
field='group'
|
||||
label={t('令牌分组')}
|
||||
placeholder={t('令牌分组,默认为用户的分组')}
|
||||
optionList={groups}
|
||||
renderOptionItem={renderGroupOption}
|
||||
showClear
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
) : (
|
||||
<Form.Select
|
||||
placeholder={t('管理员未设置用户可选分组')}
|
||||
disabled
|
||||
label={t('令牌分组')}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
)}
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={24} lg={10} xl={10}>
|
||||
<Form.DatePicker
|
||||
field='expired_time'
|
||||
label={t('过期时间')}
|
||||
type='dateTime'
|
||||
placeholder={t('请选择过期时间')}
|
||||
rules={[
|
||||
{ required: true, message: t('请选择过期时间') },
|
||||
{
|
||||
validator: (rule, value) => {
|
||||
// 允许 -1 表示永不过期,也允许空值在必填校验时被拦截
|
||||
if (value === -1 || !value) return Promise.resolve();
|
||||
const time = Date.parse(value);
|
||||
if (isNaN(time)) {
|
||||
return Promise.reject(t('过期时间格式错误!'));
|
||||
}
|
||||
if (time <= Date.now()) {
|
||||
return Promise.reject(t('过期时间不能早于当前时间!'));
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
]}
|
||||
showClear
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={24} lg={14} xl={14}>
|
||||
<Form.Slot label={t('过期时间快捷设置')}>
|
||||
<Space wrap>
|
||||
<Button
|
||||
theme='light'
|
||||
type='primary'
|
||||
onClick={() => setExpiredTime(0, 0, 0, 0)}
|
||||
>
|
||||
{t('永不过期')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
onClick={() => setExpiredTime(1, 0, 0, 0)}
|
||||
>
|
||||
{t('一个月')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
onClick={() => setExpiredTime(0, 1, 0, 0)}
|
||||
>
|
||||
{t('一天')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
onClick={() => setExpiredTime(0, 0, 1, 0)}
|
||||
>
|
||||
{t('一小时')}
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Slot>
|
||||
</Col>
|
||||
{!isEdit && (
|
||||
<Col span={24}>
|
||||
<Form.InputNumber
|
||||
field='tokenCount'
|
||||
label={t('新建数量')}
|
||||
min={1}
|
||||
extraText={t('批量创建时会在名称后自动添加随机后缀')}
|
||||
rules={[{ required: true, message: t('请输入新建数量') }]}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* 额度设置 */}
|
||||
<Card className='!rounded-2xl shadow-sm border-0'>
|
||||
<div className='flex items-center mb-2'>
|
||||
<Avatar size='small' color='green' className='mr-2 shadow-md'>
|
||||
<IconCreditCard size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className='text-lg font-medium'>{t('额度设置')}</Text>
|
||||
<div className='text-xs text-gray-600'>{t('设置令牌可用额度和数量')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<Row gutter={12}>
|
||||
<Col span={24}>
|
||||
<Form.AutoComplete
|
||||
field='remain_quota'
|
||||
label={t('额度')}
|
||||
placeholder={t('请输入额度')}
|
||||
type='number'
|
||||
disabled={values.unlimited_quota}
|
||||
extraText={renderQuotaWithPrompt(values.remain_quota)}
|
||||
rules={values.unlimited_quota ? [] : [{ required: true, message: t('请输入额度') }]}
|
||||
data={[
|
||||
{ value: 500000, label: '1$' },
|
||||
{ value: 5000000, label: '10$' },
|
||||
{ value: 25000000, label: '50$' },
|
||||
{ value: 50000000, label: '100$' },
|
||||
{ value: 250000000, label: '500$' },
|
||||
{ value: 500000000, label: '1000$' },
|
||||
]}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Switch
|
||||
field='unlimited_quota'
|
||||
label={t('无限额度')}
|
||||
size='large'
|
||||
extraText={t('令牌的额度仅用于限制令牌本身的最大额度使用量,实际的使用受到账户的剩余额度限制')}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* 访问限制 */}
|
||||
<Card className='!rounded-2xl shadow-sm border-0'>
|
||||
<div className='flex items-center mb-2'>
|
||||
<Avatar size='small' color='purple' className='mr-2 shadow-md'>
|
||||
<IconLink size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className='text-lg font-medium'>{t('访问限制')}</Text>
|
||||
<div className='text-xs text-gray-600'>{t('设置令牌的访问限制')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<Row gutter={12}>
|
||||
<Col span={24}>
|
||||
<Form.Select
|
||||
field='model_limits'
|
||||
label={t('模型限制列表')}
|
||||
placeholder={t('请选择该令牌支持的模型,留空支持所有模型')}
|
||||
multiple
|
||||
optionList={models}
|
||||
extraText={t('非必要,不建议启用模型限制')}
|
||||
filter
|
||||
searchPosition='dropdown'
|
||||
showClear
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.TextArea
|
||||
field='allow_ips'
|
||||
label={t('IP白名单')}
|
||||
placeholder={t('允许的IP,一行一个,不填写则不限制')}
|
||||
autosize
|
||||
rows={1}
|
||||
extraText={t('请勿过度信任此功能,IP可能被伪造')}
|
||||
showClear
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
</Spin>
|
||||
</SideSheet>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditToken;
|
||||
@@ -1,12 +0,0 @@
|
||||
import React from 'react';
|
||||
import TokensTable from '../../components/table/TokensTable';
|
||||
|
||||
const Token = () => {
|
||||
return (
|
||||
<div className="mt-[64px] px-2">
|
||||
<TokensTable />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Token;
|
||||
31
web/src/pages/Token/index.jsx
Normal file
31
web/src/pages/Token/index.jsx
Normal file
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import TokensTable from '../../components/table/tokens';
|
||||
|
||||
const Token = () => {
|
||||
return (
|
||||
<div className='mt-[60px] px-2'>
|
||||
<TokensTable />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Token;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,167 +0,0 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { API, showError, showSuccess } from '../../helpers';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile.js';
|
||||
import {
|
||||
Button,
|
||||
SideSheet,
|
||||
Space,
|
||||
Spin,
|
||||
Typography,
|
||||
Card,
|
||||
Tag,
|
||||
Avatar,
|
||||
Form,
|
||||
Row,
|
||||
Col,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconSave,
|
||||
IconClose,
|
||||
IconUserAdd,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
const AddUser = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const formApiRef = useRef(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const getInitValues = () => ({
|
||||
username: '',
|
||||
display_name: '',
|
||||
password: '',
|
||||
remark: '',
|
||||
});
|
||||
|
||||
const submit = async (values) => {
|
||||
setLoading(true);
|
||||
const res = await API.post(`/api/user/`, values);
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess(t('用户账户创建成功!'));
|
||||
formApiRef.current?.setValues(getInitValues());
|
||||
props.refresh();
|
||||
props.handleClose();
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
props.handleClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SideSheet
|
||||
placement={'left'}
|
||||
title={
|
||||
<Space>
|
||||
<Tag color="green" shape="circle">{t('新建')}</Tag>
|
||||
<Title heading={4} className="m-0">
|
||||
{t('添加用户')}
|
||||
</Title>
|
||||
</Space>
|
||||
}
|
||||
bodyStyle={{ padding: '0' }}
|
||||
visible={props.visible}
|
||||
width={isMobile ? '100%' : 600}
|
||||
footer={
|
||||
<div className="flex justify-end bg-white">
|
||||
<Space>
|
||||
<Button
|
||||
theme="solid"
|
||||
onClick={() => formApiRef.current?.submitForm()}
|
||||
icon={<IconSave />}
|
||||
loading={loading}
|
||||
>
|
||||
{t('提交')}
|
||||
</Button>
|
||||
<Button
|
||||
theme="light"
|
||||
type="primary"
|
||||
onClick={handleCancel}
|
||||
icon={<IconClose />}
|
||||
>
|
||||
{t('取消')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
closeIcon={null}
|
||||
onCancel={() => handleCancel()}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
<Form
|
||||
initValues={getInitValues()}
|
||||
getFormApi={(api) => formApiRef.current = api}
|
||||
onSubmit={submit}
|
||||
onSubmitFail={(errs) => {
|
||||
const first = Object.values(errs)[0];
|
||||
if (first) showError(Array.isArray(first) ? first[0] : first);
|
||||
formApiRef.current?.scrollToError();
|
||||
}}
|
||||
>
|
||||
<div className="p-2">
|
||||
<Card className="!rounded-2xl shadow-sm border-0">
|
||||
<div className="flex items-center mb-2">
|
||||
<Avatar size="small" color="blue" className="mr-2 shadow-md">
|
||||
<IconUserAdd size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className="text-lg font-medium">{t('用户信息')}</Text>
|
||||
<div className="text-xs text-gray-600">{t('创建新用户账户')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Row gutter={12}>
|
||||
<Col span={24}>
|
||||
<Form.Input
|
||||
field='username'
|
||||
label={t('用户名')}
|
||||
placeholder={t('请输入用户名')}
|
||||
rules={[{ required: true, message: t('请输入用户名') }]}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Input
|
||||
field='display_name'
|
||||
label={t('显示名称')}
|
||||
placeholder={t('请输入显示名称')}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Input
|
||||
field='password'
|
||||
label={t('密码')}
|
||||
type='password'
|
||||
placeholder={t('请输入密码')}
|
||||
rules={[{ required: true, message: t('请输入密码') }]}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Input
|
||||
field='remark'
|
||||
label={t('备注')}
|
||||
placeholder={t('请输入备注(仅管理员可见)')}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
</div>
|
||||
</Form>
|
||||
</Spin>
|
||||
</SideSheet>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddUser;
|
||||
@@ -1,351 +0,0 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
API,
|
||||
showError,
|
||||
showSuccess,
|
||||
renderQuota,
|
||||
renderQuotaWithPrompt,
|
||||
} from '../../helpers';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile.js';
|
||||
import {
|
||||
Button,
|
||||
Modal,
|
||||
SideSheet,
|
||||
Space,
|
||||
Spin,
|
||||
Typography,
|
||||
Card,
|
||||
Tag,
|
||||
Form,
|
||||
Avatar,
|
||||
Row,
|
||||
Col,
|
||||
Input,
|
||||
InputNumber,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconUser,
|
||||
IconSave,
|
||||
IconClose,
|
||||
IconLink,
|
||||
IconUserGroup,
|
||||
IconPlus,
|
||||
} from '@douyinfe/semi-icons';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
const EditUser = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const userId = props.editingUser.id;
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [addQuotaModalOpen, setIsModalOpen] = useState(false);
|
||||
const [addQuotaLocal, setAddQuotaLocal] = useState('');
|
||||
const isMobile = useIsMobile();
|
||||
const [groupOptions, setGroupOptions] = useState([]);
|
||||
const formApiRef = useRef(null);
|
||||
|
||||
const isEdit = Boolean(userId);
|
||||
|
||||
const getInitValues = () => ({
|
||||
username: '',
|
||||
display_name: '',
|
||||
password: '',
|
||||
github_id: '',
|
||||
oidc_id: '',
|
||||
wechat_id: '',
|
||||
telegram_id: '',
|
||||
email: '',
|
||||
quota: 0,
|
||||
group: 'default',
|
||||
remark: '',
|
||||
});
|
||||
|
||||
const fetchGroups = async () => {
|
||||
try {
|
||||
let res = await API.get(`/api/group/`);
|
||||
setGroupOptions(
|
||||
res.data.data.map((g) => ({ label: g, value: g }))
|
||||
);
|
||||
} catch (e) {
|
||||
showError(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => props.handleClose();
|
||||
|
||||
const loadUser = async () => {
|
||||
setLoading(true);
|
||||
const url = userId ? `/api/user/${userId}` : `/api/user/self`;
|
||||
const res = await API.get(url);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
data.password = '';
|
||||
formApiRef.current?.setValues({ ...getInitValues(), ...data });
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadUser();
|
||||
if (userId) fetchGroups();
|
||||
}, [props.editingUser.id]);
|
||||
|
||||
/* ----------------------- submit ----------------------- */
|
||||
const submit = async (values) => {
|
||||
setLoading(true);
|
||||
let payload = { ...values };
|
||||
if (typeof payload.quota === 'string') payload.quota = parseInt(payload.quota) || 0;
|
||||
if (userId) {
|
||||
payload.id = parseInt(userId);
|
||||
}
|
||||
const url = userId ? `/api/user/` : `/api/user/self`;
|
||||
const res = await API.put(url, payload);
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess(t('用户信息更新成功!'));
|
||||
props.refresh();
|
||||
props.handleClose();
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
/* --------------------- quota helper -------------------- */
|
||||
const addLocalQuota = () => {
|
||||
const current = parseInt(formApiRef.current?.getValue('quota') || 0);
|
||||
const delta = parseInt(addQuotaLocal) || 0;
|
||||
formApiRef.current?.setValue('quota', current + delta);
|
||||
};
|
||||
|
||||
/* --------------------------- UI --------------------------- */
|
||||
return (
|
||||
<>
|
||||
<SideSheet
|
||||
placement='right'
|
||||
title={
|
||||
<Space>
|
||||
<Tag color='blue' shape='circle'>
|
||||
{t(isEdit ? '编辑' : '新建')}
|
||||
</Tag>
|
||||
<Title heading={4} className='m-0'>
|
||||
{isEdit ? t('编辑用户') : t('创建用户')}
|
||||
</Title>
|
||||
</Space>
|
||||
}
|
||||
bodyStyle={{ padding: 0 }}
|
||||
visible={props.visible}
|
||||
width={isMobile ? '100%' : 600}
|
||||
footer={
|
||||
<div className='flex justify-end bg-white'>
|
||||
<Space>
|
||||
<Button
|
||||
theme='solid'
|
||||
onClick={() => formApiRef.current?.submitForm()}
|
||||
icon={<IconSave />}
|
||||
loading={loading}
|
||||
>
|
||||
{t('提交')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='light'
|
||||
type='primary'
|
||||
onClick={handleCancel}
|
||||
icon={<IconClose />}
|
||||
>
|
||||
{t('取消')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
closeIcon={null}
|
||||
onCancel={handleCancel}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
<Form
|
||||
initValues={getInitValues()}
|
||||
getFormApi={(api) => (formApiRef.current = api)}
|
||||
onSubmit={submit}
|
||||
>
|
||||
{({ values }) => (
|
||||
<div className='p-2'>
|
||||
{/* 基本信息 */}
|
||||
<Card className='!rounded-2xl shadow-sm border-0'>
|
||||
<div className='flex items-center mb-2'>
|
||||
<Avatar size='small' color='blue' className='mr-2 shadow-md'>
|
||||
<IconUser size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className='text-lg font-medium'>{t('基本信息')}</Text>
|
||||
<div className='text-xs text-gray-600'>{t('用户的基本账户信息')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Row gutter={12}>
|
||||
<Col span={24}>
|
||||
<Form.Input
|
||||
field='username'
|
||||
label={t('用户名')}
|
||||
placeholder={t('请输入新的用户名')}
|
||||
rules={[{ required: true, message: t('请输入用户名') }]}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col span={24}>
|
||||
<Form.Input
|
||||
field='password'
|
||||
label={t('密码')}
|
||||
placeholder={t('请输入新的密码,最短 8 位')}
|
||||
mode='password'
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col span={24}>
|
||||
<Form.Input
|
||||
field='display_name'
|
||||
label={t('显示名称')}
|
||||
placeholder={t('请输入新的显示名称')}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col span={24}>
|
||||
<Form.Input
|
||||
field='remark'
|
||||
label={t('备注')}
|
||||
placeholder={t('请输入备注(仅管理员可见)')}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* 权限设置 */}
|
||||
{userId && (
|
||||
<Card className='!rounded-2xl shadow-sm border-0'>
|
||||
<div className='flex items-center mb-2'>
|
||||
<Avatar size='small' color='green' className='mr-2 shadow-md'>
|
||||
<IconUserGroup size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className='text-lg font-medium'>{t('权限设置')}</Text>
|
||||
<div className='text-xs text-gray-600'>{t('用户分组和额度管理')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Row gutter={12}>
|
||||
<Col span={24}>
|
||||
<Form.Select
|
||||
field='group'
|
||||
label={t('分组')}
|
||||
placeholder={t('请选择分组')}
|
||||
optionList={groupOptions}
|
||||
allowAdditions
|
||||
search
|
||||
rules={[{ required: true, message: t('请选择分组') }]}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col span={10}>
|
||||
<Form.InputNumber
|
||||
field='quota'
|
||||
label={t('剩余额度')}
|
||||
placeholder={t('请输入新的剩余额度')}
|
||||
step={500000}
|
||||
extraText={renderQuotaWithPrompt(values.quota || 0)}
|
||||
rules={[{ required: true, message: t('请输入额度') }]}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col span={14}>
|
||||
<Form.Slot label={t('添加额度')}>
|
||||
<Button
|
||||
icon={<IconPlus />}
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
/>
|
||||
</Form.Slot>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 绑定信息 */}
|
||||
<Card className='!rounded-2xl shadow-sm border-0'>
|
||||
<div className='flex items-center mb-2'>
|
||||
<Avatar size='small' color='purple' className='mr-2 shadow-md'>
|
||||
<IconLink size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className='text-lg font-medium'>{t('绑定信息')}</Text>
|
||||
<div className='text-xs text-gray-600'>{t('第三方账户绑定状态(只读)')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Row gutter={12}>
|
||||
{['github_id', 'oidc_id', 'wechat_id', 'email', 'telegram_id'].map((field) => (
|
||||
<Col span={24} key={field}>
|
||||
<Form.Input
|
||||
field={field}
|
||||
label={t(`已绑定的 ${field.replace('_id', '').toUpperCase()} 账户`)}
|
||||
readonly
|
||||
placeholder={t('此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改')}
|
||||
/>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
</Spin>
|
||||
</SideSheet>
|
||||
|
||||
{/* 添加额度模态框 */}
|
||||
<Modal
|
||||
centered
|
||||
visible={addQuotaModalOpen}
|
||||
onOk={() => {
|
||||
addLocalQuota();
|
||||
setIsModalOpen(false);
|
||||
}}
|
||||
onCancel={() => setIsModalOpen(false)}
|
||||
closable={null}
|
||||
title={
|
||||
<div className='flex items-center'>
|
||||
<IconPlus className='mr-2' />
|
||||
{t('添加额度')}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className='mb-4'>
|
||||
{
|
||||
(() => {
|
||||
const current = formApiRef.current?.getValue('quota') || 0;
|
||||
return (
|
||||
<Text type='secondary' className='block mb-2'>
|
||||
{`${t('新额度:')}${renderQuota(current)} + ${renderQuota(addQuotaLocal)} = ${renderQuota(current + parseInt(addQuotaLocal || 0))}`}
|
||||
</Text>
|
||||
);
|
||||
})()
|
||||
}
|
||||
</div>
|
||||
<InputNumber
|
||||
placeholder={t('需要添加的额度(支持负数)')}
|
||||
value={addQuotaLocal}
|
||||
onChange={setAddQuotaLocal}
|
||||
style={{ width: '100%' }}
|
||||
showClear
|
||||
step={500000}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditUser;
|
||||
@@ -1,12 +0,0 @@
|
||||
import React from 'react';
|
||||
import UsersTable from '../../components/table/UsersTable';
|
||||
|
||||
const User = () => {
|
||||
return (
|
||||
<div className="mt-[64px] px-2">
|
||||
<UsersTable />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default User;
|
||||
31
web/src/pages/User/index.jsx
Normal file
31
web/src/pages/User/index.jsx
Normal file
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import UsersTable from '../../components/table/users';
|
||||
|
||||
const User = () => {
|
||||
return (
|
||||
<div className='mt-[60px] px-2'>
|
||||
<UsersTable />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default User;
|
||||
Reference in New Issue
Block a user