Merge branch 'main' into feat-copy-models
This commit is contained in:
@@ -1,11 +1,15 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { API, showError } from '../../helpers';
|
||||
import { marked } from 'marked';
|
||||
import { Layout } from '@douyinfe/semi-ui';
|
||||
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') || '');
|
||||
@@ -20,7 +24,7 @@ const About = () => {
|
||||
localStorage.setItem('about', aboutContent);
|
||||
} else {
|
||||
showError(message);
|
||||
setAbout('加载关于内容失败...');
|
||||
setAbout(t('加载关于内容失败...'));
|
||||
}
|
||||
setAboutLoaded(true);
|
||||
};
|
||||
@@ -29,30 +33,90 @@ const About = () => {
|
||||
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 (
|
||||
<>
|
||||
{aboutLoaded && about === '' ? (
|
||||
<>
|
||||
<Layout>
|
||||
<Layout.Header>
|
||||
<h3>关于</h3>
|
||||
</Layout.Header>
|
||||
<Layout.Content>
|
||||
<p>可在设置页面设置关于内容,支持 HTML & Markdown</p>
|
||||
New-API项目仓库地址:
|
||||
<a href='https://github.com/Calcium-Ion/new-api'>
|
||||
https://github.com/Calcium-Ion/new-api
|
||||
</a>
|
||||
<p>
|
||||
NewAPI © 2023 CalciumIon | 基于 One API v0.5.4 © 2023
|
||||
JustSong。
|
||||
</p>
|
||||
<p>
|
||||
本项目根据MIT许可证授权,需在遵守Apache-2.0协议的前提下使用。
|
||||
</p>
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
</>
|
||||
<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://') ? (
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,26 +14,35 @@ import {
|
||||
Input,
|
||||
Typography,
|
||||
Spin,
|
||||
Modal,
|
||||
Select,
|
||||
Banner,
|
||||
TextArea,
|
||||
Card,
|
||||
Tag,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import TextInput from '../../components/custom/TextInput.js';
|
||||
import { getChannelModels } from '../../components/utils.js';
|
||||
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 [basicModels, setBasicModels] = useState([]);
|
||||
const [fullModels, setFullModels] = useState([]);
|
||||
const [customModel, setCustomModel] = useState('');
|
||||
const originInputs = {
|
||||
tag: '',
|
||||
@@ -90,7 +99,6 @@ const EditTagModal = (props) => {
|
||||
if (inputs.models.length === 0) {
|
||||
setInputs((inputs) => ({ ...inputs, models: localModels }));
|
||||
}
|
||||
setBasicModels(localModels);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -102,14 +110,6 @@ const EditTagModal = (props) => {
|
||||
value: model.id,
|
||||
}));
|
||||
setOriginModelOptions(localModelOptions);
|
||||
setFullModels(res.data.data.map((model) => model.id));
|
||||
setBasicModels(
|
||||
res.data.data
|
||||
.filter((model) => {
|
||||
return model.id.startsWith('gpt-') || model.id.startsWith('text-');
|
||||
})
|
||||
.map((model) => model.id),
|
||||
);
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
}
|
||||
@@ -194,6 +194,24 @@ const EditTagModal = (props) => {
|
||||
}, [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(',') : [];
|
||||
setInputs((inputs) => ({ ...inputs, models: models }));
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
setInputs({
|
||||
...originInputs,
|
||||
tag: tag,
|
||||
@@ -201,7 +219,8 @@ const EditTagModal = (props) => {
|
||||
});
|
||||
fetchModels().then();
|
||||
fetchGroups().then();
|
||||
}, [visible]);
|
||||
fetchTagModels().then(); // Call the new function
|
||||
}, [visible, tag]); // Add tag to dependency array
|
||||
|
||||
const addCustomModels = () => {
|
||||
if (customModel.trim() === '') return;
|
||||
@@ -210,7 +229,7 @@ const EditTagModal = (props) => {
|
||||
|
||||
let localModels = [...inputs.models];
|
||||
let localModelOptions = [...modelOptions];
|
||||
let hasError = false;
|
||||
const addedModels = [];
|
||||
|
||||
modelArray.forEach((model) => {
|
||||
// 检查模型是否已存在,且模型名称非空
|
||||
@@ -222,152 +241,243 @@ const EditTagModal = (props) => {
|
||||
text: model,
|
||||
value: model,
|
||||
});
|
||||
} else if (model) {
|
||||
showError('某些模型已存在!');
|
||||
hasError = true;
|
||||
addedModels.push(model);
|
||||
}
|
||||
});
|
||||
|
||||
if (hasError) return; // 如果有错误则终止操作
|
||||
|
||||
// 更新状态值
|
||||
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
|
||||
title='编辑标签'
|
||||
placement='right'
|
||||
title={
|
||||
<Space>
|
||||
<Tag color="blue" shape="circle">{t('编辑')}</Tag>
|
||||
<Title heading={4} className="m-0">
|
||||
{t('编辑标签')}
|
||||
</Title>
|
||||
</Space>
|
||||
}
|
||||
headerStyle={{
|
||||
borderBottom: '1px solid var(--semi-color-border)',
|
||||
padding: '24px'
|
||||
}}
|
||||
bodyStyle={{
|
||||
backgroundColor: 'var(--semi-color-bg-0)',
|
||||
padding: '0'
|
||||
}}
|
||||
visible={visible}
|
||||
width={600}
|
||||
onCancel={handleClose}
|
||||
footer={
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<div className="flex justify-end bg-white">
|
||||
<Space>
|
||||
<Button onClick={handleClose}>取消</Button>
|
||||
<Button type='primary' onClick={handleSave} loading={loading}>
|
||||
保存
|
||||
<Button
|
||||
theme="solid"
|
||||
size="large"
|
||||
className="!rounded-full"
|
||||
onClick={handleSave}
|
||||
loading={loading}
|
||||
icon={<IconSave />}
|
||||
>
|
||||
{t('保存')}
|
||||
</Button>
|
||||
<Button
|
||||
theme="light"
|
||||
size="large"
|
||||
className="!rounded-full"
|
||||
type="primary"
|
||||
onClick={handleClose}
|
||||
icon={<IconClose />}
|
||||
>
|
||||
{t('取消')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
closeIcon={null}
|
||||
>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<Banner
|
||||
type={'warning'}
|
||||
description={<>所有编辑均为覆盖操作,留空则不更改</>}
|
||||
></Banner>
|
||||
</div>
|
||||
<Spin spinning={loading}>
|
||||
<TextInput
|
||||
label='标签名,留空则解散标签'
|
||||
name='newTag'
|
||||
value={inputs.new_tag}
|
||||
onChange={(value) => setInputs({ ...inputs, new_tag: value })}
|
||||
placeholder='请输入新标签'
|
||||
/>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<Typography.Text strong>模型,留空则不更改:</Typography.Text>
|
||||
<div className="p-6">
|
||||
<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">
|
||||
<IconBookmark 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>
|
||||
|
||||
<Banner
|
||||
type="warning"
|
||||
description={t('所有编辑均为覆盖操作,留空则不更改')}
|
||||
className="!rounded-lg mb-4"
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Text strong className="block mb-2">{t('标签名称')}</Text>
|
||||
<Input
|
||||
value={inputs.new_tag}
|
||||
onChange={(value) => setInputs({ ...inputs, new_tag: value })}
|
||||
placeholder={t('请输入新标签,留空则解散标签')}
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</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">
|
||||
<IconCode 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>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Text strong className="block mb-2">{t('模型')}</Text>
|
||||
<Banner
|
||||
type="info"
|
||||
description={t('当前模型列表为该标签下所有渠道模型列表最长的一个,并非所有渠道的并集,请注意可能导致某些渠道模型丢失。')}
|
||||
className="!rounded-lg mb-4"
|
||||
/>
|
||||
<Select
|
||||
placeholder={t('请选择该渠道所支持的模型,留空则不更改')}
|
||||
name='models'
|
||||
multiple
|
||||
filter
|
||||
searchPosition='dropdown'
|
||||
onChange={(value) => handleInputChange('models', value)}
|
||||
value={inputs.models}
|
||||
optionList={modelOptions}
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Input
|
||||
addonAfter={
|
||||
<Button type='primary' onClick={addCustomModels} className="!rounded-r-lg">
|
||||
{t('填入')}
|
||||
</Button>
|
||||
}
|
||||
placeholder={t('输入自定义模型名称')}
|
||||
value={customModel}
|
||||
onChange={(value) => setCustomModel(value.trim())}
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text strong className="block mb-2">{t('模型重定向')}</Text>
|
||||
<TextArea
|
||||
placeholder={t('此项可选,用于修改请求体中的模型名称,为一个 JSON 字符串,键为请求中模型名称,值为要替换的模型名称,留空则不更改')}
|
||||
name='model_mapping'
|
||||
onChange={(value) => handleInputChange('model_mapping', value)}
|
||||
autosize
|
||||
value={inputs.model_mapping}
|
||||
className="!rounded-lg font-mono"
|
||||
/>
|
||||
<Space className="mt-2">
|
||||
<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>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="!rounded-2xl shadow-sm border-0">
|
||||
<div className="flex items-center mb-4 p-6 rounded-xl" style={{
|
||||
background: 'linear-gradient(135deg, #065f46 0%, #059669 50%, #10b981 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>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Text strong className="block mb-2">{t('分组')}</Text>
|
||||
<Select
|
||||
placeholder={t('请选择可以使用该渠道的分组,留空则不更改')}
|
||||
name='groups'
|
||||
multiple
|
||||
allowAdditions
|
||||
additionLabel={t('请在系统设置页面编辑分组倍率以添加新的分组:')}
|
||||
onChange={(value) => handleInputChange('groups', value)}
|
||||
value={inputs.groups}
|
||||
optionList={groupOptions}
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<Select
|
||||
placeholder={'请选择该渠道所支持的模型,留空则不更改'}
|
||||
name='models'
|
||||
required
|
||||
multiple
|
||||
selection
|
||||
filter
|
||||
searchPosition='dropdown'
|
||||
onChange={(value) => {
|
||||
handleInputChange('models', value);
|
||||
}}
|
||||
value={inputs.models}
|
||||
autoComplete='new-password'
|
||||
optionList={modelOptions}
|
||||
/>
|
||||
<Input
|
||||
addonAfter={
|
||||
<Button type='primary' onClick={addCustomModels}>
|
||||
填入
|
||||
</Button>
|
||||
}
|
||||
placeholder='输入自定义模型名称'
|
||||
value={customModel}
|
||||
onChange={(value) => {
|
||||
setCustomModel(value.trim());
|
||||
}}
|
||||
/>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<Typography.Text strong>分组,留空则不更改:</Typography.Text>
|
||||
</div>
|
||||
<Select
|
||||
placeholder={'请选择可以使用该渠道的分组,留空则不更改'}
|
||||
name='groups'
|
||||
required
|
||||
multiple
|
||||
selection
|
||||
allowAdditions
|
||||
additionLabel={'请在系统设置页面编辑分组倍率以添加新的分组:'}
|
||||
onChange={(value) => {
|
||||
handleInputChange('groups', value);
|
||||
}}
|
||||
value={inputs.groups}
|
||||
autoComplete='new-password'
|
||||
optionList={groupOptions}
|
||||
/>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<Typography.Text strong>模型重定向:</Typography.Text>
|
||||
</div>
|
||||
<TextArea
|
||||
placeholder={`此项可选,用于修改请求体中的模型名称,为一个 JSON 字符串,键为请求中模型名称,值为要替换的模型名称,留空则不更改`}
|
||||
name='model_mapping'
|
||||
onChange={(value) => {
|
||||
handleInputChange('model_mapping', value);
|
||||
}}
|
||||
autosize
|
||||
value={inputs.model_mapping}
|
||||
autoComplete='new-password'
|
||||
/>
|
||||
<Space>
|
||||
<Typography.Text
|
||||
style={{
|
||||
color: 'rgba(var(--semi-blue-5), 1)',
|
||||
userSelect: 'none',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => {
|
||||
handleInputChange(
|
||||
'model_mapping',
|
||||
JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2),
|
||||
);
|
||||
}}
|
||||
>
|
||||
填入模板
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
style={{
|
||||
color: 'rgba(var(--semi-blue-5), 1)',
|
||||
userSelect: 'none',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => {
|
||||
handleInputChange('model_mapping', JSON.stringify({}, null, 2));
|
||||
}}
|
||||
>
|
||||
清空重定向
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
style={{
|
||||
color: 'rgba(var(--semi-blue-5), 1)',
|
||||
userSelect: 'none',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => {
|
||||
handleInputChange('model_mapping', '');
|
||||
}}
|
||||
>
|
||||
不更改
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
</Spin>
|
||||
</SideSheet>
|
||||
);
|
||||
|
||||
@@ -1,20 +1,10 @@
|
||||
import React from 'react';
|
||||
import ChannelsTable from '../../components/ChannelsTable';
|
||||
import { Layout } from '@douyinfe/semi-ui';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ChannelsTable from '../../components/table/ChannelsTable';
|
||||
|
||||
const File = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<Layout>
|
||||
<Layout.Header>
|
||||
<h3>{t('管理渠道')}</h3>
|
||||
</Layout.Header>
|
||||
<Layout.Content>
|
||||
<ChannelsTable />
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
<ChannelsTable />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useTokenKeys } from '../../components/fetchTokenKeys';
|
||||
import { Banner, Layout } from '@douyinfe/semi-ui';
|
||||
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);
|
||||
|
||||
@@ -40,12 +42,17 @@ const ChatPage = () => {
|
||||
allow='camera;microphone'
|
||||
/>
|
||||
) : (
|
||||
<div>
|
||||
<Layout>
|
||||
<Layout.Header>
|
||||
<Banner description={'正在跳转......'} type={'warning'} />
|
||||
</Layout.Header>
|
||||
</Layout>
|
||||
<div className="fixed inset-0 w-screen h-screen flex items-center justify-center bg-white/80 z-[1000]">
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useTokenKeys } from '../../components/fetchTokenKeys';
|
||||
import { useTokenKeys } from '../../hooks/useTokenKeys';
|
||||
|
||||
const chat2page = () => {
|
||||
const { keys, chatLink, serverAddress, isLoading } = useTokenKeys();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,32 +1,45 @@
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import { Card, Col, Row } from '@douyinfe/semi-ui';
|
||||
import { API, showError, showNotice, timestamp2string } from '../../helpers';
|
||||
import { Button, Typography, Tag } from '@douyinfe/semi-ui';
|
||||
import { API, showError, isMobile } from '../../helpers';
|
||||
import { StatusContext } from '../../context/Status';
|
||||
import { marked } from 'marked';
|
||||
import { StyleContext } from '../../context/Style/index.js';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IconGithubLogo, IconPlay, IconFile } 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';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const Home = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [statusState] = useContext(StatusContext);
|
||||
const [homePageContentLoaded, setHomePageContentLoaded] = useState(false);
|
||||
const [homePageContent, setHomePageContent] = useState('');
|
||||
const [styleState, styleDispatch] = useContext(StyleContext);
|
||||
const [noticeVisible, setNoticeVisible] = useState(false);
|
||||
|
||||
const displayNotice = async () => {
|
||||
const res = await API.get('/api/notice');
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
let oldNotice = localStorage.getItem('notice');
|
||||
if (data !== oldNotice && data !== '') {
|
||||
const htmlNotice = marked(data);
|
||||
showNotice(htmlNotice, true);
|
||||
localStorage.setItem('notice', data);
|
||||
const isDemoSiteMode = statusState?.status?.demo_site_enabled || false;
|
||||
const docsLink = statusState?.status?.docs_link || '';
|
||||
|
||||
useEffect(() => {
|
||||
const checkNoticeAndShow = async () => {
|
||||
const lastCloseDate = localStorage.getItem('notice_close_date');
|
||||
const today = new Date().toDateString();
|
||||
if (lastCloseDate !== today) {
|
||||
try {
|
||||
const res = await API.get('/api/notice');
|
||||
const { success, data } = res.data;
|
||||
if (success && data && data.trim() !== '') {
|
||||
setNoticeVisible(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取公告失败:', error);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
checkNoticeAndShow();
|
||||
}, []);
|
||||
|
||||
const displayHomePageContent = async () => {
|
||||
setHomePageContent(localStorage.getItem('home_page_content') || '');
|
||||
@@ -45,8 +58,6 @@ const Home = () => {
|
||||
const iframe = document.querySelector('iframe');
|
||||
if (iframe) {
|
||||
const theme = localStorage.getItem('theme-mode') || 'light';
|
||||
// 测试是否正确传递theme-mode给iframe
|
||||
// console.log('Sending theme-mode to iframe:', theme);
|
||||
iframe.onload = () => {
|
||||
iframe.contentWindow.postMessage({ themeMode: theme }, '*');
|
||||
iframe.contentWindow.postMessage({ lang: i18n.language }, '*');
|
||||
@@ -60,154 +71,165 @@ const Home = () => {
|
||||
setHomePageContentLoaded(true);
|
||||
};
|
||||
|
||||
const getStartTimeString = () => {
|
||||
const timestamp = statusState?.status?.start_time;
|
||||
return statusState.status ? timestamp2string(timestamp) : '';
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
displayNotice().then();
|
||||
displayHomePageContent().then();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full overflow-x-hidden">
|
||||
<NoticeModal
|
||||
visible={noticeVisible}
|
||||
onClose={() => setNoticeVisible(false)}
|
||||
isMobile={isMobile()}
|
||||
/>
|
||||
{homePageContentLoaded && homePageContent === '' ? (
|
||||
<>
|
||||
<Card
|
||||
bordered={false}
|
||||
headerLine={false}
|
||||
title={t('系统状况')}
|
||||
bodyStyle={{ padding: '10px 20px' }}
|
||||
>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Card
|
||||
title={t('系统信息')}
|
||||
headerExtraContent={
|
||||
<span
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: 'var(--semi-color-text-1)',
|
||||
}}
|
||||
<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="flex items-center justify-center h-full px-4 py-20 md:py-24 lg:py-32">
|
||||
{/* 居中内容区 */}
|
||||
<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-semibold text-semi-color-text-0 leading-tight">
|
||||
{i18n.language === 'en' ? (
|
||||
<>
|
||||
The Unified<br />
|
||||
LLMs API Gateway
|
||||
</>
|
||||
) : (
|
||||
t('统一的大模型接口网关')
|
||||
)}
|
||||
</h1>
|
||||
<p className="text-lg md:text-xl lg:text-2xl text-semi-color-text-1 mt-4 md:mt-6">
|
||||
{t('更好的价格,更好的稳定性,无需订阅')}
|
||||
</p>
|
||||
</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 />}>
|
||||
{t('开始使用')}
|
||||
</Button>
|
||||
</Link>
|
||||
{isDemoSiteMode && statusState?.status?.version ? (
|
||||
<Button
|
||||
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')}
|
||||
>
|
||||
{t('系统信息总览')}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<p>
|
||||
{t('名称')}:{statusState?.status?.system_name}
|
||||
</p>
|
||||
<p>
|
||||
{t('版本')}:
|
||||
{statusState?.status?.version
|
||||
? statusState?.status?.version
|
||||
: 'unknown'}
|
||||
</p>
|
||||
<p>
|
||||
{t('源码')}:
|
||||
<a
|
||||
href='https://github.com/Calcium-Ion/new-api'
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
https://github.com/Calcium-Ion/new-api
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
{t('协议')}:
|
||||
<a
|
||||
href='https://www.apache.org/licenses/LICENSE-2.0'
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
Apache-2.0 License
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
{t('启动时间')}:{getStartTimeString()}
|
||||
</p>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Card
|
||||
title={t('系统配置')}
|
||||
headerExtraContent={
|
||||
<span
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: 'var(--semi-color-text-1)',
|
||||
}}
|
||||
>
|
||||
{t('系统配置总览')}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<p>
|
||||
{t('邮箱验证')}:
|
||||
{statusState?.status?.email_verification === true
|
||||
? t('已启用')
|
||||
: t('未启用')}
|
||||
</p>
|
||||
<p>
|
||||
{t('GitHub 身份验证')}:
|
||||
{statusState?.status?.github_oauth === true
|
||||
? t('已启用')
|
||||
: t('未启用')}
|
||||
</p>
|
||||
<p>
|
||||
{t('OIDC 身份验证')}:
|
||||
{statusState?.status?.oidc === true
|
||||
? t('已启用')
|
||||
: t('未启用')}
|
||||
</p>
|
||||
<p>
|
||||
{t('微信身份验证')}:
|
||||
{statusState?.status?.wechat_login === true
|
||||
? t('已启用')
|
||||
: t('未启用')}
|
||||
</p>
|
||||
<p>
|
||||
{t('Turnstile 用户校验')}:
|
||||
{statusState?.status?.turnstile_check === true
|
||||
? t('已启用')
|
||||
: t('未启用')}
|
||||
</p>
|
||||
<p>
|
||||
{t('Telegram 身份验证')}:
|
||||
{statusState?.status?.telegram_oauth === true
|
||||
? t('已启用')
|
||||
: t('未启用')}
|
||||
</p>
|
||||
<p>
|
||||
{t('Linux DO 身份验证')}:
|
||||
{statusState?.status?.linuxdo_oauth === true
|
||||
? t('已启用')
|
||||
: t('未启用')}
|
||||
</p>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
</>
|
||||
{statusState.status.version}
|
||||
</Button>
|
||||
) : (
|
||||
docsLink && (
|
||||
<Button
|
||||
size={isMobile() ? "default" : "large"}
|
||||
className="flex items-center !rounded-3xl px-6 py-2"
|
||||
icon={<IconFile />}
|
||||
onClick={() => window.open(docsLink, '_blank')}
|
||||
>
|
||||
{t('文档')}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</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">
|
||||
{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">
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-hidden w-full">
|
||||
{homePageContent.startsWith('https://') ? (
|
||||
<iframe
|
||||
src={homePageContent}
|
||||
style={{ width: '100%', height: '100vh', border: 'none' }}
|
||||
className="w-full h-screen border-none"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{ fontSize: 'larger' }}
|
||||
className="text-base md:text-lg p-4 md:p-6 lg:p-8 overflow-x-hidden max-w-6xl mx-auto"
|
||||
dangerouslySetInnerHTML={{ __html: homePageContent }}
|
||||
></div>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import LogsTable from '../../components/LogsTable';
|
||||
import LogsTable from '../../components/table/LogsTable';
|
||||
|
||||
const Token = () => (
|
||||
<>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import MjLogsTable from '../../components/MjLogsTable';
|
||||
import MjLogsTable from '../../components/table/MjLogsTable';
|
||||
|
||||
const Midjourney = () => (
|
||||
<>
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import React from 'react';
|
||||
import { Message } from 'semantic-ui-react';
|
||||
import { Empty } from '@douyinfe/semi-ui';
|
||||
import { IllustrationNotFound, IllustrationNotFoundDark } from '@douyinfe/semi-illustrations';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const NotFound = () => (
|
||||
<>
|
||||
<Message negative>
|
||||
<Message.Header>页面不存在</Message.Header>
|
||||
<p>请检查你的浏览器地址是否正确</p>
|
||||
</Message>
|
||||
</>
|
||||
);
|
||||
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,465 +0,0 @@
|
||||
import React, { useCallback, useContext, useEffect, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { UserContext } from '../../context/User/index.js';
|
||||
import {
|
||||
API,
|
||||
getUserIdFromLocalStorage,
|
||||
showError,
|
||||
} from '../../helpers/index.js';
|
||||
import {
|
||||
Card,
|
||||
Chat,
|
||||
Input,
|
||||
Layout,
|
||||
Select,
|
||||
Slider,
|
||||
TextArea,
|
||||
Typography,
|
||||
Button,
|
||||
Highlight,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { SSE } from 'sse';
|
||||
import { IconSetting } from '@douyinfe/semi-icons';
|
||||
import { StyleContext } from '../../context/Style/index.js';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { renderGroupOption, truncateText } from '../../helpers/render.js';
|
||||
|
||||
const roleInfo = {
|
||||
user: {
|
||||
name: 'User',
|
||||
avatar:
|
||||
'https://lf3-static.bytednsdoc.com/obj/eden-cn/ptlz_zlp/ljhwZthlaukjlkulzlp/docs-icon.png',
|
||||
},
|
||||
assistant: {
|
||||
name: 'Assistant',
|
||||
avatar: 'logo.png',
|
||||
},
|
||||
system: {
|
||||
name: 'System',
|
||||
avatar:
|
||||
'https://lf3-static.bytednsdoc.com/obj/eden-cn/ptlz_zlp/ljhwZthlaukjlkulzlp/other/logo.png',
|
||||
},
|
||||
};
|
||||
|
||||
let id = 4;
|
||||
function getId() {
|
||||
return `${id++}`;
|
||||
}
|
||||
|
||||
const Playground = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const defaultMessage = [
|
||||
{
|
||||
role: 'user',
|
||||
id: '2',
|
||||
createAt: 1715676751919,
|
||||
content: t('你好'),
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
id: '3',
|
||||
createAt: 1715676751919,
|
||||
content: t('你好,请问有什么可以帮助您的吗?'),
|
||||
},
|
||||
];
|
||||
|
||||
const [inputs, setInputs] = useState({
|
||||
model: 'gpt-4o-mini',
|
||||
group: '',
|
||||
max_tokens: 0,
|
||||
temperature: 0,
|
||||
});
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [userState, userDispatch] = useContext(UserContext);
|
||||
const [status, setStatus] = useState({});
|
||||
const [systemPrompt, setSystemPrompt] = useState(
|
||||
'You are a helpful assistant. You can help me by answering my questions. You can also ask me questions.',
|
||||
);
|
||||
const [message, setMessage] = useState(defaultMessage);
|
||||
const [models, setModels] = useState([]);
|
||||
const [groups, setGroups] = useState([]);
|
||||
const [showSettings, setShowSettings] = useState(true);
|
||||
const [styleState, styleDispatch] = useContext(StyleContext);
|
||||
|
||||
const handleInputChange = (name, value) => {
|
||||
setInputs((inputs) => ({ ...inputs, [name]: value }));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams.get('expired')) {
|
||||
showError(t('未登录或登录已过期,请重新登录!'));
|
||||
}
|
||||
let status = localStorage.getItem('status');
|
||||
if (status) {
|
||||
status = JSON.parse(status);
|
||||
setStatus(status);
|
||||
}
|
||||
loadModels();
|
||||
loadGroups();
|
||||
}, []);
|
||||
|
||||
const loadModels = async () => {
|
||||
let res = await API.get(`/api/user/models`);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
let localModelOptions = data.map((model) => ({
|
||||
label: model,
|
||||
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: truncateText(info.desc, '50%'),
|
||||
value: group,
|
||||
ratio: info.ratio,
|
||||
fullLabel: info.desc, // 保存完整文本用于tooltip
|
||||
}));
|
||||
|
||||
if (localGroupOptions.length === 0) {
|
||||
localGroupOptions = [
|
||||
{
|
||||
label: t('用户分组'),
|
||||
value: '',
|
||||
ratio: 1,
|
||||
},
|
||||
];
|
||||
} else {
|
||||
const localUser = JSON.parse(localStorage.getItem('user'));
|
||||
const userGroup =
|
||||
(userState.user && userState.user.group) ||
|
||||
(localUser && localUser.group);
|
||||
|
||||
if (userGroup) {
|
||||
const userGroupIndex = localGroupOptions.findIndex(
|
||||
(g) => g.value === userGroup,
|
||||
);
|
||||
if (userGroupIndex > -1) {
|
||||
const userGroupOption = localGroupOptions.splice(
|
||||
userGroupIndex,
|
||||
1,
|
||||
)[0];
|
||||
localGroupOptions.unshift(userGroupOption);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setGroups(localGroupOptions);
|
||||
handleInputChange('group', localGroupOptions[0].value);
|
||||
} else {
|
||||
showError(t(message));
|
||||
}
|
||||
};
|
||||
|
||||
const commonOuterStyle = {
|
||||
border: '1px solid var(--semi-color-border)',
|
||||
borderRadius: '16px',
|
||||
margin: '0px 8px',
|
||||
};
|
||||
|
||||
const getSystemMessage = () => {
|
||||
if (systemPrompt !== '') {
|
||||
return {
|
||||
role: 'system',
|
||||
id: '1',
|
||||
createAt: 1715676751919,
|
||||
content: systemPrompt,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let handleSSE = (payload) => {
|
||||
let source = new SSE('/pg/chat/completions', {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'New-Api-User': getUserIdFromLocalStorage(),
|
||||
},
|
||||
method: 'POST',
|
||||
payload: JSON.stringify(payload),
|
||||
});
|
||||
source.addEventListener('message', (e) => {
|
||||
// 只有收到 [DONE] 时才结束
|
||||
if (e.data === '[DONE]') {
|
||||
source.close();
|
||||
completeMessage();
|
||||
return;
|
||||
}
|
||||
|
||||
let payload = JSON.parse(e.data);
|
||||
// 检查是否有 delta content
|
||||
if (payload.choices?.[0]?.delta?.content) {
|
||||
generateMockResponse(payload.choices[0].delta.content);
|
||||
}
|
||||
});
|
||||
|
||||
source.addEventListener('error', (e) => {
|
||||
generateMockResponse(e.data);
|
||||
completeMessage('error');
|
||||
});
|
||||
|
||||
source.addEventListener('readystatechange', (e) => {
|
||||
if (e.readyState >= 2) {
|
||||
if (source.status === undefined) {
|
||||
source.close();
|
||||
completeMessage();
|
||||
}
|
||||
}
|
||||
});
|
||||
source.stream();
|
||||
};
|
||||
|
||||
const onMessageSend = useCallback(
|
||||
(content, attachment) => {
|
||||
console.log('attachment: ', attachment);
|
||||
setMessage((prevMessage) => {
|
||||
const newMessage = [
|
||||
...prevMessage,
|
||||
{
|
||||
role: 'user',
|
||||
content: content,
|
||||
createAt: Date.now(),
|
||||
id: getId(),
|
||||
},
|
||||
];
|
||||
|
||||
// 将 getPayload 移到这里
|
||||
const getPayload = () => {
|
||||
let systemMessage = getSystemMessage();
|
||||
let messages = newMessage.map((item) => {
|
||||
return {
|
||||
role: item.role,
|
||||
content: item.content,
|
||||
};
|
||||
});
|
||||
if (systemMessage) {
|
||||
messages.unshift(systemMessage);
|
||||
}
|
||||
return {
|
||||
messages: messages,
|
||||
stream: true,
|
||||
model: inputs.model,
|
||||
group: inputs.group,
|
||||
max_tokens: parseInt(inputs.max_tokens),
|
||||
temperature: inputs.temperature,
|
||||
};
|
||||
};
|
||||
|
||||
// 使用更新后的消息状态调用 handleSSE
|
||||
handleSSE(getPayload());
|
||||
newMessage.push({
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
createAt: Date.now(),
|
||||
id: getId(),
|
||||
status: 'loading',
|
||||
});
|
||||
return newMessage;
|
||||
});
|
||||
},
|
||||
[getSystemMessage],
|
||||
);
|
||||
|
||||
const completeMessage = useCallback((status = 'complete') => {
|
||||
// console.log("Complete Message: ", status)
|
||||
setMessage((prevMessage) => {
|
||||
const lastMessage = prevMessage[prevMessage.length - 1];
|
||||
// only change the status if the last message is not complete and not error
|
||||
if (lastMessage.status === 'complete' || lastMessage.status === 'error') {
|
||||
return prevMessage;
|
||||
}
|
||||
return [...prevMessage.slice(0, -1), { ...lastMessage, status: status }];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const generateMockResponse = useCallback((content) => {
|
||||
// console.log("Generate Mock Response: ", content);
|
||||
setMessage((message) => {
|
||||
const lastMessage = message[message.length - 1];
|
||||
let newMessage = { ...lastMessage };
|
||||
if (
|
||||
lastMessage.status === 'loading' ||
|
||||
lastMessage.status === 'incomplete'
|
||||
) {
|
||||
newMessage = {
|
||||
...newMessage,
|
||||
content: (lastMessage.content || '') + content,
|
||||
status: 'incomplete',
|
||||
};
|
||||
}
|
||||
return [...message.slice(0, -1), newMessage];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const SettingsToggle = () => {
|
||||
if (!styleState.isMobile) return null;
|
||||
return (
|
||||
<Button
|
||||
icon={<IconSetting />}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: showSettings ? -10 : -20,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
zIndex: 1000,
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: '0 20px 20px 0',
|
||||
padding: 0,
|
||||
boxShadow: '2px 0 8px rgba(0, 0, 0, 0.15)',
|
||||
}}
|
||||
onClick={() => setShowSettings(!showSettings)}
|
||||
theme='solid'
|
||||
type='primary'
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
function CustomInputRender(props) {
|
||||
const { detailProps } = props;
|
||||
const { clearContextNode, uploadNode, inputNode, sendNode, onClick } =
|
||||
detailProps;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
margin: '8px 16px',
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-end',
|
||||
borderRadius: 16,
|
||||
padding: 10,
|
||||
border: '1px solid var(--semi-color-border)',
|
||||
}}
|
||||
onClick={onClick}
|
||||
>
|
||||
{/*{uploadNode}*/}
|
||||
{inputNode}
|
||||
{sendNode}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderInputArea = useCallback((props) => {
|
||||
return <CustomInputRender {...props} />;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Layout style={{ height: '100%' }}>
|
||||
{(showSettings || !styleState.isMobile) && (
|
||||
<Layout.Sider
|
||||
style={{ display: styleState.isMobile ? 'block' : 'initial' }}
|
||||
>
|
||||
<Card style={commonOuterStyle}>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<Typography.Text strong>{t('分组')}:</Typography.Text>
|
||||
</div>
|
||||
<Select
|
||||
placeholder={t('请选择分组')}
|
||||
name='group'
|
||||
required
|
||||
selection
|
||||
onChange={(value) => {
|
||||
handleInputChange('group', value);
|
||||
}}
|
||||
value={inputs.group}
|
||||
autoComplete='new-password'
|
||||
optionList={groups}
|
||||
renderOptionItem={renderGroupOption}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<Typography.Text strong>{t('模型')}:</Typography.Text>
|
||||
</div>
|
||||
<Select
|
||||
placeholder={t('请选择模型')}
|
||||
name='model'
|
||||
required
|
||||
selection
|
||||
searchPosition='dropdown'
|
||||
filter
|
||||
onChange={(value) => {
|
||||
handleInputChange('model', value);
|
||||
}}
|
||||
value={inputs.model}
|
||||
autoComplete='new-password'
|
||||
optionList={models}
|
||||
/>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<Typography.Text strong>Temperature:</Typography.Text>
|
||||
</div>
|
||||
<Slider
|
||||
step={0.1}
|
||||
min={0.1}
|
||||
max={1}
|
||||
value={inputs.temperature}
|
||||
onChange={(value) => {
|
||||
handleInputChange('temperature', value);
|
||||
}}
|
||||
/>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<Typography.Text strong>MaxTokens:</Typography.Text>
|
||||
</div>
|
||||
<Input
|
||||
placeholder='MaxTokens'
|
||||
name='max_tokens'
|
||||
required
|
||||
autoComplete='new-password'
|
||||
defaultValue={0}
|
||||
value={inputs.max_tokens}
|
||||
onChange={(value) => {
|
||||
handleInputChange('max_tokens', value);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<Typography.Text strong>System:</Typography.Text>
|
||||
</div>
|
||||
<TextArea
|
||||
placeholder='System Prompt'
|
||||
name='system'
|
||||
required
|
||||
autoComplete='new-password'
|
||||
autosize
|
||||
defaultValue={systemPrompt}
|
||||
// value={systemPrompt}
|
||||
onChange={(value) => {
|
||||
setSystemPrompt(value);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</Layout.Sider>
|
||||
)}
|
||||
<Layout.Content>
|
||||
<div style={{ height: '100%', position: 'relative' }}>
|
||||
<SettingsToggle />
|
||||
<Chat
|
||||
chatBoxRenderConfig={{
|
||||
renderChatBoxAction: () => {
|
||||
return <div></div>;
|
||||
},
|
||||
}}
|
||||
renderInputArea={renderInputArea}
|
||||
roleConfig={roleInfo}
|
||||
style={commonOuterStyle}
|
||||
chats={message}
|
||||
onMessageSend={onMessageSend}
|
||||
showClearContext
|
||||
onClear={() => {
|
||||
setMessage([]);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Playground;
|
||||
489
web/src/pages/Playground/index.js
Normal file
489
web/src/pages/Playground/index.js
Normal file
@@ -0,0 +1,489 @@
|
||||
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 { useStyle, styleActions } from '../../context/Style/index.js';
|
||||
|
||||
// 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';
|
||||
|
||||
// Constants and utils
|
||||
import {
|
||||
MESSAGE_ROLES,
|
||||
ERROR_MESSAGES
|
||||
} from '../../constants/playground.constants.js';
|
||||
import {
|
||||
getLogo,
|
||||
stringToColor,
|
||||
buildMessageContent,
|
||||
createMessage,
|
||||
createLoadingAssistantMessage,
|
||||
getTextContent,
|
||||
buildApiPayload
|
||||
} from '../../helpers';
|
||||
|
||||
// Components
|
||||
import {
|
||||
OptimizedSettingsPanel,
|
||||
OptimizedDebugPanel,
|
||||
OptimizedMessageContent,
|
||||
OptimizedMessageActions
|
||||
} from '../../components/playground/OptimizedComponents.js';
|
||||
import ChatArea from '../../components/playground/ChatArea.js';
|
||||
import FloatingButtons from '../../components/playground/FloatingButtons.js';
|
||||
|
||||
// 生成头像
|
||||
const generateAvatarDataUrl = (username) => {
|
||||
if (!username) {
|
||||
return 'https://lf3-static.bytednsdoc.com/obj/eden-cn/ptlz_zlp/ljhwZthlaukjlkulzlp/docs-icon.png';
|
||||
}
|
||||
const firstLetter = username[0].toUpperCase();
|
||||
const bgColor = stringToColor(username);
|
||||
const svg = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||
<circle cx="16" cy="16" r="16" fill="${bgColor}" />
|
||||
<text x="50%" y="50%" dominant-baseline="central" text-anchor="middle" font-size="16" fill="#ffffff" font-family="sans-serif">${firstLetter}</text>
|
||||
</svg>
|
||||
`;
|
||||
return `data:image/svg+xml;base64,${btoa(svg)}`;
|
||||
};
|
||||
|
||||
const Playground = () => {
|
||||
const { t } = useTranslation();
|
||||
const [userState] = useContext(UserContext);
|
||||
const { state: styleState, dispatch: styleDispatch } = useStyle();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const state = usePlaygroundState();
|
||||
const {
|
||||
inputs,
|
||||
parameterEnabled,
|
||||
showDebugPanel,
|
||||
customRequestMode,
|
||||
customRequestBody,
|
||||
showSettings,
|
||||
models,
|
||||
groups,
|
||||
status,
|
||||
message,
|
||||
debugData,
|
||||
activeDebugTab,
|
||||
previewPayload,
|
||||
sseSourceRef,
|
||||
chatRef,
|
||||
handleInputChange,
|
||||
handleParameterToggle,
|
||||
debouncedSaveConfig,
|
||||
saveMessagesImmediately,
|
||||
handleConfigImport,
|
||||
handleConfigReset,
|
||||
setShowSettings,
|
||||
setModels,
|
||||
setGroups,
|
||||
setStatus,
|
||||
setMessage,
|
||||
setDebugData,
|
||||
setActiveDebugTab,
|
||||
setPreviewPayload,
|
||||
setShowDebugPanel,
|
||||
setCustomRequestMode,
|
||||
setCustomRequestBody,
|
||||
} = state;
|
||||
|
||||
// API 请求相关
|
||||
const { sendRequest, onStopGenerator } = useApiRequest(
|
||||
setMessage,
|
||||
setDebugData,
|
||||
setActiveDebugTab,
|
||||
sseSourceRef,
|
||||
saveMessagesImmediately
|
||||
);
|
||||
|
||||
// 数据加载
|
||||
useDataLoader(userState, inputs, handleInputChange, setModels, setGroups);
|
||||
|
||||
// 消息编辑
|
||||
const {
|
||||
editingMessageId,
|
||||
editValue,
|
||||
setEditValue,
|
||||
handleMessageEdit,
|
||||
handleEditSave,
|
||||
handleEditCancel
|
||||
} = useMessageEdit(setMessage, inputs, parameterEnabled, sendRequest, saveMessagesImmediately);
|
||||
|
||||
// 消息和自定义请求体同步
|
||||
const { syncMessageToCustomBody, syncCustomBodyToMessage } = useSyncMessageAndCustomBody(
|
||||
customRequestMode,
|
||||
customRequestBody,
|
||||
message,
|
||||
inputs,
|
||||
setCustomRequestBody,
|
||||
setMessage,
|
||||
debouncedSaveConfig
|
||||
);
|
||||
|
||||
// 角色信息
|
||||
const roleInfo = {
|
||||
user: {
|
||||
name: userState?.user?.username || 'User',
|
||||
avatar: generateAvatarDataUrl(userState?.user?.username),
|
||||
},
|
||||
assistant: {
|
||||
name: 'Assistant',
|
||||
avatar: getLogo(),
|
||||
},
|
||||
system: {
|
||||
name: 'System',
|
||||
avatar: getLogo(),
|
||||
},
|
||||
};
|
||||
|
||||
// 消息操作
|
||||
const messageActions = useMessageActions(message, setMessage, onMessageSend, saveMessagesImmediately);
|
||||
|
||||
// 构建预览请求体
|
||||
const constructPreviewPayload = useCallback(() => {
|
||||
try {
|
||||
// 如果是自定义请求体模式且有自定义内容,直接返回解析后的自定义请求体
|
||||
if (customRequestMode && customRequestBody && customRequestBody.trim()) {
|
||||
try {
|
||||
return JSON.parse(customRequestBody);
|
||||
} catch (parseError) {
|
||||
console.warn('自定义请求体JSON解析失败,回退到默认预览:', parseError);
|
||||
}
|
||||
}
|
||||
|
||||
// 默认预览逻辑
|
||||
let messages = [...message];
|
||||
|
||||
// 如果存在用户消息
|
||||
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() !== '');
|
||||
if (validImageUrls.length > 0) {
|
||||
const textContent = getTextContent(messages[i]) || '示例消息';
|
||||
const content = buildMessageContent(textContent, validImageUrls, true);
|
||||
messages[i] = { ...messages[i], content };
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return buildApiPayload(messages, null, inputs, parameterEnabled);
|
||||
} catch (error) {
|
||||
console.error('构造预览请求体失败:', error);
|
||||
return null;
|
||||
}
|
||||
}, [inputs, parameterEnabled, message, customRequestMode, customRequestBody]);
|
||||
|
||||
// 发送消息
|
||||
function onMessageSend(content, attachment) {
|
||||
console.log('attachment: ', attachment);
|
||||
|
||||
// 创建用户消息和加载消息
|
||||
const userMessage = createMessage(MESSAGE_ROLES.USER, content);
|
||||
const loadingMessage = createLoadingAssistantMessage();
|
||||
|
||||
// 如果是自定义请求体模式
|
||||
if (customRequestMode && customRequestBody) {
|
||||
try {
|
||||
const customPayload = JSON.parse(customRequestBody);
|
||||
|
||||
setMessage(prevMessage => {
|
||||
const newMessages = [...prevMessage, userMessage, loadingMessage];
|
||||
|
||||
// 发送自定义请求体
|
||||
sendRequest(customPayload, customPayload.stream !== false);
|
||||
|
||||
// 发送消息后保存,传入新消息列表
|
||||
setTimeout(() => saveMessagesImmediately(newMessages), 0);
|
||||
|
||||
return newMessages;
|
||||
});
|
||||
return;
|
||||
} catch (error) {
|
||||
console.error('自定义请求体JSON解析失败:', error);
|
||||
Toast.error(ERROR_MESSAGES.JSON_PARSE_ERROR);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 默认模式
|
||||
const validImageUrls = inputs.imageUrls.filter(url => url.trim() !== '');
|
||||
const messageContent = buildMessageContent(content, validImageUrls, inputs.imageEnabled);
|
||||
const userMessageWithImages = createMessage(MESSAGE_ROLES.USER, messageContent);
|
||||
|
||||
setMessage(prevMessage => {
|
||||
const newMessages = [...prevMessage, userMessageWithImages];
|
||||
|
||||
const payload = buildApiPayload(newMessages, null, inputs, parameterEnabled);
|
||||
sendRequest(payload, inputs.stream);
|
||||
|
||||
// 禁用图片模式
|
||||
if (inputs.imageEnabled) {
|
||||
setTimeout(() => {
|
||||
handleInputChange('imageEnabled', false);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// 发送消息后保存,传入新消息列表(包含用户消息和加载消息)
|
||||
const messagesWithLoading = [...newMessages, loadingMessage];
|
||||
setTimeout(() => saveMessagesImmediately(messagesWithLoading), 0);
|
||||
|
||||
return messagesWithLoading;
|
||||
});
|
||||
}
|
||||
|
||||
// 切换推理展开状态
|
||||
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(
|
||||
({ message, className }) => {
|
||||
const isCurrentlyEditing = editingMessageId === message.id;
|
||||
|
||||
return (
|
||||
<OptimizedMessageContent
|
||||
message={message}
|
||||
className={className}
|
||||
styleState={styleState}
|
||||
onToggleReasoningExpansion={toggleReasoningExpansion}
|
||||
isEditing={isCurrentlyEditing}
|
||||
onEditSave={handleEditSave}
|
||||
onEditCancel={handleEditCancel}
|
||||
editValue={editValue}
|
||||
onEditValueChange={setEditValue}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[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;
|
||||
|
||||
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
|
||||
|
||||
// 同步消息和自定义请求体
|
||||
useEffect(() => {
|
||||
syncMessageToCustomBody();
|
||||
}, [message, syncMessageToCustomBody]);
|
||||
|
||||
useEffect(() => {
|
||||
syncCustomBodyToMessage();
|
||||
}, [customRequestBody, syncCustomBodyToMessage]);
|
||||
|
||||
// 处理URL参数
|
||||
useEffect(() => {
|
||||
if (searchParams.get('expired')) {
|
||||
Toast.warning(t('登录过期,请重新登录!'));
|
||||
}
|
||||
}, [searchParams, t]);
|
||||
|
||||
// 处理窗口大小变化
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
const mobile = window.innerWidth < 768;
|
||||
if (styleState.isMobile !== mobile) {
|
||||
styleDispatch(styleActions.setMobile(mobile));
|
||||
}
|
||||
};
|
||||
|
||||
handleResize();
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, [styleState.isMobile, styleDispatch]);
|
||||
|
||||
// 构建预览payload
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
const preview = constructPreviewPayload();
|
||||
setPreviewPayload(preview);
|
||||
setDebugData(prev => ({
|
||||
...prev,
|
||||
previewRequest: preview ? JSON.stringify(preview, null, 2) : null,
|
||||
previewTimestamp: preview ? new Date().toISOString() : null
|
||||
}));
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [message, inputs, parameterEnabled, customRequestMode, customRequestBody, constructPreviewPayload, setPreviewPayload, setDebugData]);
|
||||
|
||||
// 自动保存配置
|
||||
useEffect(() => {
|
||||
debouncedSaveConfig();
|
||||
}, [inputs, parameterEnabled, showDebugPanel, customRequestMode, customRequestBody, debouncedSaveConfig]);
|
||||
|
||||
// 清空对话的处理函数
|
||||
const handleClearMessages = useCallback(() => {
|
||||
setMessage([]);
|
||||
// 清空对话后保存,传入空数组
|
||||
setTimeout(() => saveMessagesImmediately([]), 0);
|
||||
}, [setMessage, saveMessagesImmediately]);
|
||||
|
||||
return (
|
||||
<div className="h-full bg-gray-50">
|
||||
<Layout style={{ height: '100%', background: 'transparent' }} className="flex flex-col md:flex-row">
|
||||
{(showSettings || !styleState.isMobile) && (
|
||||
<Layout.Sider
|
||||
style={{
|
||||
background: 'transparent',
|
||||
borderRight: 'none',
|
||||
flexShrink: 0,
|
||||
minWidth: styleState.isMobile ? '100%' : 320,
|
||||
maxWidth: styleState.isMobile ? '100%' : 320,
|
||||
height: styleState.isMobile ? 'auto' : 'calc(100vh - 66px)',
|
||||
overflow: 'auto',
|
||||
position: styleState.isMobile ? 'fixed' : 'relative',
|
||||
zIndex: styleState.isMobile ? 1000 : 1,
|
||||
width: '100%',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
}}
|
||||
width={styleState.isMobile ? '100%' : 320}
|
||||
className={styleState.isMobile ? 'bg-white shadow-lg' : ''}
|
||||
>
|
||||
<OptimizedSettingsPanel
|
||||
inputs={inputs}
|
||||
parameterEnabled={parameterEnabled}
|
||||
models={models}
|
||||
groups={groups}
|
||||
styleState={styleState}
|
||||
showSettings={showSettings}
|
||||
showDebugPanel={showDebugPanel}
|
||||
customRequestMode={customRequestMode}
|
||||
customRequestBody={customRequestBody}
|
||||
onInputChange={handleInputChange}
|
||||
onParameterToggle={handleParameterToggle}
|
||||
onCloseSettings={() => setShowSettings(false)}
|
||||
onConfigImport={handleConfigImport}
|
||||
onConfigReset={handleConfigReset}
|
||||
onCustomRequestModeChange={setCustomRequestMode}
|
||||
onCustomRequestBodyChange={setCustomRequestBody}
|
||||
previewPayload={previewPayload}
|
||||
messages={message}
|
||||
/>
|
||||
</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">
|
||||
<ChatArea
|
||||
chatRef={chatRef}
|
||||
message={message}
|
||||
inputs={inputs}
|
||||
styleState={styleState}
|
||||
showDebugPanel={showDebugPanel}
|
||||
roleInfo={roleInfo}
|
||||
onMessageSend={onMessageSend}
|
||||
onMessageCopy={messageActions.handleMessageCopy}
|
||||
onMessageReset={messageActions.handleMessageReset}
|
||||
onMessageDelete={messageActions.handleMessageDelete}
|
||||
onStopGenerator={onStopGenerator}
|
||||
onClearMessages={handleClearMessages}
|
||||
onToggleDebugPanel={() => setShowDebugPanel(!showDebugPanel)}
|
||||
renderCustomChatContent={renderCustomChatContent}
|
||||
renderChatBoxAction={renderChatBoxAction}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 调试面板 - 桌面端 */}
|
||||
{showDebugPanel && !styleState.isMobile && (
|
||||
<div className="w-96 flex-shrink-0 h-full">
|
||||
<OptimizedDebugPanel
|
||||
debugData={debugData}
|
||||
activeDebugTab={activeDebugTab}
|
||||
onActiveDebugTabChange={setActiveDebugTab}
|
||||
styleState={styleState}
|
||||
customRequestMode={customRequestMode}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 调试面板 - 移动端覆盖层 */}
|
||||
{showDebugPanel && styleState.isMobile && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: 1000,
|
||||
backgroundColor: 'white',
|
||||
overflow: 'auto',
|
||||
}}
|
||||
className="shadow-lg"
|
||||
>
|
||||
<OptimizedDebugPanel
|
||||
debugData={debugData}
|
||||
activeDebugTab={activeDebugTab}
|
||||
onActiveDebugTabChange={setActiveDebugTab}
|
||||
styleState={styleState}
|
||||
showDebugPanel={showDebugPanel}
|
||||
onCloseDebugPanel={() => setShowDebugPanel(false)}
|
||||
customRequestMode={customRequestMode}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 浮动按钮 */}
|
||||
<FloatingButtons
|
||||
styleState={styleState}
|
||||
showSettings={showSettings}
|
||||
showDebugPanel={showDebugPanel}
|
||||
onToggleSettings={() => setShowSettings(!showSettings)}
|
||||
onToggleDebugPanel={() => setShowDebugPanel(!showDebugPanel)}
|
||||
/>
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Playground;
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import ModelPricing from '../../components/ModelPricing.js';
|
||||
import ModelPricing from '../../components/table/ModelPricing.js';
|
||||
|
||||
const Pricing = () => (
|
||||
<>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
API,
|
||||
@@ -7,12 +6,9 @@ import {
|
||||
isMobile,
|
||||
showError,
|
||||
showSuccess,
|
||||
} from '../../helpers';
|
||||
import {
|
||||
getQuotaPerUnit,
|
||||
renderQuota,
|
||||
renderQuotaWithPrompt,
|
||||
} from '../../helpers/render';
|
||||
renderQuotaWithPrompt
|
||||
} from '../../helpers';
|
||||
import {
|
||||
AutoComplete,
|
||||
Button,
|
||||
@@ -22,24 +18,34 @@ import {
|
||||
Space,
|
||||
Spin,
|
||||
Typography,
|
||||
Card,
|
||||
Tag,
|
||||
Form,
|
||||
DatePicker,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import Title from '@douyinfe/semi-ui/lib/es/typography/title';
|
||||
import { Divider } from 'semantic-ui-react';
|
||||
import {
|
||||
IconCreditCard,
|
||||
IconSave,
|
||||
IconClose,
|
||||
IconPlusCircle,
|
||||
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 params = useParams();
|
||||
const navigate = useNavigate();
|
||||
const originInputs = {
|
||||
name: '',
|
||||
quota: 100000,
|
||||
count: 1,
|
||||
expired_time: 0,
|
||||
};
|
||||
const [inputs, setInputs] = useState(originInputs);
|
||||
const { name, quota, count } = inputs;
|
||||
const { name, quota, count, expired_time } = inputs;
|
||||
|
||||
const handleCancel = () => {
|
||||
props.handleClose();
|
||||
@@ -82,6 +88,9 @@ const EditRedemption = (props) => {
|
||||
localInputs.count = parseInt(localInputs.count);
|
||||
localInputs.quota = parseInt(localInputs.quota);
|
||||
localInputs.name = name;
|
||||
if (localInputs.expired_time === null || localInputs.expired_time === undefined) {
|
||||
localInputs.expired_time = 0;
|
||||
}
|
||||
let res;
|
||||
if (isEdit) {
|
||||
res = await API.put(`/api/redemption/`, {
|
||||
@@ -134,24 +143,46 @@ const EditRedemption = (props) => {
|
||||
<SideSheet
|
||||
placement={isEdit ? 'right' : 'left'}
|
||||
title={
|
||||
<Title level={3}>
|
||||
{isEdit ? t('更新兑换码信息') : t('创建新的兑换码')}
|
||||
</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>
|
||||
}
|
||||
headerStyle={{ borderBottom: '1px solid var(--semi-color-border)' }}
|
||||
bodyStyle={{ borderBottom: '1px solid var(--semi-color-border)' }}
|
||||
headerStyle={{
|
||||
borderBottom: '1px solid var(--semi-color-border)',
|
||||
padding: '24px'
|
||||
}}
|
||||
bodyStyle={{
|
||||
backgroundColor: 'var(--semi-color-bg-0)',
|
||||
padding: '0'
|
||||
}}
|
||||
visible={props.visiable}
|
||||
width={isMobile() ? '100%' : 600}
|
||||
footer={
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<div className="flex justify-end bg-white">
|
||||
<Space>
|
||||
<Button theme='solid' size={'large'} onClick={submit}>
|
||||
<Button
|
||||
theme="solid"
|
||||
size="large"
|
||||
className="!rounded-full"
|
||||
onClick={submit}
|
||||
icon={<IconSave />}
|
||||
loading={loading}
|
||||
>
|
||||
{t('提交')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='solid'
|
||||
size={'large'}
|
||||
type={'tertiary'}
|
||||
theme="light"
|
||||
size="large"
|
||||
className="!rounded-full"
|
||||
type="primary"
|
||||
onClick={handleCancel}
|
||||
icon={<IconClose />}
|
||||
>
|
||||
{t('取消')}
|
||||
</Button>
|
||||
@@ -160,59 +191,125 @@ const EditRedemption = (props) => {
|
||||
}
|
||||
closeIcon={null}
|
||||
onCancel={() => handleCancel()}
|
||||
width={isMobile() ? '100%' : 600}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
<Input
|
||||
style={{ marginTop: 20 }}
|
||||
label={t('名称')}
|
||||
name='name'
|
||||
placeholder={t('请输入名称')}
|
||||
onChange={(value) => handleInputChange('name', value)}
|
||||
value={name}
|
||||
autoComplete='new-password'
|
||||
required={!isEdit}
|
||||
/>
|
||||
<Divider />
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<Typography.Text>
|
||||
{t('额度') + renderQuotaWithPrompt(quota)}
|
||||
</Typography.Text>
|
||||
<div className="p-6">
|
||||
<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">
|
||||
<IconGift 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>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Text strong className="block mb-2">{t('名称')}</Text>
|
||||
<Input
|
||||
placeholder={t('请输入名称')}
|
||||
onChange={(value) => handleInputChange('name', value)}
|
||||
value={name}
|
||||
autoComplete="new-password"
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
showClear
|
||||
required={!isEdit}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Text strong className="block mb-2">{t('过期时间')}</Text>
|
||||
<DatePicker
|
||||
type="dateTime"
|
||||
placeholder={t('选择过期时间(可选,留空为永久)')}
|
||||
showClear
|
||||
value={expired_time ? new Date(expired_time * 1000) : null}
|
||||
onChange={(value) => {
|
||||
if (value === null || value === undefined) {
|
||||
handleInputChange('expired_time', 0);
|
||||
} else {
|
||||
const timestamp = Math.floor(value.getTime() / 1000);
|
||||
handleInputChange('expired_time', timestamp);
|
||||
}
|
||||
}}
|
||||
size="large"
|
||||
className="!rounded-lg w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="!rounded-2xl shadow-sm border-0">
|
||||
<div className="flex items-center mb-4 p-6 rounded-xl" style={{
|
||||
background: 'linear-gradient(135deg, #065f46 0%, #059669 50%, #10b981 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">
|
||||
<IconCreditCard 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>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="flex justify-between mb-2">
|
||||
<Text strong>{t('额度')}</Text>
|
||||
<Text type="tertiary">{renderQuotaWithPrompt(quota)}</Text>
|
||||
</div>
|
||||
<AutoComplete
|
||||
placeholder={t('请输入额度')}
|
||||
onChange={(value) => handleInputChange('quota', value)}
|
||||
value={quota}
|
||||
autoComplete="new-password"
|
||||
type="number"
|
||||
size="large"
|
||||
className="w-full !rounded-lg"
|
||||
prefix={<IconCreditCard />}
|
||||
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$' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isEdit && (
|
||||
<div>
|
||||
<Text strong className="block mb-2">{t('生成数量')}</Text>
|
||||
<Input
|
||||
placeholder={t('请输入生成数量')}
|
||||
onChange={(value) => handleInputChange('count', value)}
|
||||
value={count}
|
||||
autoComplete="new-password"
|
||||
type="number"
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
prefix={<IconPlusCircle />}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<AutoComplete
|
||||
style={{ marginTop: 8 }}
|
||||
name='quota'
|
||||
placeholder={t('请输入额度')}
|
||||
onChange={(value) => handleInputChange('quota', value)}
|
||||
value={quota}
|
||||
autoComplete='new-password'
|
||||
type='number'
|
||||
position={'bottom'}
|
||||
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$' },
|
||||
]}
|
||||
/>
|
||||
{!isEdit && (
|
||||
<>
|
||||
<Divider />
|
||||
<Typography.Text>{t('生成数量')}</Typography.Text>
|
||||
<Input
|
||||
style={{ marginTop: 8 }}
|
||||
label={t('生成数量')}
|
||||
name='count'
|
||||
placeholder={t('请输入生成数量')}
|
||||
onChange={(value) => handleInputChange('count', value)}
|
||||
value={count}
|
||||
autoComplete='new-password'
|
||||
type='number'
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Spin>
|
||||
</SideSheet>
|
||||
</>
|
||||
|
||||
@@ -1,20 +1,10 @@
|
||||
import React from 'react';
|
||||
import RedemptionsTable from '../../components/RedemptionsTable';
|
||||
import { Layout } from '@douyinfe/semi-ui';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import RedemptionsTable from '../../components/table/RedemptionsTable';
|
||||
|
||||
const Redemption = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<Layout>
|
||||
<Layout.Header>
|
||||
<h3>{t('管理兑换码')}</h3>
|
||||
</Layout.Header>
|
||||
<Layout.Content>
|
||||
<RedemptionsTable />
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
<RedemptionsTable />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,10 +2,7 @@ import React, { useEffect, useState, useRef } from 'react';
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
Col,
|
||||
Form,
|
||||
Popconfirm,
|
||||
Row,
|
||||
Space,
|
||||
Spin,
|
||||
} from '@douyinfe/semi-ui';
|
||||
@@ -16,7 +13,6 @@ import {
|
||||
showSuccess,
|
||||
showWarning,
|
||||
verifyJSON,
|
||||
verifyJSONPromise,
|
||||
} from '../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -80,21 +76,6 @@ export default function SettingsChats(props) {
|
||||
}
|
||||
}
|
||||
|
||||
async function resetModelRatio() {
|
||||
try {
|
||||
let res = await API.post(`/api/option/rest_model_ratio`);
|
||||
// return {success, message}
|
||||
if (res.data.success) {
|
||||
showSuccess(res.data.message);
|
||||
props.refresh();
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const currentInputs = {};
|
||||
for (let key in props.options) {
|
||||
@@ -119,13 +100,7 @@ export default function SettingsChats(props) {
|
||||
getFormApi={(formAPI) => (refForm.current = formAPI)}
|
||||
style={{ marginBottom: 15 }}
|
||||
>
|
||||
<Form.Section text={t('令牌聊天设置')}>
|
||||
<Banner
|
||||
type='warning'
|
||||
description={t(
|
||||
'必须将上方聊天链接全部设置为空,才能使用下方聊天设置功能',
|
||||
)}
|
||||
/>
|
||||
<Form.Section text={t('聊天设置')}>
|
||||
<Banner
|
||||
type='info'
|
||||
description={t(
|
||||
510
web/src/pages/Setting/Dashboard/SettingsAPIInfo.js
Normal file
510
web/src/pages/Setting/Dashboard/SettingsAPIInfo.js
Normal file
@@ -0,0 +1,510 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Space,
|
||||
Table,
|
||||
Form,
|
||||
Typography,
|
||||
Empty,
|
||||
Divider,
|
||||
Avatar,
|
||||
Modal,
|
||||
Tag,
|
||||
Switch
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import {
|
||||
Plus,
|
||||
Edit,
|
||||
Trash2,
|
||||
Save,
|
||||
Settings
|
||||
} from 'lucide-react';
|
||||
import { API, showError, showSuccess } from '../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const SettingsAPIInfo = ({ options, refresh }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [apiInfoList, setApiInfoList] = useState([]);
|
||||
const [showApiModal, setShowApiModal] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [deletingApi, setDeletingApi] = useState(null);
|
||||
const [editingApi, setEditingApi] = useState(null);
|
||||
const [modalLoading, setModalLoading] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [apiForm, setApiForm] = useState({
|
||||
url: '',
|
||||
description: '',
|
||||
route: '',
|
||||
color: 'blue'
|
||||
});
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState([]);
|
||||
|
||||
// 面板启用状态 state
|
||||
const [panelEnabled, setPanelEnabled] = useState(true);
|
||||
|
||||
const colorOptions = [
|
||||
{ value: 'blue', label: 'blue' },
|
||||
{ value: 'green', label: 'green' },
|
||||
{ value: 'cyan', label: 'cyan' },
|
||||
{ value: 'purple', label: 'purple' },
|
||||
{ value: 'pink', label: 'pink' },
|
||||
{ value: 'red', label: 'red' },
|
||||
{ value: 'orange', label: 'orange' },
|
||||
{ value: 'amber', label: 'amber' },
|
||||
{ value: 'yellow', label: 'yellow' },
|
||||
{ value: 'lime', label: 'lime' },
|
||||
{ value: 'light-green', label: 'light-green' },
|
||||
{ value: 'teal', label: 'teal' },
|
||||
{ value: 'light-blue', label: 'light-blue' },
|
||||
{ value: 'indigo', label: 'indigo' },
|
||||
{ value: 'violet', label: 'violet' },
|
||||
{ value: 'grey', label: 'grey' }
|
||||
];
|
||||
|
||||
const updateOption = async (key, value) => {
|
||||
const res = await API.put('/api/option/', {
|
||||
key,
|
||||
value,
|
||||
});
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess('API信息已更新');
|
||||
if (refresh) refresh();
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
};
|
||||
|
||||
const submitApiInfo = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const apiInfoJson = JSON.stringify(apiInfoList);
|
||||
await updateOption('console_setting.api_info', apiInfoJson);
|
||||
setHasChanges(false);
|
||||
} catch (error) {
|
||||
console.error('API信息更新失败', error);
|
||||
showError('API信息更新失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddApi = () => {
|
||||
setEditingApi(null);
|
||||
setApiForm({
|
||||
url: '',
|
||||
description: '',
|
||||
route: '',
|
||||
color: 'blue'
|
||||
});
|
||||
setShowApiModal(true);
|
||||
};
|
||||
|
||||
const handleEditApi = (api) => {
|
||||
setEditingApi(api);
|
||||
setApiForm({
|
||||
url: api.url,
|
||||
description: api.description,
|
||||
route: api.route,
|
||||
color: api.color
|
||||
});
|
||||
setShowApiModal(true);
|
||||
};
|
||||
|
||||
const handleDeleteApi = (api) => {
|
||||
setDeletingApi(api);
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
const confirmDeleteApi = () => {
|
||||
if (deletingApi) {
|
||||
const newList = apiInfoList.filter(api => api.id !== deletingApi.id);
|
||||
setApiInfoList(newList);
|
||||
setHasChanges(true);
|
||||
showSuccess('API信息已删除,请及时点击“保存设置”进行保存');
|
||||
}
|
||||
setShowDeleteModal(false);
|
||||
setDeletingApi(null);
|
||||
};
|
||||
|
||||
const handleSaveApi = async () => {
|
||||
if (!apiForm.url || !apiForm.route || !apiForm.description) {
|
||||
showError('请填写完整的API信息');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setModalLoading(true);
|
||||
|
||||
let newList;
|
||||
if (editingApi) {
|
||||
newList = apiInfoList.map(api =>
|
||||
api.id === editingApi.id
|
||||
? { ...api, ...apiForm }
|
||||
: api
|
||||
);
|
||||
} else {
|
||||
const newId = Math.max(...apiInfoList.map(api => api.id), 0) + 1;
|
||||
const newApi = {
|
||||
id: newId,
|
||||
...apiForm
|
||||
};
|
||||
newList = [...apiInfoList, newApi];
|
||||
}
|
||||
|
||||
setApiInfoList(newList);
|
||||
setHasChanges(true);
|
||||
setShowApiModal(false);
|
||||
showSuccess(editingApi ? 'API信息已更新,请及时点击“保存设置”进行保存' : 'API信息已添加,请及时点击“保存设置”进行保存');
|
||||
} catch (error) {
|
||||
showError('操作失败: ' + error.message);
|
||||
} finally {
|
||||
setModalLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const parseApiInfo = (apiInfoStr) => {
|
||||
if (!apiInfoStr) {
|
||||
setApiInfoList([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(apiInfoStr);
|
||||
setApiInfoList(Array.isArray(parsed) ? parsed : []);
|
||||
} catch (error) {
|
||||
console.error('解析API信息失败:', error);
|
||||
setApiInfoList([]);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const apiInfoStr = options['console_setting.api_info'] ?? options.ApiInfo;
|
||||
if (apiInfoStr !== undefined) {
|
||||
parseApiInfo(apiInfoStr);
|
||||
}
|
||||
}, [options['console_setting.api_info'], options.ApiInfo]);
|
||||
|
||||
useEffect(() => {
|
||||
const enabledStr = options['console_setting.api_info_enabled'];
|
||||
setPanelEnabled(enabledStr === undefined ? true : enabledStr === 'true' || enabledStr === true);
|
||||
}, [options['console_setting.api_info_enabled']]);
|
||||
|
||||
const handleToggleEnabled = async (checked) => {
|
||||
const newValue = checked ? 'true' : 'false';
|
||||
try {
|
||||
const res = await API.put('/api/option/', {
|
||||
key: 'console_setting.api_info_enabled',
|
||||
value: newValue,
|
||||
});
|
||||
if (res.data.success) {
|
||||
setPanelEnabled(checked);
|
||||
showSuccess(t('设置已保存'));
|
||||
refresh?.();
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
}
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
},
|
||||
{
|
||||
title: t('API地址'),
|
||||
dataIndex: 'url',
|
||||
render: (text, record) => (
|
||||
<Tag
|
||||
color={record.color}
|
||||
className="!rounded-full"
|
||||
style={{ maxWidth: '280px' }}
|
||||
>
|
||||
{text}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('线路描述'),
|
||||
dataIndex: 'route',
|
||||
render: (text, record) => (
|
||||
<Tag shape='circle'>
|
||||
{text}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('说明'),
|
||||
dataIndex: 'description',
|
||||
ellipsis: true,
|
||||
render: (text, record) => (
|
||||
<Tag shape='circle'>
|
||||
{text || '-'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('颜色'),
|
||||
dataIndex: 'color',
|
||||
render: (color) => (
|
||||
<Avatar
|
||||
size="extra-extra-small"
|
||||
color={color}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('操作'),
|
||||
fixed: 'right',
|
||||
width: 150,
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Button
|
||||
icon={<Edit size={14} />}
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
size='small'
|
||||
className="!rounded-full"
|
||||
onClick={() => handleEditApi(record)}
|
||||
>
|
||||
{t('编辑')}
|
||||
</Button>
|
||||
<Button
|
||||
icon={<Trash2 size={14} />}
|
||||
type='danger'
|
||||
theme='light'
|
||||
size='small'
|
||||
className="!rounded-full"
|
||||
onClick={() => handleDeleteApi(record)}
|
||||
>
|
||||
{t('删除')}
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const handleBatchDelete = () => {
|
||||
if (selectedRowKeys.length === 0) {
|
||||
showError('请先选择要删除的API信息');
|
||||
return;
|
||||
}
|
||||
|
||||
const newList = apiInfoList.filter(api => !selectedRowKeys.includes(api.id));
|
||||
setApiInfoList(newList);
|
||||
setSelectedRowKeys([]);
|
||||
setHasChanges(true);
|
||||
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>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<Button
|
||||
theme='light'
|
||||
type='primary'
|
||||
icon={<Plus size={14} />}
|
||||
className="!rounded-full w-full md:w-auto"
|
||||
onClick={handleAddApi}
|
||||
>
|
||||
{t('添加API')}
|
||||
</Button>
|
||||
<Button
|
||||
icon={<Trash2 size={14} />}
|
||||
type='danger'
|
||||
theme='light'
|
||||
onClick={handleBatchDelete}
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
className="!rounded-full w-full md:w-auto"
|
||||
>
|
||||
{t('批量删除')} {selectedRowKeys.length > 0 && `(${selectedRowKeys.length})`}
|
||||
</Button>
|
||||
<Button
|
||||
icon={<Save size={14} />}
|
||||
onClick={submitApiInfo}
|
||||
loading={loading}
|
||||
disabled={!hasChanges}
|
||||
type='secondary'
|
||||
className="!rounded-full 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}
|
||||
/>
|
||||
<Text>{panelEnabled ? t('已启用') : t('已禁用')}</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 计算当前页显示的数据
|
||||
const getCurrentPageData = () => {
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
const endIndex = startIndex + pageSize;
|
||||
return apiInfoList.slice(startIndex, endIndex);
|
||||
};
|
||||
|
||||
const rowSelection = {
|
||||
selectedRowKeys,
|
||||
onChange: (selectedRowKeys, selectedRows) => {
|
||||
setSelectedRowKeys(selectedRowKeys);
|
||||
},
|
||||
onSelect: (record, selected, selectedRows) => {
|
||||
console.log(`选择行: ${selected}`, record);
|
||||
},
|
||||
onSelectAll: (selected, selectedRows) => {
|
||||
console.log(`全选: ${selected}`, selectedRows);
|
||||
},
|
||||
getCheckboxProps: (record) => ({
|
||||
disabled: false,
|
||||
name: record.id,
|
||||
}),
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form.Section text={renderHeader()}>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={getCurrentPageData()}
|
||||
rowSelection={rowSelection}
|
||||
rowKey="id"
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{
|
||||
currentPage: currentPage,
|
||||
pageSize: pageSize,
|
||||
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);
|
||||
setPageSize(size);
|
||||
},
|
||||
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 }} />}
|
||||
description={t('暂无API信息')}
|
||||
style={{ padding: 30 }}
|
||||
/>
|
||||
}
|
||||
className="rounded-xl overflow-hidden"
|
||||
/>
|
||||
</Form.Section>
|
||||
|
||||
<Modal
|
||||
title={editingApi ? t('编辑API') : t('添加API')}
|
||||
visible={showApiModal}
|
||||
onOk={handleSaveApi}
|
||||
onCancel={() => setShowApiModal(false)}
|
||||
okText={t('保存')}
|
||||
cancelText={t('取消')}
|
||||
className="rounded-xl"
|
||||
confirmLoading={modalLoading}
|
||||
>
|
||||
<Form layout='vertical' initValues={apiForm} key={editingApi ? editingApi.id : 'new'}>
|
||||
<Form.Input
|
||||
field='url'
|
||||
label={t('API地址')}
|
||||
placeholder='https://api.example.com'
|
||||
rules={[{ required: true, message: t('请输入API地址') }]}
|
||||
onChange={(value) => setApiForm({ ...apiForm, url: value })}
|
||||
/>
|
||||
<Form.Input
|
||||
field='route'
|
||||
label={t('线路描述')}
|
||||
placeholder={t('如:香港线路')}
|
||||
rules={[{ required: true, message: t('请输入线路描述') }]}
|
||||
onChange={(value) => setApiForm({ ...apiForm, route: value })}
|
||||
/>
|
||||
<Form.Input
|
||||
field='description'
|
||||
label={t('说明')}
|
||||
placeholder={t('如:大带宽批量分析图片推荐')}
|
||||
rules={[{ required: true, message: t('请输入说明') }]}
|
||||
onChange={(value) => setApiForm({ ...apiForm, description: value })}
|
||||
/>
|
||||
<Form.Select
|
||||
field='color'
|
||||
label={t('标识颜色')}
|
||||
optionList={colorOptions}
|
||||
onChange={(value) => setApiForm({ ...apiForm, color: value })}
|
||||
render={(option) => (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Avatar
|
||||
size="extra-extra-small"
|
||||
color={option.value}
|
||||
/>
|
||||
{option.label}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={t('确认删除')}
|
||||
visible={showDeleteModal}
|
||||
onOk={confirmDeleteApi}
|
||||
onCancel={() => {
|
||||
setShowDeleteModal(false);
|
||||
setDeletingApi(null);
|
||||
}}
|
||||
okText={t('确认删除')}
|
||||
cancelText={t('取消')}
|
||||
type="warning"
|
||||
className="rounded-xl"
|
||||
okButtonProps={{
|
||||
type: 'danger',
|
||||
theme: 'solid'
|
||||
}}
|
||||
>
|
||||
<Text>{t('确定要删除此API信息吗?')}</Text>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsAPIInfo;
|
||||
530
web/src/pages/Setting/Dashboard/SettingsAnnouncements.js
Normal file
530
web/src/pages/Setting/Dashboard/SettingsAnnouncements.js
Normal file
@@ -0,0 +1,530 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Space,
|
||||
Table,
|
||||
Form,
|
||||
Typography,
|
||||
Empty,
|
||||
Divider,
|
||||
Modal,
|
||||
Tag,
|
||||
Switch
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import {
|
||||
Plus,
|
||||
Edit,
|
||||
Trash2,
|
||||
Save,
|
||||
Bell
|
||||
} from 'lucide-react';
|
||||
import { API, showError, showSuccess, getRelativeTime, formatDateTimeString } from '../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const SettingsAnnouncements = ({ options, refresh }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [announcementsList, setAnnouncementsList] = useState([]);
|
||||
const [showAnnouncementModal, setShowAnnouncementModal] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [deletingAnnouncement, setDeletingAnnouncement] = useState(null);
|
||||
const [editingAnnouncement, setEditingAnnouncement] = useState(null);
|
||||
const [modalLoading, setModalLoading] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [announcementForm, setAnnouncementForm] = useState({
|
||||
content: '',
|
||||
publishDate: new Date(),
|
||||
type: 'default',
|
||||
extra: ''
|
||||
});
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState([]);
|
||||
|
||||
// 面板启用状态
|
||||
const [panelEnabled, setPanelEnabled] = useState(true);
|
||||
|
||||
const typeOptions = [
|
||||
{ value: 'default', label: t('默认') },
|
||||
{ value: 'ongoing', label: t('进行中') },
|
||||
{ value: 'success', label: t('成功') },
|
||||
{ value: 'warning', label: t('警告') },
|
||||
{ value: 'error', label: t('错误') }
|
||||
];
|
||||
|
||||
const getTypeColor = (type) => {
|
||||
const colorMap = {
|
||||
default: 'grey',
|
||||
ongoing: 'blue',
|
||||
success: 'green',
|
||||
warning: 'orange',
|
||||
error: 'red'
|
||||
};
|
||||
return colorMap[type] || 'grey';
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('内容'),
|
||||
dataIndex: 'content',
|
||||
key: 'content',
|
||||
render: (text) => (
|
||||
<div style={{
|
||||
maxWidth: '300px',
|
||||
wordBreak: 'break-word',
|
||||
whiteSpace: 'pre-wrap'
|
||||
}}>
|
||||
{text}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('发布时间'),
|
||||
dataIndex: 'publishDate',
|
||||
key: 'publishDate',
|
||||
width: 180,
|
||||
render: (publishDate) => (
|
||||
<div>
|
||||
<div style={{ fontWeight: 'bold' }}>
|
||||
{getRelativeTime(publishDate)}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
color: 'var(--semi-color-text-2)',
|
||||
marginTop: '2px'
|
||||
}}>
|
||||
{publishDate ? formatDateTimeString(new Date(publishDate)) : '-'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('类型'),
|
||||
dataIndex: 'type',
|
||||
key: 'type',
|
||||
width: 100,
|
||||
render: (type) => (
|
||||
<Tag color={getTypeColor(type)} shape='circle'>
|
||||
{typeOptions.find(opt => opt.value === type)?.label || type}
|
||||
</Tag>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('说明'),
|
||||
dataIndex: 'extra',
|
||||
key: 'extra',
|
||||
render: (text) => (
|
||||
<div style={{
|
||||
maxWidth: '200px',
|
||||
wordBreak: 'break-word',
|
||||
color: 'var(--semi-color-text-2)'
|
||||
}}>
|
||||
{text || '-'}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('操作'),
|
||||
key: 'action',
|
||||
fixed: 'right',
|
||||
width: 150,
|
||||
render: (text, record) => (
|
||||
<Space>
|
||||
<Button
|
||||
icon={<Edit size={14} />}
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
size='small'
|
||||
className="!rounded-full"
|
||||
onClick={() => handleEditAnnouncement(record)}
|
||||
>
|
||||
{t('编辑')}
|
||||
</Button>
|
||||
<Button
|
||||
icon={<Trash2 size={14} />}
|
||||
type='danger'
|
||||
theme='light'
|
||||
size='small'
|
||||
className="!rounded-full"
|
||||
onClick={() => handleDeleteAnnouncement(record)}
|
||||
>
|
||||
{t('删除')}
|
||||
</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
const updateOption = async (key, value) => {
|
||||
const res = await API.put('/api/option/', {
|
||||
key,
|
||||
value,
|
||||
});
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess('系统公告已更新');
|
||||
if (refresh) refresh();
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
};
|
||||
|
||||
const submitAnnouncements = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const announcementsJson = JSON.stringify(announcementsList);
|
||||
await updateOption('console_setting.announcements', announcementsJson);
|
||||
setHasChanges(false);
|
||||
} catch (error) {
|
||||
console.error('系统公告更新失败', error);
|
||||
showError('系统公告更新失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddAnnouncement = () => {
|
||||
setEditingAnnouncement(null);
|
||||
setAnnouncementForm({
|
||||
content: '',
|
||||
publishDate: new Date(),
|
||||
type: 'default',
|
||||
extra: ''
|
||||
});
|
||||
setShowAnnouncementModal(true);
|
||||
};
|
||||
|
||||
const handleEditAnnouncement = (announcement) => {
|
||||
setEditingAnnouncement(announcement);
|
||||
setAnnouncementForm({
|
||||
content: announcement.content,
|
||||
publishDate: announcement.publishDate ? new Date(announcement.publishDate) : new Date(),
|
||||
type: announcement.type || 'default',
|
||||
extra: announcement.extra || ''
|
||||
});
|
||||
setShowAnnouncementModal(true);
|
||||
};
|
||||
|
||||
const handleDeleteAnnouncement = (announcement) => {
|
||||
setDeletingAnnouncement(announcement);
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
const confirmDeleteAnnouncement = () => {
|
||||
if (deletingAnnouncement) {
|
||||
const newList = announcementsList.filter(item => item.id !== deletingAnnouncement.id);
|
||||
setAnnouncementsList(newList);
|
||||
setHasChanges(true);
|
||||
showSuccess('公告已删除,请及时点击“保存设置”进行保存');
|
||||
}
|
||||
setShowDeleteModal(false);
|
||||
setDeletingAnnouncement(null);
|
||||
};
|
||||
|
||||
const handleSaveAnnouncement = async () => {
|
||||
if (!announcementForm.content || !announcementForm.publishDate) {
|
||||
showError('请填写完整的公告信息');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setModalLoading(true);
|
||||
|
||||
// 将publishDate转换为ISO字符串保存
|
||||
const formData = {
|
||||
...announcementForm,
|
||||
publishDate: announcementForm.publishDate.toISOString()
|
||||
};
|
||||
|
||||
let newList;
|
||||
if (editingAnnouncement) {
|
||||
newList = announcementsList.map(item =>
|
||||
item.id === editingAnnouncement.id
|
||||
? { ...item, ...formData }
|
||||
: item
|
||||
);
|
||||
} else {
|
||||
const newId = Math.max(...announcementsList.map(item => item.id), 0) + 1;
|
||||
const newAnnouncement = {
|
||||
id: newId,
|
||||
...formData
|
||||
};
|
||||
newList = [...announcementsList, newAnnouncement];
|
||||
}
|
||||
|
||||
setAnnouncementsList(newList);
|
||||
setHasChanges(true);
|
||||
setShowAnnouncementModal(false);
|
||||
showSuccess(editingAnnouncement ? '公告已更新,请及时点击“保存设置”进行保存' : '公告已添加,请及时点击“保存设置”进行保存');
|
||||
} catch (error) {
|
||||
showError('操作失败: ' + error.message);
|
||||
} finally {
|
||||
setModalLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const parseAnnouncements = (announcementsStr) => {
|
||||
if (!announcementsStr) {
|
||||
setAnnouncementsList([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(announcementsStr);
|
||||
const list = Array.isArray(parsed) ? parsed : [];
|
||||
// 确保每个项目都有id
|
||||
const listWithIds = list.map((item, index) => ({
|
||||
...item,
|
||||
id: item.id || index + 1
|
||||
}));
|
||||
setAnnouncementsList(listWithIds);
|
||||
} catch (error) {
|
||||
console.error('解析系统公告失败:', error);
|
||||
setAnnouncementsList([]);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const annStr = options['console_setting.announcements'] ?? options.Announcements;
|
||||
if (annStr !== undefined) {
|
||||
parseAnnouncements(annStr);
|
||||
}
|
||||
}, [options['console_setting.announcements'], options.Announcements]);
|
||||
|
||||
useEffect(() => {
|
||||
const enabledStr = options['console_setting.announcements_enabled'];
|
||||
setPanelEnabled(enabledStr === undefined ? true : enabledStr === 'true' || enabledStr === true);
|
||||
}, [options['console_setting.announcements_enabled']]);
|
||||
|
||||
const handleToggleEnabled = async (checked) => {
|
||||
const newValue = checked ? 'true' : 'false';
|
||||
try {
|
||||
const res = await API.put('/api/option/', {
|
||||
key: 'console_setting.announcements_enabled',
|
||||
value: newValue,
|
||||
});
|
||||
if (res.data.success) {
|
||||
setPanelEnabled(checked);
|
||||
showSuccess(t('设置已保存'));
|
||||
refresh?.();
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
}
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchDelete = () => {
|
||||
if (selectedRowKeys.length === 0) {
|
||||
showError('请先选择要删除的系统公告');
|
||||
return;
|
||||
}
|
||||
|
||||
const newList = announcementsList.filter(item => !selectedRowKeys.includes(item.id));
|
||||
setAnnouncementsList(newList);
|
||||
setSelectedRowKeys([]);
|
||||
setHasChanges(true);
|
||||
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>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<Button
|
||||
theme='light'
|
||||
type='primary'
|
||||
icon={<Plus size={14} />}
|
||||
className="!rounded-full w-full md:w-auto"
|
||||
onClick={handleAddAnnouncement}
|
||||
>
|
||||
{t('添加公告')}
|
||||
</Button>
|
||||
<Button
|
||||
icon={<Trash2 size={14} />}
|
||||
type='danger'
|
||||
theme='light'
|
||||
onClick={handleBatchDelete}
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
className="!rounded-full w-full md:w-auto"
|
||||
>
|
||||
{t('批量删除')} {selectedRowKeys.length > 0 && `(${selectedRowKeys.length})`}
|
||||
</Button>
|
||||
<Button
|
||||
icon={<Save size={14} />}
|
||||
onClick={submitAnnouncements}
|
||||
loading={loading}
|
||||
disabled={!hasChanges}
|
||||
type='secondary'
|
||||
className="!rounded-full 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} />
|
||||
<Text>{panelEnabled ? t('已启用') : t('已禁用')}</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 计算当前页显示的数据(按发布时间倒序排序,最新优先显示)
|
||||
const getCurrentPageData = () => {
|
||||
const sortedList = [...announcementsList].sort((a, b) => {
|
||||
const dateA = new Date(a.publishDate).getTime();
|
||||
const dateB = new Date(b.publishDate).getTime();
|
||||
return dateB - dateA; // 倒序,最新的排在前面
|
||||
});
|
||||
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
const endIndex = startIndex + pageSize;
|
||||
return sortedList.slice(startIndex, endIndex);
|
||||
};
|
||||
|
||||
const rowSelection = {
|
||||
selectedRowKeys,
|
||||
onChange: (selectedRowKeys, selectedRows) => {
|
||||
setSelectedRowKeys(selectedRowKeys);
|
||||
},
|
||||
onSelect: (record, selected, selectedRows) => {
|
||||
console.log(`选择行: ${selected}`, record);
|
||||
},
|
||||
onSelectAll: (selected, selectedRows) => {
|
||||
console.log(`全选: ${selected}`, selectedRows);
|
||||
},
|
||||
getCheckboxProps: (record) => ({
|
||||
disabled: false,
|
||||
name: record.id,
|
||||
}),
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form.Section text={renderHeader()}>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={getCurrentPageData()}
|
||||
rowSelection={rowSelection}
|
||||
rowKey="id"
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{
|
||||
currentPage: currentPage,
|
||||
pageSize: pageSize,
|
||||
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);
|
||||
setPageSize(size);
|
||||
},
|
||||
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 }} />}
|
||||
description={t('暂无系统公告')}
|
||||
style={{ padding: 30 }}
|
||||
/>
|
||||
}
|
||||
className="rounded-xl overflow-hidden"
|
||||
/>
|
||||
</Form.Section>
|
||||
|
||||
<Modal
|
||||
title={editingAnnouncement ? t('编辑公告') : t('添加公告')}
|
||||
visible={showAnnouncementModal}
|
||||
onOk={handleSaveAnnouncement}
|
||||
onCancel={() => setShowAnnouncementModal(false)}
|
||||
okText={t('保存')}
|
||||
cancelText={t('取消')}
|
||||
className="rounded-xl"
|
||||
confirmLoading={modalLoading}
|
||||
>
|
||||
<Form layout='vertical' initValues={announcementForm} key={editingAnnouncement ? editingAnnouncement.id : 'new'}>
|
||||
<Form.TextArea
|
||||
field='content'
|
||||
label={t('公告内容')}
|
||||
placeholder={t('请输入公告内容')}
|
||||
maxCount={500}
|
||||
rows={3}
|
||||
rules={[{ required: true, message: t('请输入公告内容') }]}
|
||||
onChange={(value) => setAnnouncementForm({ ...announcementForm, content: value })}
|
||||
/>
|
||||
<Form.DatePicker
|
||||
field='publishDate'
|
||||
label={t('发布日期')}
|
||||
type='dateTime'
|
||||
rules={[{ required: true, message: t('请选择发布日期') }]}
|
||||
onChange={(value) => setAnnouncementForm({ ...announcementForm, publishDate: value })}
|
||||
/>
|
||||
<Form.Select
|
||||
field='type'
|
||||
label={t('公告类型')}
|
||||
optionList={typeOptions}
|
||||
onChange={(value) => setAnnouncementForm({ ...announcementForm, type: value })}
|
||||
/>
|
||||
<Form.Input
|
||||
field='extra'
|
||||
label={t('说明信息')}
|
||||
placeholder={t('可选,公告的补充说明')}
|
||||
onChange={(value) => setAnnouncementForm({ ...announcementForm, extra: value })}
|
||||
/>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={t('确认删除')}
|
||||
visible={showDeleteModal}
|
||||
onOk={confirmDeleteAnnouncement}
|
||||
onCancel={() => {
|
||||
setShowDeleteModal(false);
|
||||
setDeletingAnnouncement(null);
|
||||
}}
|
||||
okText={t('确认删除')}
|
||||
cancelText={t('取消')}
|
||||
type="warning"
|
||||
className="rounded-xl"
|
||||
okButtonProps={{
|
||||
type: 'danger',
|
||||
theme: 'solid'
|
||||
}}
|
||||
>
|
||||
<Text>{t('确定要删除此公告吗?')}</Text>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsAnnouncements;
|
||||
451
web/src/pages/Setting/Dashboard/SettingsFAQ.js
Normal file
451
web/src/pages/Setting/Dashboard/SettingsFAQ.js
Normal file
@@ -0,0 +1,451 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Space,
|
||||
Table,
|
||||
Form,
|
||||
Typography,
|
||||
Empty,
|
||||
Divider,
|
||||
Modal,
|
||||
Switch
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import {
|
||||
Plus,
|
||||
Edit,
|
||||
Trash2,
|
||||
Save,
|
||||
HelpCircle
|
||||
} from 'lucide-react';
|
||||
import { API, showError, showSuccess } from '../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const SettingsFAQ = ({ options, refresh }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [faqList, setFaqList] = useState([]);
|
||||
const [showFaqModal, setShowFaqModal] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [deletingFaq, setDeletingFaq] = useState(null);
|
||||
const [editingFaq, setEditingFaq] = useState(null);
|
||||
const [modalLoading, setModalLoading] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [faqForm, setFaqForm] = useState({
|
||||
question: '',
|
||||
answer: ''
|
||||
});
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState([]);
|
||||
|
||||
// 面板启用状态
|
||||
const [panelEnabled, setPanelEnabled] = useState(true);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('问题标题'),
|
||||
dataIndex: 'question',
|
||||
key: 'question',
|
||||
render: (text) => (
|
||||
<div style={{
|
||||
maxWidth: '300px',
|
||||
wordBreak: 'break-word',
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
{text}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('回答内容'),
|
||||
dataIndex: 'answer',
|
||||
key: 'answer',
|
||||
render: (text) => (
|
||||
<div style={{
|
||||
maxWidth: '400px',
|
||||
wordBreak: 'break-word',
|
||||
whiteSpace: 'pre-wrap',
|
||||
color: 'var(--semi-color-text-1)'
|
||||
}}>
|
||||
{text}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('操作'),
|
||||
key: 'action',
|
||||
fixed: 'right',
|
||||
width: 150,
|
||||
render: (text, record) => (
|
||||
<Space>
|
||||
<Button
|
||||
icon={<Edit size={14} />}
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
size='small'
|
||||
className="!rounded-full"
|
||||
onClick={() => handleEditFaq(record)}
|
||||
>
|
||||
{t('编辑')}
|
||||
</Button>
|
||||
<Button
|
||||
icon={<Trash2 size={14} />}
|
||||
type='danger'
|
||||
theme='light'
|
||||
size='small'
|
||||
className="!rounded-full"
|
||||
onClick={() => handleDeleteFaq(record)}
|
||||
>
|
||||
{t('删除')}
|
||||
</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
const updateOption = async (key, value) => {
|
||||
const res = await API.put('/api/option/', {
|
||||
key,
|
||||
value,
|
||||
});
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess('常见问答已更新');
|
||||
if (refresh) refresh();
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
};
|
||||
|
||||
const submitFAQ = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const faqJson = JSON.stringify(faqList);
|
||||
await updateOption('console_setting.faq', faqJson);
|
||||
setHasChanges(false);
|
||||
} catch (error) {
|
||||
console.error('常见问答更新失败', error);
|
||||
showError('常见问答更新失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddFaq = () => {
|
||||
setEditingFaq(null);
|
||||
setFaqForm({
|
||||
question: '',
|
||||
answer: ''
|
||||
});
|
||||
setShowFaqModal(true);
|
||||
};
|
||||
|
||||
const handleEditFaq = (faq) => {
|
||||
setEditingFaq(faq);
|
||||
setFaqForm({
|
||||
question: faq.question,
|
||||
answer: faq.answer
|
||||
});
|
||||
setShowFaqModal(true);
|
||||
};
|
||||
|
||||
const handleDeleteFaq = (faq) => {
|
||||
setDeletingFaq(faq);
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
const confirmDeleteFaq = () => {
|
||||
if (deletingFaq) {
|
||||
const newList = faqList.filter(item => item.id !== deletingFaq.id);
|
||||
setFaqList(newList);
|
||||
setHasChanges(true);
|
||||
showSuccess('问答已删除,请及时点击“保存设置”进行保存');
|
||||
}
|
||||
setShowDeleteModal(false);
|
||||
setDeletingFaq(null);
|
||||
};
|
||||
|
||||
const handleSaveFaq = async () => {
|
||||
if (!faqForm.question || !faqForm.answer) {
|
||||
showError('请填写完整的问答信息');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setModalLoading(true);
|
||||
|
||||
let newList;
|
||||
if (editingFaq) {
|
||||
newList = faqList.map(item =>
|
||||
item.id === editingFaq.id
|
||||
? { ...item, ...faqForm }
|
||||
: item
|
||||
);
|
||||
} else {
|
||||
const newId = Math.max(...faqList.map(item => item.id), 0) + 1;
|
||||
const newFaq = {
|
||||
id: newId,
|
||||
...faqForm
|
||||
};
|
||||
newList = [...faqList, newFaq];
|
||||
}
|
||||
|
||||
setFaqList(newList);
|
||||
setHasChanges(true);
|
||||
setShowFaqModal(false);
|
||||
showSuccess(editingFaq ? '问答已更新,请及时点击“保存设置”进行保存' : '问答已添加,请及时点击“保存设置”进行保存');
|
||||
} catch (error) {
|
||||
showError('操作失败: ' + error.message);
|
||||
} finally {
|
||||
setModalLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const parseFAQ = (faqStr) => {
|
||||
if (!faqStr) {
|
||||
setFaqList([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(faqStr);
|
||||
const list = Array.isArray(parsed) ? parsed : [];
|
||||
// 确保每个项目都有id
|
||||
const listWithIds = list.map((item, index) => ({
|
||||
...item,
|
||||
id: item.id || index + 1
|
||||
}));
|
||||
setFaqList(listWithIds);
|
||||
} catch (error) {
|
||||
console.error('解析常见问答失败:', error);
|
||||
setFaqList([]);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (options['console_setting.faq'] !== undefined) {
|
||||
parseFAQ(options['console_setting.faq']);
|
||||
}
|
||||
}, [options['console_setting.faq']]);
|
||||
|
||||
useEffect(() => {
|
||||
const enabledStr = options['console_setting.faq_enabled'];
|
||||
setPanelEnabled(enabledStr === undefined ? true : enabledStr === 'true' || enabledStr === true);
|
||||
}, [options['console_setting.faq_enabled']]);
|
||||
|
||||
const handleToggleEnabled = async (checked) => {
|
||||
const newValue = checked ? 'true' : 'false';
|
||||
try {
|
||||
const res = await API.put('/api/option/', {
|
||||
key: 'console_setting.faq_enabled',
|
||||
value: newValue,
|
||||
});
|
||||
if (res.data.success) {
|
||||
setPanelEnabled(checked);
|
||||
showSuccess(t('设置已保存'));
|
||||
refresh?.();
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
}
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchDelete = () => {
|
||||
if (selectedRowKeys.length === 0) {
|
||||
showError('请先选择要删除的常见问答');
|
||||
return;
|
||||
}
|
||||
|
||||
const newList = faqList.filter(item => !selectedRowKeys.includes(item.id));
|
||||
setFaqList(newList);
|
||||
setSelectedRowKeys([]);
|
||||
setHasChanges(true);
|
||||
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>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<Button
|
||||
theme='light'
|
||||
type='primary'
|
||||
icon={<Plus size={14} />}
|
||||
className="!rounded-full w-full md:w-auto"
|
||||
onClick={handleAddFaq}
|
||||
>
|
||||
{t('添加问答')}
|
||||
</Button>
|
||||
<Button
|
||||
icon={<Trash2 size={14} />}
|
||||
type='danger'
|
||||
theme='light'
|
||||
onClick={handleBatchDelete}
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
className="!rounded-full w-full md:w-auto"
|
||||
>
|
||||
{t('批量删除')} {selectedRowKeys.length > 0 && `(${selectedRowKeys.length})`}
|
||||
</Button>
|
||||
<Button
|
||||
icon={<Save size={14} />}
|
||||
onClick={submitFAQ}
|
||||
loading={loading}
|
||||
disabled={!hasChanges}
|
||||
type='secondary'
|
||||
className="!rounded-full 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} />
|
||||
<Text>{panelEnabled ? t('已启用') : t('已禁用')}</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 计算当前页显示的数据
|
||||
const getCurrentPageData = () => {
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
const endIndex = startIndex + pageSize;
|
||||
return faqList.slice(startIndex, endIndex);
|
||||
};
|
||||
|
||||
const rowSelection = {
|
||||
selectedRowKeys,
|
||||
onChange: (selectedRowKeys, selectedRows) => {
|
||||
setSelectedRowKeys(selectedRowKeys);
|
||||
},
|
||||
onSelect: (record, selected, selectedRows) => {
|
||||
console.log(`选择行: ${selected}`, record);
|
||||
},
|
||||
onSelectAll: (selected, selectedRows) => {
|
||||
console.log(`全选: ${selected}`, selectedRows);
|
||||
},
|
||||
getCheckboxProps: (record) => ({
|
||||
disabled: false,
|
||||
name: record.id,
|
||||
}),
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form.Section text={renderHeader()}>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={getCurrentPageData()}
|
||||
rowSelection={rowSelection}
|
||||
rowKey="id"
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{
|
||||
currentPage: currentPage,
|
||||
pageSize: pageSize,
|
||||
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);
|
||||
setPageSize(size);
|
||||
},
|
||||
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 }} />}
|
||||
description={t('暂无常见问答')}
|
||||
style={{ padding: 30 }}
|
||||
/>
|
||||
}
|
||||
className="rounded-xl overflow-hidden"
|
||||
/>
|
||||
</Form.Section>
|
||||
|
||||
<Modal
|
||||
title={editingFaq ? t('编辑问答') : t('添加问答')}
|
||||
visible={showFaqModal}
|
||||
onOk={handleSaveFaq}
|
||||
onCancel={() => setShowFaqModal(false)}
|
||||
okText={t('保存')}
|
||||
cancelText={t('取消')}
|
||||
className="rounded-xl"
|
||||
confirmLoading={modalLoading}
|
||||
width={800}
|
||||
>
|
||||
<Form layout='vertical' initValues={faqForm} key={editingFaq ? editingFaq.id : 'new'}>
|
||||
<Form.Input
|
||||
field='question'
|
||||
label={t('问题标题')}
|
||||
placeholder={t('请输入问题标题')}
|
||||
maxLength={200}
|
||||
rules={[{ required: true, message: t('请输入问题标题') }]}
|
||||
onChange={(value) => setFaqForm({ ...faqForm, question: value })}
|
||||
/>
|
||||
<Form.TextArea
|
||||
field='answer'
|
||||
label={t('回答内容')}
|
||||
placeholder={t('请输入回答内容')}
|
||||
maxCount={1000}
|
||||
rows={6}
|
||||
rules={[{ required: true, message: t('请输入回答内容') }]}
|
||||
onChange={(value) => setFaqForm({ ...faqForm, answer: value })}
|
||||
/>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={t('确认删除')}
|
||||
visible={showDeleteModal}
|
||||
onOk={confirmDeleteFaq}
|
||||
onCancel={() => {
|
||||
setShowDeleteModal(false);
|
||||
setDeletingFaq(null);
|
||||
}}
|
||||
okText={t('确认删除')}
|
||||
cancelText={t('取消')}
|
||||
type="warning"
|
||||
className="rounded-xl"
|
||||
okButtonProps={{
|
||||
type: 'danger',
|
||||
theme: 'solid'
|
||||
}}
|
||||
>
|
||||
<Text>{t('确定要删除此问答吗?')}</Text>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsFAQ;
|
||||
482
web/src/pages/Setting/Dashboard/SettingsUptimeKuma.js
Normal file
482
web/src/pages/Setting/Dashboard/SettingsUptimeKuma.js
Normal file
@@ -0,0 +1,482 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Space,
|
||||
Table,
|
||||
Form,
|
||||
Typography,
|
||||
Empty,
|
||||
Divider,
|
||||
Modal,
|
||||
Switch
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import {
|
||||
Plus,
|
||||
Edit,
|
||||
Trash2,
|
||||
Save,
|
||||
Activity
|
||||
} from 'lucide-react';
|
||||
import { API, showError, showSuccess } from '../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const SettingsUptimeKuma = ({ options, refresh }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [uptimeGroupsList, setUptimeGroupsList] = useState([]);
|
||||
const [showUptimeModal, setShowUptimeModal] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [deletingGroup, setDeletingGroup] = useState(null);
|
||||
const [editingGroup, setEditingGroup] = useState(null);
|
||||
const [modalLoading, setModalLoading] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [uptimeForm, setUptimeForm] = useState({
|
||||
categoryName: '',
|
||||
url: '',
|
||||
slug: '',
|
||||
});
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState([]);
|
||||
const [panelEnabled, setPanelEnabled] = useState(true);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('分类名称'),
|
||||
dataIndex: 'categoryName',
|
||||
key: 'categoryName',
|
||||
render: (text) => (
|
||||
<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)'
|
||||
}}>
|
||||
{text}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('状态页面Slug'),
|
||||
dataIndex: 'slug',
|
||||
key: 'slug',
|
||||
render: (text) => (
|
||||
<div style={{
|
||||
fontFamily: 'monospace',
|
||||
color: 'var(--semi-color-text-1)'
|
||||
}}>
|
||||
{text}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('操作'),
|
||||
key: 'action',
|
||||
fixed: 'right',
|
||||
width: 150,
|
||||
render: (text, record) => (
|
||||
<Space>
|
||||
<Button
|
||||
icon={<Edit size={14} />}
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
size='small'
|
||||
className="!rounded-full"
|
||||
onClick={() => handleEditGroup(record)}
|
||||
>
|
||||
{t('编辑')}
|
||||
</Button>
|
||||
<Button
|
||||
icon={<Trash2 size={14} />}
|
||||
type='danger'
|
||||
theme='light'
|
||||
size='small'
|
||||
className="!rounded-full"
|
||||
onClick={() => handleDeleteGroup(record)}
|
||||
>
|
||||
{t('删除')}
|
||||
</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
const updateOption = async (key, value) => {
|
||||
const res = await API.put('/api/option/', {
|
||||
key,
|
||||
value,
|
||||
});
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess('Uptime Kuma配置已更新');
|
||||
if (refresh) refresh();
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
};
|
||||
|
||||
const submitUptimeGroups = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const groupsJson = JSON.stringify(uptimeGroupsList);
|
||||
await updateOption('console_setting.uptime_kuma_groups', groupsJson);
|
||||
setHasChanges(false);
|
||||
} catch (error) {
|
||||
console.error('Uptime Kuma配置更新失败', error);
|
||||
showError('Uptime Kuma配置更新失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddGroup = () => {
|
||||
setEditingGroup(null);
|
||||
setUptimeForm({
|
||||
categoryName: '',
|
||||
url: '',
|
||||
slug: '',
|
||||
});
|
||||
setShowUptimeModal(true);
|
||||
};
|
||||
|
||||
const handleEditGroup = (group) => {
|
||||
setEditingGroup(group);
|
||||
setUptimeForm({
|
||||
categoryName: group.categoryName,
|
||||
url: group.url,
|
||||
slug: group.slug,
|
||||
});
|
||||
setShowUptimeModal(true);
|
||||
};
|
||||
|
||||
const handleDeleteGroup = (group) => {
|
||||
setDeletingGroup(group);
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
const confirmDeleteGroup = () => {
|
||||
if (deletingGroup) {
|
||||
const newList = uptimeGroupsList.filter(item => item.id !== deletingGroup.id);
|
||||
setUptimeGroupsList(newList);
|
||||
setHasChanges(true);
|
||||
showSuccess('分类已删除,请及时点击“保存设置”进行保存');
|
||||
}
|
||||
setShowDeleteModal(false);
|
||||
setDeletingGroup(null);
|
||||
};
|
||||
|
||||
const handleSaveGroup = async () => {
|
||||
if (!uptimeForm.categoryName || !uptimeForm.url || !uptimeForm.slug) {
|
||||
showError('请填写完整的分类信息');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
new URL(uptimeForm.url);
|
||||
} catch (error) {
|
||||
showError('请输入有效的URL地址');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(uptimeForm.slug)) {
|
||||
showError('Slug只能包含字母、数字、下划线和连字符');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setModalLoading(true);
|
||||
|
||||
let newList;
|
||||
if (editingGroup) {
|
||||
newList = uptimeGroupsList.map(item =>
|
||||
item.id === editingGroup.id
|
||||
? { ...item, ...uptimeForm }
|
||||
: item
|
||||
);
|
||||
} else {
|
||||
const newId = Math.max(...uptimeGroupsList.map(item => item.id), 0) + 1;
|
||||
const newGroup = {
|
||||
id: newId,
|
||||
...uptimeForm
|
||||
};
|
||||
newList = [...uptimeGroupsList, newGroup];
|
||||
}
|
||||
|
||||
setUptimeGroupsList(newList);
|
||||
setHasChanges(true);
|
||||
setShowUptimeModal(false);
|
||||
showSuccess(editingGroup ? '分类已更新,请及时点击“保存设置”进行保存' : '分类已添加,请及时点击“保存设置”进行保存');
|
||||
} catch (error) {
|
||||
showError('操作失败: ' + error.message);
|
||||
} finally {
|
||||
setModalLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const parseUptimeGroups = (groupsStr) => {
|
||||
if (!groupsStr) {
|
||||
setUptimeGroupsList([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(groupsStr);
|
||||
const list = Array.isArray(parsed) ? parsed : [];
|
||||
const listWithIds = list.map((item, index) => ({
|
||||
...item,
|
||||
id: item.id || index + 1
|
||||
}));
|
||||
setUptimeGroupsList(listWithIds);
|
||||
} catch (error) {
|
||||
console.error('解析Uptime Kuma配置失败:', error);
|
||||
setUptimeGroupsList([]);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const groupsStr = options['console_setting.uptime_kuma_groups'];
|
||||
if (groupsStr !== undefined) {
|
||||
parseUptimeGroups(groupsStr);
|
||||
}
|
||||
}, [options['console_setting.uptime_kuma_groups']]);
|
||||
|
||||
useEffect(() => {
|
||||
const enabledStr = options['console_setting.uptime_kuma_enabled'];
|
||||
setPanelEnabled(enabledStr === undefined ? true : enabledStr === 'true' || enabledStr === true);
|
||||
}, [options['console_setting.uptime_kuma_enabled']]);
|
||||
|
||||
const handleToggleEnabled = async (checked) => {
|
||||
const newValue = checked ? 'true' : 'false';
|
||||
try {
|
||||
const res = await API.put('/api/option/', {
|
||||
key: 'console_setting.uptime_kuma_enabled',
|
||||
value: newValue,
|
||||
});
|
||||
if (res.data.success) {
|
||||
setPanelEnabled(checked);
|
||||
showSuccess(t('设置已保存'));
|
||||
refresh?.();
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
}
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchDelete = () => {
|
||||
if (selectedRowKeys.length === 0) {
|
||||
showError('请先选择要删除的分类');
|
||||
return;
|
||||
}
|
||||
|
||||
const newList = uptimeGroupsList.filter(item => !selectedRowKeys.includes(item.id));
|
||||
setUptimeGroupsList(newList);
|
||||
setSelectedRowKeys([]);
|
||||
setHasChanges(true);
|
||||
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>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<Button
|
||||
theme='light'
|
||||
type='primary'
|
||||
icon={<Plus size={14} />}
|
||||
className="!rounded-full w-full md:w-auto"
|
||||
onClick={handleAddGroup}
|
||||
>
|
||||
{t('添加分类')}
|
||||
</Button>
|
||||
<Button
|
||||
icon={<Trash2 size={14} />}
|
||||
type='danger'
|
||||
theme='light'
|
||||
onClick={handleBatchDelete}
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
className="!rounded-full w-full md:w-auto"
|
||||
>
|
||||
{t('批量删除')} {selectedRowKeys.length > 0 && `(${selectedRowKeys.length})`}
|
||||
</Button>
|
||||
<Button
|
||||
icon={<Save size={14} />}
|
||||
onClick={submitUptimeGroups}
|
||||
loading={loading}
|
||||
disabled={!hasChanges}
|
||||
type='secondary'
|
||||
className="!rounded-full 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} />
|
||||
<Text>{panelEnabled ? t('已启用') : t('已禁用')}</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const getCurrentPageData = () => {
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
const endIndex = startIndex + pageSize;
|
||||
return uptimeGroupsList.slice(startIndex, endIndex);
|
||||
};
|
||||
|
||||
const rowSelection = {
|
||||
selectedRowKeys,
|
||||
onChange: (selectedRowKeys, selectedRows) => {
|
||||
setSelectedRowKeys(selectedRowKeys);
|
||||
},
|
||||
onSelect: (record, selected, selectedRows) => {
|
||||
console.log(`选择行: ${selected}`, record);
|
||||
},
|
||||
onSelectAll: (selected, selectedRows) => {
|
||||
console.log(`全选: ${selected}`, selectedRows);
|
||||
},
|
||||
getCheckboxProps: (record) => ({
|
||||
disabled: false,
|
||||
name: record.id,
|
||||
}),
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form.Section text={renderHeader()}>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={getCurrentPageData()}
|
||||
rowSelection={rowSelection}
|
||||
rowKey="id"
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{
|
||||
currentPage: currentPage,
|
||||
pageSize: pageSize,
|
||||
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);
|
||||
setPageSize(size);
|
||||
},
|
||||
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 }} />}
|
||||
description={t('暂无监控数据')}
|
||||
style={{ padding: 30 }}
|
||||
/>
|
||||
}
|
||||
className="rounded-xl overflow-hidden"
|
||||
/>
|
||||
</Form.Section>
|
||||
|
||||
<Modal
|
||||
title={editingGroup ? t('编辑分类') : t('添加分类')}
|
||||
visible={showUptimeModal}
|
||||
onOk={handleSaveGroup}
|
||||
onCancel={() => setShowUptimeModal(false)}
|
||||
okText={t('保存')}
|
||||
cancelText={t('取消')}
|
||||
className="rounded-xl"
|
||||
confirmLoading={modalLoading}
|
||||
width={600}
|
||||
>
|
||||
<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 })}
|
||||
/>
|
||||
<Form.Input
|
||||
field='url'
|
||||
label={t('Uptime Kuma地址')}
|
||||
placeholder={t('请输入Uptime Kuma服务地址,如:https://status.example.com')}
|
||||
maxLength={500}
|
||||
rules={[{ required: true, message: t('请输入Uptime Kuma地址') }]}
|
||||
onChange={(value) => setUptimeForm({ ...uptimeForm, url: value })}
|
||||
/>
|
||||
<Form.Input
|
||||
field='slug'
|
||||
label={t('状态页面Slug')}
|
||||
placeholder={t('请输入状态页面的Slug,如:my-status')}
|
||||
maxLength={100}
|
||||
rules={[{ required: true, message: t('请输入状态页面Slug') }]}
|
||||
onChange={(value) => setUptimeForm({ ...uptimeForm, slug: value })}
|
||||
/>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={t('确认删除')}
|
||||
visible={showDeleteModal}
|
||||
onOk={confirmDeleteGroup}
|
||||
onCancel={() => {
|
||||
setShowDeleteModal(false);
|
||||
setDeletingGroup(null);
|
||||
}}
|
||||
okText={t('确认删除')}
|
||||
cancelText={t('取消')}
|
||||
type="warning"
|
||||
className="rounded-xl"
|
||||
okButtonProps={{
|
||||
type: 'danger',
|
||||
theme: 'solid'
|
||||
}}
|
||||
>
|
||||
<Text>{t('确定要删除此分类吗?')}</Text>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsUptimeKuma;
|
||||
@@ -27,40 +27,48 @@ export default function SettingGeminiModel(props) {
|
||||
const [inputs, setInputs] = useState({
|
||||
'gemini.safety_settings': '',
|
||||
'gemini.version_settings': '',
|
||||
'gemini.supported_imagine_models': [],
|
||||
'gemini.supported_imagine_models': '',
|
||||
'gemini.thinking_adapter_enabled': false,
|
||||
'gemini.thinking_adapter_budget_tokens_percentage': 0.6,
|
||||
});
|
||||
const refForm = useRef();
|
||||
const [inputsRow, setInputsRow] = useState(inputs);
|
||||
|
||||
function onSubmit() {
|
||||
const updateArray = compareObjects(inputs, inputsRow);
|
||||
if (!updateArray.length) return showWarning(t('你似乎并没有修改什么'));
|
||||
const requestQueue = updateArray.map((item) => {
|
||||
let value = String(inputs[item.key]);
|
||||
return API.put('/api/option/', {
|
||||
key: item.key,
|
||||
value,
|
||||
});
|
||||
});
|
||||
setLoading(true);
|
||||
Promise.all(requestQueue)
|
||||
.then((res) => {
|
||||
if (requestQueue.length === 1) {
|
||||
if (res.includes(undefined)) return;
|
||||
} else if (requestQueue.length > 1) {
|
||||
if (res.includes(undefined))
|
||||
return showError(t('部分保存失败,请重试'));
|
||||
}
|
||||
showSuccess(t('保存成功'));
|
||||
props.refresh();
|
||||
async function onSubmit() {
|
||||
await refForm.current
|
||||
.validate()
|
||||
.then(() => {
|
||||
const updateArray = compareObjects(inputs, inputsRow);
|
||||
if (!updateArray.length) return showWarning(t('你似乎并没有修改什么'));
|
||||
const requestQueue = updateArray.map((item) => {
|
||||
let value = String(inputs[item.key]);
|
||||
return API.put('/api/option/', {
|
||||
key: item.key,
|
||||
value,
|
||||
});
|
||||
});
|
||||
setLoading(true);
|
||||
Promise.all(requestQueue)
|
||||
.then((res) => {
|
||||
if (requestQueue.length === 1) {
|
||||
if (res.includes(undefined)) return;
|
||||
} else if (requestQueue.length > 1) {
|
||||
if (res.includes(undefined))
|
||||
return showError(t('部分保存失败,请重试'));
|
||||
}
|
||||
showSuccess(t('保存成功'));
|
||||
props.refresh();
|
||||
})
|
||||
.catch(() => {
|
||||
showError(t('保存失败,请重试'));
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
showError(t('保存失败,请重试'));
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
.catch((error) => {
|
||||
console.error('Validation failed:', error);
|
||||
showError(t('请检查输入'));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -146,6 +154,14 @@ export default function SettingGeminiModel(props) {
|
||||
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 })}
|
||||
trigger='blur'
|
||||
stopValidateWithError
|
||||
rules={[
|
||||
{
|
||||
validator: (rule, value) => verifyJSON(value),
|
||||
message: t('不是合法的 JSON 字符串'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
@@ -157,7 +173,8 @@ export default function SettingGeminiModel(props) {
|
||||
<Text>
|
||||
{t(
|
||||
"和Claude不同,默认情况下Gemini的思考模型会自动决定要不要思考,就算不开启适配模型也可以正常使用," +
|
||||
"如果您需要计费,推荐设置无后缀模型价格按思考价格设置"
|
||||
"如果您需要计费,推荐设置无后缀模型价格按思考价格设置。" +
|
||||
"支持使用 gemini-2.5-pro-preview-06-05-thinking-128 格式来精确传递思考预算。"
|
||||
)}
|
||||
</Text>
|
||||
</Col>
|
||||
@@ -167,7 +184,7 @@ export default function SettingGeminiModel(props) {
|
||||
<Form.Switch
|
||||
label={t('启用Gemini思考后缀适配')}
|
||||
field={'gemini.thinking_adapter_enabled'}
|
||||
extraText={"适配-thinking和-nothinking后缀"}
|
||||
extraText={t('适配 -thinking、-thinking-预算数字 和 -nothinking 后缀')}
|
||||
onChange={(value) =>
|
||||
setInputs({
|
||||
...inputs,
|
||||
@@ -189,11 +206,11 @@ export default function SettingGeminiModel(props) {
|
||||
<Row>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.InputNumber
|
||||
label={t('请求模型带-thinking后缀的BudgetTokens数(超出24576的部分将被忽略)')}
|
||||
label={t('思考预算占比')}
|
||||
field={'gemini.thinking_adapter_budget_tokens_percentage'}
|
||||
initValue={''}
|
||||
extraText={t('0.1-1之间的小数')}
|
||||
min={0.1}
|
||||
extraText={t('0.002-1之间的小数')}
|
||||
min={0.002}
|
||||
max={1}
|
||||
onChange={(value) =>
|
||||
setInputs({
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
Form,
|
||||
Row,
|
||||
Spin,
|
||||
Collapse,
|
||||
Modal,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
@@ -92,10 +91,6 @@ export default function GeneralSettings(props) {
|
||||
return (
|
||||
<>
|
||||
<Spin spinning={loading}>
|
||||
<Banner
|
||||
type='warning'
|
||||
description={t('聊天链接功能已经弃用,请使用下方聊天设置功能')}
|
||||
/>
|
||||
<Form
|
||||
values={inputs}
|
||||
getFormApi={(formAPI) => (refForm.current = formAPI)}
|
||||
|
||||
74
web/src/pages/Setting/Payment/SettingsGeneralPayment.js
Normal file
74
web/src/pages/Setting/Payment/SettingsGeneralPayment.js
Normal file
@@ -0,0 +1,74 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
Spin,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
API,
|
||||
removeTrailingSlash,
|
||||
showError,
|
||||
showSuccess,
|
||||
} from '../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function SettingsGeneralPayment(props) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [inputs, setInputs] = useState({
|
||||
ServerAddress: '',
|
||||
});
|
||||
const formApiRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.options && formApiRef.current) {
|
||||
const currentInputs = { ServerAddress: props.options.ServerAddress || '' };
|
||||
setInputs(currentInputs);
|
||||
formApiRef.current.setValues(currentInputs);
|
||||
}
|
||||
}, [props.options]);
|
||||
|
||||
const handleFormChange = (values) => {
|
||||
setInputs(values);
|
||||
};
|
||||
|
||||
const submitServerAddress = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
let ServerAddress = removeTrailingSlash(inputs.ServerAddress);
|
||||
const res = await API.put('/api/option/', {
|
||||
key: 'ServerAddress',
|
||||
value: ServerAddress,
|
||||
});
|
||||
if (res.data.success) {
|
||||
showSuccess(t('更新成功'));
|
||||
props.refresh && props.refresh();
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('更新失败'));
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Spin spinning={loading}>
|
||||
<Form
|
||||
initValues={inputs}
|
||||
onValueChange={handleFormChange}
|
||||
getFormApi={(api) => (formApiRef.current = api)}
|
||||
>
|
||||
<Form.Section text={t('通用设置')}>
|
||||
<Form.Input
|
||||
field='ServerAddress'
|
||||
label={t('服务器地址')}
|
||||
placeholder={'https://yourdomain.com'}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<Button onClick={submitServerAddress}>{t('更新服务器地址')}</Button>
|
||||
</Form.Section>
|
||||
</Form>
|
||||
</Spin>
|
||||
);
|
||||
}
|
||||
218
web/src/pages/Setting/Payment/SettingsPaymentGateway.js
Normal file
218
web/src/pages/Setting/Payment/SettingsPaymentGateway.js
Normal file
@@ -0,0 +1,218 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
Row,
|
||||
Col,
|
||||
Typography,
|
||||
Spin,
|
||||
} from '@douyinfe/semi-ui';
|
||||
const { Text } = Typography;
|
||||
import {
|
||||
API,
|
||||
removeTrailingSlash,
|
||||
showError,
|
||||
showSuccess,
|
||||
verifyJSON,
|
||||
} from '../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function SettingsPaymentGateway(props) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [inputs, setInputs] = useState({
|
||||
PayAddress: '',
|
||||
EpayId: '',
|
||||
EpayKey: '',
|
||||
Price: 7.3,
|
||||
MinTopUp: 1,
|
||||
TopupGroupRatio: '',
|
||||
CustomCallbackAddress: '',
|
||||
PayMethods: '',
|
||||
});
|
||||
const [originInputs, setOriginInputs] = useState({});
|
||||
const formApiRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.options && formApiRef.current) {
|
||||
const currentInputs = {
|
||||
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,
|
||||
TopupGroupRatio: props.options.TopupGroupRatio || '',
|
||||
CustomCallbackAddress: props.options.CustomCallbackAddress || '',
|
||||
PayMethods: props.options.PayMethods || '',
|
||||
};
|
||||
setInputs(currentInputs);
|
||||
setOriginInputs({ ...currentInputs });
|
||||
formApiRef.current.setValues(currentInputs);
|
||||
}
|
||||
}, [props.options]);
|
||||
|
||||
const handleFormChange = (values) => {
|
||||
setInputs(values);
|
||||
};
|
||||
|
||||
const submitPayAddress = async () => {
|
||||
if (props.options.ServerAddress === '') {
|
||||
showError(t('请先填写服务器地址'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (originInputs['TopupGroupRatio'] !== inputs.TopupGroupRatio) {
|
||||
if (!verifyJSON(inputs.TopupGroupRatio)) {
|
||||
showError(t('充值分组倍率不是合法的 JSON 字符串'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (originInputs['PayMethods'] !== inputs.PayMethods) {
|
||||
if (!verifyJSON(inputs.PayMethods)) {
|
||||
showError(t('充值方式设置不是合法的 JSON 字符串'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const options = [
|
||||
{ key: 'PayAddress', value: removeTrailingSlash(inputs.PayAddress) },
|
||||
];
|
||||
|
||||
if (inputs.EpayId !== '') {
|
||||
options.push({ key: 'EpayId', value: inputs.EpayId });
|
||||
}
|
||||
if (inputs.EpayKey !== undefined && inputs.EpayKey !== '') {
|
||||
options.push({ key: 'EpayKey', value: inputs.EpayKey });
|
||||
}
|
||||
if (inputs.Price !== '') {
|
||||
options.push({ key: 'Price', value: inputs.Price.toString() });
|
||||
}
|
||||
if (inputs.MinTopUp !== '') {
|
||||
options.push({ key: 'MinTopUp', value: inputs.MinTopUp.toString() });
|
||||
}
|
||||
if (inputs.CustomCallbackAddress !== '') {
|
||||
options.push({
|
||||
key: 'CustomCallbackAddress',
|
||||
value: inputs.CustomCallbackAddress,
|
||||
});
|
||||
}
|
||||
if (originInputs['TopupGroupRatio'] !== inputs.TopupGroupRatio) {
|
||||
options.push({ key: 'TopupGroupRatio', value: inputs.TopupGroupRatio });
|
||||
}
|
||||
if (originInputs['PayMethods'] !== inputs.PayMethods) {
|
||||
options.push({ key: 'PayMethods', value: inputs.PayMethods });
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
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);
|
||||
if (errorResults.length > 0) {
|
||||
errorResults.forEach(res => {
|
||||
showError(res.data.message);
|
||||
});
|
||||
} else {
|
||||
showSuccess(t('更新成功'));
|
||||
// 更新本地存储的原始值
|
||||
setOriginInputs({ ...inputs });
|
||||
props.refresh && props.refresh();
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('更新失败'));
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Spin spinning={loading}>
|
||||
<Form
|
||||
initValues={inputs}
|
||||
onValueChange={handleFormChange}
|
||||
getFormApi={(api) => (formApiRef.current = api)}
|
||||
>
|
||||
<Form.Section text={t('支付设置')}>
|
||||
<Text>
|
||||
{t('(当前仅支持易支付接口,默认使用上方服务器地址作为回调地址!)')}
|
||||
</Text>
|
||||
<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'
|
||||
label={t('支付地址')}
|
||||
placeholder={t('例如:https://yourdomain.com')}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
field='EpayId'
|
||||
label={t('易支付商户ID')}
|
||||
placeholder={t('例如:0001')}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
field='EpayKey'
|
||||
label={t('易支付商户密钥')}
|
||||
placeholder={t('敏感信息不会发送到前端显示')}
|
||||
type='password'
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row
|
||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
field='CustomCallbackAddress'
|
||||
label={t('回调地址')}
|
||||
placeholder={t('例如:https://yourdomain.com')}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.InputNumber
|
||||
field='Price'
|
||||
precision={2}
|
||||
label={t('充值价格(x元/美金)')}
|
||||
placeholder={t('例如:7,就是7元/美金')}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.InputNumber
|
||||
field='MinTopUp'
|
||||
label={t('最低充值美元数量')}
|
||||
placeholder={t('例如:2,就是最低充值2$')}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.TextArea
|
||||
field='TopupGroupRatio'
|
||||
label={t('充值分组倍率')}
|
||||
placeholder={t('为一个 JSON 文本,键为组名称,值为倍率')}
|
||||
autosize
|
||||
/>
|
||||
<Form.TextArea
|
||||
field='PayMethods'
|
||||
label={t('充值方式设置')}
|
||||
placeholder={t('为一个 JSON 文本')}
|
||||
autosize
|
||||
/>
|
||||
<Button onClick={submitPayAddress}>{t('更新支付设置')}</Button>
|
||||
</Form.Section>
|
||||
</Form>
|
||||
</Spin>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,9 @@ export default function GroupRatioSettings(props) {
|
||||
const [inputs, setInputs] = useState({
|
||||
GroupRatio: '',
|
||||
UserUsableGroups: '',
|
||||
GroupGroupRatio: '',
|
||||
AutoGroups: '',
|
||||
DefaultUseAutoGroup: false,
|
||||
});
|
||||
const refForm = useRef();
|
||||
const [inputsRow, setInputsRow] = useState(inputs);
|
||||
@@ -99,6 +102,9 @@ export default function GroupRatioSettings(props) {
|
||||
<Form.TextArea
|
||||
label={t('分组倍率')}
|
||||
placeholder={t('为一个 JSON 文本,键为分组名称,值为倍率')}
|
||||
extraText={t(
|
||||
'分组倍率设置,可以在此处新增分组或修改现有分组的倍率,格式为 JSON 字符串,例如:{"vip": 0.5, "test": 1},表示 vip 分组的倍率为 0.5,test 分组的倍率为 1',
|
||||
)}
|
||||
field={'GroupRatio'}
|
||||
autosize={{ minRows: 6, maxRows: 12 }}
|
||||
trigger='blur'
|
||||
@@ -120,6 +126,9 @@ export default function GroupRatioSettings(props) {
|
||||
<Form.TextArea
|
||||
label={t('用户可选分组')}
|
||||
placeholder={t('为一个 JSON 文本,键为分组名称,值为分组描述')}
|
||||
extraText={t(
|
||||
'用户新建令牌时可选的分组,格式为 JSON 字符串,例如:{"vip": "VIP 用户", "test": "测试"},表示用户可以选择 vip 分组和 test 分组',
|
||||
)}
|
||||
field={'UserUsableGroups'}
|
||||
autosize={{ minRows: 6, maxRows: 12 }}
|
||||
trigger='blur'
|
||||
@@ -136,6 +145,83 @@ export default function GroupRatioSettings(props) {
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={16}>
|
||||
<Form.TextArea
|
||||
label={t('分组特殊倍率')}
|
||||
placeholder={t('为一个 JSON 文本')}
|
||||
extraText={t(
|
||||
'键为分组名称,值为另一个 JSON 对象,键为分组名称,值为该分组的用户的特殊分组倍率,例如:{"vip": {"default": 0.5, "test": 1}},表示 vip 分组的用户在使用default分组的令牌时倍率为0.5,使用test分组时倍率为1',
|
||||
)}
|
||||
field={'GroupGroupRatio'}
|
||||
autosize={{ minRows: 6, maxRows: 12 }}
|
||||
trigger='blur'
|
||||
stopValidateWithError
|
||||
rules={[
|
||||
{
|
||||
validator: (rule, value) => verifyJSON(value),
|
||||
message: t('不是合法的 JSON 字符串'),
|
||||
},
|
||||
]}
|
||||
onChange={(value) =>
|
||||
setInputs({ ...inputs, GroupGroupRatio: value })
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={16}>
|
||||
<Form.TextArea
|
||||
label={t('自动分组auto,从第一个开始选择')}
|
||||
placeholder={t('为一个 JSON 文本')}
|
||||
field={'AutoGroups'}
|
||||
autosize={{ minRows: 6, maxRows: 12 }}
|
||||
trigger='blur'
|
||||
stopValidateWithError
|
||||
rules={[
|
||||
{
|
||||
validator: (rule, value) => {
|
||||
if (!value || value.trim() === '') {
|
||||
return true; // Allow empty values
|
||||
}
|
||||
|
||||
// First check if it's valid JSON
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
|
||||
// Check if it's an array
|
||||
if (!Array.isArray(parsed)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if every element is a string
|
||||
return parsed.every(item => typeof item === 'string');
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
message: t('必须是有效的 JSON 字符串数组,例如:["g1","g2"]'),
|
||||
},
|
||||
]}
|
||||
onChange={(value) =>
|
||||
setInputs({ ...inputs, AutoGroups: value })
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={16}>
|
||||
<Form.Switch
|
||||
label={t(
|
||||
'创建令牌默认选择auto分组,初始令牌也将设为auto(否则留空,为用户默认分组)',
|
||||
)}
|
||||
field={'DefaultUseAutoGroup'}
|
||||
onChange={(value) =>
|
||||
setInputs({ ...inputs, DefaultUseAutoGroup: value })
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form.Section>
|
||||
</Form>
|
||||
<Button onClick={onSubmit}>{t('保存分组倍率设置')}</Button>
|
||||
@@ -25,6 +25,7 @@ export default function ModelRatioSettings(props) {
|
||||
ModelRatio: '',
|
||||
CacheRatio: '',
|
||||
CompletionRatio: '',
|
||||
ExposeRatioEnabled: false,
|
||||
});
|
||||
const refForm = useRef();
|
||||
const [inputsRow, setInputsRow] = useState(inputs);
|
||||
@@ -206,6 +207,17 @@ export default function ModelRatioSettings(props) {
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={16}>
|
||||
<Form.Switch
|
||||
label={t('暴露倍率接口')}
|
||||
field={'ExposeRatioEnabled'}
|
||||
onChange={(value) =>
|
||||
setInputs({ ...inputs, ExposeRatioEnabled: value })
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form.Section>
|
||||
</Form>
|
||||
<Space>
|
||||
@@ -17,8 +17,7 @@ import {
|
||||
IconSave,
|
||||
IconBolt,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { showError, showSuccess } from '../../../helpers';
|
||||
import { API } from '../../../helpers';
|
||||
import { API, showError, showSuccess } from '../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function ModelRatioNotSetEditor(props) {
|
||||
@@ -373,7 +372,7 @@ export default function ModelRatioNotSetEditor(props) {
|
||||
return (
|
||||
<>
|
||||
<Space vertical align='start' style={{ width: '100%' }}>
|
||||
<Space>
|
||||
<Space className='mt-2'>
|
||||
<Button icon={<IconPlus />} onClick={() => setVisible(true)}>
|
||||
{t('添加模型')}
|
||||
</Button>
|
||||
@@ -1,5 +1,5 @@
|
||||
// ModelSettingsVisualEditor.js
|
||||
import React, { useContext, useEffect, useState, useRef } from 'react';
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
@@ -8,9 +8,7 @@ import {
|
||||
Form,
|
||||
Space,
|
||||
RadioGroup,
|
||||
Radio,
|
||||
Tabs,
|
||||
TabPane,
|
||||
Radio
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconDelete,
|
||||
@@ -19,11 +17,8 @@ import {
|
||||
IconSave,
|
||||
IconEdit,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { showError, showSuccess } from '../../../helpers';
|
||||
import { API } from '../../../helpers';
|
||||
import { API, showError, showSuccess, getQuotaPerUnit } from '../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { StatusContext } from '../../../context/Status/index.js';
|
||||
import { getQuotaPerUnit } from '../../../helpers/render.js';
|
||||
|
||||
export default function ModelSettingsVisualEditor(props) {
|
||||
const { t } = useTranslation();
|
||||
@@ -304,11 +299,11 @@ export default function ModelSettingsVisualEditor(props) {
|
||||
prev.map((model, index) =>
|
||||
index === existingModelIndex
|
||||
? {
|
||||
name: values.name,
|
||||
price: values.price || '',
|
||||
ratio: values.ratio || '',
|
||||
completionRatio: values.completionRatio || '',
|
||||
}
|
||||
name: values.name,
|
||||
price: values.price || '',
|
||||
ratio: values.ratio || '',
|
||||
completionRatio: values.completionRatio || '',
|
||||
}
|
||||
: model,
|
||||
),
|
||||
);
|
||||
@@ -409,7 +404,7 @@ export default function ModelSettingsVisualEditor(props) {
|
||||
return (
|
||||
<>
|
||||
<Space vertical align='start' style={{ width: '100%' }}>
|
||||
<Space>
|
||||
<Space className='mt-2'>
|
||||
<Button
|
||||
icon={<IconPlus />}
|
||||
onClick={() => {
|
||||
@@ -456,8 +451,8 @@ export default function ModelSettingsVisualEditor(props) {
|
||||
<Modal
|
||||
title={
|
||||
currentModel &&
|
||||
currentModel.name &&
|
||||
models.some((model) => model.name === currentModel.name)
|
||||
currentModel.name &&
|
||||
models.some((model) => model.name === currentModel.name)
|
||||
? t('编辑模型')
|
||||
: t('添加模型')
|
||||
}
|
||||
503
web/src/pages/Setting/Ratio/UpstreamRatioSync.js
Normal file
503
web/src/pages/Setting/Ratio/UpstreamRatioSync.js
Normal file
@@ -0,0 +1,503 @@
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Table,
|
||||
Tag,
|
||||
Empty,
|
||||
Checkbox,
|
||||
Form,
|
||||
Input,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { IconSearch } from '@douyinfe/semi-icons';
|
||||
import {
|
||||
RefreshCcw,
|
||||
CheckSquare,
|
||||
} from 'lucide-react';
|
||||
import { API, showError, showSuccess, showWarning, stringToColor } from '../../../helpers';
|
||||
import { DEFAULT_ENDPOINT } from '../../../constants';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import ChannelSelectorModal from '../../../components/settings/ChannelSelectorModal';
|
||||
|
||||
export default function UpstreamRatioSync(props) {
|
||||
const { t } = useTranslation();
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [syncLoading, setSyncLoading] = useState(false);
|
||||
|
||||
// 渠道选择相关
|
||||
const [allChannels, setAllChannels] = useState([]);
|
||||
const [selectedChannelIds, setSelectedChannelIds] = useState([]);
|
||||
|
||||
// 渠道端点配置
|
||||
const [channelEndpoints, setChannelEndpoints] = useState({}); // { channelId: endpoint }
|
||||
|
||||
// 差异数据和测试结果
|
||||
const [differences, setDifferences] = useState({});
|
||||
const [resolutions, setResolutions] = useState({});
|
||||
|
||||
// 是否已经执行过同步
|
||||
const [hasSynced, setHasSynced] = useState(false);
|
||||
|
||||
// 分页相关状态
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
|
||||
// 搜索相关状态
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
|
||||
const fetchAllChannels = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.get('/api/ratio_sync/channels');
|
||||
|
||||
if (res.data.success) {
|
||||
const channels = res.data.data || [];
|
||||
|
||||
const transferData = channels.map(channel => ({
|
||||
key: channel.id,
|
||||
label: channel.name,
|
||||
value: channel.id,
|
||||
disabled: false,
|
||||
_originalData: channel,
|
||||
}));
|
||||
|
||||
setAllChannels(transferData);
|
||||
|
||||
const initialEndpoints = {};
|
||||
transferData.forEach(channel => {
|
||||
initialEndpoints[channel.key] = DEFAULT_ENDPOINT;
|
||||
});
|
||||
setChannelEndpoints(initialEndpoints);
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('获取渠道失败:') + error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmChannelSelection = () => {
|
||||
const selected = allChannels
|
||||
.filter(ch => selectedChannelIds.includes(ch.value))
|
||||
.map(ch => ch._originalData);
|
||||
|
||||
if (selected.length === 0) {
|
||||
showWarning(t('请至少选择一个渠道'));
|
||||
return;
|
||||
}
|
||||
|
||||
setModalVisible(false);
|
||||
fetchRatiosFromChannels(selected);
|
||||
};
|
||||
|
||||
const fetchRatiosFromChannels = async (channelList) => {
|
||||
setSyncLoading(true);
|
||||
|
||||
const payload = {
|
||||
channel_ids: channelList.map(ch => parseInt(ch.id)),
|
||||
timeout: 10,
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await API.post('/api/ratio_sync/fetch', payload);
|
||||
|
||||
if (!res.data.success) {
|
||||
showError(res.data.message || t('后端请求失败'));
|
||||
setSyncLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { differences = {}, test_results = [] } = res.data.data;
|
||||
|
||||
const errorResults = test_results.filter(r => r.status === 'error');
|
||||
if (errorResults.length > 0) {
|
||||
showWarning(t('部分渠道测试失败:') + errorResults.map(r => `${r.name}: ${r.error}`).join(', '));
|
||||
}
|
||||
|
||||
setDifferences(differences);
|
||||
setResolutions({});
|
||||
setHasSynced(true);
|
||||
|
||||
if (Object.keys(differences).length === 0) {
|
||||
showSuccess(t('未找到差异化倍率,无需同步'));
|
||||
}
|
||||
} catch (e) {
|
||||
showError(t('请求后端接口失败:') + e.message);
|
||||
} finally {
|
||||
setSyncLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const selectValue = (model, ratioType, value) => {
|
||||
setResolutions(prev => ({
|
||||
...prev,
|
||||
[model]: {
|
||||
...prev[model],
|
||||
[ratioType]: value,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const applySync = async () => {
|
||||
const currentRatios = {
|
||||
ModelRatio: JSON.parse(props.options.ModelRatio || '{}'),
|
||||
CompletionRatio: JSON.parse(props.options.CompletionRatio || '{}'),
|
||||
CacheRatio: JSON.parse(props.options.CacheRatio || '{}'),
|
||||
ModelPrice: JSON.parse(props.options.ModelPrice || '{}'),
|
||||
};
|
||||
|
||||
Object.entries(resolutions).forEach(([model, ratios]) => {
|
||||
Object.entries(ratios).forEach(([ratioType, value]) => {
|
||||
const optionKey = ratioType
|
||||
.split('_')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join('');
|
||||
currentRatios[optionKey][model] = parseFloat(value);
|
||||
});
|
||||
});
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const updates = Object.entries(currentRatios).map(([key, value]) =>
|
||||
API.put('/api/option/', {
|
||||
key,
|
||||
value: JSON.stringify(value, null, 2),
|
||||
})
|
||||
);
|
||||
|
||||
const results = await Promise.all(updates);
|
||||
|
||||
if (results.every(res => res.data.success)) {
|
||||
showSuccess(t('同步成功'));
|
||||
props.refresh();
|
||||
|
||||
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];
|
||||
|
||||
if (Object.keys(newDifferences[model]).length === 0) {
|
||||
delete newDifferences[model];
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return newDifferences;
|
||||
});
|
||||
|
||||
setResolutions({});
|
||||
} else {
|
||||
showError(t('部分保存失败'));
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('保存失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getCurrentPageData = (dataSource) => {
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
const endIndex = startIndex + pageSize;
|
||||
return dataSource.slice(startIndex, endIndex);
|
||||
};
|
||||
|
||||
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 gap-2 w-full md:w-auto order-2 md:order-1">
|
||||
<Button
|
||||
icon={<RefreshCcw size={14} />}
|
||||
className="!rounded-full w-full md:w-auto mt-2"
|
||||
onClick={() => {
|
||||
setModalVisible(true);
|
||||
fetchAllChannels();
|
||||
}}
|
||||
>
|
||||
{t('选择同步渠道')}
|
||||
</Button>
|
||||
|
||||
{(() => {
|
||||
const hasSelections = Object.keys(resolutions).length > 0;
|
||||
|
||||
return (
|
||||
<Button
|
||||
icon={<CheckSquare size={14} />}
|
||||
type='secondary'
|
||||
onClick={applySync}
|
||||
disabled={!hasSelections}
|
||||
className="!rounded-full w-full md:w-auto mt-2"
|
||||
>
|
||||
{t('应用同步')}
|
||||
</Button>
|
||||
);
|
||||
})()}
|
||||
|
||||
<Input
|
||||
prefix={<IconSearch size={14} />}
|
||||
placeholder={t('搜索模型名称')}
|
||||
value={searchKeyword}
|
||||
onChange={setSearchKeyword}
|
||||
className="!rounded-full w-full md:w-64 mt-2"
|
||||
showClear
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderDifferenceTable = () => {
|
||||
const dataSource = useMemo(() => {
|
||||
const tmp = [];
|
||||
|
||||
Object.entries(differences).forEach(([model, ratioTypes]) => {
|
||||
Object.entries(ratioTypes).forEach(([ratioType, diff]) => {
|
||||
tmp.push({
|
||||
key: `${model}_${ratioType}`,
|
||||
model,
|
||||
ratioType,
|
||||
current: diff.current,
|
||||
upstreams: diff.upstreams,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return tmp;
|
||||
}, [differences]);
|
||||
|
||||
const filteredDataSource = useMemo(() => {
|
||||
if (!searchKeyword.trim()) {
|
||||
return dataSource;
|
||||
}
|
||||
|
||||
const keyword = searchKeyword.toLowerCase().trim();
|
||||
return dataSource.filter(item =>
|
||||
item.model.toLowerCase().includes(keyword)
|
||||
);
|
||||
}, [dataSource, searchKeyword]);
|
||||
|
||||
const upstreamNames = useMemo(() => {
|
||||
const set = new Set();
|
||||
filteredDataSource.forEach((row) => {
|
||||
Object.keys(row.upstreams || {}).forEach((name) => set.add(name));
|
||||
});
|
||||
return Array.from(set);
|
||||
}, [filteredDataSource]);
|
||||
|
||||
if (filteredDataSource.length === 0) {
|
||||
return (
|
||||
<Empty
|
||||
image={<IllustrationNoResult 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('请先选择同步渠道'))
|
||||
}
|
||||
style={{ padding: 30 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('模型'),
|
||||
dataIndex: 'model',
|
||||
fixed: 'left',
|
||||
},
|
||||
{
|
||||
title: t('倍率类型'),
|
||||
dataIndex: 'ratioType',
|
||||
render: (text) => {
|
||||
const typeMap = {
|
||||
model_ratio: t('模型倍率'),
|
||||
completion_ratio: t('补全倍率'),
|
||||
cache_ratio: t('缓存倍率'),
|
||||
model_price: t('固定价格'),
|
||||
};
|
||||
return <Tag color={stringToColor(text)} shape="circle">{typeMap[text] || text}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('当前值'),
|
||||
dataIndex: 'current',
|
||||
render: (text) => (
|
||||
<Tag color={text !== null && text !== undefined ? 'blue' : 'default'} shape="circle">
|
||||
{text !== null && text !== undefined ? text : t('未设置')}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
...upstreamNames.map((upName) => {
|
||||
const channelStats = (() => {
|
||||
let selectableCount = 0;
|
||||
let selectedCount = 0;
|
||||
|
||||
filteredDataSource.forEach((row) => {
|
||||
const upstreamVal = row.upstreams?.[upName];
|
||||
if (upstreamVal !== null && upstreamVal !== undefined && upstreamVal !== 'same') {
|
||||
selectableCount++;
|
||||
const isSelected = resolutions[row.model]?.[row.ratioType] === upstreamVal;
|
||||
if (isSelected) {
|
||||
selectedCount++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
selectableCount,
|
||||
selectedCount,
|
||||
allSelected: selectableCount > 0 && selectedCount === selectableCount,
|
||||
partiallySelected: selectedCount > 0 && selectedCount < selectableCount,
|
||||
hasSelectableItems: selectableCount > 0
|
||||
};
|
||||
})();
|
||||
|
||||
const handleBulkSelect = (checked) => {
|
||||
setResolutions((prev) => {
|
||||
const newRes = { ...prev };
|
||||
|
||||
filteredDataSource.forEach((row) => {
|
||||
const upstreamVal = row.upstreams?.[upName];
|
||||
if (upstreamVal !== null && upstreamVal !== undefined && upstreamVal !== 'same') {
|
||||
if (checked) {
|
||||
if (!newRes[row.model]) newRes[row.model] = {};
|
||||
newRes[row.model][row.ratioType] = upstreamVal;
|
||||
} else {
|
||||
if (newRes[row.model]) {
|
||||
delete newRes[row.model][row.ratioType];
|
||||
if (Object.keys(newRes[row.model]).length === 0) {
|
||||
delete newRes[row.model];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return newRes;
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
title: channelStats.hasSelectableItems ? (
|
||||
<Checkbox
|
||||
checked={channelStats.allSelected}
|
||||
indeterminate={channelStats.partiallySelected}
|
||||
onChange={(e) => handleBulkSelect(e.target.checked)}
|
||||
>
|
||||
{upName}
|
||||
</Checkbox>
|
||||
) : (
|
||||
<span>{upName}</span>
|
||||
),
|
||||
dataIndex: upName,
|
||||
render: (_, record) => {
|
||||
const upstreamVal = record.upstreams?.[upName];
|
||||
|
||||
if (upstreamVal === null || upstreamVal === undefined) {
|
||||
return <Tag color="default" shape="circle">{t('未设置')}</Tag>;
|
||||
}
|
||||
|
||||
if (upstreamVal === 'same') {
|
||||
return <Tag color="blue" shape="circle">{t('与本地相同')}</Tag>;
|
||||
}
|
||||
|
||||
const isSelected = resolutions[record.model]?.[record.ratioType] === upstreamVal;
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onChange={(e) => {
|
||||
const isChecked = e.target.checked;
|
||||
if (isChecked) {
|
||||
selectValue(record.model, record.ratioType, upstreamVal);
|
||||
} else {
|
||||
setResolutions((prev) => {
|
||||
const newRes = { ...prev };
|
||||
if (newRes[record.model]) {
|
||||
delete newRes[record.model][record.ratioType];
|
||||
if (Object.keys(newRes[record.model]).length === 0) {
|
||||
delete newRes[record.model];
|
||||
}
|
||||
}
|
||||
return newRes;
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{upstreamVal}
|
||||
</Checkbox>
|
||||
);
|
||||
},
|
||||
};
|
||||
}),
|
||||
];
|
||||
|
||||
return (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={getCurrentPageData(filteredDataSource)}
|
||||
pagination={{
|
||||
currentPage: currentPage,
|
||||
pageSize: pageSize,
|
||||
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);
|
||||
setPageSize(size);
|
||||
},
|
||||
onShowSizeChange: (current, size) => {
|
||||
setCurrentPage(1);
|
||||
setPageSize(size);
|
||||
}
|
||||
}}
|
||||
scroll={{ x: 'max-content' }}
|
||||
size='middle'
|
||||
loading={loading || syncLoading}
|
||||
className="rounded-xl overflow-hidden"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const updateChannelEndpoint = useCallback((channelId, endpoint) => {
|
||||
setChannelEndpoints(prev => ({ ...prev, [channelId]: endpoint }));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form.Section text={renderHeader()}>
|
||||
{renderDifferenceTable()}
|
||||
</Form.Section>
|
||||
|
||||
<ChannelSelectorModal
|
||||
t={t}
|
||||
visible={modalVisible}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
onOk={confirmChannelSelection}
|
||||
allChannels={allChannels}
|
||||
selectedChannelIds={selectedChannelIds}
|
||||
setSelectedChannelIds={setSelectedChannelIds}
|
||||
channelEndpoints={channelEndpoints}
|
||||
updateChannelEndpoint={updateChannelEndpoint}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,14 +2,30 @@ import React, { useEffect, useState } from 'react';
|
||||
import { Layout, TabPane, Tabs } from '@douyinfe/semi-ui';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Settings,
|
||||
Calculator,
|
||||
Gauge,
|
||||
Shapes,
|
||||
Cog,
|
||||
MoreHorizontal,
|
||||
LayoutDashboard,
|
||||
MessageSquare,
|
||||
Palette,
|
||||
CreditCard
|
||||
} from 'lucide-react';
|
||||
|
||||
import SystemSetting from '../../components/SystemSetting';
|
||||
import SystemSetting from '../../components/settings/SystemSetting.js';
|
||||
import { isRoot } from '../../helpers';
|
||||
import OtherSetting from '../../components/OtherSetting';
|
||||
import PersonalSetting from '../../components/PersonalSetting';
|
||||
import OperationSetting from '../../components/OperationSetting';
|
||||
import RateLimitSetting from '../../components/RateLimitSetting.js';
|
||||
import ModelSetting from '../../components/ModelSetting.js';
|
||||
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';
|
||||
|
||||
const Setting = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -20,27 +36,102 @@ const Setting = () => {
|
||||
|
||||
if (isRoot()) {
|
||||
panes.push({
|
||||
tab: t('运营设置'),
|
||||
tab: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
|
||||
<Settings size={18} />
|
||||
{t('运营设置')}
|
||||
</span>
|
||||
),
|
||||
content: <OperationSetting />,
|
||||
itemKey: 'operation',
|
||||
});
|
||||
panes.push({
|
||||
tab: t('速率限制设置'),
|
||||
tab: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
|
||||
<MessageSquare size={18} />
|
||||
{t('聊天设置')}
|
||||
</span>
|
||||
),
|
||||
content: <ChatsSetting />,
|
||||
itemKey: 'chats',
|
||||
});
|
||||
panes.push({
|
||||
tab: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
|
||||
<Palette size={18} />
|
||||
{t('绘图设置')}
|
||||
</span>
|
||||
),
|
||||
content: <DrawingSetting />,
|
||||
itemKey: 'drawing',
|
||||
});
|
||||
panes.push({
|
||||
tab: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
|
||||
<CreditCard size={18} />
|
||||
{t('支付设置')}
|
||||
</span>
|
||||
),
|
||||
content: <PaymentSetting />,
|
||||
itemKey: 'payment',
|
||||
});
|
||||
panes.push({
|
||||
tab: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
|
||||
<Calculator size={18} />
|
||||
{t('倍率设置')}
|
||||
</span>
|
||||
),
|
||||
content: <RatioSetting />,
|
||||
itemKey: 'ratio',
|
||||
});
|
||||
panes.push({
|
||||
tab: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
|
||||
<Gauge size={18} />
|
||||
{t('速率限制设置')}
|
||||
</span>
|
||||
),
|
||||
content: <RateLimitSetting />,
|
||||
itemKey: 'ratelimit',
|
||||
});
|
||||
panes.push({
|
||||
tab: t('模型相关设置'),
|
||||
tab: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
|
||||
<Shapes size={18} />
|
||||
{t('模型相关设置')}
|
||||
</span>
|
||||
),
|
||||
content: <ModelSetting />,
|
||||
itemKey: 'models',
|
||||
});
|
||||
panes.push({
|
||||
tab: t('系统设置'),
|
||||
tab: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
|
||||
<Cog size={18} />
|
||||
{t('系统设置')}
|
||||
</span>
|
||||
),
|
||||
content: <SystemSetting />,
|
||||
itemKey: 'system',
|
||||
});
|
||||
panes.push({
|
||||
tab: t('其他设置'),
|
||||
tab: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
|
||||
<LayoutDashboard size={18} />
|
||||
{t('仪表盘设置')}
|
||||
</span>
|
||||
),
|
||||
content: <DashboardSetting />,
|
||||
itemKey: 'dashboard',
|
||||
});
|
||||
panes.push({
|
||||
tab: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
|
||||
<MoreHorizontal size={18} />
|
||||
{t('其他设置')}
|
||||
</span>
|
||||
),
|
||||
content: <OtherSetting />,
|
||||
itemKey: 'other',
|
||||
});
|
||||
@@ -63,7 +154,8 @@ const Setting = () => {
|
||||
<Layout>
|
||||
<Layout.Content>
|
||||
<Tabs
|
||||
type='line'
|
||||
type='card'
|
||||
collapsible
|
||||
activeKey={tabActiveKey}
|
||||
onChange={(key) => onChangeTab(key)}
|
||||
>
|
||||
|
||||
@@ -1,32 +1,28 @@
|
||||
import React, { useContext, useEffect, useState, useRef } from 'react';
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import {
|
||||
Card,
|
||||
Col,
|
||||
Row,
|
||||
Form,
|
||||
Button,
|
||||
Typography,
|
||||
Space,
|
||||
RadioGroup,
|
||||
Radio,
|
||||
Modal,
|
||||
Banner,
|
||||
Layout,
|
||||
Tag,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { API, showError, showNotice, timestamp2string } from '../../helpers';
|
||||
import { StatusContext } from '../../context/Status';
|
||||
import { marked } from 'marked';
|
||||
import { StyleContext } from '../../context/Style/index.js';
|
||||
import { API, showError, showNotice } from '../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
IconHelpCircle,
|
||||
IconInfoCircle,
|
||||
IconAlertTriangle,
|
||||
IconUser,
|
||||
IconLock,
|
||||
IconSetting,
|
||||
IconCheckCircleStroked,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { Shield, Rocket, FlaskConical, Database, Layers } from 'lucide-react';
|
||||
|
||||
const Setup = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [statusState] = useContext(StatusContext);
|
||||
const [styleState, styleDispatch] = useContext(StyleContext);
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selfUseModeInfoVisible, setUsageModeInfoVisible] = useState(false);
|
||||
const [setupStatus, setSetupStatus] = useState({
|
||||
@@ -137,163 +133,445 @@ const Setup = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ maxWidth: '800px', margin: '0 auto', padding: '20px' }}>
|
||||
<Card>
|
||||
<Title heading={2} style={{ marginBottom: '24px' }}>
|
||||
{t('系统初始化')}
|
||||
</Title>
|
||||
|
||||
{setupStatus.database_type === 'sqlite' && (
|
||||
<Banner
|
||||
type='warning'
|
||||
icon={<IconAlertTriangle size='large' />}
|
||||
closeIcon={null}
|
||||
title={t('数据库警告')}
|
||||
description={
|
||||
<div>
|
||||
<p>
|
||||
{t(
|
||||
'您正在使用 SQLite 数据库。如果您在容器环境中运行,请确保已正确设置数据库文件的持久化映射,否则容器重启后所有数据将丢失!',
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
{t(
|
||||
'建议在生产环境中使用 MySQL 或 PostgreSQL 数据库,或确保 SQLite 数据库文件已映射到宿主机的持久化存储。',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
style={{ marginBottom: '24px' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Form
|
||||
getFormApi={(formApi) => {
|
||||
formRef.current = formApi;
|
||||
console.log('Form API set:', formApi);
|
||||
}}
|
||||
initValues={formData}
|
||||
>
|
||||
{setupStatus.root_init ? (
|
||||
<Banner
|
||||
type='info'
|
||||
icon={<IconInfoCircle />}
|
||||
closeIcon={null}
|
||||
description={t('管理员账号已经初始化过,请继续设置系统参数')}
|
||||
style={{ marginBottom: '24px' }}
|
||||
/>
|
||||
) : (
|
||||
<Form.Section text={t('管理员账号')}>
|
||||
<Form.Input
|
||||
field='username'
|
||||
label={t('用户名')}
|
||||
placeholder={t('请输入管理员用户名')}
|
||||
showClear
|
||||
onChange={(value) =>
|
||||
setFormData({ ...formData, username: value })
|
||||
}
|
||||
/>
|
||||
<Form.Input
|
||||
field='password'
|
||||
label={t('密码')}
|
||||
placeholder={t('请输入管理员密码')}
|
||||
type='password'
|
||||
showClear
|
||||
onChange={(value) =>
|
||||
setFormData({ ...formData, password: value })
|
||||
}
|
||||
/>
|
||||
<Form.Input
|
||||
field='confirmPassword'
|
||||
label={t('确认密码')}
|
||||
placeholder={t('请确认管理员密码')}
|
||||
type='password'
|
||||
showClear
|
||||
onChange={(value) =>
|
||||
setFormData({ ...formData, confirmPassword: value })
|
||||
}
|
||||
/>
|
||||
</Form.Section>
|
||||
)}
|
||||
|
||||
<Form.Section
|
||||
text={
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
{t('系统设置')}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Form.RadioGroup
|
||||
field='usageMode'
|
||||
label={
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
{t('使用模式')}
|
||||
<IconHelpCircle
|
||||
style={{
|
||||
marginLeft: '4px',
|
||||
color: 'var(--semi-color-primary)',
|
||||
verticalAlign: 'middle',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
// e.preventDefault();
|
||||
// e.stopPropagation();
|
||||
setUsageModeInfoVisible(true);
|
||||
}}
|
||||
/>
|
||||
<div className="bg-gray-50">
|
||||
<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>
|
||||
}
|
||||
extraText={t('可在初始化后修改')}
|
||||
initValue='external'
|
||||
onChange={handleUsageModeChange}
|
||||
>
|
||||
<Form.Radio value='external'>{t('对外运营模式')}</Form.Radio>
|
||||
<Form.Radio value='self'>{t('自用模式')}</Form.Radio>
|
||||
<Form.Radio value='demo'>{t('演示站点模式')}</Form.Radio>
|
||||
</Form.RadioGroup>
|
||||
</Form.Section>
|
||||
</Form>
|
||||
|
||||
<div style={{ marginTop: '24px', textAlign: 'right' }}>
|
||||
<Button type='primary' onClick={onSubmit} loading={loading}>
|
||||
{t('初始化系统')}
|
||||
</Button>
|
||||
<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' size='small' className="ml-2 !rounded-full">
|
||||
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' size='small' className="ml-2 !rounded-full">
|
||||
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' size='small' className="ml-2 !rounded-full">
|
||||
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' size='small' className="!rounded-full 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' size='small' className="!rounded-full 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' size='small' className="!rounded-full 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>
|
||||
</Card>
|
||||
</div>
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
|
||||
{/* 使用模式说明模态框 */}
|
||||
<Modal
|
||||
title={t('使用模式说明')}
|
||||
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('确定')}
|
||||
okText={t('我已了解')}
|
||||
cancelText={null}
|
||||
centered={true}
|
||||
size='medium'
|
||||
className="[&_.semi-modal-body]:!p-6"
|
||||
>
|
||||
<div style={{ padding: '8px 0' }}>
|
||||
<Title heading={6}>{t('对外运营模式')}</Title>
|
||||
<p>{t('默认模式,适用于为多个用户提供服务的场景。')}</p>
|
||||
<p>
|
||||
{t(
|
||||
'此模式下,系统将计算每次调用的用量,您需要对每个模型都设置价格,如果没有设置价格,用户将无法使用该模型。',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ padding: '8px 0' }}>
|
||||
<Title heading={6}>{t('自用模式')}</Title>
|
||||
<p>{t('适用于个人使用的场景。')}</p>
|
||||
<p>
|
||||
{t('不需要设置模型价格,系统将弱化用量计算,您可专注于使用模型。')}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ padding: '8px 0' }}>
|
||||
<Title heading={6}>{t('演示站点模式')}</Title>
|
||||
<p>{t('适用于展示系统功能的场景。')}</p>
|
||||
<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' className="!rounded-full mr-2">{t('计费模式')}</Tag>
|
||||
<Tag color='blue' className="!rounded-full">{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' className="!rounded-full mr-2">{t('无需计费')}</Tag>
|
||||
<Tag color='green' className="!rounded-full">{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' className="!rounded-full mr-2">{t('功能演示')}</Tag>
|
||||
<Tag color='purple' className="!rounded-full">{t('体验试用')}</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import TaskLogsTable from '../../components/TaskLogsTable.js';
|
||||
import TaskLogsTable from '../../components/table/TaskLogsTable.js';
|
||||
|
||||
const Task = () => (
|
||||
<>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useContext } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
API,
|
||||
@@ -6,8 +6,9 @@ import {
|
||||
showError,
|
||||
showSuccess,
|
||||
timestamp2string,
|
||||
renderGroupOption,
|
||||
renderQuotaWithPrompt,
|
||||
} from '../../helpers';
|
||||
import { renderGroupOption, renderQuotaWithPrompt } from '../../helpers/render';
|
||||
import {
|
||||
AutoComplete,
|
||||
Banner,
|
||||
@@ -21,12 +22,28 @@ import {
|
||||
Spin,
|
||||
TextArea,
|
||||
Typography,
|
||||
Card,
|
||||
Tag,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import Title from '@douyinfe/semi-ui/lib/es/typography/title';
|
||||
import { Divider } from 'semantic-ui-react';
|
||||
import {
|
||||
IconClock,
|
||||
IconCalendar,
|
||||
IconCreditCard,
|
||||
IconLink,
|
||||
IconServer,
|
||||
IconUserGroup,
|
||||
IconSave,
|
||||
IconClose,
|
||||
IconPlusCircle,
|
||||
} 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 [isEdit, setIsEdit] = useState(false);
|
||||
const [loading, setLoading] = useState(isEdit);
|
||||
const originInputs = {
|
||||
@@ -50,17 +67,18 @@ const EditToken = (props) => {
|
||||
allow_ips,
|
||||
group,
|
||||
} = inputs;
|
||||
// const [visible, setVisible] = useState(false);
|
||||
const [models, setModels] = useState([]);
|
||||
const [groups, setGroups] = useState([]);
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleInputChange = (name, value) => {
|
||||
setInputs((inputs) => ({ ...inputs, [name]: value }));
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
props.handleClose();
|
||||
};
|
||||
|
||||
const setExpiredTime = (month, day, hour, minute) => {
|
||||
let now = new Date();
|
||||
let timestamp = now.getTime() / 1000;
|
||||
@@ -103,7 +121,19 @@ const EditToken = (props) => {
|
||||
value: group,
|
||||
ratio: info.ratio,
|
||||
}));
|
||||
if (statusState?.status?.default_use_auto_group) {
|
||||
// if contain auto, add it to the first position
|
||||
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) {
|
||||
setInputs({ ...inputs, group: 'auto' });
|
||||
}
|
||||
} else {
|
||||
showError(t(message));
|
||||
}
|
||||
@@ -128,6 +158,7 @@ const EditToken = (props) => {
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setIsEdit(props.editingToken.id !== undefined);
|
||||
}, [props.editingToken.id]);
|
||||
@@ -202,9 +233,15 @@ const EditToken = (props) => {
|
||||
let successCount = 0; // 记录成功创建的令牌数量
|
||||
for (let i = 0; i < tokenCount; i++) {
|
||||
let localInputs = { ...inputs };
|
||||
if (i !== 0) {
|
||||
// 如果用户想要创建多个令牌,则给每个令牌一个序号后缀
|
||||
localInputs.name = `${inputs.name}-${generateRandomSuffix()}`;
|
||||
|
||||
// 检查用户是否填写了令牌名称
|
||||
const baseName = inputs.name.trim() === '' ? 'default' : inputs.name;
|
||||
|
||||
if (i !== 0 || inputs.name.trim() === '') {
|
||||
// 如果创建多个令牌(i !== 0)或者用户没有填写名称,则添加随机后缀
|
||||
localInputs.name = `${baseName}-${generateRandomSuffix()}`;
|
||||
} else {
|
||||
localInputs.name = baseName;
|
||||
}
|
||||
localInputs.remain_quota = parseInt(localInputs.remain_quota);
|
||||
|
||||
@@ -241,237 +278,430 @@ const EditToken = (props) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SideSheet
|
||||
placement={isEdit ? 'right' : 'left'}
|
||||
title={
|
||||
<Title level={3}>
|
||||
<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>
|
||||
}
|
||||
headerStyle={{ borderBottom: '1px solid var(--semi-color-border)' }}
|
||||
bodyStyle={{ borderBottom: '1px solid var(--semi-color-border)' }}
|
||||
visible={props.visiable}
|
||||
footer={
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Space>
|
||||
<Button theme='solid' size={'large'} onClick={submit}>
|
||||
{t('提交')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='solid'
|
||||
size={'large'}
|
||||
type={'tertiary'}
|
||||
onClick={handleCancel}
|
||||
>
|
||||
{t('取消')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
closeIcon={null}
|
||||
onCancel={() => handleCancel()}
|
||||
width={isMobile() ? '100%' : 600}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
<Input
|
||||
style={{ marginTop: 20 }}
|
||||
label={t('名称')}
|
||||
name='name'
|
||||
placeholder={t('请输入名称')}
|
||||
onChange={(value) => handleInputChange('name', value)}
|
||||
value={name}
|
||||
autoComplete='new-password'
|
||||
required={!isEdit}
|
||||
/>
|
||||
<Divider />
|
||||
<DatePicker
|
||||
label={t('过期时间')}
|
||||
name='expired_time'
|
||||
placeholder={t('请选择过期时间')}
|
||||
onChange={(value) => handleInputChange('expired_time', value)}
|
||||
value={expired_time}
|
||||
autoComplete='new-password'
|
||||
type='dateTime'
|
||||
/>
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<Space>
|
||||
<Button
|
||||
type={'tertiary'}
|
||||
onClick={() => {
|
||||
setExpiredTime(0, 0, 0, 0);
|
||||
}}
|
||||
>
|
||||
{t('永不过期')}
|
||||
</Button>
|
||||
<Button
|
||||
type={'tertiary'}
|
||||
onClick={() => {
|
||||
setExpiredTime(0, 0, 1, 0);
|
||||
}}
|
||||
>
|
||||
{t('一小时')}
|
||||
</Button>
|
||||
<Button
|
||||
type={'tertiary'}
|
||||
onClick={() => {
|
||||
setExpiredTime(1, 0, 0, 0);
|
||||
}}
|
||||
>
|
||||
{t('一个月')}
|
||||
</Button>
|
||||
<Button
|
||||
type={'tertiary'}
|
||||
onClick={() => {
|
||||
setExpiredTime(0, 1, 0, 0);
|
||||
}}
|
||||
>
|
||||
{t('一天')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
<Banner
|
||||
type={'warning'}
|
||||
description={t(
|
||||
'注意,令牌的额度仅用于限制令牌本身的最大额度使用量,实际的使用受到账户的剩余额度限制。',
|
||||
)}
|
||||
></Banner>
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<Typography.Text>{`${t('额度')}${renderQuotaWithPrompt(remain_quota)}`}</Typography.Text>
|
||||
</div>
|
||||
<AutoComplete
|
||||
style={{ marginTop: 8 }}
|
||||
name='remain_quota'
|
||||
placeholder={t('请输入额度')}
|
||||
onChange={(value) => handleInputChange('remain_quota', value)}
|
||||
value={remain_quota}
|
||||
autoComplete='new-password'
|
||||
type='number'
|
||||
// position={'top'}
|
||||
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$' },
|
||||
]}
|
||||
disabled={unlimited_quota}
|
||||
/>
|
||||
|
||||
{!isEdit && (
|
||||
<>
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<Typography.Text>{t('新建数量')}</Typography.Text>
|
||||
</div>
|
||||
<AutoComplete
|
||||
style={{ marginTop: 8 }}
|
||||
label={t('数量')}
|
||||
placeholder={t('请选择或输入创建令牌的数量')}
|
||||
onChange={(value) => handleTokenCountChange(value)}
|
||||
onSelect={(value) => handleTokenCountChange(value)}
|
||||
value={tokenCount.toString()}
|
||||
autoComplete='off'
|
||||
type='number'
|
||||
data={[
|
||||
{ value: 10, label: t('10个') },
|
||||
{ value: 20, label: t('20个') },
|
||||
{ value: 30, label: t('30个') },
|
||||
{ value: 100, label: t('100个') },
|
||||
]}
|
||||
disabled={unlimited_quota}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div>
|
||||
</Space>
|
||||
}
|
||||
headerStyle={{
|
||||
borderBottom: '1px solid var(--semi-color-border)',
|
||||
padding: '24px',
|
||||
}}
|
||||
bodyStyle={{
|
||||
backgroundColor: 'var(--semi-color-bg-0)',
|
||||
padding: '0',
|
||||
}}
|
||||
visible={props.visiable}
|
||||
width={isMobile() ? '100%' : 600}
|
||||
footer={
|
||||
<div className='flex justify-end bg-white'>
|
||||
<Space>
|
||||
<Button
|
||||
style={{ marginTop: 8 }}
|
||||
type={'warning'}
|
||||
onClick={() => {
|
||||
setUnlimitedQuota();
|
||||
theme='solid'
|
||||
size='large'
|
||||
className='!rounded-full'
|
||||
onClick={submit}
|
||||
icon={<IconSave />}
|
||||
loading={loading}
|
||||
>
|
||||
{t('提交')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='light'
|
||||
size='large'
|
||||
className='!rounded-full'
|
||||
type='primary'
|
||||
onClick={handleCancel}
|
||||
icon={<IconClose />}
|
||||
>
|
||||
{t('取消')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
closeIcon={null}
|
||||
onCancel={() => handleCancel()}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
<div className='p-6'>
|
||||
<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',
|
||||
}}
|
||||
>
|
||||
{unlimited_quota ? t('取消无限额度') : t('设为无限额度')}
|
||||
</Button>
|
||||
</div>
|
||||
<Divider />
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<Typography.Text>
|
||||
{t('IP白名单(请勿过度信任此功能)')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<TextArea
|
||||
label={t('IP白名单')}
|
||||
name='allow_ips'
|
||||
placeholder={t('允许的IP,一行一个,不填写则不限制')}
|
||||
onChange={(value) => {
|
||||
handleInputChange('allow_ips', value);
|
||||
}}
|
||||
value={inputs.allow_ips}
|
||||
style={{ fontFamily: 'JetBrains Mono, Consolas' }}
|
||||
/>
|
||||
<div style={{ marginTop: 10, display: 'flex' }}>
|
||||
<Space>
|
||||
<Checkbox
|
||||
name='model_limits_enabled'
|
||||
checked={model_limits_enabled}
|
||||
onChange={(e) =>
|
||||
handleInputChange('model_limits_enabled', e.target.checked)
|
||||
}
|
||||
>
|
||||
{t('启用模型限制(非必要,不建议启用)')}
|
||||
</Checkbox>
|
||||
</Space>
|
||||
</div>
|
||||
<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'>
|
||||
<IconPlusCircle 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>
|
||||
|
||||
<Select
|
||||
style={{ marginTop: 8 }}
|
||||
placeholder={t('请选择该渠道所支持的模型')}
|
||||
name='models'
|
||||
required
|
||||
multiple
|
||||
selection
|
||||
onChange={(value) => {
|
||||
handleInputChange('model_limits', value);
|
||||
}}
|
||||
value={inputs.model_limits}
|
||||
autoComplete='new-password'
|
||||
optionList={models}
|
||||
disabled={!model_limits_enabled}
|
||||
/>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<Typography.Text>{t('令牌分组,默认为用户的分组')}</Typography.Text>
|
||||
</div>
|
||||
{groups.length > 0 ? (
|
||||
<Select
|
||||
style={{ marginTop: 8 }}
|
||||
placeholder={t('令牌分组,默认为用户的分组')}
|
||||
name='gruop'
|
||||
required
|
||||
selection
|
||||
onChange={(value) => {
|
||||
handleInputChange('group', value);
|
||||
<div className='space-y-4'>
|
||||
<div>
|
||||
<Text strong className='block mb-2'>
|
||||
{t('名称')}
|
||||
</Text>
|
||||
<Input
|
||||
placeholder={t('请输入名称')}
|
||||
onChange={(value) => handleInputChange('name', value)}
|
||||
value={name}
|
||||
autoComplete='new-password'
|
||||
size='large'
|
||||
className='!rounded-lg'
|
||||
showClear
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text strong className='block mb-2'>
|
||||
{t('过期时间')}
|
||||
</Text>
|
||||
<div className='mb-2'>
|
||||
<DatePicker
|
||||
placeholder={t('请选择过期时间')}
|
||||
onChange={(value) =>
|
||||
handleInputChange('expired_time', value)
|
||||
}
|
||||
value={expired_time}
|
||||
autoComplete='new-password'
|
||||
type='dateTime'
|
||||
className='w-full !rounded-lg'
|
||||
size='large'
|
||||
prefix={<IconCalendar />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Button
|
||||
theme='light'
|
||||
type='primary'
|
||||
onClick={() => setExpiredTime(0, 0, 0, 0)}
|
||||
className='!rounded-full'
|
||||
>
|
||||
{t('永不过期')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
onClick={() => setExpiredTime(0, 0, 1, 0)}
|
||||
className='!rounded-full'
|
||||
icon={<IconClock />}
|
||||
>
|
||||
{t('一小时')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
onClick={() => setExpiredTime(0, 1, 0, 0)}
|
||||
className='!rounded-full'
|
||||
icon={<IconCalendar />}
|
||||
>
|
||||
{t('一天')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
onClick={() => setExpiredTime(1, 0, 0, 0)}
|
||||
className='!rounded-full'
|
||||
icon={<IconCalendar />}
|
||||
>
|
||||
{t('一个月')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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, #065f46 0%, #059669 50%, #10b981 100%)',
|
||||
position: 'relative',
|
||||
}}
|
||||
position={'topLeft'}
|
||||
renderOptionItem={renderGroupOption}
|
||||
value={inputs.group}
|
||||
autoComplete='new-password'
|
||||
optionList={groups}
|
||||
>
|
||||
<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'>
|
||||
<IconCreditCard 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>
|
||||
|
||||
<Banner
|
||||
type='warning'
|
||||
description={t(
|
||||
'注意,令牌的额度仅用于限制令牌本身的最大额度使用量,实际的使用受到账户的剩余额度限制。',
|
||||
)}
|
||||
className='mb-4 !rounded-lg'
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
style={{ marginTop: 8 }}
|
||||
placeholder={t('管理员未设置用户可选分组')}
|
||||
name='gruop'
|
||||
disabled={true}
|
||||
/>
|
||||
)}
|
||||
</Spin>
|
||||
</SideSheet>
|
||||
</>
|
||||
|
||||
<div className='space-y-4'>
|
||||
<div>
|
||||
<div className='flex justify-between mb-2'>
|
||||
<Text strong>{t('额度')}</Text>
|
||||
<Text type='tertiary'>
|
||||
{renderQuotaWithPrompt(remain_quota)}
|
||||
</Text>
|
||||
</div>
|
||||
<AutoComplete
|
||||
placeholder={t('请输入额度')}
|
||||
onChange={(value) => handleInputChange('remain_quota', value)}
|
||||
value={remain_quota}
|
||||
autoComplete='new-password'
|
||||
type='number'
|
||||
size='large'
|
||||
className='w-full !rounded-lg'
|
||||
prefix={<IconCreditCard />}
|
||||
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$' },
|
||||
]}
|
||||
disabled={unlimited_quota}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isEdit && (
|
||||
<div>
|
||||
<Text strong className='block mb-2'>
|
||||
{t('新建数量')}
|
||||
</Text>
|
||||
<AutoComplete
|
||||
placeholder={t('请选择或输入创建令牌的数量')}
|
||||
onChange={(value) => handleTokenCountChange(value)}
|
||||
onSelect={(value) => handleTokenCountChange(value)}
|
||||
value={tokenCount.toString()}
|
||||
autoComplete='off'
|
||||
type='number'
|
||||
className='w-full !rounded-lg'
|
||||
size='large'
|
||||
prefix={<IconPlusCircle />}
|
||||
data={[
|
||||
{ value: 10, label: t('10个') },
|
||||
{ value: 20, label: t('20个') },
|
||||
{ value: 30, label: t('30个') },
|
||||
{ value: 100, label: t('100个') },
|
||||
]}
|
||||
disabled={unlimited_quota}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='flex justify-end'>
|
||||
<Button
|
||||
theme='light'
|
||||
type={unlimited_quota ? 'danger' : 'warning'}
|
||||
onClick={setUnlimitedQuota}
|
||||
className='!rounded-full'
|
||||
>
|
||||
{unlimited_quota ? t('取消无限额度') : t('设为无限额度')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</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'>
|
||||
<IconLink 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>
|
||||
|
||||
<div className='space-y-4'>
|
||||
<div>
|
||||
<Text strong className='block mb-2'>
|
||||
{t('IP白名单')}
|
||||
</Text>
|
||||
<TextArea
|
||||
placeholder={t('允许的IP,一行一个,不填写则不限制')}
|
||||
onChange={(value) => handleInputChange('allow_ips', value)}
|
||||
value={inputs.allow_ips}
|
||||
style={{ fontFamily: 'JetBrains Mono, Consolas' }}
|
||||
className='!rounded-lg'
|
||||
rows={4}
|
||||
/>
|
||||
<Text type='tertiary' className='mt-1 block text-xs'>
|
||||
{t('请勿过度信任此功能,IP可能被伪造')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className='flex items-center mb-2'>
|
||||
<Checkbox
|
||||
checked={model_limits_enabled}
|
||||
onChange={(e) =>
|
||||
handleInputChange(
|
||||
'model_limits_enabled',
|
||||
e.target.checked,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Text strong>{t('模型限制')}</Text>
|
||||
</Checkbox>
|
||||
</div>
|
||||
<Select
|
||||
placeholder={
|
||||
model_limits_enabled
|
||||
? t('请选择该渠道所支持的模型')
|
||||
: t('勾选启用模型限制后可选择')
|
||||
}
|
||||
onChange={(value) => handleInputChange('model_limits', value)}
|
||||
value={inputs.model_limits}
|
||||
multiple
|
||||
size='large'
|
||||
className='w-full !rounded-lg'
|
||||
prefix={<IconServer />}
|
||||
optionList={models}
|
||||
disabled={!model_limits_enabled}
|
||||
maxTagCount={3}
|
||||
/>
|
||||
<Text type='tertiary' className='mt-1 block text-xs'>
|
||||
{t('非必要,不建议启用模型限制')}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className='!rounded-2xl shadow-sm border-0'>
|
||||
<div
|
||||
className='flex items-center mb-4 p-6 rounded-xl'
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(135deg, #92400e 0%, #d97706 50%, #f59e0b 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'>
|
||||
<IconUserGroup 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>
|
||||
|
||||
<div>
|
||||
<Text strong className='block mb-2'>
|
||||
{t('令牌分组')}
|
||||
</Text>
|
||||
{groups.length > 0 ? (
|
||||
<Select
|
||||
placeholder={t('令牌分组,默认为用户的分组')}
|
||||
onChange={(value) => handleInputChange('group', value)}
|
||||
renderOptionItem={renderGroupOption}
|
||||
value={inputs.group}
|
||||
size='large'
|
||||
className='w-full !rounded-lg'
|
||||
prefix={<IconUserGroup />}
|
||||
optionList={groups}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
placeholder={t('管理员未设置用户可选分组')}
|
||||
disabled={true}
|
||||
size='large'
|
||||
className='w-full !rounded-lg'
|
||||
prefix={<IconUserGroup />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Spin>
|
||||
</SideSheet>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,24 +1,10 @@
|
||||
import React from 'react';
|
||||
import TokensTable from '../../components/TokensTable';
|
||||
import { Banner, Layout } from '@douyinfe/semi-ui';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import TokensTable from '../../components/table/TokensTable';
|
||||
|
||||
const Token = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<Layout>
|
||||
<Layout.Header>
|
||||
<Banner
|
||||
type='warning'
|
||||
description={t(
|
||||
'令牌无法精确控制使用额度,只允许自用,请勿直接将令牌分发给他人。',
|
||||
)}
|
||||
/>
|
||||
</Layout.Header>
|
||||
<Layout.Content>
|
||||
<TokensTable />
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
<TokensTable />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,17 +1,38 @@
|
||||
import React, { useState } from 'react';
|
||||
import { API, isMobile, showError, showSuccess } from '../../helpers';
|
||||
import Title from '@douyinfe/semi-ui/lib/es/typography/title';
|
||||
import { Button, Input, SideSheet, Space, Spin } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
SideSheet,
|
||||
Space,
|
||||
Spin,
|
||||
Typography,
|
||||
Card,
|
||||
Tag
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconUser,
|
||||
IconSave,
|
||||
IconClose,
|
||||
IconKey,
|
||||
IconUserAdd,
|
||||
IconEdit,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
const AddUser = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const originInputs = {
|
||||
username: '',
|
||||
display_name: '',
|
||||
password: '',
|
||||
remark: '',
|
||||
};
|
||||
const [inputs, setInputs] = useState(originInputs);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { username, display_name, password } = inputs;
|
||||
const { username, display_name, password, remark } = inputs;
|
||||
|
||||
const handleInputChange = (name, value) => {
|
||||
setInputs((inputs) => ({ ...inputs, [name]: value }));
|
||||
@@ -21,13 +42,13 @@ const AddUser = (props) => {
|
||||
setLoading(true);
|
||||
if (inputs.username === '' || inputs.password === '') {
|
||||
setLoading(false);
|
||||
showError('用户名和密码不能为空!');
|
||||
showError(t('用户名和密码不能为空!'));
|
||||
return;
|
||||
}
|
||||
const res = await API.post(`/api/user/`, inputs);
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess('用户账户创建成功!');
|
||||
showSuccess(t('用户账户创建成功!'));
|
||||
setInputs(originInputs);
|
||||
props.refresh();
|
||||
props.handleClose();
|
||||
@@ -45,63 +66,134 @@ const AddUser = (props) => {
|
||||
<>
|
||||
<SideSheet
|
||||
placement={'left'}
|
||||
title={<Title level={3}>{'添加用户'}</Title>}
|
||||
headerStyle={{ borderBottom: '1px solid var(--semi-color-border)' }}
|
||||
bodyStyle={{ borderBottom: '1px solid var(--semi-color-border)' }}
|
||||
title={
|
||||
<Space>
|
||||
<Tag color="green" shape="circle">{t('新建')}</Tag>
|
||||
<Title heading={4} className="m-0">
|
||||
{t('添加用户')}
|
||||
</Title>
|
||||
</Space>
|
||||
}
|
||||
headerStyle={{
|
||||
borderBottom: '1px solid var(--semi-color-border)',
|
||||
padding: '24px'
|
||||
}}
|
||||
bodyStyle={{
|
||||
backgroundColor: 'var(--semi-color-bg-0)',
|
||||
padding: '0'
|
||||
}}
|
||||
visible={props.visible}
|
||||
width={isMobile() ? '100%' : 600}
|
||||
footer={
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<div className="flex justify-end bg-white">
|
||||
<Space>
|
||||
<Button theme='solid' size={'large'} onClick={submit}>
|
||||
提交
|
||||
<Button
|
||||
theme="solid"
|
||||
size="large"
|
||||
className="!rounded-full"
|
||||
onClick={submit}
|
||||
icon={<IconSave />}
|
||||
loading={loading}
|
||||
>
|
||||
{t('提交')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='solid'
|
||||
size={'large'}
|
||||
type={'tertiary'}
|
||||
theme="light"
|
||||
size="large"
|
||||
className="!rounded-full"
|
||||
type="primary"
|
||||
onClick={handleCancel}
|
||||
icon={<IconClose />}
|
||||
>
|
||||
取消
|
||||
{t('取消')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
closeIcon={null}
|
||||
onCancel={() => handleCancel()}
|
||||
width={isMobile() ? '100%' : 600}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
<Input
|
||||
style={{ marginTop: 20 }}
|
||||
label='用户名'
|
||||
name='username'
|
||||
addonBefore={'用户名'}
|
||||
placeholder={'请输入用户名'}
|
||||
onChange={(value) => handleInputChange('username', value)}
|
||||
value={username}
|
||||
autoComplete='off'
|
||||
/>
|
||||
<Input
|
||||
style={{ marginTop: 20 }}
|
||||
addonBefore={'显示名'}
|
||||
label='显示名称'
|
||||
name='display_name'
|
||||
autoComplete='off'
|
||||
placeholder={'请输入显示名称'}
|
||||
onChange={(value) => handleInputChange('display_name', value)}
|
||||
value={display_name}
|
||||
/>
|
||||
<Input
|
||||
style={{ marginTop: 20 }}
|
||||
label='密 码'
|
||||
name='password'
|
||||
type={'password'}
|
||||
addonBefore={'密码'}
|
||||
placeholder={'请输入密码'}
|
||||
onChange={(value) => handleInputChange('password', value)}
|
||||
value={password}
|
||||
autoComplete='off'
|
||||
/>
|
||||
<div className="p-6">
|
||||
<Card className="!rounded-2xl shadow-sm border-0">
|
||||
<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">
|
||||
<IconUserAdd 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>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Text strong className="block mb-2">{t('用户名')}</Text>
|
||||
<Input
|
||||
placeholder={t('请输入用户名')}
|
||||
onChange={(value) => handleInputChange('username', value)}
|
||||
value={username}
|
||||
autoComplete="off"
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
prefix={<IconUser />}
|
||||
showClear
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text strong className="block mb-2">{t('显示名称')}</Text>
|
||||
<Input
|
||||
placeholder={t('请输入显示名称')}
|
||||
onChange={(value) => handleInputChange('display_name', value)}
|
||||
value={display_name}
|
||||
autoComplete="off"
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
prefix={<IconUser />}
|
||||
showClear
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text strong className="block mb-2">{t('密码')}</Text>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={t('请输入密码')}
|
||||
onChange={(value) => handleInputChange('password', value)}
|
||||
value={password}
|
||||
autoComplete="off"
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
prefix={<IconKey />}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text strong className="block mb-2">{t('备注')}</Text>
|
||||
<Input
|
||||
placeholder={t('请输入备注(仅管理员可见)')}
|
||||
onChange={(value) => handleInputChange('remark', value)}
|
||||
value={remark}
|
||||
autoComplete="off"
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
prefix={<IconEdit />}
|
||||
showClear
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Spin>
|
||||
</SideSheet>
|
||||
</>
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { API, isMobile, showError, showSuccess } from '../../helpers';
|
||||
import { renderQuota, renderQuotaWithPrompt } from '../../helpers/render';
|
||||
import Title from '@douyinfe/semi-ui/lib/es/typography/title';
|
||||
import { API, isMobile, showError, showSuccess, renderQuota, renderQuotaWithPrompt } from '../../helpers';
|
||||
import {
|
||||
Button,
|
||||
Divider,
|
||||
Input,
|
||||
Modal,
|
||||
Select,
|
||||
@@ -13,9 +10,24 @@ import {
|
||||
Space,
|
||||
Spin,
|
||||
Typography,
|
||||
Card,
|
||||
Tag,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconUser,
|
||||
IconSave,
|
||||
IconClose,
|
||||
IconKey,
|
||||
IconCreditCard,
|
||||
IconLink,
|
||||
IconUserGroup,
|
||||
IconPlus,
|
||||
IconEdit,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
const EditUser = (props) => {
|
||||
const userId = props.editingUser.id;
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -31,6 +43,7 @@ const EditUser = (props) => {
|
||||
email: '',
|
||||
quota: 0,
|
||||
group: 'default',
|
||||
remark: '',
|
||||
});
|
||||
const [groupOptions, setGroupOptions] = useState([]);
|
||||
const {
|
||||
@@ -44,6 +57,7 @@ const EditUser = (props) => {
|
||||
email,
|
||||
quota,
|
||||
group,
|
||||
remark,
|
||||
} = inputs;
|
||||
const handleInputChange = (name, value) => {
|
||||
setInputs((inputs) => ({ ...inputs, [name]: value }));
|
||||
@@ -129,21 +143,44 @@ const EditUser = (props) => {
|
||||
<>
|
||||
<SideSheet
|
||||
placement={'right'}
|
||||
title={<Title level={3}>{t('编辑用户')}</Title>}
|
||||
headerStyle={{ borderBottom: '1px solid var(--semi-color-border)' }}
|
||||
bodyStyle={{ borderBottom: '1px solid var(--semi-color-border)' }}
|
||||
title={
|
||||
<Space>
|
||||
<Tag color="blue" shape="circle">{t('编辑')}</Tag>
|
||||
<Title heading={4} className="m-0">
|
||||
{t('编辑用户')}
|
||||
</Title>
|
||||
</Space>
|
||||
}
|
||||
headerStyle={{
|
||||
borderBottom: '1px solid var(--semi-color-border)',
|
||||
padding: '24px'
|
||||
}}
|
||||
bodyStyle={{
|
||||
backgroundColor: 'var(--semi-color-bg-0)',
|
||||
padding: '0'
|
||||
}}
|
||||
visible={props.visible}
|
||||
width={isMobile() ? '100%' : 600}
|
||||
footer={
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<div className="flex justify-end bg-white">
|
||||
<Space>
|
||||
<Button theme='solid' size={'large'} onClick={submit}>
|
||||
<Button
|
||||
theme="solid"
|
||||
size="large"
|
||||
className="!rounded-full"
|
||||
onClick={submit}
|
||||
icon={<IconSave />}
|
||||
loading={loading}
|
||||
>
|
||||
{t('提交')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='solid'
|
||||
size={'large'}
|
||||
type={'tertiary'}
|
||||
theme="light"
|
||||
size="large"
|
||||
className="!rounded-full"
|
||||
type="primary"
|
||||
onClick={handleCancel}
|
||||
icon={<IconClose />}
|
||||
>
|
||||
{t('取消')}
|
||||
</Button>
|
||||
@@ -152,141 +189,246 @@ const EditUser = (props) => {
|
||||
}
|
||||
closeIcon={null}
|
||||
onCancel={() => handleCancel()}
|
||||
width={isMobile() ? '100%' : 600}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<Typography.Text>{t('用户名')}</Typography.Text>
|
||||
</div>
|
||||
<Input
|
||||
label={t('用户名')}
|
||||
name='username'
|
||||
placeholder={t('请输入新的用户名')}
|
||||
onChange={(value) => handleInputChange('username', value)}
|
||||
value={username}
|
||||
autoComplete='new-password'
|
||||
/>
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<Typography.Text>{t('密码')}</Typography.Text>
|
||||
</div>
|
||||
<Input
|
||||
label={t('密码')}
|
||||
name='password'
|
||||
type={'password'}
|
||||
placeholder={t('请输入新的密码,最短 8 位')}
|
||||
onChange={(value) => handleInputChange('password', value)}
|
||||
value={password}
|
||||
autoComplete='new-password'
|
||||
/>
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<Typography.Text>{t('显示名称')}</Typography.Text>
|
||||
</div>
|
||||
<Input
|
||||
label={t('显示名称')}
|
||||
name='display_name'
|
||||
placeholder={t('请输入新的显示名称')}
|
||||
onChange={(value) => handleInputChange('display_name', value)}
|
||||
value={display_name}
|
||||
autoComplete='new-password'
|
||||
/>
|
||||
{userId && (
|
||||
<>
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<Typography.Text>{t('分组')}</Typography.Text>
|
||||
<div className="p-6">
|
||||
<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>
|
||||
<Select
|
||||
placeholder={t('请选择分组')}
|
||||
name='group'
|
||||
fluid
|
||||
search
|
||||
selection
|
||||
allowAdditions
|
||||
additionLabel={t(
|
||||
'请在系统设置页面编辑分组倍率以添加新的分组:',
|
||||
)}
|
||||
onChange={(value) => handleInputChange('group', value)}
|
||||
value={inputs.group}
|
||||
autoComplete='new-password'
|
||||
optionList={groupOptions}
|
||||
/>
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<Typography.Text>{`${t('剩余额度')}${renderQuotaWithPrompt(quota)}`}</Typography.Text>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Text strong className="block mb-2">{t('用户名')}</Text>
|
||||
<Input
|
||||
placeholder={t('请输入新的用户名')}
|
||||
onChange={(value) => handleInputChange('username', value)}
|
||||
value={username}
|
||||
autoComplete="new-password"
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
showClear
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text strong className="block mb-2">{t('密码')}</Text>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={t('请输入新的密码,最短 8 位')}
|
||||
onChange={(value) => handleInputChange('password', value)}
|
||||
value={password}
|
||||
autoComplete="new-password"
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
prefix={<IconKey />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text strong className="block mb-2">{t('显示名称')}</Text>
|
||||
<Input
|
||||
placeholder={t('请输入新的显示名称')}
|
||||
onChange={(value) => handleInputChange('display_name', value)}
|
||||
value={display_name}
|
||||
autoComplete="new-password"
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
showClear
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text strong className="block mb-2">{t('备注')}</Text>
|
||||
<Input
|
||||
placeholder={t('请输入备注(仅管理员可见)')}
|
||||
onChange={(value) => handleInputChange('remark', value)}
|
||||
value={remark}
|
||||
autoComplete="off"
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
prefix={<IconEdit />}
|
||||
showClear
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Space>
|
||||
<Input
|
||||
name='quota'
|
||||
placeholder={t('请输入新的剩余额度')}
|
||||
onChange={(value) => handleInputChange('quota', value)}
|
||||
value={quota}
|
||||
type={'number'}
|
||||
autoComplete='new-password'
|
||||
/>
|
||||
<Button onClick={openAddQuotaModal}>{t('添加额度')}</Button>
|
||||
</Space>
|
||||
</>
|
||||
)}
|
||||
<Divider style={{ marginTop: 20 }}>{t('以下信息不可修改')}</Divider>
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<Typography.Text>{t('已绑定的 GitHub 账户')}</Typography.Text>
|
||||
</div>
|
||||
<Input
|
||||
name='github_id'
|
||||
value={github_id}
|
||||
autoComplete='new-password'
|
||||
placeholder={t(
|
||||
'此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改',
|
||||
</Card>
|
||||
|
||||
{userId && (
|
||||
<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, #065f46 0%, #059669 50%, #10b981 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">
|
||||
<IconUserGroup 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>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Text strong className="block mb-2">{t('分组')}</Text>
|
||||
<Select
|
||||
placeholder={t('请选择分组')}
|
||||
search
|
||||
allowAdditions
|
||||
additionLabel={t(
|
||||
'请在系统设置页面编辑分组倍率以添加新的分组:',
|
||||
)}
|
||||
onChange={(value) => handleInputChange('group', value)}
|
||||
value={inputs.group}
|
||||
autoComplete="new-password"
|
||||
optionList={groupOptions}
|
||||
size="large"
|
||||
className="w-full !rounded-lg"
|
||||
prefix={<IconUserGroup />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between mb-2">
|
||||
<Text strong>{t('剩余额度')}</Text>
|
||||
<Text type="tertiary">{renderQuotaWithPrompt(quota)}</Text>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder={t('请输入新的剩余额度')}
|
||||
onChange={(value) => handleInputChange('quota', value)}
|
||||
value={quota}
|
||||
type="number"
|
||||
autoComplete="new-password"
|
||||
size="large"
|
||||
className="flex-1 !rounded-lg"
|
||||
prefix={<IconCreditCard />}
|
||||
/>
|
||||
<Button
|
||||
onClick={openAddQuotaModal}
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
icon={<IconPlus />}
|
||||
>
|
||||
{t('添加额度')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
readonly
|
||||
/>
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<Typography.Text>{t('已绑定的 OIDC 账户')}</Typography.Text>
|
||||
|
||||
<Card className="!rounded-2xl shadow-sm border-0">
|
||||
<div className="flex items-center mb-4 p-6 rounded-xl" style={{
|
||||
background: 'linear-gradient(135deg, #92400e 0%, #d97706 50%, #f59e0b 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">
|
||||
<IconLink 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>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Text strong className="block mb-2">{t('已绑定的 GitHub 账户')}</Text>
|
||||
<Input
|
||||
value={github_id}
|
||||
autoComplete="new-password"
|
||||
placeholder={t(
|
||||
'此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改',
|
||||
)}
|
||||
readonly
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text strong className="block mb-2">{t('已绑定的 OIDC 账户')}</Text>
|
||||
<Input
|
||||
value={oidc_id}
|
||||
placeholder={t(
|
||||
'此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改',
|
||||
)}
|
||||
readonly
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text strong className="block mb-2">{t('已绑定的微信账户')}</Text>
|
||||
<Input
|
||||
value={wechat_id}
|
||||
autoComplete="new-password"
|
||||
placeholder={t(
|
||||
'此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改',
|
||||
)}
|
||||
readonly
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text strong className="block mb-2">{t('已绑定的邮箱账户')}</Text>
|
||||
<Input
|
||||
value={email}
|
||||
autoComplete="new-password"
|
||||
placeholder={t(
|
||||
'此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改',
|
||||
)}
|
||||
readonly
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text strong className="block mb-2">{t('已绑定的 Telegram 账户')}</Text>
|
||||
<Input
|
||||
value={telegram_id}
|
||||
autoComplete="new-password"
|
||||
placeholder={t(
|
||||
'此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改',
|
||||
)}
|
||||
readonly
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<Input
|
||||
name='oidc_id'
|
||||
value={oidc_id}
|
||||
placeholder={t(
|
||||
'此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改',
|
||||
)}
|
||||
readonly
|
||||
/>
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<Typography.Text>{t('已绑定的微信账户')}</Typography.Text>
|
||||
</div>
|
||||
<Input
|
||||
name='wechat_id'
|
||||
value={wechat_id}
|
||||
autoComplete='new-password'
|
||||
placeholder={t(
|
||||
'此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改',
|
||||
)}
|
||||
readonly
|
||||
/>
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<Typography.Text>{t('已绑定的邮箱账户')}</Typography.Text>
|
||||
</div>
|
||||
<Input
|
||||
name='email'
|
||||
value={email}
|
||||
autoComplete='new-password'
|
||||
placeholder={t(
|
||||
'此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改',
|
||||
)}
|
||||
readonly
|
||||
/>
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<Typography.Text>{t('已绑定的Telegram账户')}</Typography.Text>
|
||||
</div>
|
||||
<Input
|
||||
name='telegram_id'
|
||||
value={telegram_id}
|
||||
autoComplete='new-password'
|
||||
placeholder={t(
|
||||
'此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改',
|
||||
)}
|
||||
readonly
|
||||
/>
|
||||
</Spin>
|
||||
</SideSheet>
|
||||
|
||||
<Modal
|
||||
centered={true}
|
||||
visible={addQuotaModalOpen}
|
||||
@@ -296,19 +438,29 @@ const EditUser = (props) => {
|
||||
}}
|
||||
onCancel={() => setIsModalOpen(false)}
|
||||
closable={null}
|
||||
title={
|
||||
<div className="flex items-center">
|
||||
<IconPlus className="mr-2" />
|
||||
{t('添加额度')}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<Typography.Text>{`${t('新额度')}${renderQuota(quota)} + ${renderQuota(addQuotaLocal)} = ${renderQuota(quota + parseInt(addQuotaLocal))}`}</Typography.Text>
|
||||
<div className="mb-4">
|
||||
<Text type="secondary" className="block mb-2">
|
||||
{`${t('新额度')}${renderQuota(quota)} + ${renderQuota(addQuotaLocal)} = ${renderQuota(quota + parseInt(addQuotaLocal || 0))}`}
|
||||
</Text>
|
||||
</div>
|
||||
<Input
|
||||
name='addQuotaLocal'
|
||||
placeholder={t('需要添加的额度(支持负数)')}
|
||||
onChange={(value) => {
|
||||
setAddQuotaLocal(value);
|
||||
}}
|
||||
value={addQuotaLocal}
|
||||
type={'number'}
|
||||
autoComplete='new-password'
|
||||
type="number"
|
||||
autoComplete="new-password"
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
prefix={<IconCreditCard />}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
|
||||
@@ -1,20 +1,10 @@
|
||||
import React from 'react';
|
||||
import UsersTable from '../../components/UsersTable';
|
||||
import { Layout } from '@douyinfe/semi-ui';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import UsersTable from '../../components/table/UsersTable';
|
||||
|
||||
const User = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<Layout>
|
||||
<Layout.Header>
|
||||
<h3>{t('管理用户')}</h3>
|
||||
</Layout.Header>
|
||||
<Layout.Content>
|
||||
<UsersTable />
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
<UsersTable />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user