Merge branch 'alpha' into feature/simple_stripe
This commit is contained in:
@@ -105,7 +105,7 @@ const About = () => {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mt-[64px]">
|
||||
<div className="mt-[64px] px-2">
|
||||
{aboutLoaded && about === '' ? (
|
||||
<div className="flex justify-center items-center h-screen p-8">
|
||||
<Empty
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import React, { useEffect, useState, useRef, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
API,
|
||||
isMobile,
|
||||
showError,
|
||||
showInfo,
|
||||
showSuccess,
|
||||
verifyJSON,
|
||||
} from '../../helpers';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile.js';
|
||||
import { CHANNEL_OPTIONS } from '../../constants';
|
||||
import {
|
||||
SideSheet,
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
Row,
|
||||
Col,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { getChannelModels, copy } from '../../helpers';
|
||||
import { getChannelModels, copy, getChannelIcon, getModelCategories } from '../../helpers';
|
||||
import {
|
||||
IconSave,
|
||||
IconClose,
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
IconSetting,
|
||||
IconCode,
|
||||
IconGlobe,
|
||||
IconBolt,
|
||||
} from '@douyinfe/semi-icons';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
@@ -80,6 +81,7 @@ const EditChannel = (props) => {
|
||||
const channelId = props.editingChannel.id;
|
||||
const isEdit = channelId !== undefined;
|
||||
const [loading, setLoading] = useState(isEdit);
|
||||
const isMobile = useIsMobile();
|
||||
const handleCancel = () => {
|
||||
props.handleClose();
|
||||
};
|
||||
@@ -100,10 +102,12 @@ const EditChannel = (props) => {
|
||||
priority: 0,
|
||||
weight: 0,
|
||||
tag: '',
|
||||
multi_key_mode: 'random',
|
||||
};
|
||||
const [batch, setBatch] = useState(false);
|
||||
const [multiToSingle, setMultiToSingle] = useState(false);
|
||||
const [multiKeyMode, setMultiKeyMode] = useState('random');
|
||||
const [autoBan, setAutoBan] = useState(true);
|
||||
// const [autoBan, setAutoBan] = useState(true);
|
||||
const [inputs, setInputs] = useState(originInputs);
|
||||
const [originModelOptions, setOriginModelOptions] = useState([]);
|
||||
const [modelOptions, setModelOptions] = useState([]);
|
||||
@@ -114,6 +118,10 @@ const EditChannel = (props) => {
|
||||
const [modalImageUrl, setModalImageUrl] = useState('');
|
||||
const [isModalOpenurl, setIsModalOpenurl] = useState(false);
|
||||
const formApiRef = useRef(null);
|
||||
const [vertexKeys, setVertexKeys] = useState([]);
|
||||
const [vertexFileList, setVertexFileList] = useState([]);
|
||||
const vertexErroredNames = useRef(new Set()); // 避免重复报错
|
||||
const [isMultiKeyChannel, setIsMultiKeyChannel] = useState(false);
|
||||
const getInitValues = () => ({ ...originInputs });
|
||||
const handleInputChange = (name, value) => {
|
||||
if (formApiRef.current) {
|
||||
@@ -211,6 +219,19 @@ const EditChannel = (props) => {
|
||||
2,
|
||||
);
|
||||
}
|
||||
const chInfo = data.channel_info || {};
|
||||
const isMulti = chInfo.is_multi_key === true;
|
||||
setIsMultiKeyChannel(isMulti);
|
||||
if (isMulti) {
|
||||
setBatch(true);
|
||||
setMultiToSingle(true);
|
||||
const modeVal = chInfo.multi_key_mode || 'random';
|
||||
setMultiKeyMode(modeVal);
|
||||
data.multi_key_mode = modeVal;
|
||||
} else {
|
||||
setBatch(false);
|
||||
setMultiToSingle(false);
|
||||
}
|
||||
setInputs(data);
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValues(data);
|
||||
@@ -238,9 +259,9 @@ const EditChannel = (props) => {
|
||||
let err = false;
|
||||
|
||||
if (isEdit) {
|
||||
// 如果是编辑模式,使用已有的channel id获取模型列表
|
||||
const res = await API.get('/api/channel/fetch_models/' + channelId);
|
||||
if (res.data && res.data.success) {
|
||||
// 如果是编辑模式,使用已有的 channelId 获取模型列表
|
||||
const res = await API.get('/api/channel/fetch_models/' + channelId, { skipErrorHandler: true });
|
||||
if (res && res.data && res.data.success) {
|
||||
models.push(...res.data.data);
|
||||
} else {
|
||||
err = true;
|
||||
@@ -252,13 +273,17 @@ const EditChannel = (props) => {
|
||||
err = true;
|
||||
} else {
|
||||
try {
|
||||
const res = await API.post('/api/channel/fetch_models', {
|
||||
base_url: inputs['base_url'],
|
||||
type: inputs['type'],
|
||||
key: inputs['key'],
|
||||
});
|
||||
const res = await API.post(
|
||||
'/api/channel/fetch_models',
|
||||
{
|
||||
base_url: inputs['base_url'],
|
||||
type: inputs['type'],
|
||||
key: inputs['key'],
|
||||
},
|
||||
{ skipErrorHandler: true },
|
||||
);
|
||||
|
||||
if (res.data && res.data.success) {
|
||||
if (res && res.data && res.data.success) {
|
||||
models.push(...res.data.data);
|
||||
} else {
|
||||
err = true;
|
||||
@@ -324,14 +349,14 @@ const EditChannel = (props) => {
|
||||
useEffect(() => {
|
||||
const modelMap = new Map();
|
||||
|
||||
originModelOptions.forEach(option => {
|
||||
originModelOptions.forEach((option) => {
|
||||
const v = (option.value || '').trim();
|
||||
if (!modelMap.has(v)) {
|
||||
modelMap.set(v, option);
|
||||
}
|
||||
});
|
||||
|
||||
inputs.models.forEach(model => {
|
||||
inputs.models.forEach((model) => {
|
||||
const v = (model || '').trim();
|
||||
if (!modelMap.has(v)) {
|
||||
modelMap.set(v, {
|
||||
@@ -342,8 +367,29 @@ const EditChannel = (props) => {
|
||||
}
|
||||
});
|
||||
|
||||
setModelOptions(Array.from(modelMap.values()));
|
||||
}, [originModelOptions, inputs.models]);
|
||||
const categories = getModelCategories(t);
|
||||
const optionsWithIcon = Array.from(modelMap.values()).map((opt) => {
|
||||
const modelName = opt.value;
|
||||
let icon = null;
|
||||
for (const [key, category] of Object.entries(categories)) {
|
||||
if (key !== 'all' && category.filter({ model_name: modelName })) {
|
||||
icon = category.icon;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return {
|
||||
...opt,
|
||||
label: (
|
||||
<span className="flex items-center gap-1">
|
||||
{icon}
|
||||
{modelName}
|
||||
</span>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
setModelOptions(optionsWithIcon);
|
||||
}, [originModelOptions, inputs.models, t]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchModels().then();
|
||||
@@ -377,10 +423,96 @@ const EditChannel = (props) => {
|
||||
}
|
||||
}, [props.visible, channelId]);
|
||||
|
||||
const handleVertexUploadChange = ({ fileList }) => {
|
||||
vertexErroredNames.current.clear();
|
||||
(async () => {
|
||||
let validFiles = [];
|
||||
let keys = [];
|
||||
const errorNames = [];
|
||||
for (const item of fileList) {
|
||||
const fileObj = item.fileInstance;
|
||||
if (!fileObj) continue;
|
||||
try {
|
||||
const txt = await fileObj.text();
|
||||
keys.push(JSON.parse(txt));
|
||||
validFiles.push(item);
|
||||
} catch (err) {
|
||||
if (!vertexErroredNames.current.has(item.name)) {
|
||||
errorNames.push(item.name);
|
||||
vertexErroredNames.current.add(item.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 非批量模式下只保留一个文件(最新选择的),避免重复叠加
|
||||
if (!batch && validFiles.length > 1) {
|
||||
validFiles = [validFiles[validFiles.length - 1]];
|
||||
keys = [keys[keys.length - 1]];
|
||||
}
|
||||
|
||||
setVertexKeys(keys);
|
||||
setVertexFileList(validFiles);
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValue('vertex_files', validFiles);
|
||||
}
|
||||
setInputs((prev) => ({ ...prev, vertex_files: validFiles }));
|
||||
|
||||
if (errorNames.length > 0) {
|
||||
showError(t('以下文件解析失败,已忽略:{{list}}', { list: errorNames.join(', ') }));
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
const formValues = formApiRef.current ? formApiRef.current.getValues() : {};
|
||||
let localInputs = { ...formValues };
|
||||
|
||||
if (localInputs.type === 41) {
|
||||
let keys = vertexKeys;
|
||||
|
||||
// 若当前未选择文件,尝试从已上传文件列表解析(异步读取)
|
||||
if (keys.length === 0 && vertexFileList.length > 0) {
|
||||
try {
|
||||
const parsed = await Promise.all(
|
||||
vertexFileList.map(async (item) => {
|
||||
const fileObj = item.fileInstance;
|
||||
if (!fileObj) return null;
|
||||
const txt = await fileObj.text();
|
||||
return JSON.parse(txt);
|
||||
})
|
||||
);
|
||||
keys = parsed.filter(Boolean);
|
||||
} catch (err) {
|
||||
showError(t('解析密钥文件失败: {{msg}}', { msg: err.message }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 创建模式必须上传密钥;编辑模式可选
|
||||
if (keys.length === 0) {
|
||||
if (!isEdit) {
|
||||
showInfo(t('请上传密钥文件!'));
|
||||
return;
|
||||
} else {
|
||||
// 编辑模式且未上传新密钥,不修改 key
|
||||
delete localInputs.key;
|
||||
}
|
||||
} else {
|
||||
// 有新密钥,则覆盖
|
||||
if (batch) {
|
||||
localInputs.key = JSON.stringify(keys);
|
||||
} else {
|
||||
localInputs.key = JSON.stringify(keys[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果是编辑模式且 key 为空字符串,避免提交空值覆盖旧密钥
|
||||
if (isEdit && (!localInputs.key || localInputs.key.trim() === '')) {
|
||||
delete localInputs.key;
|
||||
}
|
||||
delete localInputs.vertex_files;
|
||||
|
||||
if (!isEdit && (!localInputs.name || !localInputs.key)) {
|
||||
showInfo(t('请填写渠道名称和渠道密钥!'));
|
||||
return;
|
||||
@@ -406,13 +538,23 @@ const EditChannel = (props) => {
|
||||
localInputs.auto_ban = localInputs.auto_ban ? 1 : 0;
|
||||
localInputs.models = localInputs.models.join(',');
|
||||
localInputs.group = (localInputs.groups || []).join(',');
|
||||
|
||||
let mode = 'single';
|
||||
if (batch) {
|
||||
mode = multiToSingle ? 'multi_to_single' : 'batch';
|
||||
}
|
||||
|
||||
if (isEdit) {
|
||||
res = await API.put(`/api/channel/`, {
|
||||
...localInputs,
|
||||
id: parseInt(channelId),
|
||||
});
|
||||
} else {
|
||||
res = await API.post(`/api/channel/`, localInputs);
|
||||
res = await API.post(`/api/channel/`, {
|
||||
mode: mode,
|
||||
multi_key_mode: mode === 'multi_to_single' ? multiKeyMode : undefined,
|
||||
channel: localInputs,
|
||||
});
|
||||
}
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
@@ -465,11 +607,79 @@ const EditChannel = (props) => {
|
||||
}
|
||||
};
|
||||
|
||||
const batchAllowed = !isEdit && inputs.type !== 41;
|
||||
const batchAllowed = !isEdit || isMultiKeyChannel;
|
||||
const batchExtra = batchAllowed ? (
|
||||
<Checkbox checked={batch} onChange={() => setBatch(!batch)}>{t('批量创建')}</Checkbox>
|
||||
<Space>
|
||||
<Checkbox
|
||||
disabled={isEdit}
|
||||
checked={batch}
|
||||
onChange={(e) => {
|
||||
const checked = e.target.checked;
|
||||
|
||||
if (!checked && vertexFileList.length > 1) {
|
||||
Modal.confirm({
|
||||
title: t('切换为单密钥模式'),
|
||||
content: t('将仅保留第一个密钥文件,其余文件将被移除,是否继续?'),
|
||||
onOk: () => {
|
||||
const firstFile = vertexFileList[0];
|
||||
const firstKey = vertexKeys[0] ? [vertexKeys[0]] : [];
|
||||
|
||||
setVertexFileList([firstFile]);
|
||||
setVertexKeys(firstKey);
|
||||
|
||||
formApiRef.current?.setValue('vertex_files', [firstFile]);
|
||||
setInputs((prev) => ({ ...prev, vertex_files: [firstFile] }));
|
||||
|
||||
setBatch(false);
|
||||
setMultiToSingle(false);
|
||||
setMultiKeyMode('random');
|
||||
},
|
||||
onCancel: () => {
|
||||
setBatch(true);
|
||||
},
|
||||
centered: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setBatch(checked);
|
||||
if (!checked) {
|
||||
setMultiToSingle(false);
|
||||
setMultiKeyMode('random');
|
||||
}
|
||||
}}
|
||||
>{t('批量创建')}</Checkbox>
|
||||
{batch && (
|
||||
<Checkbox disabled={isEdit} checked={multiToSingle} onChange={() => {
|
||||
setMultiToSingle(prev => !prev);
|
||||
setInputs(prev => {
|
||||
const newInputs = { ...prev };
|
||||
if (!multiToSingle) {
|
||||
newInputs.multi_key_mode = multiKeyMode;
|
||||
} else {
|
||||
delete newInputs.multi_key_mode;
|
||||
}
|
||||
return newInputs;
|
||||
});
|
||||
}}>{t('密钥聚合模式')}</Checkbox>
|
||||
)}
|
||||
</Space>
|
||||
) : null;
|
||||
|
||||
const channelOptionList = useMemo(
|
||||
() =>
|
||||
CHANNEL_OPTIONS.map((opt) => ({
|
||||
...opt,
|
||||
label: (
|
||||
<span className="flex items-center gap-2">
|
||||
{getChannelIcon(opt.value)}
|
||||
{opt.label}
|
||||
</span>
|
||||
),
|
||||
})),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SideSheet
|
||||
@@ -484,7 +694,7 @@ const EditChannel = (props) => {
|
||||
}
|
||||
bodyStyle={{ padding: '0' }}
|
||||
visible={props.visible}
|
||||
width={isMobile() ? '100%' : 600}
|
||||
width={isMobile ? '100%' : 600}
|
||||
footer={
|
||||
<div className="flex justify-end bg-white">
|
||||
<Space>
|
||||
@@ -535,7 +745,7 @@ const EditChannel = (props) => {
|
||||
label={t('类型')}
|
||||
placeholder={t('请选择渠道类型')}
|
||||
rules={[{ required: true, message: t('请选择渠道类型') }]}
|
||||
optionList={CHANNEL_OPTIONS}
|
||||
optionList={channelOptionList}
|
||||
style={{ width: '100%' }}
|
||||
filter
|
||||
searchPosition='dropdown'
|
||||
@@ -553,56 +763,170 @@ const EditChannel = (props) => {
|
||||
/>
|
||||
|
||||
{batch ? (
|
||||
<Form.TextArea
|
||||
field='key'
|
||||
label={t('密钥')}
|
||||
placeholder={t('请输入密钥,一行一个')}
|
||||
rules={isEdit ? [] : [{ required: true, message: t('请输入密钥') }]}
|
||||
autosize={{ minRows: 6, maxRows: 6 }}
|
||||
autoComplete='new-password'
|
||||
onChange={(value) => handleInputChange('key', value)}
|
||||
extraText={batchExtra}
|
||||
/>
|
||||
inputs.type === 41 ? (
|
||||
<Form.Upload
|
||||
field='vertex_files'
|
||||
label={t('密钥文件 (.json)')}
|
||||
accept='.json'
|
||||
multiple
|
||||
draggable
|
||||
dragIcon={<IconBolt />}
|
||||
dragMainText={t('点击上传文件或拖拽文件到这里')}
|
||||
dragSubText={t('仅支持 JSON 文件,支持多文件')}
|
||||
style={{ marginTop: 10 }}
|
||||
uploadTrigger='custom'
|
||||
beforeUpload={() => false}
|
||||
onChange={handleVertexUploadChange}
|
||||
fileList={vertexFileList}
|
||||
rules={isEdit ? [] : [{ required: true, message: t('请上传密钥文件') }]}
|
||||
extraText={batchExtra}
|
||||
/>
|
||||
) : (
|
||||
<Form.TextArea
|
||||
field='key'
|
||||
label={t('密钥')}
|
||||
placeholder={t('请输入密钥,一行一个')}
|
||||
rules={isEdit ? [] : [{ required: true, message: t('请输入密钥') }]}
|
||||
autosize
|
||||
autoComplete='new-password'
|
||||
onChange={(value) => handleInputChange('key', value)}
|
||||
extraText={batchExtra}
|
||||
showClear
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{inputs.type === 41 ? (
|
||||
<Form.TextArea
|
||||
field='key'
|
||||
label={t('密钥')}
|
||||
placeholder={
|
||||
'{\n' +
|
||||
' "type": "service_account",\n' +
|
||||
' "project_id": "abc-bcd-123-456",\n' +
|
||||
' "private_key_id": "123xxxxx456",\n' +
|
||||
' "private_key": "-----BEGIN PRIVATE KEY-----xxxx\n' +
|
||||
' "client_email": "xxx@developer.gserviceaccount.com",\n' +
|
||||
' "client_id": "111222333",\n' +
|
||||
' "auth_uri": "https://accounts.google.com/o/oauth2/auth",\n' +
|
||||
' "token_uri": "https://oauth2.googleapis.com/token",\n' +
|
||||
' "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",\n' +
|
||||
' "client_x509_cert_url": "https://xxxxx.gserviceaccount.com",\n' +
|
||||
' "universe_domain": "googleapis.com"\n' +
|
||||
'}'
|
||||
}
|
||||
rules={isEdit ? [] : [{ required: true, message: t('请输入密钥') }]}
|
||||
autosize={{ minRows: 10 }}
|
||||
autoComplete='new-password'
|
||||
onChange={(value) => handleInputChange('key', value)}
|
||||
<Form.Upload
|
||||
field='vertex_files'
|
||||
label={t('密钥文件 (.json)')}
|
||||
accept='.json'
|
||||
draggable
|
||||
dragIcon={<IconBolt />}
|
||||
dragMainText={t('点击上传文件或拖拽文件到这里')}
|
||||
dragSubText={t('仅支持 JSON 文件')}
|
||||
style={{ marginTop: 10 }}
|
||||
uploadTrigger='custom'
|
||||
beforeUpload={() => false}
|
||||
onChange={handleVertexUploadChange}
|
||||
fileList={vertexFileList}
|
||||
rules={isEdit ? [] : [{ required: true, message: t('请上传密钥文件') }]}
|
||||
extraText={batchExtra}
|
||||
/>
|
||||
) : (
|
||||
<Form.Input
|
||||
field='key'
|
||||
label={t('密钥')}
|
||||
label={isEdit ? t('密钥(编辑模式下,保存的密钥不会显示)') : t('密钥')}
|
||||
placeholder={t(type2secretPrompt(inputs.type))}
|
||||
rules={isEdit ? [] : [{ required: true, message: t('请输入密钥') }]}
|
||||
autoComplete='new-password'
|
||||
onChange={(value) => handleInputChange('key', value)}
|
||||
extraText={batchExtra}
|
||||
showClear
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{batch && multiToSingle && (
|
||||
<>
|
||||
<Form.Select
|
||||
field='multi_key_mode'
|
||||
label={t('密钥聚合模式')}
|
||||
placeholder={t('请选择多密钥使用策略')}
|
||||
optionList={[
|
||||
{ label: t('随机'), value: 'random' },
|
||||
{ label: t('轮询'), value: 'polling' },
|
||||
]}
|
||||
style={{ width: '100%' }}
|
||||
value={inputs.multi_key_mode || 'random'}
|
||||
onChange={(value) => {
|
||||
setMultiKeyMode(value);
|
||||
handleInputChange('multi_key_mode', value);
|
||||
}}
|
||||
/>
|
||||
{inputs.multi_key_mode === 'polling' && (
|
||||
<Banner
|
||||
type='warning'
|
||||
description={t('轮询模式必须搭配Redis和内存缓存功能使用,否则性能将大幅降低,并且无法实现轮询功能')}
|
||||
className='!rounded-lg mt-2'
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{inputs.type === 18 && (
|
||||
<Form.Input
|
||||
field='other'
|
||||
label={t('模型版本')}
|
||||
placeholder={'请输入星火大模型版本,注意是接口地址中的版本号,例如:v2.1'}
|
||||
onChange={(value) => handleInputChange('other', value)}
|
||||
showClear
|
||||
/>
|
||||
)}
|
||||
|
||||
{inputs.type === 41 && (
|
||||
<Form.TextArea
|
||||
field='other'
|
||||
label={t('部署地区')}
|
||||
placeholder={t(
|
||||
'请输入部署地区,例如:us-central1\n支持使用模型映射格式\n{\n "default": "us-central1",\n "claude-3-5-sonnet-20240620": "europe-west1"\n}'
|
||||
)}
|
||||
autosize
|
||||
onChange={(value) => handleInputChange('other', value)}
|
||||
rules={[{ required: true, message: t('请填写部署地区') }]}
|
||||
extraText={
|
||||
<Text
|
||||
className="!text-semi-color-primary cursor-pointer"
|
||||
onClick={() => handleInputChange('other', JSON.stringify(REGION_EXAMPLE, null, 2))}
|
||||
>
|
||||
{t('填入模板')}
|
||||
</Text>
|
||||
}
|
||||
showClear
|
||||
/>
|
||||
)}
|
||||
|
||||
{inputs.type === 21 && (
|
||||
<Form.Input
|
||||
field='other'
|
||||
label={t('知识库 ID')}
|
||||
placeholder={'请输入知识库 ID,例如:123456'}
|
||||
onChange={(value) => handleInputChange('other', value)}
|
||||
showClear
|
||||
/>
|
||||
)}
|
||||
|
||||
{inputs.type === 39 && (
|
||||
<Form.Input
|
||||
field='other'
|
||||
label='Account ID'
|
||||
placeholder={'请输入Account ID,例如:d6b5da8hk1awo8nap34ube6gh'}
|
||||
onChange={(value) => handleInputChange('other', value)}
|
||||
showClear
|
||||
/>
|
||||
)}
|
||||
|
||||
{inputs.type === 49 && (
|
||||
<Form.Input
|
||||
field='other'
|
||||
label={t('智能体ID')}
|
||||
placeholder={'请输入智能体ID,例如:7342866812345'}
|
||||
onChange={(value) => handleInputChange('other', value)}
|
||||
showClear
|
||||
/>
|
||||
)}
|
||||
|
||||
{inputs.type === 1 && (
|
||||
<Form.Input
|
||||
field='openai_organization'
|
||||
label={t('组织')}
|
||||
placeholder={t('请输入组织org-xxx')}
|
||||
showClear
|
||||
helpText={t('组织,不填则为默认组织')}
|
||||
onChange={(value) => handleInputChange('openai_organization', value)}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* API Configuration Card */}
|
||||
@@ -747,7 +1071,7 @@ const EditChannel = (props) => {
|
||||
<Form.Select
|
||||
field='models'
|
||||
label={t('模型')}
|
||||
placeholder={isEdit ? t('请选择该渠道所支持的模型') : t('创建后可在编辑渠道时获取上游模型列表')}
|
||||
placeholder={t('请选择该渠道所支持的模型')}
|
||||
rules={[{ required: true, message: t('请选择模型') }]}
|
||||
multiple
|
||||
filter
|
||||
@@ -763,11 +1087,9 @@ const EditChannel = (props) => {
|
||||
<Button size='small' type='secondary' onClick={() => handleInputChange('models', fullModels)}>
|
||||
{t('填入所有模型')}
|
||||
</Button>
|
||||
{isEdit && (
|
||||
<Button size='small' type='tertiary' onClick={() => fetchUpstreamModelList('models')}>
|
||||
{t('获取模型列表')}
|
||||
</Button>
|
||||
)}
|
||||
<Button size='small' type='tertiary' onClick={() => fetchUpstreamModelList('models')}>
|
||||
{t('获取模型列表')}
|
||||
</Button>
|
||||
<Button size='small' type='warning' onClick={() => handleInputChange('models', [])}>
|
||||
{t('清除所有模型')}
|
||||
</Button>
|
||||
@@ -860,77 +1182,6 @@ const EditChannel = (props) => {
|
||||
onChange={(value) => handleInputChange('groups', value)}
|
||||
/>
|
||||
|
||||
{inputs.type === 18 && (
|
||||
<Form.Input
|
||||
field='other'
|
||||
label={t('模型版本')}
|
||||
placeholder={'请输入星火大模型版本,注意是接口地址中的版本号,例如:v2.1'}
|
||||
onChange={(value) => handleInputChange('other', value)}
|
||||
showClear
|
||||
/>
|
||||
)}
|
||||
|
||||
{inputs.type === 41 && (
|
||||
<Form.TextArea
|
||||
field='other'
|
||||
label={t('部署地区')}
|
||||
placeholder={t(
|
||||
'请输入部署地区,例如:us-central1\n支持使用模型映射格式\n{\n "default": "us-central1",\n "claude-3-5-sonnet-20240620": "europe-west1"\n}'
|
||||
)}
|
||||
autosize={{ minRows: 2 }}
|
||||
onChange={(value) => handleInputChange('other', value)}
|
||||
extraText={
|
||||
<Text
|
||||
className="!text-semi-color-primary cursor-pointer"
|
||||
onClick={() => handleInputChange('other', JSON.stringify(REGION_EXAMPLE, null, 2))}
|
||||
>
|
||||
{t('填入模板')}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{inputs.type === 21 && (
|
||||
<Form.Input
|
||||
field='other'
|
||||
label={t('知识库 ID')}
|
||||
placeholder={'请输入知识库 ID,例如:123456'}
|
||||
onChange={(value) => handleInputChange('other', value)}
|
||||
showClear
|
||||
/>
|
||||
)}
|
||||
|
||||
{inputs.type === 39 && (
|
||||
<Form.Input
|
||||
field='other'
|
||||
label='Account ID'
|
||||
placeholder={'请输入Account ID,例如:d6b5da8hk1awo8nap34ube6gh'}
|
||||
onChange={(value) => handleInputChange('other', value)}
|
||||
showClear
|
||||
/>
|
||||
)}
|
||||
|
||||
{inputs.type === 49 && (
|
||||
<Form.Input
|
||||
field='other'
|
||||
label={t('智能体ID')}
|
||||
placeholder={'请输入智能体ID,例如:7342866812345'}
|
||||
onChange={(value) => handleInputChange('other', value)}
|
||||
showClear
|
||||
/>
|
||||
)}
|
||||
|
||||
{inputs.type === 1 && (
|
||||
<Form.Input
|
||||
field='openai_organization'
|
||||
label={t('组织')}
|
||||
placeholder={t('请输入组织org-xxx')}
|
||||
showClear
|
||||
helpText={t('组织,可选,不填则为默认组织')}
|
||||
onChange={(value) => handleInputChange('openai_organization', value)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Form.Input
|
||||
field='tag'
|
||||
label={t('渠道标签')}
|
||||
|
||||
@@ -3,7 +3,7 @@ import ChannelsTable from '../../components/table/ChannelsTable';
|
||||
|
||||
const File = () => {
|
||||
return (
|
||||
<div className="mt-[64px]">
|
||||
<div className="mt-[64px] px-2">
|
||||
<ChannelsTable />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -17,7 +17,7 @@ const chat2page = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-[64px]">
|
||||
<div className="mt-[64px] px-2">
|
||||
<h3>正在加载,请稍候...</h3>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import React, { useContext, useEffect, useRef, useState, useMemo, useCallback } from 'react';
|
||||
import { initVChartSemiTheme } from '@visactor/vchart-semi-theme';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Wallet, Activity, Zap, Gauge, PieChart, Server, Bell, HelpCircle } from 'lucide-react';
|
||||
import { Wallet, Activity, Zap, Gauge, PieChart, Server, Bell, HelpCircle, ExternalLink } from 'lucide-react';
|
||||
import { marked } from 'marked';
|
||||
|
||||
import {
|
||||
Card,
|
||||
Form,
|
||||
Spin,
|
||||
IconButton,
|
||||
Button,
|
||||
Modal,
|
||||
Avatar,
|
||||
Tabs,
|
||||
@@ -18,14 +18,14 @@ import {
|
||||
Timeline,
|
||||
Collapse,
|
||||
Progress,
|
||||
Divider
|
||||
Divider,
|
||||
Skeleton
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconRefresh,
|
||||
IconSearch,
|
||||
IconMoneyExchangeStroked,
|
||||
IconHistogram,
|
||||
IconRotate,
|
||||
IconCoinMoneyStroked,
|
||||
IconTextStroked,
|
||||
IconPulse,
|
||||
@@ -33,15 +33,17 @@ import {
|
||||
IconTypograph,
|
||||
IconPieChart2Stroked,
|
||||
IconPlus,
|
||||
IconMinus
|
||||
IconMinus,
|
||||
IconSend
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { IllustrationConstruction, IllustrationConstructionDark } from '@douyinfe/semi-illustrations';
|
||||
import { VChart } from '@visactor/react-vchart';
|
||||
import {
|
||||
API,
|
||||
isAdmin,
|
||||
isMobile,
|
||||
showError,
|
||||
showSuccess,
|
||||
showWarning,
|
||||
timestamp2string,
|
||||
timestamp2string1,
|
||||
getQuotaWithUnit,
|
||||
@@ -50,9 +52,9 @@ import {
|
||||
renderQuota,
|
||||
modelToColor,
|
||||
copy,
|
||||
showSuccess,
|
||||
getRelativeTime
|
||||
} from '../../helpers';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile.js';
|
||||
import { UserContext } from '../../context/User/index.js';
|
||||
import { StatusContext } from '../../context/Status/index.js';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -65,6 +67,7 @@ const Detail = (props) => {
|
||||
// ========== Hooks - Navigation & Translation ==========
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// ========== Hooks - Refs ==========
|
||||
const formRef = useRef();
|
||||
@@ -192,6 +195,7 @@ const Detail = (props) => {
|
||||
const [dataExportDefaultTime, setDataExportDefaultTime] = useState(getDefaultTime());
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [greetingVisible, setGreetingVisible] = useState(false);
|
||||
const [quotaData, setQuotaData] = useState([]);
|
||||
const [consumeQuota, setConsumeQuota] = useState(0);
|
||||
const [consumeTokens, setConsumeTokens] = useState(0);
|
||||
@@ -449,7 +453,7 @@ const Detail = (props) => {
|
||||
// ========== Hooks - Memoized Values ==========
|
||||
const performanceMetrics = useMemo(() => {
|
||||
const timeDiff = (Date.parse(end_timestamp) - Date.parse(start_timestamp)) / 60000;
|
||||
const avgRPM = (times / timeDiff).toFixed(3);
|
||||
const avgRPM = isNaN(times / timeDiff) ? '0' : (times / timeDiff).toFixed(3);
|
||||
const avgTPM = isNaN(consumeTokens / timeDiff) ? '0' : (consumeTokens / timeDiff).toFixed(3);
|
||||
|
||||
return { avgRPM, avgTPM, timeDiff };
|
||||
@@ -518,7 +522,7 @@ const Detail = (props) => {
|
||||
{
|
||||
title: t('当前余额'),
|
||||
value: renderQuota(userState?.user?.quota),
|
||||
icon: <IconMoneyExchangeStroked size="large" />,
|
||||
icon: <IconMoneyExchangeStroked />,
|
||||
avatarColor: 'blue',
|
||||
onClick: () => navigate('/console/topup'),
|
||||
trendData: [],
|
||||
@@ -527,7 +531,7 @@ const Detail = (props) => {
|
||||
{
|
||||
title: t('历史消耗'),
|
||||
value: renderQuota(userState?.user?.used_quota),
|
||||
icon: <IconHistogram size="large" />,
|
||||
icon: <IconHistogram />,
|
||||
avatarColor: 'purple',
|
||||
trendData: [],
|
||||
trendColor: '#8b5cf6'
|
||||
@@ -541,7 +545,7 @@ const Detail = (props) => {
|
||||
{
|
||||
title: t('请求次数'),
|
||||
value: userState.user?.request_count,
|
||||
icon: <IconRotate size="large" />,
|
||||
icon: <IconSend />,
|
||||
avatarColor: 'green',
|
||||
trendData: [],
|
||||
trendColor: '#10b981'
|
||||
@@ -549,7 +553,7 @@ const Detail = (props) => {
|
||||
{
|
||||
title: t('统计次数'),
|
||||
value: times,
|
||||
icon: <IconPulse size="large" />,
|
||||
icon: <IconPulse />,
|
||||
avatarColor: 'cyan',
|
||||
trendData: trendData.times,
|
||||
trendColor: '#06b6d4'
|
||||
@@ -563,7 +567,7 @@ const Detail = (props) => {
|
||||
{
|
||||
title: t('统计额度'),
|
||||
value: renderQuota(consumeQuota),
|
||||
icon: <IconCoinMoneyStroked size="large" />,
|
||||
icon: <IconCoinMoneyStroked />,
|
||||
avatarColor: 'yellow',
|
||||
trendData: trendData.consumeQuota,
|
||||
trendColor: '#f59e0b'
|
||||
@@ -571,7 +575,7 @@ const Detail = (props) => {
|
||||
{
|
||||
title: t('统计Tokens'),
|
||||
value: isNaN(consumeTokens) ? 0 : consumeTokens,
|
||||
icon: <IconTextStroked size="large" />,
|
||||
icon: <IconTextStroked />,
|
||||
avatarColor: 'pink',
|
||||
trendData: trendData.tokens,
|
||||
trendColor: '#ec4899'
|
||||
@@ -585,7 +589,7 @@ const Detail = (props) => {
|
||||
{
|
||||
title: t('平均RPM'),
|
||||
value: performanceMetrics.avgRPM,
|
||||
icon: <IconStopwatchStroked size="large" />,
|
||||
icon: <IconStopwatchStroked />,
|
||||
avatarColor: 'indigo',
|
||||
trendData: trendData.rpm,
|
||||
trendColor: '#6366f1'
|
||||
@@ -593,7 +597,7 @@ const Detail = (props) => {
|
||||
{
|
||||
title: t('平均TPM'),
|
||||
value: performanceMetrics.avgTPM,
|
||||
icon: <IconTypograph size="large" />,
|
||||
icon: <IconTypograph />,
|
||||
avatarColor: 'orange',
|
||||
trendData: trendData.tpm,
|
||||
trendColor: '#f97316'
|
||||
@@ -614,7 +618,7 @@ const Detail = (props) => {
|
||||
const handleSpeedTest = useCallback((apiUrl) => {
|
||||
const encodedUrl = encodeURIComponent(apiUrl);
|
||||
const speedTestUrl = `https://www.tcptest.cn/http/${encodedUrl}`;
|
||||
window.open(speedTestUrl, '_blank');
|
||||
window.open(speedTestUrl, '_blank', 'noopener,noreferrer');
|
||||
}, []);
|
||||
|
||||
const handleInputChange = useCallback((value, name) => {
|
||||
@@ -627,6 +631,7 @@ const Detail = (props) => {
|
||||
|
||||
const loadQuotaData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
let url = '';
|
||||
let localStartTimestamp = Date.parse(start_timestamp) / 1000;
|
||||
@@ -654,7 +659,11 @@ const Detail = (props) => {
|
||||
showError(message);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
const elapsed = Date.now() - startTime;
|
||||
const remainingTime = Math.max(0, 500 - elapsed);
|
||||
setTimeout(() => {
|
||||
setLoading(false);
|
||||
}, remainingTime);
|
||||
}
|
||||
}, [start_timestamp, end_timestamp, username, dataExportDefaultTime, isAdminUser]);
|
||||
|
||||
@@ -746,6 +755,13 @@ const Detail = (props) => {
|
||||
return () => clearTimeout(timer);
|
||||
}, [uptimeData, activeUptimeTab]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setGreetingVisible(true);
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
const getUserData = async () => {
|
||||
let res = await API.get(`/api/user/self`);
|
||||
const { success, message, data } = res.data;
|
||||
@@ -1104,16 +1120,23 @@ const Detail = (props) => {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="bg-gray-50 h-full mt-[64px]">
|
||||
<div className="bg-gray-50 h-full mt-[64px] px-2">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-2xl font-semibold text-gray-800">{getGreeting}</h2>
|
||||
<h2
|
||||
className="text-2xl font-semibold text-gray-800 transition-opacity duration-1000 ease-in-out"
|
||||
style={{ opacity: greetingVisible ? 1 : 0 }}
|
||||
>
|
||||
{getGreeting}
|
||||
</h2>
|
||||
<div className="flex gap-3">
|
||||
<IconButton
|
||||
<Button
|
||||
type='tertiary'
|
||||
icon={<IconSearch />}
|
||||
onClick={showSearchModal}
|
||||
className={`bg-green-500 hover:bg-green-600 ${ICON_BUTTON_CLASS}`}
|
||||
/>
|
||||
<IconButton
|
||||
<Button
|
||||
type='tertiary'
|
||||
icon={<IconRefresh />}
|
||||
onClick={refresh}
|
||||
loading={loading}
|
||||
@@ -1129,7 +1152,7 @@ const Detail = (props) => {
|
||||
onOk={handleSearchConfirm}
|
||||
onCancel={handleCloseModal}
|
||||
closeOnEsc={true}
|
||||
size={isMobile() ? 'full-width' : 'small'}
|
||||
size={isMobile ? 'full-width' : 'small'}
|
||||
centered
|
||||
>
|
||||
<Form ref={formRef} layout='vertical' className="w-full">
|
||||
@@ -1174,143 +1197,159 @@ const Detail = (props) => {
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Spin spinning={loading}>
|
||||
<div className="mb-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{groupedStatsData.map((group, idx) => (
|
||||
<Card
|
||||
key={idx}
|
||||
{...CARD_PROPS}
|
||||
className={`${group.color} border-0 !rounded-2xl w-full`}
|
||||
title={group.title}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{group.items.map((item, itemIdx) => (
|
||||
<div
|
||||
key={itemIdx}
|
||||
className="flex items-center justify-between cursor-pointer"
|
||||
onClick={item.onClick}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Avatar
|
||||
className="mr-3"
|
||||
size="small"
|
||||
color={item.avatarColor}
|
||||
>
|
||||
{item.icon}
|
||||
</Avatar>
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">{item.title}</div>
|
||||
<div className="text-lg font-semibold">{item.value}</div>
|
||||
<div className="mb-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{groupedStatsData.map((group, idx) => (
|
||||
<Card
|
||||
key={idx}
|
||||
{...CARD_PROPS}
|
||||
className={`${group.color} border-0 !rounded-2xl w-full`}
|
||||
title={group.title}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{group.items.map((item, itemIdx) => (
|
||||
<div
|
||||
key={itemIdx}
|
||||
className="flex items-center justify-between cursor-pointer"
|
||||
onClick={item.onClick}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Avatar
|
||||
className="mr-3"
|
||||
size="small"
|
||||
color={item.avatarColor}
|
||||
>
|
||||
{item.icon}
|
||||
</Avatar>
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">{item.title}</div>
|
||||
<div className="text-lg font-semibold">
|
||||
<Skeleton
|
||||
loading={loading}
|
||||
active
|
||||
placeholder={
|
||||
<Skeleton.Paragraph
|
||||
active
|
||||
rows={1}
|
||||
style={{ width: '65px', height: '24px', marginTop: '4px' }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{item.value}
|
||||
</Skeleton>
|
||||
</div>
|
||||
</div>
|
||||
{item.trendData && item.trendData.length > 0 && (
|
||||
<div className="w-24 h-10">
|
||||
<VChart
|
||||
spec={getTrendSpec(item.trendData, item.trendColor)}
|
||||
option={CHART_CONFIG}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<div className={`grid grid-cols-1 gap-4 ${hasApiInfoPanel ? 'lg:grid-cols-4' : ''}`}>
|
||||
<Card
|
||||
{...CARD_PROPS}
|
||||
className={`shadow-sm !rounded-2xl ${hasApiInfoPanel ? 'lg:col-span-3' : ''}`}
|
||||
title={
|
||||
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between w-full gap-3">
|
||||
<div className={FLEX_CENTER_GAP2}>
|
||||
<PieChart size={16} />
|
||||
{t('模型数据分析')}
|
||||
{(loading || (item.trendData && item.trendData.length > 0)) && (
|
||||
<div className="w-24 h-10">
|
||||
<VChart
|
||||
spec={getTrendSpec(item.trendData, item.trendColor)}
|
||||
option={CHART_CONFIG}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Tabs
|
||||
type="button"
|
||||
activeKey={activeChartTab}
|
||||
onChange={setActiveChartTab}
|
||||
>
|
||||
<TabPane tab={
|
||||
<span>
|
||||
<IconHistogram />
|
||||
{t('消耗分布')}
|
||||
</span>
|
||||
} itemKey="1" />
|
||||
<TabPane tab={
|
||||
<span>
|
||||
<IconPulse />
|
||||
{t('消耗趋势')}
|
||||
</span>
|
||||
} itemKey="2" />
|
||||
<TabPane tab={
|
||||
<span>
|
||||
<IconPieChart2Stroked />
|
||||
{t('调用次数分布')}
|
||||
</span>
|
||||
} itemKey="3" />
|
||||
<TabPane tab={
|
||||
<span>
|
||||
<IconHistogram />
|
||||
{t('调用次数排行')}
|
||||
</span>
|
||||
} itemKey="4" />
|
||||
</Tabs>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div style={{ height: 400 }}>
|
||||
{activeChartTab === '1' && (
|
||||
<VChart
|
||||
spec={spec_line}
|
||||
option={CHART_CONFIG}
|
||||
/>
|
||||
)}
|
||||
{activeChartTab === '2' && (
|
||||
<VChart
|
||||
spec={spec_model_line}
|
||||
option={CHART_CONFIG}
|
||||
/>
|
||||
)}
|
||||
{activeChartTab === '3' && (
|
||||
<VChart
|
||||
spec={spec_pie}
|
||||
option={CHART_CONFIG}
|
||||
/>
|
||||
)}
|
||||
{activeChartTab === '4' && (
|
||||
<VChart
|
||||
spec={spec_rank_bar}
|
||||
option={CHART_CONFIG}
|
||||
/>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasApiInfoPanel && (
|
||||
<Card
|
||||
{...CARD_PROPS}
|
||||
className="bg-gray-50 border-0 !rounded-2xl"
|
||||
title={
|
||||
<div className={FLEX_CENTER_GAP2}>
|
||||
<Server size={16} />
|
||||
{t('API信息')}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="card-content-container">
|
||||
<div
|
||||
ref={apiScrollRef}
|
||||
className="space-y-3 max-h-96 overflow-y-auto card-content-scroll"
|
||||
onScroll={handleApiScroll}
|
||||
>
|
||||
{apiInfoData.length > 0 ? (
|
||||
apiInfoData.map((api) => (
|
||||
<div className="mb-4">
|
||||
<div className={`grid grid-cols-1 gap-4 ${hasApiInfoPanel ? 'lg:grid-cols-4' : ''}`}>
|
||||
<Card
|
||||
{...CARD_PROPS}
|
||||
className={`shadow-sm !rounded-2xl ${hasApiInfoPanel ? 'lg:col-span-3' : ''}`}
|
||||
title={
|
||||
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between w-full gap-3">
|
||||
<div className={FLEX_CENTER_GAP2}>
|
||||
<PieChart size={16} />
|
||||
{t('模型数据分析')}
|
||||
</div>
|
||||
<Tabs
|
||||
type="button"
|
||||
activeKey={activeChartTab}
|
||||
onChange={setActiveChartTab}
|
||||
>
|
||||
<TabPane tab={
|
||||
<span>
|
||||
<IconHistogram />
|
||||
{t('消耗分布')}
|
||||
</span>
|
||||
} itemKey="1" />
|
||||
<TabPane tab={
|
||||
<span>
|
||||
<IconPulse />
|
||||
{t('消耗趋势')}
|
||||
</span>
|
||||
} itemKey="2" />
|
||||
<TabPane tab={
|
||||
<span>
|
||||
<IconPieChart2Stroked />
|
||||
{t('调用次数分布')}
|
||||
</span>
|
||||
} itemKey="3" />
|
||||
<TabPane tab={
|
||||
<span>
|
||||
<IconHistogram />
|
||||
{t('调用次数排行')}
|
||||
</span>
|
||||
} itemKey="4" />
|
||||
</Tabs>
|
||||
</div>
|
||||
}
|
||||
bodyStyle={{ padding: 0 }}
|
||||
>
|
||||
<div className="h-96 p-2">
|
||||
{activeChartTab === '1' && (
|
||||
<VChart
|
||||
spec={spec_line}
|
||||
option={CHART_CONFIG}
|
||||
/>
|
||||
)}
|
||||
{activeChartTab === '2' && (
|
||||
<VChart
|
||||
spec={spec_model_line}
|
||||
option={CHART_CONFIG}
|
||||
/>
|
||||
)}
|
||||
{activeChartTab === '3' && (
|
||||
<VChart
|
||||
spec={spec_pie}
|
||||
option={CHART_CONFIG}
|
||||
/>
|
||||
)}
|
||||
{activeChartTab === '4' && (
|
||||
<VChart
|
||||
spec={spec_rank_bar}
|
||||
option={CHART_CONFIG}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{hasApiInfoPanel && (
|
||||
<Card
|
||||
{...CARD_PROPS}
|
||||
className="bg-gray-50 border-0 !rounded-2xl"
|
||||
title={
|
||||
<div className={FLEX_CENTER_GAP2}>
|
||||
<Server size={16} />
|
||||
{t('API信息')}
|
||||
</div>
|
||||
}
|
||||
bodyStyle={{ padding: 0 }}
|
||||
>
|
||||
<div className="card-content-container">
|
||||
<div
|
||||
ref={apiScrollRef}
|
||||
className="p-2 max-h-96 overflow-y-auto card-content-scroll"
|
||||
onScroll={handleApiScroll}
|
||||
>
|
||||
{apiInfoData.length > 0 ? (
|
||||
apiInfoData.map((api) => (
|
||||
<>
|
||||
<div key={api.id} className="flex p-2 hover:bg-white rounded-lg transition-colors cursor-pointer">
|
||||
<div className="flex-shrink-0 mr-3">
|
||||
<Avatar
|
||||
@@ -1321,18 +1360,32 @@ const Detail = (props) => {
|
||||
</Avatar>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-gray-900 mb-1 !font-bold flex items-center gap-2">
|
||||
<Tag
|
||||
prefixIcon={<Gauge size={12} />}
|
||||
size="small"
|
||||
color="white"
|
||||
shape='circle'
|
||||
onClick={() => handleSpeedTest(api.url)}
|
||||
className="cursor-pointer hover:opacity-80 text-xs"
|
||||
>
|
||||
{t('测速')}
|
||||
</Tag>
|
||||
{api.route}
|
||||
<div className="flex flex-wrap items-center justify-between mb-1 w-full gap-2">
|
||||
<span className="text-sm font-medium text-gray-900 !font-bold break-all">
|
||||
{api.route}
|
||||
</span>
|
||||
<div className="flex items-center gap-1 mt-1 lg:mt-0">
|
||||
<Tag
|
||||
prefixIcon={<Gauge size={12} />}
|
||||
size="small"
|
||||
color="white"
|
||||
shape='circle'
|
||||
onClick={() => handleSpeedTest(api.url)}
|
||||
className="cursor-pointer hover:opacity-80 text-xs"
|
||||
>
|
||||
{t('测速')}
|
||||
</Tag>
|
||||
<Tag
|
||||
prefixIcon={<ExternalLink size={12} />}
|
||||
size="small"
|
||||
color="white"
|
||||
shape='circle'
|
||||
onClick={() => window.open(api.url, '_blank', 'noopener,noreferrer')}
|
||||
className="cursor-pointer hover:opacity-80 text-xs"
|
||||
>
|
||||
{t('跳转')}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="!text-semi-color-primary break-all cursor-pointer hover:underline mb-1"
|
||||
@@ -1345,30 +1398,33 @@ const Detail = (props) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="flex justify-center items-center py-8">
|
||||
<Empty
|
||||
image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
|
||||
darkModeImage={<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />}
|
||||
title={t('暂无API信息')}
|
||||
description={t('请联系管理员在系统设置中配置API信息')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="card-content-fade-indicator"
|
||||
style={{ opacity: showApiScrollHint ? 1 : 0 }}
|
||||
/>
|
||||
<Divider />
|
||||
</>
|
||||
))
|
||||
) : (
|
||||
<div className="flex justify-center items-center py-8">
|
||||
<Empty
|
||||
image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
|
||||
darkModeImage={<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />}
|
||||
title={t('暂无API信息')}
|
||||
description={t('请联系管理员在系统设置中配置API信息')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="card-content-fade-indicator"
|
||||
style={{ opacity: showApiScrollHint ? 1 : 0 }}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 系统公告和常见问答卡片 */}
|
||||
{hasInfoPanels && (
|
||||
{/* 系统公告和常见问答卡片 */}
|
||||
{
|
||||
hasInfoPanels && (
|
||||
<div className="mb-4">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4">
|
||||
{/* 公告卡片 */}
|
||||
@@ -1381,7 +1437,7 @@ const Detail = (props) => {
|
||||
<div className="flex items-center gap-2">
|
||||
<Bell size={16} />
|
||||
{t('系统公告')}
|
||||
<Tag size="small" color="grey" shape="circle">
|
||||
<Tag color="white" shape="circle">
|
||||
{t('显示最新20条')}
|
||||
</Tag>
|
||||
</div>
|
||||
@@ -1405,6 +1461,7 @@ const Detail = (props) => {
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
bodyStyle={{ padding: 0 }}
|
||||
>
|
||||
<div className="card-content-container">
|
||||
<div
|
||||
@@ -1513,19 +1570,20 @@ const Detail = (props) => {
|
||||
{uptimeEnabled && (
|
||||
<Card
|
||||
{...CARD_PROPS}
|
||||
className="shadow-sm !rounded-2xl lg:col-span-1 flex flex-col"
|
||||
className="shadow-sm !rounded-2xl lg:col-span-1"
|
||||
title={
|
||||
<div className="flex items-center justify-between w-full gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Gauge size={16} />
|
||||
{t('服务可用性')}
|
||||
</div>
|
||||
<IconButton
|
||||
<Button
|
||||
icon={<IconRefresh />}
|
||||
onClick={loadUptimeData}
|
||||
loading={uptimeLoading}
|
||||
size="small"
|
||||
theme="borderless"
|
||||
type='tertiary'
|
||||
className="text-gray-500 hover:text-blue-500 hover:bg-blue-50 !rounded-full"
|
||||
/>
|
||||
</div>
|
||||
@@ -1533,7 +1591,7 @@ const Detail = (props) => {
|
||||
bodyStyle={{ padding: 0 }}
|
||||
>
|
||||
{/* 内容区域 */}
|
||||
<div className="flex-1 relative">
|
||||
<div className="relative">
|
||||
<Spin spinning={uptimeLoading}>
|
||||
{uptimeData.length > 0 ? (
|
||||
uptimeData.length === 1 ? (
|
||||
@@ -1613,9 +1671,9 @@ const Detail = (props) => {
|
||||
</Spin>
|
||||
</div>
|
||||
|
||||
{/* 固定在底部的图例 */}
|
||||
{/* 图例 */}
|
||||
{uptimeData.length > 0 && (
|
||||
<div className="p-3 mt-auto bg-gray-50 rounded-b-2xl">
|
||||
<div className="p-3 bg-gray-50 rounded-b-2xl">
|
||||
<div className="flex flex-wrap gap-3 text-xs justify-center">
|
||||
{uptimeLegendData.map((legend, index) => (
|
||||
<div key={index} className="flex items-center gap-1">
|
||||
@@ -1633,9 +1691,9 @@ const Detail = (props) => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Spin>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div >
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import { Button, Typography, Tag, Input, ScrollList, ScrollItem } from '@douyinfe/semi-ui';
|
||||
import { API, showError, isMobile, copy, showSuccess } from '../../helpers';
|
||||
import { API, showError, copy, showSuccess } from '../../helpers';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile.js';
|
||||
import { API_ENDPOINTS } from '../../constants/common.constant';
|
||||
import { StatusContext } from '../../context/Status';
|
||||
import { marked } from 'marked';
|
||||
@@ -18,6 +19,7 @@ const Home = () => {
|
||||
const [homePageContentLoaded, setHomePageContentLoaded] = useState(false);
|
||||
const [homePageContent, setHomePageContent] = useState('');
|
||||
const [noticeVisible, setNoticeVisible] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
const isDemoSiteMode = statusState?.status?.demo_site_enabled || false;
|
||||
const docsLink = statusState?.status?.docs_link || '';
|
||||
const serverAddress = statusState?.status?.server_address || window.location.origin;
|
||||
@@ -98,7 +100,7 @@ const Home = () => {
|
||||
<NoticeModal
|
||||
visible={noticeVisible}
|
||||
onClose={() => setNoticeVisible(false)}
|
||||
isMobile={isMobile()}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
{homePageContentLoaded && homePageContent === '' ? (
|
||||
<div className="w-full overflow-x-hidden">
|
||||
@@ -133,7 +135,7 @@ const Home = () => {
|
||||
readonly
|
||||
value={serverAddress}
|
||||
className="flex-1 !rounded-full"
|
||||
size={isMobile() ? 'default' : 'large'}
|
||||
size={isMobile ? 'default' : 'large'}
|
||||
suffix={
|
||||
<div className="flex items-center gap-2">
|
||||
<ScrollList bodyHeight={32} style={{ border: 'unset', boxShadow: 'unset' }}>
|
||||
@@ -160,13 +162,13 @@ const Home = () => {
|
||||
{/* 操作按钮 */}
|
||||
<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 />}>
|
||||
<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"}
|
||||
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')}
|
||||
@@ -176,7 +178,7 @@ const Home = () => {
|
||||
) : (
|
||||
docsLink && (
|
||||
<Button
|
||||
size={isMobile() ? "default" : "large"}
|
||||
size={isMobile ? "default" : "large"}
|
||||
className="flex items-center !rounded-3xl px-6 py-2"
|
||||
icon={<IconFile />}
|
||||
onClick={() => window.open(docsLink, '_blank')}
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import LogsTable from '../../components/table/LogsTable';
|
||||
|
||||
const Token = () => (
|
||||
<div className="mt-[64px]">
|
||||
<div className="mt-[64px] px-2">
|
||||
<LogsTable />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import MjLogsTable from '../../components/table/MjLogsTable';
|
||||
|
||||
const Midjourney = () => (
|
||||
<div className="mt-[64px]">
|
||||
<div className="mt-[64px] px-2">
|
||||
<MjLogsTable />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Layout, Toast, Modal } from '@douyinfe/semi-ui';
|
||||
|
||||
// Context
|
||||
import { UserContext } from '../../context/User/index.js';
|
||||
import { useStyle, styleActions } from '../../context/Style/index.js';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile.js';
|
||||
|
||||
// hooks
|
||||
import { usePlaygroundState } from '../../hooks/usePlaygroundState.js';
|
||||
@@ -59,7 +59,8 @@ const generateAvatarDataUrl = (username) => {
|
||||
const Playground = () => {
|
||||
const { t } = useTranslation();
|
||||
const [userState] = useContext(UserContext);
|
||||
const { state: styleState, dispatch: styleDispatch } = useStyle();
|
||||
const isMobile = useIsMobile();
|
||||
const styleState = { isMobile };
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const state = usePlaygroundState();
|
||||
@@ -321,19 +322,7 @@ const Playground = () => {
|
||||
}
|
||||
}, [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]);
|
||||
// Playground 组件无需再监听窗口变化,isMobile 由 useIsMobile Hook 自动更新
|
||||
|
||||
// 构建预览payload
|
||||
useEffect(() => {
|
||||
@@ -365,26 +354,26 @@ const Playground = () => {
|
||||
return (
|
||||
<div className="h-full bg-gray-50 mt-[64px]">
|
||||
<Layout style={{ height: '100%', background: 'transparent' }} className="flex flex-col md:flex-row">
|
||||
{(showSettings || !styleState.isMobile) && (
|
||||
{(showSettings || !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)',
|
||||
minWidth: isMobile ? '100%' : 320,
|
||||
maxWidth: isMobile ? '100%' : 320,
|
||||
height: isMobile ? 'auto' : 'calc(100vh - 66px)',
|
||||
overflow: 'auto',
|
||||
position: styleState.isMobile ? 'fixed' : 'relative',
|
||||
zIndex: styleState.isMobile ? 1000 : 1,
|
||||
position: isMobile ? 'fixed' : 'relative',
|
||||
zIndex: 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' : ''}
|
||||
width={isMobile ? '100%' : 320}
|
||||
className={isMobile ? 'bg-white shadow-lg' : ''}
|
||||
>
|
||||
<OptimizedSettingsPanel
|
||||
inputs={inputs}
|
||||
@@ -432,7 +421,7 @@ const Playground = () => {
|
||||
</div>
|
||||
|
||||
{/* 调试面板 - 桌面端 */}
|
||||
{showDebugPanel && !styleState.isMobile && (
|
||||
{showDebugPanel && !isMobile && (
|
||||
<div className="w-96 flex-shrink-0 h-full">
|
||||
<OptimizedDebugPanel
|
||||
debugData={debugData}
|
||||
@@ -446,7 +435,7 @@ const Playground = () => {
|
||||
</div>
|
||||
|
||||
{/* 调试面板 - 移动端覆盖层 */}
|
||||
{showDebugPanel && styleState.isMobile && (
|
||||
{showDebugPanel && isMobile && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import ModelPricing from '../../components/table/ModelPricing.js';
|
||||
|
||||
const Pricing = () => (
|
||||
<div className="mt-[64px]">
|
||||
<div className="mt-[64px] px-2">
|
||||
<ModelPricing />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,12 +3,12 @@ import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
API,
|
||||
downloadTextAsFile,
|
||||
isMobile,
|
||||
showError,
|
||||
showSuccess,
|
||||
renderQuota,
|
||||
renderQuotaWithPrompt,
|
||||
} from '../../helpers';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile.js';
|
||||
import {
|
||||
Button,
|
||||
Modal,
|
||||
@@ -36,6 +36,7 @@ const EditRedemption = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const isEdit = props.editingRedemption.id !== undefined;
|
||||
const [loading, setLoading] = useState(isEdit);
|
||||
const isMobile = useIsMobile();
|
||||
const formApiRef = useRef(null);
|
||||
|
||||
const getInitValues = () => ({
|
||||
@@ -155,7 +156,7 @@ const EditRedemption = (props) => {
|
||||
}
|
||||
bodyStyle={{ padding: '0' }}
|
||||
visible={props.visiable}
|
||||
width={isMobile() ? '100%' : 600}
|
||||
width={isMobile ? '100%' : 600}
|
||||
footer={
|
||||
<div className="flex justify-end bg-white">
|
||||
<Space>
|
||||
|
||||
@@ -3,7 +3,7 @@ import RedemptionsTable from '../../components/table/RedemptionsTable';
|
||||
|
||||
const Redemption = () => {
|
||||
return (
|
||||
<div className="mt-[64px]">
|
||||
<div className="mt-[64px] px-2">
|
||||
<RedemptionsTable />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -65,6 +65,7 @@ export default function SettingsGeneralPayment(props) {
|
||||
label={t('服务器地址')}
|
||||
placeholder={'https://yourdomain.com'}
|
||||
style={{ width: '100%' }}
|
||||
extraText={t('该服务器地址将影响支付回调地址以及默认首页展示的地址,请确保正确配置')}
|
||||
/>
|
||||
<Button onClick={submitServerAddress}>{t('更新服务器地址')}</Button>
|
||||
</Form.Section>
|
||||
|
||||
@@ -18,7 +18,8 @@ import {
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
} from 'lucide-react';
|
||||
import { API, showError, showSuccess, showWarning, stringToColor, isMobile } from '../../../helpers';
|
||||
import { API, showError, showSuccess, showWarning, stringToColor } from '../../../helpers';
|
||||
import { useIsMobile } from '../../../hooks/useIsMobile.js';
|
||||
import { DEFAULT_ENDPOINT } from '../../../constants';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
import ChannelSelectorModal from '../../../components/settings/ChannelSelectorModal';
|
||||
|
||||
function ConflictConfirmModal({ t, visible, items, onOk, onCancel }) {
|
||||
const isMobile = useIsMobile();
|
||||
const columns = [
|
||||
{ title: t('渠道'), dataIndex: 'channel' },
|
||||
{ title: t('模型'), dataIndex: 'model' },
|
||||
@@ -49,7 +51,7 @@ function ConflictConfirmModal({ t, visible, items, onOk, onCancel }) {
|
||||
visible={visible}
|
||||
onCancel={onCancel}
|
||||
onOk={onOk}
|
||||
size={isMobile() ? 'full-width' : 'large'}
|
||||
size={isMobile ? 'full-width' : 'large'}
|
||||
>
|
||||
<Table columns={columns} dataSource={items} pagination={false} size="small" />
|
||||
</Modal>
|
||||
@@ -61,6 +63,7 @@ export default function UpstreamRatioSync(props) {
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [syncLoading, setSyncLoading] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// 渠道选择相关
|
||||
const [allChannels, setAllChannels] = useState([]);
|
||||
|
||||
@@ -150,7 +150,7 @@ const Setting = () => {
|
||||
}
|
||||
}, [location.search]);
|
||||
return (
|
||||
<div className="mt-[64px]">
|
||||
<div className="mt-[64px] px-2">
|
||||
<Layout>
|
||||
<Layout.Content>
|
||||
<Tabs
|
||||
|
||||
@@ -183,7 +183,7 @@ const Setup = () => {
|
||||
title={
|
||||
<div className="flex items-center">
|
||||
<span className="font-medium">{t('数据库警告')}</span>
|
||||
<Tag color='orange' size='small' className="ml-2 !rounded-full">
|
||||
<Tag color='orange' shape='circle' className="ml-2">
|
||||
SQLite
|
||||
</Tag>
|
||||
</div>
|
||||
@@ -222,7 +222,7 @@ const Setup = () => {
|
||||
title={
|
||||
<div className="flex items-center">
|
||||
<span className="font-medium">{t('数据库信息')}</span>
|
||||
<Tag color='blue' size='small' className="ml-2 !rounded-full">
|
||||
<Tag color='blue' shape='circle' className="ml-2">
|
||||
MySQL
|
||||
</Tag>
|
||||
</div>
|
||||
@@ -256,7 +256,7 @@ const Setup = () => {
|
||||
title={
|
||||
<div className="flex items-center">
|
||||
<span className="font-medium">{t('数据库信息')}</span>
|
||||
<Tag color='green' size='small' className="ml-2 !rounded-full">
|
||||
<Tag color='green' shape='circle' className="ml-2">
|
||||
PostgreSQL
|
||||
</Tag>
|
||||
</div>
|
||||
@@ -425,7 +425,7 @@ const Setup = () => {
|
||||
<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">
|
||||
<Tag color='blue' shape='circle' className="mt-2">
|
||||
{t('默认模式')}
|
||||
</Tag>
|
||||
</div>
|
||||
@@ -443,7 +443,7 @@ const Setup = () => {
|
||||
<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">
|
||||
<Tag color='green' shape='circle' className="mt-2">
|
||||
{t('无需计费')}
|
||||
</Tag>
|
||||
</div>
|
||||
@@ -461,7 +461,7 @@ const Setup = () => {
|
||||
<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">
|
||||
<Tag color='purple' shape='circle' className="mt-2">
|
||||
{t('演示体验')}
|
||||
</Tag>
|
||||
</div>
|
||||
@@ -522,8 +522,8 @@ const Setup = () => {
|
||||
<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>
|
||||
<Tag color='blue' shape='circle' className="mr-2">{t('计费模式')}</Tag>
|
||||
<Tag color='blue' shape='circle'>{t('多用户支持')}</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -542,8 +542,8 @@ const Setup = () => {
|
||||
<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>
|
||||
<Tag color='green' shape='circle' className="mr-2">{t('无需计费')}</Tag>
|
||||
<Tag color='green' shape='circle'>{t('个人使用')}</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -562,8 +562,8 @@ const Setup = () => {
|
||||
<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>
|
||||
<Tag color='purple' shape='circle' className="mr-2">{t('功能演示')}</Tag>
|
||||
<Tag color='purple' shape='circle'>{t('体验试用')}</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import TaskLogsTable from '../../components/table/TaskLogsTable.js';
|
||||
|
||||
const Task = () => (
|
||||
<div className="mt-[64px]">
|
||||
<div className="mt-[64px] px-2">
|
||||
<TaskLogsTable />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import React, { useEffect, useState, useContext, useRef } from 'react';
|
||||
import {
|
||||
API,
|
||||
isMobile,
|
||||
showError,
|
||||
showSuccess,
|
||||
timestamp2string,
|
||||
renderGroupOption,
|
||||
renderQuotaWithPrompt,
|
||||
getModelCategories,
|
||||
} from '../../helpers';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile.js';
|
||||
import {
|
||||
Button,
|
||||
SideSheet,
|
||||
@@ -37,6 +38,7 @@ const EditToken = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const [statusState, statusDispatch] = useContext(StatusContext);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
const formApiRef = useRef(null);
|
||||
const [models, setModels] = useState([]);
|
||||
const [groups, setGroups] = useState([]);
|
||||
@@ -78,10 +80,25 @@ const EditToken = (props) => {
|
||||
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,
|
||||
}));
|
||||
const categories = getModelCategories(t);
|
||||
let localModelOptions = data.map((model) => {
|
||||
let icon = null;
|
||||
for (const [key, category] of Object.entries(categories)) {
|
||||
if (key !== 'all' && category.filter({ model_name: model })) {
|
||||
icon = category.icon;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return {
|
||||
label: (
|
||||
<span className="flex items-center gap-1">
|
||||
{icon}
|
||||
{model}
|
||||
</span>
|
||||
),
|
||||
value: model,
|
||||
};
|
||||
});
|
||||
setModels(localModelOptions);
|
||||
} else {
|
||||
showError(t(message));
|
||||
@@ -261,7 +278,7 @@ const EditToken = (props) => {
|
||||
}
|
||||
bodyStyle={{ padding: '0' }}
|
||||
visible={props.visiable}
|
||||
width={isMobile() ? '100%' : 600}
|
||||
width={isMobile ? '100%' : 600}
|
||||
footer={
|
||||
<div className='flex justify-end bg-white'>
|
||||
<Space>
|
||||
@@ -345,7 +362,23 @@ const EditToken = (props) => {
|
||||
label={t('过期时间')}
|
||||
type='dateTime'
|
||||
placeholder={t('请选择过期时间')}
|
||||
rules={[{ required: true, message: t('请选择过期时间') }]}
|
||||
rules={[
|
||||
{ required: true, message: t('请选择过期时间') },
|
||||
{
|
||||
validator: (rule, value) => {
|
||||
// 允许 -1 表示永不过期,也允许空值在必填校验时被拦截
|
||||
if (value === -1 || !value) return Promise.resolve();
|
||||
const time = Date.parse(value);
|
||||
if (isNaN(time)) {
|
||||
return Promise.reject(t('过期时间格式错误!'));
|
||||
}
|
||||
if (time <= Date.now()) {
|
||||
return Promise.reject(t('过期时间不能早于当前时间!'));
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
]}
|
||||
showClear
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
@@ -453,6 +486,20 @@ const EditToken = (props) => {
|
||||
</div>
|
||||
</div>
|
||||
<Row gutter={12}>
|
||||
<Col span={24}>
|
||||
<Form.Select
|
||||
field='model_limits'
|
||||
label={t('模型限制列表')}
|
||||
placeholder={t('请选择该令牌支持的模型,留空支持所有模型')}
|
||||
multiple
|
||||
optionList={models}
|
||||
extraText={t('非必要,不建议启用模型限制')}
|
||||
filter
|
||||
searchPosition='dropdown'
|
||||
showClear
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.TextArea
|
||||
field='allow_ips'
|
||||
@@ -465,19 +512,6 @@ const EditToken = (props) => {
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Select
|
||||
field='model_limits'
|
||||
label={t('模型限制列表')}
|
||||
placeholder={t('请选择该令牌支持的模型,留空支持所有模型')}
|
||||
multiple
|
||||
optionList={models}
|
||||
maxTagCount={3}
|
||||
extraText={t('非必要,不建议启用模型限制')}
|
||||
showClear
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import TokensTable from '../../components/table/TokensTable';
|
||||
|
||||
const Token = () => {
|
||||
return (
|
||||
<div className="mt-[64px]">
|
||||
<div className="mt-[64px] px-2">
|
||||
<TokensTable />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { API, isMobile, showError, showSuccess } from '../../helpers';
|
||||
import { API, showError, showSuccess } from '../../helpers';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile.js';
|
||||
import {
|
||||
Button,
|
||||
SideSheet,
|
||||
@@ -26,6 +27,7 @@ const AddUser = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const formApiRef = useRef(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const getInitValues = () => ({
|
||||
username: '',
|
||||
@@ -67,7 +69,7 @@ const AddUser = (props) => {
|
||||
}
|
||||
bodyStyle={{ padding: '0' }}
|
||||
visible={props.visible}
|
||||
width={isMobile() ? '100%' : 600}
|
||||
width={isMobile ? '100%' : 600}
|
||||
footer={
|
||||
<div className="flex justify-end bg-white">
|
||||
<Space>
|
||||
|
||||
@@ -2,12 +2,12 @@ import React, { useEffect, useState, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
API,
|
||||
isMobile,
|
||||
showError,
|
||||
showSuccess,
|
||||
renderQuota,
|
||||
renderQuotaWithPrompt,
|
||||
} from '../../helpers';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile.js';
|
||||
import {
|
||||
Button,
|
||||
Modal,
|
||||
@@ -41,6 +41,7 @@ const EditUser = (props) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [addQuotaModalOpen, setIsModalOpen] = useState(false);
|
||||
const [addQuotaLocal, setAddQuotaLocal] = useState('');
|
||||
const isMobile = useIsMobile();
|
||||
const [groupOptions, setGroupOptions] = useState([]);
|
||||
const formApiRef = useRef(null);
|
||||
|
||||
@@ -137,7 +138,7 @@ const EditUser = (props) => {
|
||||
}
|
||||
bodyStyle={{ padding: 0 }}
|
||||
visible={props.visible}
|
||||
width={isMobile() ? '100%' : 600}
|
||||
width={isMobile ? '100%' : 600}
|
||||
footer={
|
||||
<div className='flex justify-end bg-white'>
|
||||
<Space>
|
||||
|
||||
@@ -3,7 +3,7 @@ import UsersTable from '../../components/table/UsersTable';
|
||||
|
||||
const User = () => {
|
||||
return (
|
||||
<div className="mt-[64px]">
|
||||
<div className="mt-[64px] px-2">
|
||||
<UsersTable />
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user