初始化导入 new-api 源码
This commit is contained in:
329
web/src/components/table/channels/ChannelsActions.jsx
Normal file
329
web/src/components/table/channels/ChannelsActions.jsx
Normal file
@@ -0,0 +1,329 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Button,
|
||||
Dropdown,
|
||||
Modal,
|
||||
Switch,
|
||||
Typography,
|
||||
Select,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import CompactModeToggle from '../../common/ui/CompactModeToggle';
|
||||
|
||||
const ChannelsActions = ({
|
||||
enableBatchDelete,
|
||||
batchDeleteChannels,
|
||||
setShowBatchSetTag,
|
||||
testAllChannels,
|
||||
fixChannelsAbilities,
|
||||
updateAllChannelsBalance,
|
||||
deleteAllDisabledChannels,
|
||||
applyAllUpstreamUpdates,
|
||||
detectAllUpstreamUpdates,
|
||||
detectAllUpstreamUpdatesLoading,
|
||||
applyAllUpstreamUpdatesLoading,
|
||||
compactMode,
|
||||
setCompactMode,
|
||||
idSort,
|
||||
setIdSort,
|
||||
setEnableBatchDelete,
|
||||
enableTagMode,
|
||||
setEnableTagMode,
|
||||
statusFilter,
|
||||
setStatusFilter,
|
||||
getFormValues,
|
||||
loadChannels,
|
||||
searchChannels,
|
||||
activeTypeKey,
|
||||
activePage,
|
||||
pageSize,
|
||||
setActivePage,
|
||||
t,
|
||||
}) => {
|
||||
return (
|
||||
<div className='flex flex-col gap-2'>
|
||||
{/* 第一行:批量操作按钮 + 设置开关 */}
|
||||
<div className='flex flex-col md:flex-row justify-between gap-2'>
|
||||
{/* 左侧:批量操作按钮 */}
|
||||
<div className='flex flex-wrap md:flex-nowrap items-center gap-2 w-full md:w-auto order-2 md:order-1'>
|
||||
<Button
|
||||
size='small'
|
||||
disabled={!enableBatchDelete}
|
||||
type='danger'
|
||||
className='w-full md:w-auto'
|
||||
onClick={() => {
|
||||
Modal.confirm({
|
||||
title: t('确定是否要删除所选通道?'),
|
||||
content: t('此修改将不可逆'),
|
||||
onOk: () => batchDeleteChannels(),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t('删除所选通道')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size='small'
|
||||
disabled={!enableBatchDelete}
|
||||
type='tertiary'
|
||||
onClick={() => setShowBatchSetTag(true)}
|
||||
className='w-full md:w-auto'
|
||||
>
|
||||
{t('批量设置标签')}
|
||||
</Button>
|
||||
|
||||
<Dropdown
|
||||
size='small'
|
||||
trigger='click'
|
||||
render={
|
||||
<Dropdown.Menu>
|
||||
<Dropdown.Item>
|
||||
<Button
|
||||
size='small'
|
||||
type='tertiary'
|
||||
className='w-full'
|
||||
loading={detectAllUpstreamUpdatesLoading}
|
||||
disabled={detectAllUpstreamUpdatesLoading}
|
||||
onClick={() => {
|
||||
Modal.confirm({
|
||||
title: t('确定?'),
|
||||
content: t('确定要测试所有未手动禁用渠道吗?'),
|
||||
onOk: () => testAllChannels(),
|
||||
size: 'small',
|
||||
centered: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t('测试所有未手动禁用渠道')}
|
||||
</Button>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item>
|
||||
<Button
|
||||
size='small'
|
||||
className='w-full'
|
||||
onClick={() => {
|
||||
Modal.confirm({
|
||||
title: t('确定是否要修复数据库一致性?'),
|
||||
content: t(
|
||||
'进行该操作时,可能导致渠道访问错误,请仅在数据库出现问题时使用',
|
||||
),
|
||||
onOk: () => fixChannelsAbilities(),
|
||||
size: 'sm',
|
||||
centered: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t('修复数据库一致性')}
|
||||
</Button>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item>
|
||||
<Button
|
||||
size='small'
|
||||
type='secondary'
|
||||
className='w-full'
|
||||
onClick={() => {
|
||||
Modal.confirm({
|
||||
title: t('确定?'),
|
||||
content: t('确定要更新所有已启用通道余额吗?'),
|
||||
onOk: () => updateAllChannelsBalance(),
|
||||
size: 'sm',
|
||||
centered: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t('更新所有已启用通道余额')}
|
||||
</Button>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item>
|
||||
<Button
|
||||
size='small'
|
||||
type='tertiary'
|
||||
className='w-full'
|
||||
onClick={() => {
|
||||
Modal.confirm({
|
||||
title: t('确定?'),
|
||||
content: t(
|
||||
'确定要仅检测全部渠道上游模型更新吗?(不执行新增/删除)',
|
||||
),
|
||||
onOk: () => detectAllUpstreamUpdates(),
|
||||
size: 'sm',
|
||||
centered: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t('检测全部渠道上游更新')}
|
||||
</Button>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item>
|
||||
<Button
|
||||
size='small'
|
||||
type='primary'
|
||||
className='w-full'
|
||||
loading={applyAllUpstreamUpdatesLoading}
|
||||
disabled={applyAllUpstreamUpdatesLoading}
|
||||
onClick={() => {
|
||||
Modal.confirm({
|
||||
title: t('确定?'),
|
||||
content: t('确定要对全部渠道执行上游模型更新吗?'),
|
||||
onOk: () => applyAllUpstreamUpdates(),
|
||||
size: 'sm',
|
||||
centered: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t('处理全部渠道上游更新')}
|
||||
</Button>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item>
|
||||
<Button
|
||||
size='small'
|
||||
type='danger'
|
||||
className='w-full'
|
||||
onClick={() => {
|
||||
Modal.confirm({
|
||||
title: t('确定是否要删除禁用通道?'),
|
||||
content: t('此修改将不可逆'),
|
||||
onOk: () => deleteAllDisabledChannels(),
|
||||
size: 'sm',
|
||||
centered: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t('删除禁用通道')}
|
||||
</Button>
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
size='small'
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
className='w-full md:w-auto'
|
||||
>
|
||||
{t('批量操作')}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
|
||||
<CompactModeToggle
|
||||
compactMode={compactMode}
|
||||
setCompactMode={setCompactMode}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 右侧:设置开关区域 */}
|
||||
<div className='flex flex-col md:flex-row items-start md:items-center gap-2 w-full md:w-auto order-1 md:order-2'>
|
||||
<div className='flex items-center justify-between w-full md:w-auto'>
|
||||
<Typography.Text strong className='mr-2'>
|
||||
{t('使用ID排序')}
|
||||
</Typography.Text>
|
||||
<Switch
|
||||
size='small'
|
||||
checked={idSort}
|
||||
onChange={(v) => {
|
||||
localStorage.setItem('id-sort', v + '');
|
||||
setIdSort(v);
|
||||
const { searchKeyword, searchGroup, searchModel } =
|
||||
getFormValues();
|
||||
if (
|
||||
searchKeyword === '' &&
|
||||
searchGroup === '' &&
|
||||
searchModel === ''
|
||||
) {
|
||||
loadChannels(activePage, pageSize, v, enableTagMode);
|
||||
} else {
|
||||
searchChannels(
|
||||
enableTagMode,
|
||||
activeTypeKey,
|
||||
statusFilter,
|
||||
activePage,
|
||||
pageSize,
|
||||
v,
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between w-full md:w-auto'>
|
||||
<Typography.Text strong className='mr-2'>
|
||||
{t('开启批量操作')}
|
||||
</Typography.Text>
|
||||
<Switch
|
||||
size='small'
|
||||
checked={enableBatchDelete}
|
||||
onChange={(v) => {
|
||||
localStorage.setItem('enable-batch-delete', v + '');
|
||||
setEnableBatchDelete(v);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between w-full md:w-auto'>
|
||||
<Typography.Text strong className='mr-2'>
|
||||
{t('标签聚合模式')}
|
||||
</Typography.Text>
|
||||
<Switch
|
||||
size='small'
|
||||
checked={enableTagMode}
|
||||
onChange={(v) => {
|
||||
localStorage.setItem('enable-tag-mode', v + '');
|
||||
setEnableTagMode(v);
|
||||
setActivePage(1);
|
||||
loadChannels(1, pageSize, idSort, v);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between w-full md:w-auto'>
|
||||
<Typography.Text strong className='mr-2'>
|
||||
{t('状态筛选')}
|
||||
</Typography.Text>
|
||||
<Select
|
||||
size='small'
|
||||
value={statusFilter}
|
||||
onChange={(v) => {
|
||||
localStorage.setItem('channel-status-filter', v);
|
||||
setStatusFilter(v);
|
||||
setActivePage(1);
|
||||
loadChannels(
|
||||
1,
|
||||
pageSize,
|
||||
idSort,
|
||||
enableTagMode,
|
||||
activeTypeKey,
|
||||
v,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Select.Option value='all'>{t('全部')}</Select.Option>
|
||||
<Select.Option value='enabled'>{t('已启用')}</Select.Option>
|
||||
<Select.Option value='disabled'>{t('已禁用')}</Select.Option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelsActions;
|
||||
906
web/src/components/table/channels/ChannelsColumnDefs.jsx
Normal file
906
web/src/components/table/channels/ChannelsColumnDefs.jsx
Normal file
@@ -0,0 +1,906 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Button,
|
||||
Dropdown,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Space,
|
||||
SplitButtonGroup,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
timestamp2string,
|
||||
renderGroup,
|
||||
renderQuota,
|
||||
getChannelIcon,
|
||||
renderQuotaWithAmount,
|
||||
showSuccess,
|
||||
showError,
|
||||
showInfo,
|
||||
} from '../../../helpers';
|
||||
import {
|
||||
CHANNEL_OPTIONS,
|
||||
MODEL_FETCHABLE_CHANNEL_TYPES,
|
||||
} from '../../../constants';
|
||||
import { parseUpstreamUpdateMeta } from '../../../hooks/channels/upstreamUpdateUtils';
|
||||
import {
|
||||
IconTreeTriangleDown,
|
||||
IconMore,
|
||||
IconAlertTriangle,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { FaRandom } from 'react-icons/fa';
|
||||
|
||||
// Render functions
|
||||
const renderType = (type, record = {}, t) => {
|
||||
const channelInfo = record?.channel_info;
|
||||
let type2label = new Map();
|
||||
for (let i = 0; i < CHANNEL_OPTIONS.length; i++) {
|
||||
type2label[CHANNEL_OPTIONS[i].value] = CHANNEL_OPTIONS[i];
|
||||
}
|
||||
type2label[0] = { value: 0, label: t('未知类型'), color: 'grey' };
|
||||
|
||||
let icon = getChannelIcon(type);
|
||||
|
||||
if (channelInfo?.is_multi_key) {
|
||||
icon =
|
||||
channelInfo?.multi_key_mode === 'random' ? (
|
||||
<div className='flex items-center gap-1'>
|
||||
<FaRandom className='text-blue-500' />
|
||||
{icon}
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex items-center gap-1'>
|
||||
<IconTreeTriangleDown className='text-blue-500' />
|
||||
{icon}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const typeTag = (
|
||||
<Tag color={type2label[type]?.color} shape='circle' prefixIcon={icon}>
|
||||
{type2label[type]?.label}
|
||||
</Tag>
|
||||
);
|
||||
|
||||
let ionetMeta = null;
|
||||
if (record?.other_info) {
|
||||
try {
|
||||
const parsed = JSON.parse(record.other_info);
|
||||
if (parsed && typeof parsed === 'object' && parsed.source === 'ionet') {
|
||||
ionetMeta = parsed;
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore invalid metadata
|
||||
}
|
||||
}
|
||||
|
||||
if (!ionetMeta) {
|
||||
return typeTag;
|
||||
}
|
||||
|
||||
const handleNavigate = (event) => {
|
||||
event?.stopPropagation?.();
|
||||
if (!ionetMeta?.deployment_id) {
|
||||
return;
|
||||
}
|
||||
const targetUrl = `/console/deployment?deployment_id=${ionetMeta.deployment_id}`;
|
||||
window.open(targetUrl, '_blank', 'noopener');
|
||||
};
|
||||
|
||||
return (
|
||||
<Space spacing={6}>
|
||||
{typeTag}
|
||||
<Tooltip
|
||||
content={
|
||||
<div className='max-w-xs'>
|
||||
<div className='text-xs text-gray-600'>
|
||||
{t('来源于 IO.NET 部署')}
|
||||
</div>
|
||||
{ionetMeta?.deployment_id && (
|
||||
<div className='text-xs text-gray-500 mt-1'>
|
||||
{t('部署 ID')}: {ionetMeta.deployment_id}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<Tag
|
||||
color='purple'
|
||||
type='light'
|
||||
className='cursor-pointer'
|
||||
onClick={handleNavigate}
|
||||
>
|
||||
IO.NET
|
||||
</Tag>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
|
||||
const renderTagType = (t) => {
|
||||
return (
|
||||
<Tag color='light-blue' shape='circle' type='light'>
|
||||
{t('标签聚合')}
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
|
||||
const renderStatus = (status, channelInfo = undefined, t) => {
|
||||
if (channelInfo) {
|
||||
if (channelInfo.is_multi_key) {
|
||||
let keySize = channelInfo.multi_key_size;
|
||||
let enabledKeySize = keySize;
|
||||
if (channelInfo.multi_key_status_list) {
|
||||
enabledKeySize =
|
||||
keySize - Object.keys(channelInfo.multi_key_status_list).length;
|
||||
}
|
||||
return renderMultiKeyStatus(status, keySize, enabledKeySize, t);
|
||||
}
|
||||
}
|
||||
switch (status) {
|
||||
case 1:
|
||||
return (
|
||||
<Tag color='green' shape='circle'>
|
||||
{t('已启用')}
|
||||
</Tag>
|
||||
);
|
||||
case 2:
|
||||
return (
|
||||
<Tag color='red' shape='circle'>
|
||||
{t('已禁用')}
|
||||
</Tag>
|
||||
);
|
||||
case 3:
|
||||
return (
|
||||
<Tag color='yellow' shape='circle'>
|
||||
{t('自动禁用')}
|
||||
</Tag>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Tag color='grey' shape='circle'>
|
||||
{t('未知状态')}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const renderMultiKeyStatus = (status, keySize, enabledKeySize, t) => {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return (
|
||||
<Tag color='green' shape='circle'>
|
||||
{t('已启用')} {enabledKeySize}/{keySize}
|
||||
</Tag>
|
||||
);
|
||||
case 2:
|
||||
return (
|
||||
<Tag color='red' shape='circle'>
|
||||
{t('已禁用')} {enabledKeySize}/{keySize}
|
||||
</Tag>
|
||||
);
|
||||
case 3:
|
||||
return (
|
||||
<Tag color='yellow' shape='circle'>
|
||||
{t('自动禁用')} {enabledKeySize}/{keySize}
|
||||
</Tag>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Tag color='grey' shape='circle'>
|
||||
{t('未知状态')} {enabledKeySize}/{keySize}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const renderResponseTime = (responseTime, t) => {
|
||||
let time = responseTime / 1000;
|
||||
time = time.toFixed(2) + t(' 秒');
|
||||
if (responseTime === 0) {
|
||||
return (
|
||||
<Tag color='grey' shape='circle'>
|
||||
{t('未测试')}
|
||||
</Tag>
|
||||
);
|
||||
} else if (responseTime <= 1000) {
|
||||
return (
|
||||
<Tag color='green' shape='circle'>
|
||||
{time}
|
||||
</Tag>
|
||||
);
|
||||
} else if (responseTime <= 3000) {
|
||||
return (
|
||||
<Tag color='lime' shape='circle'>
|
||||
{time}
|
||||
</Tag>
|
||||
);
|
||||
} else if (responseTime <= 5000) {
|
||||
return (
|
||||
<Tag color='yellow' shape='circle'>
|
||||
{time}
|
||||
</Tag>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Tag color='red' shape='circle'>
|
||||
{time}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const isRequestPassThroughEnabled = (record) => {
|
||||
if (!record || record.children !== undefined) {
|
||||
return false;
|
||||
}
|
||||
const settingValue = record.setting;
|
||||
if (!settingValue) {
|
||||
return false;
|
||||
}
|
||||
if (typeof settingValue === 'object') {
|
||||
return settingValue.pass_through_body_enabled === true;
|
||||
}
|
||||
if (typeof settingValue !== 'string') {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(settingValue);
|
||||
return parsed?.pass_through_body_enabled === true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const getUpstreamUpdateMeta = (record) => {
|
||||
const supported =
|
||||
!!record &&
|
||||
record.children === undefined &&
|
||||
MODEL_FETCHABLE_CHANNEL_TYPES.has(record.type);
|
||||
if (!record || record.children !== undefined) {
|
||||
return {
|
||||
supported: false,
|
||||
enabled: false,
|
||||
pendingAddModels: [],
|
||||
pendingRemoveModels: [],
|
||||
};
|
||||
}
|
||||
const parsed =
|
||||
record?.upstreamUpdateMeta && typeof record.upstreamUpdateMeta === 'object'
|
||||
? record.upstreamUpdateMeta
|
||||
: parseUpstreamUpdateMeta(record?.settings);
|
||||
return {
|
||||
supported,
|
||||
enabled: parsed?.enabled === true,
|
||||
pendingAddModels: Array.isArray(parsed?.pendingAddModels)
|
||||
? parsed.pendingAddModels
|
||||
: [],
|
||||
pendingRemoveModels: Array.isArray(parsed?.pendingRemoveModels)
|
||||
? parsed.pendingRemoveModels
|
||||
: [],
|
||||
};
|
||||
};
|
||||
|
||||
export const getChannelsColumns = ({
|
||||
t,
|
||||
COLUMN_KEYS,
|
||||
updateChannelBalance,
|
||||
manageChannel,
|
||||
manageTag,
|
||||
submitTagEdit,
|
||||
testChannel,
|
||||
setCurrentTestChannel,
|
||||
setShowModelTestModal,
|
||||
setEditingChannel,
|
||||
setShowEdit,
|
||||
setShowEditTag,
|
||||
setEditingTag,
|
||||
copySelectedChannel,
|
||||
refresh,
|
||||
activePage,
|
||||
channels,
|
||||
checkOllamaVersion,
|
||||
setShowMultiKeyManageModal,
|
||||
setCurrentMultiKeyChannel,
|
||||
openUpstreamUpdateModal,
|
||||
detectChannelUpstreamUpdates,
|
||||
}) => {
|
||||
return [
|
||||
{
|
||||
key: COLUMN_KEYS.ID,
|
||||
title: t('ID'),
|
||||
dataIndex: 'id',
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.NAME,
|
||||
title: t('名称'),
|
||||
dataIndex: 'name',
|
||||
render: (text, record, index) => {
|
||||
const passThroughEnabled = isRequestPassThroughEnabled(record);
|
||||
const upstreamUpdateMeta = getUpstreamUpdateMeta(record);
|
||||
const pendingAddCount = upstreamUpdateMeta.pendingAddModels.length;
|
||||
const pendingRemoveCount =
|
||||
upstreamUpdateMeta.pendingRemoveModels.length;
|
||||
const showUpstreamUpdateTag =
|
||||
upstreamUpdateMeta.supported &&
|
||||
upstreamUpdateMeta.enabled &&
|
||||
(pendingAddCount > 0 || pendingRemoveCount > 0);
|
||||
const nameNode =
|
||||
record.remark && record.remark.trim() !== '' ? (
|
||||
<Tooltip
|
||||
content={
|
||||
<div className='flex flex-col gap-2 max-w-xs'>
|
||||
<div className='text-sm'>{record.remark}</div>
|
||||
<Button
|
||||
size='small'
|
||||
type='primary'
|
||||
theme='outline'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard
|
||||
.writeText(record.remark)
|
||||
.then(() => {
|
||||
showSuccess(t('复制成功'));
|
||||
})
|
||||
.catch(() => {
|
||||
showError(t('复制失败'));
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t('复制')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
trigger='hover'
|
||||
position='topLeft'
|
||||
>
|
||||
<span>{text}</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span>{text}</span>
|
||||
);
|
||||
|
||||
if (!passThroughEnabled && !showUpstreamUpdateTag) {
|
||||
return nameNode;
|
||||
}
|
||||
|
||||
return (
|
||||
<Space spacing={6} align='center'>
|
||||
{nameNode}
|
||||
{passThroughEnabled && (
|
||||
<Tooltip
|
||||
content={t(
|
||||
'该渠道已开启请求透传:参数覆写、模型重定向、渠道适配等 NewAPI 内置功能将失效,非最佳实践;如因此产生问题,请勿提交 issue 反馈。',
|
||||
)}
|
||||
trigger='hover'
|
||||
position='topLeft'
|
||||
>
|
||||
<span className='inline-flex items-center'>
|
||||
<IconAlertTriangle
|
||||
style={{ color: 'var(--semi-color-warning)' }}
|
||||
/>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{showUpstreamUpdateTag && (
|
||||
<Space spacing={4} align='center'>
|
||||
{pendingAddCount > 0 ? (
|
||||
<Tooltip content={t('点击处理新增模型')} position='top'>
|
||||
<Tag
|
||||
color='green'
|
||||
type='light'
|
||||
size='small'
|
||||
shape='circle'
|
||||
className='cursor-pointer transition-all duration-150 hover:opacity-85 hover:-translate-y-px active:scale-95'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openUpstreamUpdateModal(
|
||||
record,
|
||||
upstreamUpdateMeta.pendingAddModels,
|
||||
upstreamUpdateMeta.pendingRemoveModels,
|
||||
'add',
|
||||
);
|
||||
}}
|
||||
>
|
||||
+{pendingAddCount}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{pendingRemoveCount > 0 ? (
|
||||
<Tooltip content={t('点击处理删除模型')} position='top'>
|
||||
<Tag
|
||||
color='red'
|
||||
type='light'
|
||||
size='small'
|
||||
shape='circle'
|
||||
className='cursor-pointer transition-all duration-150 hover:opacity-85 hover:-translate-y-px active:scale-95'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openUpstreamUpdateModal(
|
||||
record,
|
||||
upstreamUpdateMeta.pendingAddModels,
|
||||
upstreamUpdateMeta.pendingRemoveModels,
|
||||
'remove',
|
||||
);
|
||||
}}
|
||||
>
|
||||
-{pendingRemoveCount}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Space>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.GROUP,
|
||||
title: t('分组'),
|
||||
dataIndex: 'group',
|
||||
render: (text, record, index) => (
|
||||
<div>
|
||||
<Space spacing={2}>
|
||||
{text
|
||||
?.split(',')
|
||||
.sort((a, b) => {
|
||||
if (a === 'default') return -1;
|
||||
if (b === 'default') return 1;
|
||||
return a.localeCompare(b);
|
||||
})
|
||||
.map((item, index) => renderGroup(item))}
|
||||
</Space>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.TYPE,
|
||||
title: t('类型'),
|
||||
dataIndex: 'type',
|
||||
render: (text, record, index) => {
|
||||
if (record.children === undefined) {
|
||||
return <>{renderType(text, record, t)}</>;
|
||||
} else {
|
||||
return <>{renderTagType(t)}</>;
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.STATUS,
|
||||
title: t('状态'),
|
||||
dataIndex: 'status',
|
||||
render: (text, record, index) => {
|
||||
if (text === 3) {
|
||||
if (record.other_info === '') {
|
||||
record.other_info = '{}';
|
||||
}
|
||||
let otherInfo = JSON.parse(record.other_info);
|
||||
let reason = otherInfo['status_reason'];
|
||||
let time = otherInfo['status_time'];
|
||||
return (
|
||||
<div>
|
||||
<Tooltip
|
||||
content={
|
||||
t('原因:') + reason + t(',时间:') + timestamp2string(time)
|
||||
}
|
||||
>
|
||||
{renderStatus(text, record.channel_info, t)}
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return renderStatus(text, record.channel_info, t);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.RESPONSE_TIME,
|
||||
title: t('响应时间'),
|
||||
dataIndex: 'response_time',
|
||||
render: (text, record, index) => <div>{renderResponseTime(text, t)}</div>,
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.BALANCE,
|
||||
title: t('已用/剩余'),
|
||||
dataIndex: 'expired_time',
|
||||
render: (text, record, index) => {
|
||||
if (record.children === undefined) {
|
||||
return (
|
||||
<div>
|
||||
<Space spacing={1}>
|
||||
<Tooltip content={t('已用额度')}>
|
||||
<Tag color='white' type='ghost' shape='circle'>
|
||||
{renderQuota(record.used_quota)}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
content={
|
||||
record.type === 57
|
||||
? t('查看 Codex 帐号信息与用量')
|
||||
: t('剩余额度') +
|
||||
': ' +
|
||||
renderQuotaWithAmount(record.balance) +
|
||||
t(',点击更新')
|
||||
}
|
||||
>
|
||||
<Tag
|
||||
color={record.type === 57 ? 'light-blue' : 'white'}
|
||||
type={record.type === 57 ? 'light' : 'ghost'}
|
||||
shape='circle'
|
||||
className={record.type === 57 ? 'cursor-pointer' : ''}
|
||||
onClick={() => updateChannelBalance(record)}
|
||||
>
|
||||
{record.type === 57
|
||||
? t('帐号信息')
|
||||
: renderQuotaWithAmount(record.balance)}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Tooltip content={t('已用额度')}>
|
||||
<Tag color='white' type='ghost' shape='circle'>
|
||||
{renderQuota(record.used_quota)}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.PRIORITY,
|
||||
title: t('优先级'),
|
||||
dataIndex: 'priority',
|
||||
render: (text, record, index) => {
|
||||
if (record.children === undefined) {
|
||||
return (
|
||||
<div>
|
||||
<InputNumber
|
||||
style={{ width: 70 }}
|
||||
name='priority'
|
||||
onBlur={(e) => {
|
||||
manageChannel(record.id, 'priority', record, e.target.value);
|
||||
}}
|
||||
keepFocus={true}
|
||||
innerButtons
|
||||
defaultValue={record.priority}
|
||||
min={-999}
|
||||
size='small'
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<InputNumber
|
||||
style={{ width: 70 }}
|
||||
name='priority'
|
||||
keepFocus={true}
|
||||
onBlur={(e) => {
|
||||
Modal.warning({
|
||||
title: t('修改子渠道优先级'),
|
||||
content:
|
||||
t('确定要修改所有子渠道优先级为 ') +
|
||||
e.target.value +
|
||||
t(' 吗?'),
|
||||
onOk: () => {
|
||||
if (e.target.value === '') {
|
||||
return;
|
||||
}
|
||||
submitTagEdit('priority', {
|
||||
tag: record.key,
|
||||
priority: e.target.value,
|
||||
});
|
||||
},
|
||||
});
|
||||
}}
|
||||
innerButtons
|
||||
defaultValue={record.priority}
|
||||
min={-999}
|
||||
size='small'
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.WEIGHT,
|
||||
title: t('权重'),
|
||||
dataIndex: 'weight',
|
||||
render: (text, record, index) => {
|
||||
if (record.children === undefined) {
|
||||
return (
|
||||
<div>
|
||||
<InputNumber
|
||||
style={{ width: 70 }}
|
||||
name='weight'
|
||||
onBlur={(e) => {
|
||||
manageChannel(record.id, 'weight', record, e.target.value);
|
||||
}}
|
||||
keepFocus={true}
|
||||
innerButtons
|
||||
defaultValue={record.weight}
|
||||
min={0}
|
||||
size='small'
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<InputNumber
|
||||
style={{ width: 70 }}
|
||||
name='weight'
|
||||
keepFocus={true}
|
||||
onBlur={(e) => {
|
||||
Modal.warning({
|
||||
title: t('修改子渠道权重'),
|
||||
content:
|
||||
t('确定要修改所有子渠道权重为 ') +
|
||||
e.target.value +
|
||||
t(' 吗?'),
|
||||
onOk: () => {
|
||||
if (e.target.value === '') {
|
||||
return;
|
||||
}
|
||||
submitTagEdit('weight', {
|
||||
tag: record.key,
|
||||
weight: e.target.value,
|
||||
});
|
||||
},
|
||||
});
|
||||
}}
|
||||
innerButtons
|
||||
defaultValue={record.weight}
|
||||
min={-999}
|
||||
size='small'
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.OPERATE,
|
||||
title: '',
|
||||
dataIndex: 'operate',
|
||||
fixed: 'right',
|
||||
render: (text, record, index) => {
|
||||
if (record.children === undefined) {
|
||||
const upstreamUpdateMeta = getUpstreamUpdateMeta(record);
|
||||
const moreMenuItems = [
|
||||
{
|
||||
node: 'item',
|
||||
name: t('删除'),
|
||||
type: 'danger',
|
||||
onClick: () => {
|
||||
Modal.confirm({
|
||||
title: t('确定是否要删除此渠道?'),
|
||||
content: t('此修改将不可逆'),
|
||||
onOk: () => {
|
||||
(async () => {
|
||||
await manageChannel(record.id, 'delete', record);
|
||||
await refresh();
|
||||
setTimeout(() => {
|
||||
if (channels.length === 0 && activePage > 1) {
|
||||
refresh(activePage - 1);
|
||||
}
|
||||
}, 100);
|
||||
})();
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
node: 'item',
|
||||
name: t('复制'),
|
||||
type: 'tertiary',
|
||||
onClick: () => {
|
||||
Modal.confirm({
|
||||
title: t('确定是否要复制此渠道?'),
|
||||
content: t('复制渠道的所有信息'),
|
||||
onOk: () => copySelectedChannel(record),
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (upstreamUpdateMeta.supported) {
|
||||
moreMenuItems.push({
|
||||
node: 'item',
|
||||
name: t('仅检测上游模型更新'),
|
||||
type: 'tertiary',
|
||||
onClick: () => {
|
||||
detectChannelUpstreamUpdates(record);
|
||||
},
|
||||
});
|
||||
moreMenuItems.push({
|
||||
node: 'item',
|
||||
name: t('处理上游模型更新'),
|
||||
type: 'tertiary',
|
||||
onClick: () => {
|
||||
if (!upstreamUpdateMeta.enabled) {
|
||||
showInfo(t('该渠道未开启上游模型更新检测'));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
upstreamUpdateMeta.pendingAddModels.length === 0 &&
|
||||
upstreamUpdateMeta.pendingRemoveModels.length === 0
|
||||
) {
|
||||
showInfo(t('该渠道暂无可处理的上游模型更新'));
|
||||
return;
|
||||
}
|
||||
openUpstreamUpdateModal(
|
||||
record,
|
||||
upstreamUpdateMeta.pendingAddModels,
|
||||
upstreamUpdateMeta.pendingRemoveModels,
|
||||
upstreamUpdateMeta.pendingAddModels.length > 0
|
||||
? 'add'
|
||||
: 'remove',
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (record.type === 4) {
|
||||
moreMenuItems.unshift({
|
||||
node: 'item',
|
||||
name: t('测活'),
|
||||
type: 'tertiary',
|
||||
onClick: () => checkOllamaVersion(record),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Space wrap>
|
||||
<SplitButtonGroup
|
||||
className='overflow-hidden'
|
||||
aria-label={t('测试单个渠道操作项目组')}
|
||||
>
|
||||
<Button
|
||||
size='small'
|
||||
type='tertiary'
|
||||
onClick={() => testChannel(record, '')}
|
||||
>
|
||||
{t('测试')}
|
||||
</Button>
|
||||
<Button
|
||||
size='small'
|
||||
type='tertiary'
|
||||
icon={<IconTreeTriangleDown />}
|
||||
onClick={() => {
|
||||
setCurrentTestChannel(record);
|
||||
setShowModelTestModal(true);
|
||||
}}
|
||||
/>
|
||||
</SplitButtonGroup>
|
||||
|
||||
{record.status === 1 ? (
|
||||
<Button
|
||||
type='danger'
|
||||
size='small'
|
||||
onClick={() => manageChannel(record.id, 'disable', record)}
|
||||
>
|
||||
{t('禁用')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size='small'
|
||||
onClick={() => manageChannel(record.id, 'enable', record)}
|
||||
>
|
||||
{t('启用')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{record.channel_info?.is_multi_key ? (
|
||||
<SplitButtonGroup aria-label={t('多密钥渠道操作项目组')}>
|
||||
<Button
|
||||
type='tertiary'
|
||||
size='small'
|
||||
onClick={() => {
|
||||
setEditingChannel(record);
|
||||
setShowEdit(true);
|
||||
}}
|
||||
>
|
||||
{t('编辑')}
|
||||
</Button>
|
||||
<Dropdown
|
||||
trigger='click'
|
||||
position='bottomRight'
|
||||
menu={[
|
||||
{
|
||||
node: 'item',
|
||||
name: t('多密钥管理'),
|
||||
onClick: () => {
|
||||
setCurrentMultiKeyChannel(record);
|
||||
setShowMultiKeyManageModal(true);
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Button
|
||||
type='tertiary'
|
||||
size='small'
|
||||
icon={<IconTreeTriangleDown />}
|
||||
/>
|
||||
</Dropdown>
|
||||
</SplitButtonGroup>
|
||||
) : (
|
||||
<Button
|
||||
type='tertiary'
|
||||
size='small'
|
||||
onClick={() => {
|
||||
setEditingChannel(record);
|
||||
setShowEdit(true);
|
||||
}}
|
||||
>
|
||||
{t('编辑')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Dropdown
|
||||
trigger='click'
|
||||
position='bottomRight'
|
||||
menu={moreMenuItems}
|
||||
>
|
||||
<Button icon={<IconMore />} type='tertiary' size='small' />
|
||||
</Dropdown>
|
||||
</Space>
|
||||
);
|
||||
} else {
|
||||
// 标签操作按钮
|
||||
return (
|
||||
<Space wrap>
|
||||
<Button
|
||||
type='tertiary'
|
||||
size='small'
|
||||
onClick={() => manageTag(record.key, 'enable')}
|
||||
>
|
||||
{t('启用全部')}
|
||||
</Button>
|
||||
<Button
|
||||
type='tertiary'
|
||||
size='small'
|
||||
onClick={() => manageTag(record.key, 'disable')}
|
||||
>
|
||||
{t('禁用全部')}
|
||||
</Button>
|
||||
<Button
|
||||
type='tertiary'
|
||||
size='small'
|
||||
onClick={() => {
|
||||
setShowEditTag(true);
|
||||
setEditingTag(record.key);
|
||||
}}
|
||||
>
|
||||
{t('编辑')}
|
||||
</Button>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
159
web/src/components/table/channels/ChannelsFilters.jsx
Normal file
159
web/src/components/table/channels/ChannelsFilters.jsx
Normal file
@@ -0,0 +1,159 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Button, Form } from '@douyinfe/semi-ui';
|
||||
import { IconSearch } from '@douyinfe/semi-icons';
|
||||
|
||||
const ChannelsFilters = ({
|
||||
setEditingChannel,
|
||||
setShowEdit,
|
||||
refresh,
|
||||
setShowColumnSelector,
|
||||
formInitValues,
|
||||
setFormApi,
|
||||
searchChannels,
|
||||
enableTagMode,
|
||||
formApi,
|
||||
groupOptions,
|
||||
loading,
|
||||
searching,
|
||||
t,
|
||||
}) => {
|
||||
return (
|
||||
<div className='flex flex-col md:flex-row justify-between items-center gap-2 w-full'>
|
||||
<div className='flex gap-2 w-full md:w-auto order-2 md:order-1'>
|
||||
<Button
|
||||
size='small'
|
||||
theme='light'
|
||||
type='primary'
|
||||
className='w-full md:w-auto'
|
||||
onClick={() => {
|
||||
setEditingChannel({
|
||||
id: undefined,
|
||||
});
|
||||
setShowEdit(true);
|
||||
}}
|
||||
>
|
||||
{t('添加渠道')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size='small'
|
||||
type='tertiary'
|
||||
className='w-full md:w-auto'
|
||||
onClick={refresh}
|
||||
>
|
||||
{t('刷新')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size='small'
|
||||
type='tertiary'
|
||||
onClick={() => setShowColumnSelector(true)}
|
||||
className='w-full md:w-auto'
|
||||
>
|
||||
{t('列设置')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col md:flex-row items-center gap-2 w-full md:w-auto order-1 md:order-2'>
|
||||
<Form
|
||||
initValues={formInitValues}
|
||||
getFormApi={(api) => setFormApi(api)}
|
||||
onSubmit={() => searchChannels(enableTagMode)}
|
||||
allowEmpty={true}
|
||||
autoComplete='off'
|
||||
layout='horizontal'
|
||||
trigger='change'
|
||||
stopValidateWithError={false}
|
||||
className='flex flex-col md:flex-row items-center gap-2 w-full'
|
||||
>
|
||||
<div className='relative w-full md:w-64'>
|
||||
<Form.Input
|
||||
size='small'
|
||||
field='searchKeyword'
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('渠道ID,名称,密钥,API地址')}
|
||||
showClear
|
||||
pure
|
||||
/>
|
||||
</div>
|
||||
<div className='w-full md:w-48'>
|
||||
<Form.Input
|
||||
size='small'
|
||||
field='searchModel'
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('模型关键字')}
|
||||
showClear
|
||||
pure
|
||||
/>
|
||||
</div>
|
||||
<div className='w-full md:w-32'>
|
||||
<Form.Select
|
||||
size='small'
|
||||
field='searchGroup'
|
||||
placeholder={t('选择分组')}
|
||||
optionList={[
|
||||
{ label: t('选择分组'), value: null },
|
||||
...groupOptions,
|
||||
]}
|
||||
className='w-full'
|
||||
showClear
|
||||
pure
|
||||
onChange={() => {
|
||||
// 延迟执行搜索,让表单值先更新
|
||||
setTimeout(() => {
|
||||
searchChannels(enableTagMode);
|
||||
}, 0);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size='small'
|
||||
type='tertiary'
|
||||
htmlType='submit'
|
||||
loading={loading || searching}
|
||||
className='w-full md:w-auto'
|
||||
>
|
||||
{t('查询')}
|
||||
</Button>
|
||||
<Button
|
||||
size='small'
|
||||
type='tertiary'
|
||||
onClick={() => {
|
||||
if (formApi) {
|
||||
formApi.reset();
|
||||
// 重置后立即查询,使用setTimeout确保表单重置完成
|
||||
setTimeout(() => {
|
||||
refresh();
|
||||
}, 100);
|
||||
}
|
||||
}}
|
||||
className='w-full md:w-auto'
|
||||
>
|
||||
{t('重置')}
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelsFilters;
|
||||
177
web/src/components/table/channels/ChannelsTable.jsx
Normal file
177
web/src/components/table/channels/ChannelsTable.jsx
Normal file
@@ -0,0 +1,177 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { Empty } from '@douyinfe/semi-ui';
|
||||
import CardTable from '../../common/ui/CardTable';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import { getChannelsColumns } from './ChannelsColumnDefs';
|
||||
|
||||
const ChannelsTable = (channelsData) => {
|
||||
const {
|
||||
channels,
|
||||
loading,
|
||||
searching,
|
||||
activePage,
|
||||
pageSize,
|
||||
channelCount,
|
||||
enableBatchDelete,
|
||||
compactMode,
|
||||
visibleColumns,
|
||||
setSelectedChannels,
|
||||
handlePageChange,
|
||||
handlePageSizeChange,
|
||||
handleRow,
|
||||
t,
|
||||
COLUMN_KEYS,
|
||||
// Column functions and data
|
||||
updateChannelBalance,
|
||||
manageChannel,
|
||||
manageTag,
|
||||
submitTagEdit,
|
||||
testChannel,
|
||||
setCurrentTestChannel,
|
||||
setShowModelTestModal,
|
||||
setEditingChannel,
|
||||
setShowEdit,
|
||||
setShowEditTag,
|
||||
setEditingTag,
|
||||
copySelectedChannel,
|
||||
refresh,
|
||||
checkOllamaVersion,
|
||||
// Multi-key management
|
||||
setShowMultiKeyManageModal,
|
||||
setCurrentMultiKeyChannel,
|
||||
openUpstreamUpdateModal,
|
||||
detectChannelUpstreamUpdates,
|
||||
} = channelsData;
|
||||
|
||||
// Get all columns
|
||||
const allColumns = useMemo(() => {
|
||||
return getChannelsColumns({
|
||||
t,
|
||||
COLUMN_KEYS,
|
||||
updateChannelBalance,
|
||||
manageChannel,
|
||||
manageTag,
|
||||
submitTagEdit,
|
||||
testChannel,
|
||||
setCurrentTestChannel,
|
||||
setShowModelTestModal,
|
||||
setEditingChannel,
|
||||
setShowEdit,
|
||||
setShowEditTag,
|
||||
setEditingTag,
|
||||
copySelectedChannel,
|
||||
refresh,
|
||||
activePage,
|
||||
channels,
|
||||
checkOllamaVersion,
|
||||
setShowMultiKeyManageModal,
|
||||
setCurrentMultiKeyChannel,
|
||||
openUpstreamUpdateModal,
|
||||
detectChannelUpstreamUpdates,
|
||||
});
|
||||
}, [
|
||||
t,
|
||||
COLUMN_KEYS,
|
||||
updateChannelBalance,
|
||||
manageChannel,
|
||||
manageTag,
|
||||
submitTagEdit,
|
||||
testChannel,
|
||||
setCurrentTestChannel,
|
||||
setShowModelTestModal,
|
||||
setEditingChannel,
|
||||
setShowEdit,
|
||||
setShowEditTag,
|
||||
setEditingTag,
|
||||
copySelectedChannel,
|
||||
refresh,
|
||||
activePage,
|
||||
channels,
|
||||
checkOllamaVersion,
|
||||
setShowMultiKeyManageModal,
|
||||
setCurrentMultiKeyChannel,
|
||||
openUpstreamUpdateModal,
|
||||
detectChannelUpstreamUpdates,
|
||||
]);
|
||||
|
||||
// Filter columns based on visibility settings
|
||||
const getVisibleColumns = () => {
|
||||
return allColumns.filter((column) => visibleColumns[column.key]);
|
||||
};
|
||||
|
||||
const visibleColumnsList = useMemo(() => {
|
||||
return getVisibleColumns();
|
||||
}, [visibleColumns, allColumns]);
|
||||
|
||||
const tableColumns = useMemo(() => {
|
||||
return compactMode
|
||||
? visibleColumnsList.map(({ fixed, ...rest }) => rest)
|
||||
: visibleColumnsList;
|
||||
}, [compactMode, visibleColumnsList]);
|
||||
|
||||
return (
|
||||
<CardTable
|
||||
columns={tableColumns}
|
||||
dataSource={channels}
|
||||
scroll={compactMode ? undefined : { x: 'max-content' }}
|
||||
pagination={{
|
||||
currentPage: activePage,
|
||||
pageSize: pageSize,
|
||||
total: channelCount,
|
||||
pageSizeOpts: [10, 20, 50, 100],
|
||||
showSizeChanger: true,
|
||||
onPageSizeChange: handlePageSizeChange,
|
||||
onPageChange: handlePageChange,
|
||||
}}
|
||||
hidePagination={true}
|
||||
expandAllRows={false}
|
||||
onRow={handleRow}
|
||||
rowSelection={
|
||||
enableBatchDelete
|
||||
? {
|
||||
onChange: (selectedRowKeys, selectedRows) => {
|
||||
setSelectedChannels(selectedRows);
|
||||
},
|
||||
}
|
||||
: null
|
||||
}
|
||||
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'
|
||||
size='middle'
|
||||
loading={loading || searching}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelsTable;
|
||||
97
web/src/components/table/channels/ChannelsTabs.jsx
Normal file
97
web/src/components/table/channels/ChannelsTabs.jsx
Normal file
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Tabs, TabPane, Tag } from '@douyinfe/semi-ui';
|
||||
import { CHANNEL_OPTIONS } from '../../../constants';
|
||||
import { getChannelIcon } from '../../../helpers';
|
||||
|
||||
const ChannelsTabs = ({
|
||||
enableTagMode,
|
||||
activeTypeKey,
|
||||
setActiveTypeKey,
|
||||
channelTypeCounts,
|
||||
availableTypeKeys,
|
||||
loadChannels,
|
||||
activePage,
|
||||
pageSize,
|
||||
idSort,
|
||||
setActivePage,
|
||||
t,
|
||||
}) => {
|
||||
if (enableTagMode) return null;
|
||||
|
||||
const handleTabChange = (key) => {
|
||||
setActiveTypeKey(key);
|
||||
setActivePage(1);
|
||||
loadChannels(1, pageSize, idSort, enableTagMode, key);
|
||||
};
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
activeKey={activeTypeKey}
|
||||
type='card'
|
||||
collapsible
|
||||
onChange={handleTabChange}
|
||||
className='mb-2'
|
||||
>
|
||||
<TabPane
|
||||
itemKey='all'
|
||||
tab={
|
||||
<span className='flex items-center gap-2'>
|
||||
{t('全部')}
|
||||
<Tag
|
||||
color={activeTypeKey === 'all' ? 'red' : 'grey'}
|
||||
shape='circle'
|
||||
>
|
||||
{channelTypeCounts['all'] || 0}
|
||||
</Tag>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
||||
{CHANNEL_OPTIONS.filter((opt) =>
|
||||
availableTypeKeys.includes(String(opt.value)),
|
||||
).map((option) => {
|
||||
const key = String(option.value);
|
||||
const count = channelTypeCounts[option.value] || 0;
|
||||
return (
|
||||
<TabPane
|
||||
key={key}
|
||||
itemKey={key}
|
||||
tab={
|
||||
<span className='flex items-center gap-2'>
|
||||
{getChannelIcon(option.value)}
|
||||
{option.label}
|
||||
<Tag
|
||||
color={activeTypeKey === key ? 'red' : 'grey'}
|
||||
shape='circle'
|
||||
>
|
||||
{count}
|
||||
</Tag>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelsTabs;
|
||||
116
web/src/components/table/channels/index.jsx
Normal file
116
web/src/components/table/channels/index.jsx
Normal file
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Banner } from '@douyinfe/semi-ui';
|
||||
import { IconAlertTriangle } from '@douyinfe/semi-icons';
|
||||
import CardPro from '../../common/ui/CardPro';
|
||||
import ChannelsTable from './ChannelsTable';
|
||||
import ChannelsActions from './ChannelsActions';
|
||||
import ChannelsFilters from './ChannelsFilters';
|
||||
import ChannelsTabs from './ChannelsTabs';
|
||||
import { useChannelsData } from '../../../hooks/channels/useChannelsData';
|
||||
import { useIsMobile } from '../../../hooks/common/useIsMobile';
|
||||
import BatchTagModal from './modals/BatchTagModal';
|
||||
import ModelTestModal from './modals/ModelTestModal';
|
||||
import ColumnSelectorModal from './modals/ColumnSelectorModal';
|
||||
import EditChannelModal from './modals/EditChannelModal';
|
||||
import EditTagModal from './modals/EditTagModal';
|
||||
import MultiKeyManageModal from './modals/MultiKeyManageModal';
|
||||
import ChannelUpstreamUpdateModal from './modals/ChannelUpstreamUpdateModal';
|
||||
import { createCardProPagination } from '../../../helpers/utils';
|
||||
|
||||
const ChannelsPage = () => {
|
||||
const channelsData = useChannelsData();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Modals */}
|
||||
<ColumnSelectorModal {...channelsData} />
|
||||
<EditTagModal
|
||||
visible={channelsData.showEditTag}
|
||||
tag={channelsData.editingTag}
|
||||
handleClose={() => channelsData.setShowEditTag(false)}
|
||||
refresh={channelsData.refresh}
|
||||
/>
|
||||
<EditChannelModal
|
||||
refresh={channelsData.refresh}
|
||||
visible={channelsData.showEdit}
|
||||
handleClose={channelsData.closeEdit}
|
||||
editingChannel={channelsData.editingChannel}
|
||||
/>
|
||||
<BatchTagModal {...channelsData} />
|
||||
<ModelTestModal {...channelsData} />
|
||||
<MultiKeyManageModal
|
||||
visible={channelsData.showMultiKeyManageModal}
|
||||
onCancel={() => channelsData.setShowMultiKeyManageModal(false)}
|
||||
channel={channelsData.currentMultiKeyChannel}
|
||||
onRefresh={channelsData.refresh}
|
||||
/>
|
||||
<ChannelUpstreamUpdateModal
|
||||
visible={channelsData.showUpstreamUpdateModal}
|
||||
addModels={channelsData.upstreamUpdateAddModels}
|
||||
removeModels={channelsData.upstreamUpdateRemoveModels}
|
||||
preferredTab={channelsData.upstreamUpdatePreferredTab}
|
||||
confirmLoading={channelsData.upstreamApplyLoading}
|
||||
onConfirm={channelsData.applyUpstreamUpdates}
|
||||
onCancel={channelsData.closeUpstreamUpdateModal}
|
||||
/>
|
||||
|
||||
{/* Main Content */}
|
||||
{channelsData.globalPassThroughEnabled ? (
|
||||
<Banner
|
||||
type='warning'
|
||||
closeIcon={null}
|
||||
icon={
|
||||
<IconAlertTriangle
|
||||
size='large'
|
||||
style={{ color: 'var(--semi-color-warning)' }}
|
||||
/>
|
||||
}
|
||||
description={channelsData.t(
|
||||
'已开启全局请求透传:参数覆写、模型重定向、渠道适配等 NewAPI 内置功能将失效,非最佳实践;如因此产生问题,请勿提交 issue 反馈。',
|
||||
)}
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
) : null}
|
||||
<CardPro
|
||||
type='type3'
|
||||
tabsArea={<ChannelsTabs {...channelsData} />}
|
||||
actionsArea={<ChannelsActions {...channelsData} />}
|
||||
searchArea={<ChannelsFilters {...channelsData} />}
|
||||
paginationArea={createCardProPagination({
|
||||
currentPage: channelsData.activePage,
|
||||
pageSize: channelsData.pageSize,
|
||||
total: channelsData.channelCount,
|
||||
onPageChange: channelsData.handlePageChange,
|
||||
onPageSizeChange: channelsData.handlePageSizeChange,
|
||||
isMobile: isMobile,
|
||||
t: channelsData.t,
|
||||
})}
|
||||
t={channelsData.t}
|
||||
>
|
||||
<ChannelsTable {...channelsData} />
|
||||
</CardPro>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelsPage;
|
||||
63
web/src/components/table/channels/modals/BatchTagModal.jsx
Normal file
63
web/src/components/table/channels/modals/BatchTagModal.jsx
Normal file
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Modal, Input, Typography } from '@douyinfe/semi-ui';
|
||||
|
||||
const BatchTagModal = ({
|
||||
showBatchSetTag,
|
||||
setShowBatchSetTag,
|
||||
batchSetChannelTag,
|
||||
batchSetTagValue,
|
||||
setBatchSetTagValue,
|
||||
selectedChannels,
|
||||
t,
|
||||
}) => {
|
||||
return (
|
||||
<Modal
|
||||
title={t('批量设置标签')}
|
||||
visible={showBatchSetTag}
|
||||
onOk={batchSetChannelTag}
|
||||
onCancel={() => setShowBatchSetTag(false)}
|
||||
maskClosable={false}
|
||||
centered={true}
|
||||
size='small'
|
||||
className='!rounded-lg'
|
||||
>
|
||||
<div className='mb-5'>
|
||||
<Typography.Text>{t('请输入要设置的标签名称')}</Typography.Text>
|
||||
</div>
|
||||
<Input
|
||||
placeholder={t('请输入标签名称')}
|
||||
value={batchSetTagValue}
|
||||
onChange={(v) => setBatchSetTagValue(v)}
|
||||
/>
|
||||
<div className='mt-4'>
|
||||
<Typography.Text type='secondary'>
|
||||
{t('已选择 ${count} 个渠道').replace(
|
||||
'${count}',
|
||||
selectedChannels.length,
|
||||
)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default BatchTagModal;
|
||||
@@ -0,0 +1,313 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Modal,
|
||||
Checkbox,
|
||||
Empty,
|
||||
Input,
|
||||
Tabs,
|
||||
Typography,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import { IconSearch } from '@douyinfe/semi-icons';
|
||||
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
||||
|
||||
const normalizeModels = (models = []) =>
|
||||
Array.from(
|
||||
new Set(
|
||||
(models || []).map((model) => String(model || '').trim()).filter(Boolean),
|
||||
),
|
||||
);
|
||||
|
||||
const filterByKeyword = (models = [], keyword = '') => {
|
||||
const normalizedKeyword = String(keyword || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!normalizedKeyword) {
|
||||
return models;
|
||||
}
|
||||
return models.filter((model) =>
|
||||
String(model).toLowerCase().includes(normalizedKeyword),
|
||||
);
|
||||
};
|
||||
|
||||
const ChannelUpstreamUpdateModal = ({
|
||||
visible,
|
||||
addModels = [],
|
||||
removeModels = [],
|
||||
preferredTab = 'add',
|
||||
confirmLoading = false,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const normalizedAddModels = useMemo(
|
||||
() => normalizeModels(addModels),
|
||||
[addModels],
|
||||
);
|
||||
const normalizedRemoveModels = useMemo(
|
||||
() => normalizeModels(removeModels),
|
||||
[removeModels],
|
||||
);
|
||||
|
||||
const [selectedAddModels, setSelectedAddModels] = useState([]);
|
||||
const [selectedRemoveModels, setSelectedRemoveModels] = useState([]);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [activeTab, setActiveTab] = useState('add');
|
||||
const [partialSubmitConfirmed, setPartialSubmitConfirmed] = useState(false);
|
||||
|
||||
const addTabEnabled = normalizedAddModels.length > 0;
|
||||
const removeTabEnabled = normalizedRemoveModels.length > 0;
|
||||
const filteredAddModels = useMemo(
|
||||
() => filterByKeyword(normalizedAddModels, keyword),
|
||||
[normalizedAddModels, keyword],
|
||||
);
|
||||
const filteredRemoveModels = useMemo(
|
||||
() => filterByKeyword(normalizedRemoveModels, keyword),
|
||||
[normalizedRemoveModels, keyword],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
return;
|
||||
}
|
||||
setSelectedAddModels([]);
|
||||
setSelectedRemoveModels([]);
|
||||
setKeyword('');
|
||||
setPartialSubmitConfirmed(false);
|
||||
const normalizedPreferredTab = preferredTab === 'remove' ? 'remove' : 'add';
|
||||
if (normalizedPreferredTab === 'remove' && removeTabEnabled) {
|
||||
setActiveTab('remove');
|
||||
return;
|
||||
}
|
||||
if (normalizedPreferredTab === 'add' && addTabEnabled) {
|
||||
setActiveTab('add');
|
||||
return;
|
||||
}
|
||||
setActiveTab(addTabEnabled ? 'add' : 'remove');
|
||||
}, [visible, addTabEnabled, removeTabEnabled, preferredTab]);
|
||||
|
||||
const currentModels =
|
||||
activeTab === 'add' ? filteredAddModels : filteredRemoveModels;
|
||||
const currentSelectedModels =
|
||||
activeTab === 'add' ? selectedAddModels : selectedRemoveModels;
|
||||
const currentSetSelectedModels =
|
||||
activeTab === 'add' ? setSelectedAddModels : setSelectedRemoveModels;
|
||||
const selectedAddCount = selectedAddModels.length;
|
||||
const selectedRemoveCount = selectedRemoveModels.length;
|
||||
const checkedCount = currentModels.filter((model) =>
|
||||
currentSelectedModels.includes(model),
|
||||
).length;
|
||||
const isAllChecked =
|
||||
currentModels.length > 0 && checkedCount === currentModels.length;
|
||||
const isIndeterminate =
|
||||
checkedCount > 0 && checkedCount < currentModels.length;
|
||||
|
||||
const handleToggleAllCurrent = (checked) => {
|
||||
if (checked) {
|
||||
const merged = normalizeModels([
|
||||
...currentSelectedModels,
|
||||
...currentModels,
|
||||
]);
|
||||
currentSetSelectedModels(merged);
|
||||
return;
|
||||
}
|
||||
const currentSet = new Set(currentModels);
|
||||
currentSetSelectedModels(
|
||||
currentSelectedModels.filter((model) => !currentSet.has(model)),
|
||||
);
|
||||
};
|
||||
|
||||
const tabList = [
|
||||
{
|
||||
itemKey: 'add',
|
||||
tab: `${t('新增模型')} (${selectedAddCount}/${normalizedAddModels.length})`,
|
||||
disabled: !addTabEnabled,
|
||||
},
|
||||
{
|
||||
itemKey: 'remove',
|
||||
tab: `${t('删除模型')} (${selectedRemoveCount}/${normalizedRemoveModels.length})`,
|
||||
disabled: !removeTabEnabled,
|
||||
},
|
||||
];
|
||||
|
||||
const submitSelectedChanges = () => {
|
||||
onConfirm?.({
|
||||
addModels: selectedAddModels,
|
||||
removeModels: selectedRemoveModels,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
const hasAnySelected = selectedAddCount > 0 || selectedRemoveCount > 0;
|
||||
if (!hasAnySelected) {
|
||||
submitSelectedChanges();
|
||||
return;
|
||||
}
|
||||
|
||||
const hasBothPending = addTabEnabled && removeTabEnabled;
|
||||
const hasUnselectedAdd = addTabEnabled && selectedAddCount === 0;
|
||||
const hasUnselectedRemove = removeTabEnabled && selectedRemoveCount === 0;
|
||||
if (hasBothPending && (hasUnselectedAdd || hasUnselectedRemove)) {
|
||||
if (partialSubmitConfirmed) {
|
||||
submitSelectedChanges();
|
||||
return;
|
||||
}
|
||||
const missingTab = hasUnselectedAdd ? 'add' : 'remove';
|
||||
const missingType = hasUnselectedAdd ? t('新增') : t('删除');
|
||||
const missingCount = hasUnselectedAdd
|
||||
? normalizedAddModels.length
|
||||
: normalizedRemoveModels.length;
|
||||
setActiveTab(missingTab);
|
||||
Modal.confirm({
|
||||
title: t('仍有未处理项'),
|
||||
content: t(
|
||||
'你还没有处理{{type}}模型({{count}}个)。是否仅提交当前已勾选内容?',
|
||||
{
|
||||
type: missingType,
|
||||
count: missingCount,
|
||||
},
|
||||
),
|
||||
okText: t('仅提交已勾选'),
|
||||
cancelText: t('去处理{{type}}', { type: missingType }),
|
||||
centered: true,
|
||||
onOk: () => {
|
||||
setPartialSubmitConfirmed(true);
|
||||
submitSelectedChanges();
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
submitSelectedChanges();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
title={t('处理上游模型更新')}
|
||||
okText={t('确定')}
|
||||
cancelText={t('取消')}
|
||||
size={isMobile ? 'full-width' : 'medium'}
|
||||
centered
|
||||
closeOnEsc
|
||||
maskClosable
|
||||
confirmLoading={confirmLoading}
|
||||
onCancel={onCancel}
|
||||
onOk={handleSubmit}
|
||||
>
|
||||
<div className='flex flex-col gap-3'>
|
||||
<Typography.Text type='secondary' size='small'>
|
||||
{t(
|
||||
'可勾选需要执行的变更:新增会加入渠道模型列表,删除会从渠道模型列表移除。',
|
||||
)}
|
||||
</Typography.Text>
|
||||
|
||||
<Tabs
|
||||
type='slash'
|
||||
size='small'
|
||||
tabList={tabList}
|
||||
activeKey={activeTab}
|
||||
onChange={(key) => setActiveTab(key)}
|
||||
/>
|
||||
<div className='flex items-center gap-3 text-xs text-gray-500'>
|
||||
<span>
|
||||
{t('新增已选 {{selected}} / {{total}}', {
|
||||
selected: selectedAddCount,
|
||||
total: normalizedAddModels.length,
|
||||
})}
|
||||
</span>
|
||||
<span>
|
||||
{t('删除已选 {{selected}} / {{total}}', {
|
||||
selected: selectedRemoveCount,
|
||||
total: normalizedRemoveModels.length,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
prefix={<IconSearch size={14} />}
|
||||
placeholder={t('搜索模型')}
|
||||
value={keyword}
|
||||
onChange={(value) => setKeyword(value)}
|
||||
showClear
|
||||
/>
|
||||
|
||||
<div style={{ maxHeight: 320, overflowY: 'auto', paddingRight: 8 }}>
|
||||
{currentModels.length === 0 ? (
|
||||
<Empty
|
||||
image={
|
||||
<IllustrationNoResult style={{ width: 150, height: 150 }} />
|
||||
}
|
||||
darkModeImage={
|
||||
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
|
||||
}
|
||||
description={t('暂无匹配模型')}
|
||||
style={{ padding: 24 }}
|
||||
/>
|
||||
) : (
|
||||
<Checkbox.Group
|
||||
value={currentSelectedModels}
|
||||
onChange={(values) =>
|
||||
currentSetSelectedModels(normalizeModels(values))
|
||||
}
|
||||
>
|
||||
<div className='grid grid-cols-1 md:grid-cols-2 gap-x-4'>
|
||||
{currentModels.map((model) => (
|
||||
<Checkbox
|
||||
key={`${activeTab}:${model}`}
|
||||
value={model}
|
||||
className='my-1'
|
||||
>
|
||||
{model}
|
||||
</Checkbox>
|
||||
))}
|
||||
</div>
|
||||
</Checkbox.Group>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-end gap-2'>
|
||||
<Typography.Text type='secondary' size='small'>
|
||||
{t('已选择 {{selected}} / {{total}}', {
|
||||
selected: checkedCount,
|
||||
total: currentModels.length,
|
||||
})}
|
||||
</Typography.Text>
|
||||
<Checkbox
|
||||
checked={isAllChecked}
|
||||
indeterminate={isIndeterminate}
|
||||
aria-label={t('全选当前列表模型')}
|
||||
onChange={(e) => handleToggleAllCurrent(e.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelUpstreamUpdateModal;
|
||||
172
web/src/components/table/channels/modals/CodexOAuthModal.jsx
Normal file
172
web/src/components/table/channels/modals/CodexOAuthModal.jsx
Normal file
@@ -0,0 +1,172 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Modal,
|
||||
Button,
|
||||
Space,
|
||||
Typography,
|
||||
Input,
|
||||
Banner,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { API, copy, showError, showSuccess } from '../../../../helpers';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const CodexOAuthModal = ({ visible, onCancel, onSuccess }) => {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [authorizeUrl, setAuthorizeUrl] = useState('');
|
||||
const [input, setInput] = useState('');
|
||||
|
||||
const startOAuth = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.post(
|
||||
'/api/channel/codex/oauth/start',
|
||||
{},
|
||||
{ skipErrorHandler: true },
|
||||
);
|
||||
if (!res?.data?.success) {
|
||||
console.error('Codex OAuth start failed:', res?.data?.message);
|
||||
throw new Error(t('启动授权失败'));
|
||||
}
|
||||
const url = res?.data?.data?.authorize_url || '';
|
||||
if (!url) {
|
||||
console.error(
|
||||
'Codex OAuth start response missing authorize_url:',
|
||||
res?.data,
|
||||
);
|
||||
throw new Error(t('响应缺少授权链接'));
|
||||
}
|
||||
setAuthorizeUrl(url);
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
showSuccess(t('已打开授权页面'));
|
||||
} catch (error) {
|
||||
showError(error?.message || t('启动授权失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const completeOAuth = async () => {
|
||||
if (!input || !input.trim()) {
|
||||
showError(t('请先粘贴回调 URL'));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.post(
|
||||
'/api/channel/codex/oauth/complete',
|
||||
{ input },
|
||||
{ skipErrorHandler: true },
|
||||
);
|
||||
if (!res?.data?.success) {
|
||||
console.error('Codex OAuth complete failed:', res?.data?.message);
|
||||
throw new Error(t('授权失败'));
|
||||
}
|
||||
|
||||
const key = res?.data?.data?.key || '';
|
||||
if (!key) {
|
||||
console.error('Codex OAuth complete response missing key:', res?.data);
|
||||
throw new Error(t('响应缺少凭据'));
|
||||
}
|
||||
|
||||
onSuccess && onSuccess(key);
|
||||
showSuccess(t('已生成授权凭据'));
|
||||
onCancel && onCancel();
|
||||
} catch (error) {
|
||||
showError(error?.message || t('授权失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
setAuthorizeUrl('');
|
||||
setInput('');
|
||||
}, [visible]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('Codex 授权')}
|
||||
visible={visible}
|
||||
onCancel={onCancel}
|
||||
maskClosable={false}
|
||||
closeOnEsc
|
||||
width={720}
|
||||
footer={
|
||||
<Space>
|
||||
<Button theme='borderless' onClick={onCancel} disabled={loading}>
|
||||
{t('取消')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='solid'
|
||||
type='primary'
|
||||
onClick={completeOAuth}
|
||||
loading={loading}
|
||||
>
|
||||
{t('生成并填入')}
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Space vertical spacing='tight' style={{ width: '100%' }}>
|
||||
<Banner
|
||||
type='info'
|
||||
description={t(
|
||||
'1) 点击「打开授权页面」完成登录;2) 浏览器会跳转到 localhost(页面打不开也没关系);3) 复制地址栏完整 URL 粘贴到下方;4) 点击「生成并填入」。',
|
||||
)}
|
||||
/>
|
||||
|
||||
<Space wrap>
|
||||
<Button type='primary' onClick={startOAuth} loading={loading}>
|
||||
{t('打开授权页面')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='outline'
|
||||
disabled={!authorizeUrl || loading}
|
||||
onClick={() => copy(authorizeUrl)}
|
||||
>
|
||||
{t('复制授权链接')}
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Input
|
||||
value={input}
|
||||
onChange={(value) => setInput(value)}
|
||||
placeholder={t('请粘贴完整回调 URL(包含 code 与 state)')}
|
||||
showClear
|
||||
/>
|
||||
|
||||
<Text type='tertiary' size='small'>
|
||||
{t(
|
||||
'说明:生成结果是可直接粘贴到渠道密钥里的 JSON(包含 access_token / refresh_token / account_id)。',
|
||||
)}
|
||||
</Text>
|
||||
</Space>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CodexOAuthModal;
|
||||
514
web/src/components/table/channels/modals/CodexUsageModal.jsx
Normal file
514
web/src/components/table/channels/modals/CodexUsageModal.jsx
Normal file
@@ -0,0 +1,514 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Button,
|
||||
Progress,
|
||||
Typography,
|
||||
Spin,
|
||||
Tag,
|
||||
Descriptions,
|
||||
Collapse,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { API, showError } from '../../../../helpers';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const clampPercent = (value) => {
|
||||
const v = Number(value);
|
||||
if (!Number.isFinite(v)) return 0;
|
||||
return Math.max(0, Math.min(100, v));
|
||||
};
|
||||
|
||||
const pickStrokeColor = (percent) => {
|
||||
const p = clampPercent(percent);
|
||||
if (p >= 95) return '#ef4444';
|
||||
if (p >= 80) return '#f59e0b';
|
||||
return '#3b82f6';
|
||||
};
|
||||
|
||||
const normalizePlanType = (value) => {
|
||||
if (value == null) return '';
|
||||
return String(value).trim().toLowerCase();
|
||||
};
|
||||
|
||||
const getWindowDurationSeconds = (windowData) => {
|
||||
const value = Number(windowData?.limit_window_seconds);
|
||||
if (!Number.isFinite(value) || value <= 0) return null;
|
||||
return value;
|
||||
};
|
||||
|
||||
const classifyWindowByDuration = (windowData) => {
|
||||
const seconds = getWindowDurationSeconds(windowData);
|
||||
if (seconds == null) return null;
|
||||
return seconds >= 24 * 60 * 60 ? 'weekly' : 'fiveHour';
|
||||
};
|
||||
|
||||
const resolveRateLimitWindows = (data) => {
|
||||
const rateLimit = data?.rate_limit ?? {};
|
||||
const primary = rateLimit?.primary_window ?? null;
|
||||
const secondary = rateLimit?.secondary_window ?? null;
|
||||
const windows = [primary, secondary].filter(Boolean);
|
||||
const planType = normalizePlanType(data?.plan_type ?? rateLimit?.plan_type);
|
||||
|
||||
let fiveHourWindow = null;
|
||||
let weeklyWindow = null;
|
||||
|
||||
for (const windowData of windows) {
|
||||
const bucket = classifyWindowByDuration(windowData);
|
||||
if (bucket === 'fiveHour' && !fiveHourWindow) {
|
||||
fiveHourWindow = windowData;
|
||||
continue;
|
||||
}
|
||||
if (bucket === 'weekly' && !weeklyWindow) {
|
||||
weeklyWindow = windowData;
|
||||
}
|
||||
}
|
||||
|
||||
if (planType === 'free') {
|
||||
if (!weeklyWindow) {
|
||||
weeklyWindow = primary ?? secondary ?? null;
|
||||
}
|
||||
return { fiveHourWindow: null, weeklyWindow };
|
||||
}
|
||||
|
||||
if (!fiveHourWindow && !weeklyWindow) {
|
||||
return {
|
||||
fiveHourWindow: primary ?? null,
|
||||
weeklyWindow: secondary ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
if (!fiveHourWindow) {
|
||||
fiveHourWindow = windows.find((windowData) => windowData !== weeklyWindow) ?? null;
|
||||
}
|
||||
if (!weeklyWindow) {
|
||||
weeklyWindow = windows.find((windowData) => windowData !== fiveHourWindow) ?? null;
|
||||
}
|
||||
|
||||
return { fiveHourWindow, weeklyWindow };
|
||||
};
|
||||
|
||||
const formatDurationSeconds = (seconds, t) => {
|
||||
const tt = typeof t === 'function' ? t : (v) => v;
|
||||
const s = Number(seconds);
|
||||
if (!Number.isFinite(s) || s <= 0) return '-';
|
||||
const total = Math.floor(s);
|
||||
const hours = Math.floor(total / 3600);
|
||||
const minutes = Math.floor((total % 3600) / 60);
|
||||
const secs = total % 60;
|
||||
if (hours > 0) return `${hours}${tt('小时')} ${minutes}${tt('分钟')}`;
|
||||
if (minutes > 0) return `${minutes}${tt('分钟')} ${secs}${tt('秒')}`;
|
||||
return `${secs}${tt('秒')}`;
|
||||
};
|
||||
|
||||
const formatUnixSeconds = (unixSeconds) => {
|
||||
const v = Number(unixSeconds);
|
||||
if (!Number.isFinite(v) || v <= 0) return '-';
|
||||
try {
|
||||
return new Date(v * 1000).toLocaleString();
|
||||
} catch (error) {
|
||||
return String(unixSeconds);
|
||||
}
|
||||
};
|
||||
|
||||
const getDisplayText = (value) => {
|
||||
if (value == null) return '';
|
||||
return String(value).trim();
|
||||
};
|
||||
|
||||
const formatAccountTypeLabel = (value, t) => {
|
||||
const tt = typeof t === 'function' ? t : (v) => v;
|
||||
const normalized = normalizePlanType(value);
|
||||
switch (normalized) {
|
||||
case 'free':
|
||||
return 'Free';
|
||||
case 'plus':
|
||||
return 'Plus';
|
||||
case 'pro':
|
||||
return 'Pro';
|
||||
case 'team':
|
||||
return 'Team';
|
||||
case 'enterprise':
|
||||
return 'Enterprise';
|
||||
default:
|
||||
return getDisplayText(value) || tt('未识别');
|
||||
}
|
||||
};
|
||||
|
||||
const getAccountTypeTagColor = (value) => {
|
||||
const normalized = normalizePlanType(value);
|
||||
switch (normalized) {
|
||||
case 'enterprise':
|
||||
return 'green';
|
||||
case 'team':
|
||||
return 'cyan';
|
||||
case 'pro':
|
||||
return 'blue';
|
||||
case 'plus':
|
||||
return 'violet';
|
||||
case 'free':
|
||||
return 'amber';
|
||||
default:
|
||||
return 'grey';
|
||||
}
|
||||
};
|
||||
|
||||
const resolveUsageStatusTag = (t, rateLimit) => {
|
||||
const tt = typeof t === 'function' ? t : (v) => v;
|
||||
if (!rateLimit || Object.keys(rateLimit).length === 0) {
|
||||
return <Tag color='grey'>{tt('待确认')}</Tag>;
|
||||
}
|
||||
if (rateLimit?.allowed && !rateLimit?.limit_reached) {
|
||||
return <Tag color='green'>{tt('可用')}</Tag>;
|
||||
}
|
||||
return <Tag color='red'>{tt('受限')}</Tag>;
|
||||
};
|
||||
|
||||
const AccountInfoValue = ({ t, value, onCopy, monospace = false }) => {
|
||||
const tt = typeof t === 'function' ? t : (v) => v;
|
||||
const text = getDisplayText(value);
|
||||
const hasValue = text !== '';
|
||||
|
||||
return (
|
||||
<div className='flex min-w-0 items-start justify-between gap-2'>
|
||||
<div
|
||||
className={`min-w-0 flex-1 break-all text-xs leading-5 text-semi-color-text-1 ${
|
||||
monospace ? 'font-mono' : ''
|
||||
}`}
|
||||
>
|
||||
{hasValue ? text : '-'}
|
||||
</div>
|
||||
<Button
|
||||
size='small'
|
||||
type='tertiary'
|
||||
theme='borderless'
|
||||
className='shrink-0 px-1 text-xs'
|
||||
disabled={!hasValue}
|
||||
onClick={() => onCopy?.(text)}
|
||||
>
|
||||
{tt('复制')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const RateLimitWindowCard = ({ t, title, windowData }) => {
|
||||
const tt = typeof t === 'function' ? t : (v) => v;
|
||||
const hasWindowData =
|
||||
!!windowData &&
|
||||
typeof windowData === 'object' &&
|
||||
Object.keys(windowData).length > 0;
|
||||
const percent = clampPercent(windowData?.used_percent ?? 0);
|
||||
const resetAt = windowData?.reset_at;
|
||||
const resetAfterSeconds = windowData?.reset_after_seconds;
|
||||
const limitWindowSeconds = windowData?.limit_window_seconds;
|
||||
|
||||
return (
|
||||
<div className='rounded-lg border border-semi-color-border bg-semi-color-bg-0 p-3'>
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
<div className='font-medium'>{title}</div>
|
||||
<Text type='tertiary' size='small'>
|
||||
{tt('重置时间:')}
|
||||
{formatUnixSeconds(resetAt)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{hasWindowData ? (
|
||||
<div className='mt-2'>
|
||||
<Progress
|
||||
percent={percent}
|
||||
stroke={pickStrokeColor(percent)}
|
||||
showInfo={true}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className='mt-3 text-sm text-semi-color-text-2'>-</div>
|
||||
)}
|
||||
|
||||
<div className='mt-1 flex flex-wrap items-center gap-2 text-xs text-semi-color-text-2'>
|
||||
<div>
|
||||
{tt('已使用:')}
|
||||
{hasWindowData ? `${percent}%` : '-'}
|
||||
</div>
|
||||
<div>
|
||||
{tt('距离重置:')}
|
||||
{hasWindowData ? formatDurationSeconds(resetAfterSeconds, tt) : '-'}
|
||||
</div>
|
||||
<div>
|
||||
{tt('窗口:')}
|
||||
{hasWindowData ? formatDurationSeconds(limitWindowSeconds, tt) : '-'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CodexUsageView = ({ t, record, payload, onCopy, onRefresh }) => {
|
||||
const tt = typeof t === 'function' ? t : (v) => v;
|
||||
const [showRawJson, setShowRawJson] = useState(false);
|
||||
const data = payload?.data ?? null;
|
||||
const rateLimit = data?.rate_limit ?? {};
|
||||
const { fiveHourWindow, weeklyWindow } = resolveRateLimitWindows(data);
|
||||
const upstreamStatus = payload?.upstream_status;
|
||||
const accountType = data?.plan_type ?? rateLimit?.plan_type;
|
||||
const accountTypeLabel = formatAccountTypeLabel(accountType, tt);
|
||||
const accountTypeTagColor = getAccountTypeTagColor(accountType);
|
||||
const statusTag = resolveUsageStatusTag(tt, rateLimit);
|
||||
const userId = data?.user_id;
|
||||
const email = data?.email;
|
||||
const accountId = data?.account_id;
|
||||
const errorMessage =
|
||||
payload?.success === false ? getDisplayText(payload?.message) || tt('获取用量失败') : '';
|
||||
|
||||
const rawText =
|
||||
typeof data === 'string' ? data : JSON.stringify(data ?? payload, null, 2);
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-4'>
|
||||
{errorMessage && (
|
||||
<div className='rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700'>
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='rounded-xl border border-semi-color-border bg-semi-color-bg-0 p-3'>
|
||||
<div className='flex flex-wrap items-start justify-between gap-2'>
|
||||
<div className='min-w-0'>
|
||||
<div className='text-xs font-medium text-semi-color-text-2'>
|
||||
{tt('Codex 帐号')}
|
||||
</div>
|
||||
<div className='mt-2 flex flex-wrap items-center gap-2'>
|
||||
<Tag
|
||||
color={accountTypeTagColor}
|
||||
type='light'
|
||||
shape='circle'
|
||||
size='large'
|
||||
className='font-semibold'
|
||||
>
|
||||
{accountTypeLabel}
|
||||
</Tag>
|
||||
{statusTag}
|
||||
<Tag color='grey' type='light' shape='circle'>
|
||||
{tt('上游状态码:')}
|
||||
{upstreamStatus ?? '-'}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
<Button size='small' type='tertiary' theme='outline' onClick={onRefresh}>
|
||||
{tt('刷新')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='mt-2 rounded-lg bg-semi-color-fill-0 px-3 py-2'>
|
||||
<Descriptions>
|
||||
<Descriptions.Item itemKey='User ID'>
|
||||
<AccountInfoValue
|
||||
t={tt}
|
||||
value={userId}
|
||||
onCopy={onCopy}
|
||||
monospace={true}
|
||||
/>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item itemKey={tt('邮箱')}>
|
||||
<AccountInfoValue t={tt} value={email} onCopy={onCopy} />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item itemKey='Account ID'>
|
||||
<AccountInfoValue
|
||||
t={tt}
|
||||
value={accountId}
|
||||
onCopy={onCopy}
|
||||
monospace={true}
|
||||
/>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</div>
|
||||
|
||||
<div className='mt-2 text-xs text-semi-color-text-2'>
|
||||
{tt('渠道:')}
|
||||
{record?.name || '-'} ({tt('编号:')}
|
||||
{record?.id || '-'})
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className='mb-2'>
|
||||
<div className='text-sm font-semibold text-semi-color-text-0'>
|
||||
{tt('额度窗口')}
|
||||
</div>
|
||||
<Text type='tertiary' size='small'>
|
||||
{tt('用于观察当前帐号在 Codex 上游的限额使用情况')}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 gap-3 md:grid-cols-2'>
|
||||
<RateLimitWindowCard
|
||||
t={tt}
|
||||
title={tt('5小时窗口')}
|
||||
windowData={fiveHourWindow}
|
||||
/>
|
||||
<RateLimitWindowCard
|
||||
t={tt}
|
||||
title={tt('每周窗口')}
|
||||
windowData={weeklyWindow}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Collapse
|
||||
activeKey={showRawJson ? ['raw-json'] : []}
|
||||
onChange={(activeKey) => {
|
||||
const keys = Array.isArray(activeKey) ? activeKey : [activeKey];
|
||||
setShowRawJson(keys.includes('raw-json'));
|
||||
}}
|
||||
>
|
||||
<Collapse.Panel header={tt('原始 JSON')} itemKey='raw-json'>
|
||||
<div className='mb-2 flex justify-end'>
|
||||
<Button
|
||||
size='small'
|
||||
type='primary'
|
||||
theme='outline'
|
||||
onClick={() => onCopy?.(rawText)}
|
||||
disabled={!rawText}
|
||||
>
|
||||
{tt('复制')}
|
||||
</Button>
|
||||
</div>
|
||||
<pre className='max-h-[50vh] overflow-y-auto rounded-lg bg-semi-color-fill-0 p-3 text-xs text-semi-color-text-0'>
|
||||
{rawText}
|
||||
</pre>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CodexUsageLoader = ({ t, record, initialPayload, onCopy }) => {
|
||||
const tt = typeof t === 'function' ? t : (v) => v;
|
||||
const [loading, setLoading] = useState(!initialPayload);
|
||||
const [payload, setPayload] = useState(initialPayload ?? null);
|
||||
const hasShownErrorRef = useRef(false);
|
||||
const mountedRef = useRef(true);
|
||||
const recordId = record?.id;
|
||||
|
||||
const fetchUsage = useCallback(async () => {
|
||||
if (!recordId) {
|
||||
if (mountedRef.current) setPayload(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (mountedRef.current) setLoading(true);
|
||||
try {
|
||||
const res = await API.get(`/api/channel/${recordId}/codex/usage`, {
|
||||
skipErrorHandler: true,
|
||||
});
|
||||
if (!mountedRef.current) return;
|
||||
setPayload(res?.data ?? null);
|
||||
if (!res?.data?.success && !hasShownErrorRef.current) {
|
||||
hasShownErrorRef.current = true;
|
||||
showError(tt('获取用量失败'));
|
||||
}
|
||||
} catch (error) {
|
||||
if (!mountedRef.current) return;
|
||||
if (!hasShownErrorRef.current) {
|
||||
hasShownErrorRef.current = true;
|
||||
showError(tt('获取用量失败'));
|
||||
}
|
||||
setPayload({ success: false, message: String(error) });
|
||||
} finally {
|
||||
if (mountedRef.current) setLoading(false);
|
||||
}
|
||||
}, [recordId, tt]);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialPayload) return;
|
||||
fetchUsage().catch(() => {});
|
||||
}, [fetchUsage, initialPayload]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className='flex items-center justify-center py-10'>
|
||||
<Spin spinning={true} size='large' tip={tt('加载中...')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!payload) {
|
||||
return (
|
||||
<div className='flex flex-col gap-3'>
|
||||
<Text type='danger'>{tt('获取用量失败')}</Text>
|
||||
<div className='flex justify-end'>
|
||||
<Button
|
||||
size='small'
|
||||
type='primary'
|
||||
theme='outline'
|
||||
onClick={fetchUsage}
|
||||
>
|
||||
{tt('刷新')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CodexUsageView
|
||||
t={tt}
|
||||
record={record}
|
||||
payload={payload}
|
||||
onCopy={onCopy}
|
||||
onRefresh={fetchUsage}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const openCodexUsageModal = ({ t, record, payload, onCopy }) => {
|
||||
const tt = typeof t === 'function' ? t : (v) => v;
|
||||
|
||||
Modal.info({
|
||||
title: tt('Codex 帐号与用量'),
|
||||
centered: true,
|
||||
width: 900,
|
||||
style: { maxWidth: '95vw' },
|
||||
content: (
|
||||
<CodexUsageLoader
|
||||
t={tt}
|
||||
record={record}
|
||||
initialPayload={payload}
|
||||
onCopy={onCopy}
|
||||
/>
|
||||
),
|
||||
footer: (
|
||||
<div className='flex justify-end gap-2'>
|
||||
<Button type='primary' theme='solid' onClick={() => Modal.destroyAll()}>
|
||||
{tt('关闭')}
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
};
|
||||
128
web/src/components/table/channels/modals/ColumnSelectorModal.jsx
Normal file
128
web/src/components/table/channels/modals/ColumnSelectorModal.jsx
Normal file
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Modal, Button, Checkbox } from '@douyinfe/semi-ui';
|
||||
import { getChannelsColumns } from '../ChannelsColumnDefs';
|
||||
|
||||
const ColumnSelectorModal = ({
|
||||
showColumnSelector,
|
||||
setShowColumnSelector,
|
||||
visibleColumns,
|
||||
handleColumnVisibilityChange,
|
||||
handleSelectAll,
|
||||
initDefaultColumns,
|
||||
COLUMN_KEYS,
|
||||
t,
|
||||
// Props needed for getChannelsColumns
|
||||
updateChannelBalance,
|
||||
manageChannel,
|
||||
manageTag,
|
||||
submitTagEdit,
|
||||
testChannel,
|
||||
setCurrentTestChannel,
|
||||
setShowModelTestModal,
|
||||
setEditingChannel,
|
||||
setShowEdit,
|
||||
setShowEditTag,
|
||||
setEditingTag,
|
||||
copySelectedChannel,
|
||||
refresh,
|
||||
activePage,
|
||||
channels,
|
||||
}) => {
|
||||
// Get all columns for display in selector
|
||||
const allColumns = getChannelsColumns({
|
||||
t,
|
||||
COLUMN_KEYS,
|
||||
updateChannelBalance,
|
||||
manageChannel,
|
||||
manageTag,
|
||||
submitTagEdit,
|
||||
testChannel,
|
||||
setCurrentTestChannel,
|
||||
setShowModelTestModal,
|
||||
setEditingChannel,
|
||||
setShowEdit,
|
||||
setShowEditTag,
|
||||
setEditingTag,
|
||||
copySelectedChannel,
|
||||
refresh,
|
||||
activePage,
|
||||
channels,
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('列设置')}
|
||||
visible={showColumnSelector}
|
||||
onCancel={() => setShowColumnSelector(false)}
|
||||
footer={
|
||||
<div className='flex justify-end'>
|
||||
<Button onClick={() => initDefaultColumns()}>{t('重置')}</Button>
|
||||
<Button onClick={() => setShowColumnSelector(false)}>
|
||||
{t('取消')}
|
||||
</Button>
|
||||
<Button onClick={() => setShowColumnSelector(false)}>
|
||||
{t('确定')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<Checkbox
|
||||
checked={Object.values(visibleColumns).every((v) => v === true)}
|
||||
indeterminate={
|
||||
Object.values(visibleColumns).some((v) => v === true) &&
|
||||
!Object.values(visibleColumns).every((v) => v === true)
|
||||
}
|
||||
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||
>
|
||||
{t('全选')}
|
||||
</Checkbox>
|
||||
</div>
|
||||
<div
|
||||
className='flex flex-wrap max-h-96 overflow-y-auto rounded-lg p-4'
|
||||
style={{ border: '1px solid var(--semi-color-border)' }}
|
||||
>
|
||||
{allColumns.map((column) => {
|
||||
// Skip columns without title
|
||||
if (!column.title) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={column.key} className='w-1/2 mb-4 pr-2'>
|
||||
<Checkbox
|
||||
checked={!!visibleColumns[column.key]}
|
||||
onChange={(e) =>
|
||||
handleColumnVisibilityChange(column.key, e.target.checked)
|
||||
}
|
||||
>
|
||||
{column.title}
|
||||
</Checkbox>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ColumnSelectorModal;
|
||||
4123
web/src/components/table/channels/modals/EditChannelModal.jsx
Normal file
4123
web/src/components/table/channels/modals/EditChannelModal.jsx
Normal file
File diff suppressed because it is too large
Load Diff
754
web/src/components/table/channels/modals/EditTagModal.jsx
Normal file
754
web/src/components/table/channels/modals/EditTagModal.jsx
Normal file
@@ -0,0 +1,754 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import {
|
||||
API,
|
||||
showError,
|
||||
showInfo,
|
||||
showSuccess,
|
||||
showWarning,
|
||||
verifyJSON,
|
||||
selectFilter,
|
||||
} from '../../../../helpers';
|
||||
import {
|
||||
SideSheet,
|
||||
Space,
|
||||
Button,
|
||||
Typography,
|
||||
Spin,
|
||||
Banner,
|
||||
Card,
|
||||
Tag,
|
||||
Avatar,
|
||||
Form,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconSave,
|
||||
IconClose,
|
||||
IconBookmark,
|
||||
IconUser,
|
||||
IconCode,
|
||||
IconSetting,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { getChannelModels } from '../../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
const MODEL_MAPPING_EXAMPLE = {
|
||||
'gpt-3.5-turbo': 'gpt-3.5-turbo-0125',
|
||||
};
|
||||
|
||||
const EditTagModal = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const { visible, tag, handleClose, refresh } = props;
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [originModelOptions, setOriginModelOptions] = useState([]);
|
||||
const [modelOptions, setModelOptions] = useState([]);
|
||||
const [groupOptions, setGroupOptions] = useState([]);
|
||||
const [customModel, setCustomModel] = useState('');
|
||||
const [modelSearchValue, setModelSearchValue] = useState('');
|
||||
const originInputs = {
|
||||
tag: '',
|
||||
new_tag: null,
|
||||
model_mapping: null,
|
||||
groups: [],
|
||||
models: [],
|
||||
param_override: null,
|
||||
header_override: null,
|
||||
};
|
||||
const [inputs, setInputs] = useState(originInputs);
|
||||
const modelSearchMatchedCount = useMemo(() => {
|
||||
const keyword = modelSearchValue.trim();
|
||||
if (!keyword) {
|
||||
return modelOptions.length;
|
||||
}
|
||||
return modelOptions.reduce(
|
||||
(count, option) => count + (selectFilter(keyword, option) ? 1 : 0),
|
||||
0,
|
||||
);
|
||||
}, [modelOptions, modelSearchValue]);
|
||||
const modelSearchHintText = useMemo(() => {
|
||||
const keyword = modelSearchValue.trim();
|
||||
if (!keyword || modelSearchMatchedCount !== 0) {
|
||||
return '';
|
||||
}
|
||||
return t('未匹配到模型,按回车键可将「{{name}}」作为自定义模型名添加', {
|
||||
name: keyword,
|
||||
});
|
||||
}, [modelSearchMatchedCount, modelSearchValue, t]);
|
||||
const formApiRef = useRef(null);
|
||||
const getInitValues = () => ({ ...originInputs });
|
||||
|
||||
const handleInputChange = (name, value) => {
|
||||
setInputs((inputs) => ({ ...inputs, [name]: value }));
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValue(name, value);
|
||||
}
|
||||
if (name === 'type') {
|
||||
let localModels = [];
|
||||
switch (value) {
|
||||
case 2:
|
||||
localModels = [
|
||||
'mj_imagine',
|
||||
'mj_variation',
|
||||
'mj_reroll',
|
||||
'mj_blend',
|
||||
'mj_upscale',
|
||||
'mj_describe',
|
||||
'mj_uploads',
|
||||
];
|
||||
break;
|
||||
case 5:
|
||||
localModels = [
|
||||
'swap_face',
|
||||
'mj_imagine',
|
||||
'mj_video',
|
||||
'mj_edits',
|
||||
'mj_variation',
|
||||
'mj_reroll',
|
||||
'mj_blend',
|
||||
'mj_upscale',
|
||||
'mj_describe',
|
||||
'mj_zoom',
|
||||
'mj_shorten',
|
||||
'mj_modal',
|
||||
'mj_inpaint',
|
||||
'mj_custom_zoom',
|
||||
'mj_high_variation',
|
||||
'mj_low_variation',
|
||||
'mj_pan',
|
||||
'mj_uploads',
|
||||
];
|
||||
break;
|
||||
case 36:
|
||||
localModels = ['suno_music', 'suno_lyrics'];
|
||||
break;
|
||||
case 53:
|
||||
localModels = [
|
||||
'NousResearch/Hermes-4-405B-FP8',
|
||||
'Qwen/Qwen3-235B-A22B-Thinking-2507',
|
||||
'Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8',
|
||||
'Qwen/Qwen3-235B-A22B-Instruct-2507',
|
||||
'zai-org/GLM-4.5-FP8',
|
||||
'openai/gpt-oss-120b',
|
||||
'deepseek-ai/DeepSeek-R1-0528',
|
||||
'deepseek-ai/DeepSeek-R1',
|
||||
'deepseek-ai/DeepSeek-V3-0324',
|
||||
'deepseek-ai/DeepSeek-V3.1',
|
||||
];
|
||||
break;
|
||||
default:
|
||||
localModels = getChannelModels(value);
|
||||
break;
|
||||
}
|
||||
if (inputs.models.length === 0) {
|
||||
setInputs((inputs) => ({ ...inputs, models: localModels }));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const fetchModels = async () => {
|
||||
try {
|
||||
let res = await API.get(`/api/channel/models`);
|
||||
let localModelOptions = res.data.data.map((model) => ({
|
||||
label: model.id,
|
||||
value: model.id,
|
||||
}));
|
||||
setOriginModelOptions(localModelOptions);
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchGroups = async () => {
|
||||
try {
|
||||
let res = await API.get(`/api/group/`);
|
||||
if (res === undefined) {
|
||||
return;
|
||||
}
|
||||
setGroupOptions(
|
||||
res.data.data.map((group) => ({
|
||||
label: group,
|
||||
value: group,
|
||||
})),
|
||||
);
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async (values) => {
|
||||
setLoading(true);
|
||||
const formVals = values || formApiRef.current?.getValues() || {};
|
||||
let data = { tag };
|
||||
if (formVals.model_mapping) {
|
||||
if (!verifyJSON(formVals.model_mapping)) {
|
||||
showInfo('模型映射必须是合法的 JSON 格式!');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
data.model_mapping = formVals.model_mapping;
|
||||
}
|
||||
if (formVals.groups && formVals.groups.length > 0) {
|
||||
data.groups = formVals.groups.join(',');
|
||||
}
|
||||
if (formVals.models && formVals.models.length > 0) {
|
||||
data.models = formVals.models.join(',');
|
||||
}
|
||||
if (
|
||||
formVals.param_override !== undefined &&
|
||||
formVals.param_override !== null
|
||||
) {
|
||||
if (typeof formVals.param_override !== 'string') {
|
||||
showInfo('参数覆盖必须是合法的 JSON 格式!');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const trimmedParamOverride = formVals.param_override.trim();
|
||||
if (trimmedParamOverride !== '' && !verifyJSON(trimmedParamOverride)) {
|
||||
showInfo('参数覆盖必须是合法的 JSON 格式!');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
data.param_override = trimmedParamOverride;
|
||||
}
|
||||
if (
|
||||
formVals.header_override !== undefined &&
|
||||
formVals.header_override !== null
|
||||
) {
|
||||
if (typeof formVals.header_override !== 'string') {
|
||||
showInfo('请求头覆盖必须是合法的 JSON 格式!');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const trimmedHeaderOverride = formVals.header_override.trim();
|
||||
if (trimmedHeaderOverride !== '' && !verifyJSON(trimmedHeaderOverride)) {
|
||||
showInfo('请求头覆盖必须是合法的 JSON 格式!');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
data.header_override = trimmedHeaderOverride;
|
||||
}
|
||||
data.new_tag = formVals.new_tag;
|
||||
if (
|
||||
data.model_mapping === undefined &&
|
||||
data.groups === undefined &&
|
||||
data.models === undefined &&
|
||||
data.new_tag === undefined &&
|
||||
data.param_override === undefined &&
|
||||
data.header_override === undefined
|
||||
) {
|
||||
showWarning('没有任何修改!');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
await submit(data);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const submit = async (data) => {
|
||||
try {
|
||||
const res = await API.put('/api/channel/tag', data);
|
||||
if (res?.data?.success) {
|
||||
showSuccess('标签更新成功!');
|
||||
refresh();
|
||||
handleClose();
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let localModelOptions = [...originModelOptions];
|
||||
inputs.models.forEach((model) => {
|
||||
if (!localModelOptions.find((option) => option.label === model)) {
|
||||
localModelOptions.push({
|
||||
label: model,
|
||||
value: model,
|
||||
});
|
||||
}
|
||||
});
|
||||
setModelOptions(localModelOptions);
|
||||
}, [originModelOptions, inputs.models]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTagModels = async () => {
|
||||
if (!tag) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.get(`/api/channel/tag/models?tag=${tag}`);
|
||||
if (res?.data?.success) {
|
||||
const models = res.data.data ? res.data.data.split(',') : [];
|
||||
handleInputChange('models', models);
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchModels().then();
|
||||
fetchGroups().then();
|
||||
fetchTagModels().then();
|
||||
setModelSearchValue('');
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValues({
|
||||
...getInitValues(),
|
||||
tag: tag,
|
||||
new_tag: tag,
|
||||
});
|
||||
}
|
||||
|
||||
setInputs({
|
||||
...originInputs,
|
||||
tag: tag,
|
||||
new_tag: tag,
|
||||
});
|
||||
}, [visible, tag]);
|
||||
|
||||
useEffect(() => {
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValues(inputs);
|
||||
}
|
||||
}, [inputs]);
|
||||
|
||||
const addCustomModels = () => {
|
||||
if (customModel.trim() === '') return;
|
||||
const modelArray = customModel.split(',').map((model) => model.trim());
|
||||
|
||||
let localModels = [...inputs.models];
|
||||
let localModelOptions = [...modelOptions];
|
||||
const addedModels = [];
|
||||
|
||||
modelArray.forEach((model) => {
|
||||
if (model && !localModels.includes(model)) {
|
||||
localModels.push(model);
|
||||
localModelOptions.push({
|
||||
key: model,
|
||||
text: model,
|
||||
value: model,
|
||||
});
|
||||
addedModels.push(model);
|
||||
}
|
||||
});
|
||||
|
||||
setModelOptions(localModelOptions);
|
||||
setCustomModel('');
|
||||
handleInputChange('models', localModels);
|
||||
|
||||
if (addedModels.length > 0) {
|
||||
showSuccess(
|
||||
t('已新增 {{count}} 个模型:{{list}}', {
|
||||
count: addedModels.length,
|
||||
list: addedModels.join(', '),
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
showInfo(t('未发现新增模型'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SideSheet
|
||||
placement='right'
|
||||
title={
|
||||
<Space>
|
||||
<Tag color='blue' shape='circle'>
|
||||
{t('编辑')}
|
||||
</Tag>
|
||||
<Title heading={4} className='m-0'>
|
||||
{t('编辑标签')}
|
||||
</Title>
|
||||
</Space>
|
||||
}
|
||||
bodyStyle={{ padding: '0' }}
|
||||
visible={visible}
|
||||
width={600}
|
||||
onCancel={handleClose}
|
||||
footer={
|
||||
<div className='flex justify-end bg-white'>
|
||||
<Space>
|
||||
<Button
|
||||
theme='solid'
|
||||
onClick={() => formApiRef.current?.submitForm()}
|
||||
loading={loading}
|
||||
icon={<IconSave />}
|
||||
>
|
||||
{t('保存')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='light'
|
||||
type='primary'
|
||||
onClick={handleClose}
|
||||
icon={<IconClose />}
|
||||
>
|
||||
{t('取消')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
closeIcon={null}
|
||||
>
|
||||
<Form
|
||||
key={tag || 'edit'}
|
||||
initValues={getInitValues()}
|
||||
getFormApi={(api) => (formApiRef.current = api)}
|
||||
onSubmit={handleSave}
|
||||
>
|
||||
{() => (
|
||||
<Spin spinning={loading}>
|
||||
<div className='p-2'>
|
||||
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
|
||||
{/* Header: Tag Info */}
|
||||
<div className='flex items-center mb-2'>
|
||||
<Avatar size='small' color='blue' className='mr-2 shadow-md'>
|
||||
<IconBookmark size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className='text-lg font-medium'>{t('标签信息')}</Text>
|
||||
<div className='text-xs text-gray-600'>
|
||||
{t('标签的基本配置')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Banner
|
||||
type='warning'
|
||||
description={t('所有编辑均为覆盖操作,留空则不更改')}
|
||||
className='!rounded-lg mb-4'
|
||||
/>
|
||||
|
||||
<div className='space-y-4'>
|
||||
<Form.Input
|
||||
field='new_tag'
|
||||
label={t('标签名称')}
|
||||
placeholder={t('请输入新标签,留空则解散标签')}
|
||||
onChange={(value) => handleInputChange('new_tag', value)}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
|
||||
{/* Header: Model Config */}
|
||||
<div className='flex items-center mb-2'>
|
||||
<Avatar
|
||||
size='small'
|
||||
color='purple'
|
||||
className='mr-2 shadow-md'
|
||||
>
|
||||
<IconCode size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className='text-lg font-medium'>{t('模型配置')}</Text>
|
||||
<div className='text-xs text-gray-600'>
|
||||
{t('模型选择和映射设置')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-4'>
|
||||
<Banner
|
||||
type='info'
|
||||
description={t(
|
||||
'当前模型列表为该标签下所有渠道模型列表最长的一个,并非所有渠道的并集,请注意可能导致某些渠道模型丢失。',
|
||||
)}
|
||||
className='!rounded-lg mb-4'
|
||||
/>
|
||||
<Form.Select
|
||||
field='models'
|
||||
label={t('模型')}
|
||||
placeholder={t('请选择该渠道所支持的模型,留空则不更改')}
|
||||
multiple
|
||||
filter={selectFilter}
|
||||
allowCreate
|
||||
autoClearSearchValue={false}
|
||||
searchPosition='dropdown'
|
||||
optionList={modelOptions}
|
||||
onSearch={(value) => setModelSearchValue(value)}
|
||||
innerBottomSlot={
|
||||
modelSearchHintText ? (
|
||||
<Text className='px-3 py-2 block text-xs !text-semi-color-text-2'>
|
||||
{modelSearchHintText}
|
||||
</Text>
|
||||
) : null
|
||||
}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(value) => handleInputChange('models', value)}
|
||||
/>
|
||||
|
||||
<Form.Input
|
||||
field='custom_model'
|
||||
label={t('自定义模型名称')}
|
||||
placeholder={t('输入自定义模型名称')}
|
||||
onChange={(value) => setCustomModel(value.trim())}
|
||||
suffix={
|
||||
<Button
|
||||
size='small'
|
||||
type='primary'
|
||||
onClick={addCustomModels}
|
||||
>
|
||||
{t('填入')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Form.TextArea
|
||||
field='model_mapping'
|
||||
label={t('模型重定向')}
|
||||
placeholder={t(
|
||||
'此项可选,用于修改请求体中的模型名称,为一个 JSON 字符串,键为请求中模型名称,值为要替换的模型名称,留空则不更改',
|
||||
)}
|
||||
autosize
|
||||
onChange={(value) =>
|
||||
handleInputChange('model_mapping', value)
|
||||
}
|
||||
extraText={
|
||||
<Space>
|
||||
<Text
|
||||
className='!text-semi-color-primary cursor-pointer'
|
||||
onClick={() =>
|
||||
handleInputChange(
|
||||
'model_mapping',
|
||||
JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('填入模板')}
|
||||
</Text>
|
||||
<Text
|
||||
className='!text-semi-color-primary cursor-pointer'
|
||||
onClick={() =>
|
||||
handleInputChange(
|
||||
'model_mapping',
|
||||
JSON.stringify({}, null, 2),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('清空重定向')}
|
||||
</Text>
|
||||
<Text
|
||||
className='!text-semi-color-primary cursor-pointer'
|
||||
onClick={() => handleInputChange('model_mapping', '')}
|
||||
>
|
||||
{t('不更改')}
|
||||
</Text>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
|
||||
{/* Header: Advanced Settings */}
|
||||
<div className='flex items-center mb-2'>
|
||||
<Avatar
|
||||
size='small'
|
||||
color='orange'
|
||||
className='mr-2 shadow-md'
|
||||
>
|
||||
<IconSetting size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className='text-lg font-medium'>{t('高级设置')}</Text>
|
||||
<div className='text-xs text-gray-600'>
|
||||
{t('渠道的高级配置选项')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-4'>
|
||||
<Form.TextArea
|
||||
field='param_override'
|
||||
label={t('参数覆盖')}
|
||||
placeholder={
|
||||
t('此项可选,用于覆盖请求参数。不支持覆盖 stream 参数') +
|
||||
'\n' +
|
||||
t('旧格式(直接覆盖):') +
|
||||
'\n{\n "temperature": 0,\n "max_tokens": 1000\n}' +
|
||||
'\n\n' +
|
||||
t('新格式(支持条件判断与json自定义):') +
|
||||
'\n{\n "operations": [\n {\n "path": "temperature",\n "mode": "set",\n "value": 0.7,\n "conditions": [\n {\n "path": "model",\n "mode": "prefix",\n "value": "gpt"\n }\n ]\n }\n ]\n}'
|
||||
}
|
||||
autosize
|
||||
showClear
|
||||
onChange={(value) =>
|
||||
handleInputChange('param_override', value)
|
||||
}
|
||||
extraText={
|
||||
<div className='flex gap-2 flex-wrap'>
|
||||
<Text
|
||||
className='!text-semi-color-primary cursor-pointer'
|
||||
onClick={() =>
|
||||
handleInputChange(
|
||||
'param_override',
|
||||
JSON.stringify({ temperature: 0 }, null, 2),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('旧格式模板')}
|
||||
</Text>
|
||||
<Text
|
||||
className='!text-semi-color-primary cursor-pointer'
|
||||
onClick={() =>
|
||||
handleInputChange(
|
||||
'param_override',
|
||||
JSON.stringify(
|
||||
{
|
||||
operations: [
|
||||
{
|
||||
path: 'temperature',
|
||||
mode: 'set',
|
||||
value: 0.7,
|
||||
conditions: [
|
||||
{
|
||||
path: 'model',
|
||||
mode: 'prefix',
|
||||
value: 'gpt',
|
||||
},
|
||||
],
|
||||
logic: 'AND',
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('新格式模板')}
|
||||
</Text>
|
||||
<Text
|
||||
className='!text-semi-color-primary cursor-pointer'
|
||||
onClick={() =>
|
||||
handleInputChange('param_override', null)
|
||||
}
|
||||
>
|
||||
{t('不更改')}
|
||||
</Text>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<Form.TextArea
|
||||
field='header_override'
|
||||
label={t('请求头覆盖')}
|
||||
placeholder={
|
||||
t('此项可选,用于覆盖请求头参数') +
|
||||
'\n' +
|
||||
t('格式示例:') +
|
||||
'\n{\n "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36 Edg/139.0.0.0",\n "Authorization": "Bearer {api_key}"\n}'
|
||||
}
|
||||
autosize
|
||||
showClear
|
||||
onChange={(value) =>
|
||||
handleInputChange('header_override', value)
|
||||
}
|
||||
extraText={
|
||||
<div className='flex flex-col gap-1'>
|
||||
<div className='flex gap-2 flex-wrap items-center'>
|
||||
<Text
|
||||
className='!text-semi-color-primary cursor-pointer'
|
||||
onClick={() =>
|
||||
handleInputChange(
|
||||
'header_override',
|
||||
JSON.stringify(
|
||||
{
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36 Edg/139.0.0.0',
|
||||
Authorization: 'Bearer {api_key}',
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('填入模板')}
|
||||
</Text>
|
||||
<Text
|
||||
className='!text-semi-color-primary cursor-pointer'
|
||||
onClick={() =>
|
||||
handleInputChange('header_override', null)
|
||||
}
|
||||
>
|
||||
{t('不更改')}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text type='tertiary' size='small'>
|
||||
{t('支持变量:')}
|
||||
</Text>
|
||||
<div className='text-xs text-tertiary ml-2'>
|
||||
<div>
|
||||
{t('渠道密钥')}: {'{api_key}'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className='!rounded-2xl shadow-sm border-0'>
|
||||
{/* Header: Group Settings */}
|
||||
<div className='flex items-center mb-2'>
|
||||
<Avatar size='small' color='green' className='mr-2 shadow-md'>
|
||||
<IconUser size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className='text-lg font-medium'>{t('分组设置')}</Text>
|
||||
<div className='text-xs text-gray-600'>
|
||||
{t('用户分组配置')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-4'>
|
||||
<Form.Select
|
||||
field='groups'
|
||||
label={t('分组')}
|
||||
placeholder={t('请选择可以使用该渠道的分组,留空则不更改')}
|
||||
multiple
|
||||
allowAdditions
|
||||
additionLabel={t(
|
||||
'请在系统设置页面编辑分组倍率以添加新的分组:',
|
||||
)}
|
||||
optionList={groupOptions}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(value) => handleInputChange('groups', value)}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Spin>
|
||||
)}
|
||||
</Form>
|
||||
</SideSheet>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditTagModal;
|
||||
419
web/src/components/table/channels/modals/ModelSelectModal.jsx
Normal file
419
web/src/components/table/channels/modals/ModelSelectModal.jsx
Normal file
@@ -0,0 +1,419 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
||||
import {
|
||||
Modal,
|
||||
Checkbox,
|
||||
Spin,
|
||||
Input,
|
||||
Typography,
|
||||
Empty,
|
||||
Tabs,
|
||||
Collapse,
|
||||
Tooltip,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import { IconSearch, IconInfoCircle } from '@douyinfe/semi-icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getModelCategories } from '../../../../helpers/render';
|
||||
|
||||
const ModelSelectModal = ({
|
||||
visible,
|
||||
models = [],
|
||||
selected = [],
|
||||
redirectModels = [],
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const getModelName = (model) => {
|
||||
if (!model) return '';
|
||||
if (typeof model === 'string') return model;
|
||||
if (typeof model === 'object' && model.model_name) return model.model_name;
|
||||
return String(model ?? '');
|
||||
};
|
||||
|
||||
const normalizedSelected = useMemo(
|
||||
() => (selected || []).map(getModelName),
|
||||
[selected],
|
||||
);
|
||||
|
||||
const [checkedList, setCheckedList] = useState(normalizedSelected);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [activeTab, setActiveTab] = useState('new');
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
const normalizeModelName = (model) =>
|
||||
typeof model === 'string' ? model.trim() : '';
|
||||
const normalizedRedirectModels = useMemo(
|
||||
() =>
|
||||
Array.from(
|
||||
new Set(
|
||||
(redirectModels || [])
|
||||
.map((model) => normalizeModelName(model))
|
||||
.filter(Boolean),
|
||||
),
|
||||
),
|
||||
[redirectModels],
|
||||
);
|
||||
const normalizedSelectedSet = useMemo(() => {
|
||||
const set = new Set();
|
||||
(selected || []).forEach((model) => {
|
||||
const normalized = normalizeModelName(model);
|
||||
if (normalized) {
|
||||
set.add(normalized);
|
||||
}
|
||||
});
|
||||
return set;
|
||||
}, [selected]);
|
||||
const classificationSet = useMemo(() => {
|
||||
const set = new Set(normalizedSelectedSet);
|
||||
normalizedRedirectModels.forEach((model) => set.add(model));
|
||||
return set;
|
||||
}, [normalizedSelectedSet, normalizedRedirectModels]);
|
||||
const redirectOnlySet = useMemo(() => {
|
||||
const set = new Set();
|
||||
normalizedRedirectModels.forEach((model) => {
|
||||
if (!normalizedSelectedSet.has(model)) {
|
||||
set.add(model);
|
||||
}
|
||||
});
|
||||
return set;
|
||||
}, [normalizedRedirectModels, normalizedSelectedSet]);
|
||||
|
||||
const filteredModels = models.filter((m) =>
|
||||
String(m || '')
|
||||
.toLowerCase()
|
||||
.includes(keyword.toLowerCase()),
|
||||
);
|
||||
|
||||
// 分类模型:新获取的模型和已有模型
|
||||
const isExistingModel = (model) =>
|
||||
classificationSet.has(normalizeModelName(model));
|
||||
const newModels = filteredModels.filter((model) => !isExistingModel(model));
|
||||
const existingModels = filteredModels.filter((model) =>
|
||||
isExistingModel(model),
|
||||
);
|
||||
|
||||
// 同步外部选中值
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setCheckedList(normalizedSelected);
|
||||
}
|
||||
}, [visible, normalizedSelected]);
|
||||
|
||||
// 当模型列表变化时,设置默认tab
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
// 默认显示新获取模型tab,如果没有新模型则显示已有模型
|
||||
const hasNewModels = newModels.length > 0;
|
||||
setActiveTab(hasNewModels ? 'new' : 'existing');
|
||||
}
|
||||
}, [visible, newModels.length, selected]);
|
||||
|
||||
const handleOk = () => {
|
||||
onConfirm && onConfirm(checkedList);
|
||||
};
|
||||
|
||||
// 按厂商分类模型
|
||||
const categorizeModels = (models) => {
|
||||
const categories = getModelCategories(t);
|
||||
const categorizedModels = {};
|
||||
const uncategorizedModels = [];
|
||||
|
||||
models.forEach((model) => {
|
||||
let foundCategory = false;
|
||||
for (const [key, category] of Object.entries(categories)) {
|
||||
if (key !== 'all' && category.filter({ model_name: model })) {
|
||||
if (!categorizedModels[key]) {
|
||||
categorizedModels[key] = {
|
||||
label: category.label,
|
||||
icon: category.icon,
|
||||
models: [],
|
||||
};
|
||||
}
|
||||
categorizedModels[key].models.push(model);
|
||||
foundCategory = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!foundCategory) {
|
||||
uncategorizedModels.push(model);
|
||||
}
|
||||
});
|
||||
|
||||
// 如果有未分类模型,添加到"其他"分类
|
||||
if (uncategorizedModels.length > 0) {
|
||||
categorizedModels['other'] = {
|
||||
label: t('其他'),
|
||||
icon: null,
|
||||
models: uncategorizedModels,
|
||||
};
|
||||
}
|
||||
|
||||
return categorizedModels;
|
||||
};
|
||||
|
||||
const newModelsByCategory = categorizeModels(newModels);
|
||||
const existingModelsByCategory = categorizeModels(existingModels);
|
||||
|
||||
// Tab列表配置
|
||||
const tabList = [
|
||||
...(newModels.length > 0
|
||||
? [
|
||||
{
|
||||
tab: `${t('新获取的模型')} (${newModels.length})`,
|
||||
itemKey: 'new',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(existingModels.length > 0
|
||||
? [
|
||||
{
|
||||
tab: `${t('已有的模型')} (${existingModels.length})`,
|
||||
itemKey: 'existing',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
// 处理分类全选/取消全选
|
||||
const handleCategorySelectAll = (categoryModels, isChecked) => {
|
||||
let newCheckedList = [...checkedList];
|
||||
|
||||
if (isChecked) {
|
||||
// 全选:添加该分类下所有未选中的模型
|
||||
categoryModels.forEach((model) => {
|
||||
if (!newCheckedList.includes(model)) {
|
||||
newCheckedList.push(model);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 取消全选:移除该分类下所有已选中的模型
|
||||
newCheckedList = newCheckedList.filter(
|
||||
(model) => !categoryModels.includes(model),
|
||||
);
|
||||
}
|
||||
|
||||
setCheckedList(newCheckedList);
|
||||
};
|
||||
|
||||
// 检查分类是否全选
|
||||
const isCategoryAllSelected = (categoryModels) => {
|
||||
return (
|
||||
categoryModels.length > 0 &&
|
||||
categoryModels.every((model) => checkedList.includes(model))
|
||||
);
|
||||
};
|
||||
|
||||
// 检查分类是否部分选中
|
||||
const isCategoryIndeterminate = (categoryModels) => {
|
||||
const selectedCount = categoryModels.filter((model) =>
|
||||
checkedList.includes(model),
|
||||
).length;
|
||||
return selectedCount > 0 && selectedCount < categoryModels.length;
|
||||
};
|
||||
|
||||
const renderModelsByCategory = (modelsByCategory, categoryKeyPrefix) => {
|
||||
const categoryEntries = Object.entries(modelsByCategory);
|
||||
if (categoryEntries.length === 0) return null;
|
||||
|
||||
// 生成所有面板的key,确保都展开
|
||||
const allActiveKeys = categoryEntries.map(
|
||||
(_, index) => `${categoryKeyPrefix}_${index}`,
|
||||
);
|
||||
|
||||
return (
|
||||
<Collapse
|
||||
key={`${categoryKeyPrefix}_${categoryEntries.length}`}
|
||||
defaultActiveKey={[]}
|
||||
>
|
||||
{categoryEntries.map(([key, categoryData], index) => (
|
||||
<Collapse.Panel
|
||||
key={`${categoryKeyPrefix}_${index}`}
|
||||
itemKey={`${categoryKeyPrefix}_${index}`}
|
||||
header={`${categoryData.label} (${categoryData.models.length})`}
|
||||
extra={
|
||||
<Checkbox
|
||||
checked={isCategoryAllSelected(categoryData.models)}
|
||||
indeterminate={isCategoryIndeterminate(categoryData.models)}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation(); // 防止触发面板折叠
|
||||
handleCategorySelectAll(
|
||||
categoryData.models,
|
||||
e.target.checked,
|
||||
);
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()} // 防止点击checkbox时折叠面板
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className='flex items-center gap-2 mb-3'>
|
||||
{categoryData.icon}
|
||||
<Typography.Text type='secondary' size='small'>
|
||||
{t('已选择 {{selected}} / {{total}}', {
|
||||
selected: categoryData.models.filter((model) =>
|
||||
checkedList.includes(model),
|
||||
).length,
|
||||
total: categoryData.models.length,
|
||||
})}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className='grid grid-cols-2 gap-x-4'>
|
||||
{categoryData.models.map((model) => (
|
||||
<Checkbox key={model} value={model} className='my-1'>
|
||||
<span className='flex items-center gap-2'>
|
||||
<span>{model}</span>
|
||||
{redirectOnlySet.has(normalizeModelName(model)) && (
|
||||
<Tooltip
|
||||
position='top'
|
||||
content={t('来自模型重定向,尚未加入模型列表')}
|
||||
>
|
||||
<IconInfoCircle
|
||||
size='small'
|
||||
className='text-amber-500 cursor-help'
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
</Checkbox>
|
||||
))}
|
||||
</div>
|
||||
</Collapse.Panel>
|
||||
))}
|
||||
</Collapse>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
header={
|
||||
<div className='flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-4 py-4'>
|
||||
<Typography.Title heading={5} className='m-0'>
|
||||
{t('选择模型')}
|
||||
</Typography.Title>
|
||||
<div className='flex-shrink-0'>
|
||||
<Tabs
|
||||
type='slash'
|
||||
size='small'
|
||||
tabList={tabList}
|
||||
activeKey={activeTab}
|
||||
onChange={(key) => setActiveTab(key)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
visible={visible}
|
||||
onOk={handleOk}
|
||||
onCancel={onCancel}
|
||||
okText={t('确定')}
|
||||
cancelText={t('取消')}
|
||||
size={isMobile ? 'full-width' : 'large'}
|
||||
closeOnEsc
|
||||
maskClosable
|
||||
centered
|
||||
>
|
||||
<Input
|
||||
prefix={<IconSearch size={14} />}
|
||||
placeholder={t('搜索模型')}
|
||||
value={keyword}
|
||||
onChange={(v) => setKeyword(v)}
|
||||
showClear
|
||||
/>
|
||||
|
||||
<Spin spinning={!models || models.length === 0}>
|
||||
<div style={{ maxHeight: 400, overflowY: 'auto', paddingRight: 8 }}>
|
||||
{filteredModels.length === 0 ? (
|
||||
<Empty
|
||||
image={
|
||||
<IllustrationNoResult style={{ width: 150, height: 150 }} />
|
||||
}
|
||||
darkModeImage={
|
||||
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
|
||||
}
|
||||
description={t('暂无匹配模型')}
|
||||
style={{ padding: 30 }}
|
||||
/>
|
||||
) : (
|
||||
<Checkbox.Group
|
||||
value={checkedList}
|
||||
onChange={(vals) => setCheckedList(vals)}
|
||||
>
|
||||
{activeTab === 'new' && newModels.length > 0 && (
|
||||
<div>{renderModelsByCategory(newModelsByCategory, 'new')}</div>
|
||||
)}
|
||||
{activeTab === 'existing' && existingModels.length > 0 && (
|
||||
<div>
|
||||
{renderModelsByCategory(existingModelsByCategory, 'existing')}
|
||||
</div>
|
||||
)}
|
||||
</Checkbox.Group>
|
||||
)}
|
||||
</div>
|
||||
</Spin>
|
||||
|
||||
<Typography.Text
|
||||
type='secondary'
|
||||
size='small'
|
||||
className='block text-right mt-4'
|
||||
>
|
||||
<div className='flex items-center justify-end gap-2'>
|
||||
{(() => {
|
||||
const currentModels =
|
||||
activeTab === 'new' ? newModels : existingModels;
|
||||
const currentSelected = currentModels.filter((model) =>
|
||||
checkedList.includes(model),
|
||||
).length;
|
||||
const isAllSelected =
|
||||
currentModels.length > 0 &&
|
||||
currentSelected === currentModels.length;
|
||||
const isIndeterminate =
|
||||
currentSelected > 0 && currentSelected < currentModels.length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<span>
|
||||
{t('已选择 {{selected}} / {{total}}', {
|
||||
selected: currentSelected,
|
||||
total: currentModels.length,
|
||||
})}
|
||||
</span>
|
||||
<Checkbox
|
||||
checked={isAllSelected}
|
||||
indeterminate={isIndeterminate}
|
||||
onChange={(e) => {
|
||||
handleCategorySelectAll(currentModels, e.target.checked);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</Typography.Text>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelSelectModal;
|
||||
369
web/src/components/table/channels/modals/ModelTestModal.jsx
Normal file
369
web/src/components/table/channels/modals/ModelTestModal.jsx
Normal file
@@ -0,0 +1,369 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Button,
|
||||
Input,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
Select,
|
||||
Switch,
|
||||
Banner,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { IconSearch, IconInfoCircle } from '@douyinfe/semi-icons';
|
||||
import { copy, showError, showInfo, showSuccess } from '../../../../helpers';
|
||||
import { MODEL_TABLE_PAGE_SIZE } from '../../../../constants';
|
||||
|
||||
const ModelTestModal = ({
|
||||
showModelTestModal,
|
||||
currentTestChannel,
|
||||
handleCloseModal,
|
||||
isBatchTesting,
|
||||
batchTestModels,
|
||||
modelSearchKeyword,
|
||||
setModelSearchKeyword,
|
||||
selectedModelKeys,
|
||||
setSelectedModelKeys,
|
||||
modelTestResults,
|
||||
testingModels,
|
||||
testChannel,
|
||||
modelTablePage,
|
||||
setModelTablePage,
|
||||
selectedEndpointType,
|
||||
setSelectedEndpointType,
|
||||
isStreamTest,
|
||||
setIsStreamTest,
|
||||
allSelectingRef,
|
||||
isMobile,
|
||||
t,
|
||||
}) => {
|
||||
const hasChannel = Boolean(currentTestChannel);
|
||||
const streamToggleDisabled = [
|
||||
'embeddings',
|
||||
'image-generation',
|
||||
'jina-rerank',
|
||||
'openai-response-compact',
|
||||
].includes(selectedEndpointType);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (streamToggleDisabled && isStreamTest) {
|
||||
setIsStreamTest(false);
|
||||
}
|
||||
}, [streamToggleDisabled, isStreamTest, setIsStreamTest]);
|
||||
|
||||
const filteredModels = hasChannel
|
||||
? currentTestChannel.models
|
||||
.split(',')
|
||||
.filter((model) =>
|
||||
model.toLowerCase().includes(modelSearchKeyword.toLowerCase()),
|
||||
)
|
||||
: [];
|
||||
|
||||
const endpointTypeOptions = [
|
||||
{ value: '', label: t('自动检测') },
|
||||
{ value: 'openai', label: 'OpenAI (/v1/chat/completions)' },
|
||||
{ value: 'openai-response', label: 'OpenAI Response (/v1/responses)' },
|
||||
{
|
||||
value: 'openai-response-compact',
|
||||
label: 'OpenAI Response Compaction (/v1/responses/compact)',
|
||||
},
|
||||
{ value: 'anthropic', label: 'Anthropic (/v1/messages)' },
|
||||
{
|
||||
value: 'gemini',
|
||||
label: 'Gemini (/v1beta/models/{model}:generateContent)',
|
||||
},
|
||||
{ value: 'jina-rerank', label: 'Jina Rerank (/v1/rerank)' },
|
||||
{
|
||||
value: 'image-generation',
|
||||
label: t('图像生成') + ' (/v1/images/generations)',
|
||||
},
|
||||
{ value: 'embeddings', label: 'Embeddings (/v1/embeddings)' },
|
||||
];
|
||||
|
||||
const handleCopySelected = () => {
|
||||
if (selectedModelKeys.length === 0) {
|
||||
showError(t('请先选择模型!'));
|
||||
return;
|
||||
}
|
||||
copy(selectedModelKeys.join(',')).then((ok) => {
|
||||
if (ok) {
|
||||
showSuccess(
|
||||
t('已复制 ${count} 个模型').replace(
|
||||
'${count}',
|
||||
selectedModelKeys.length,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
showError(t('复制失败,请手动复制'));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectSuccess = () => {
|
||||
if (!currentTestChannel) return;
|
||||
const successKeys = currentTestChannel.models
|
||||
.split(',')
|
||||
.filter((m) => m.toLowerCase().includes(modelSearchKeyword.toLowerCase()))
|
||||
.filter((m) => {
|
||||
const result = modelTestResults[`${currentTestChannel.id}-${m}`];
|
||||
return result && result.success;
|
||||
});
|
||||
if (successKeys.length === 0) {
|
||||
showInfo(t('暂无成功模型'));
|
||||
}
|
||||
setSelectedModelKeys(successKeys);
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('模型名称'),
|
||||
dataIndex: 'model',
|
||||
render: (text) => (
|
||||
<div className='flex items-center'>
|
||||
<Typography.Text strong>{text}</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('状态'),
|
||||
dataIndex: 'status',
|
||||
render: (text, record) => {
|
||||
const testResult =
|
||||
modelTestResults[`${currentTestChannel.id}-${record.model}`];
|
||||
const isTesting = testingModels.has(record.model);
|
||||
|
||||
if (isTesting) {
|
||||
return (
|
||||
<Tag color='blue' shape='circle'>
|
||||
{t('测试中')}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
if (!testResult) {
|
||||
return (
|
||||
<Tag color='grey' shape='circle'>
|
||||
{t('未开始')}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-2'>
|
||||
<Tag color={testResult.success ? 'green' : 'red'} shape='circle'>
|
||||
{testResult.success ? t('成功') : t('失败')}
|
||||
</Tag>
|
||||
{testResult.success && (
|
||||
<Typography.Text type='tertiary'>
|
||||
{t('请求时长: ${time}s').replace(
|
||||
'${time}',
|
||||
testResult.time.toFixed(2),
|
||||
)}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
dataIndex: 'operate',
|
||||
render: (text, record) => {
|
||||
const isTesting = testingModels.has(record.model);
|
||||
return (
|
||||
<Button
|
||||
type='tertiary'
|
||||
onClick={() =>
|
||||
testChannel(
|
||||
currentTestChannel,
|
||||
record.model,
|
||||
selectedEndpointType,
|
||||
isStreamTest,
|
||||
)
|
||||
}
|
||||
loading={isTesting}
|
||||
size='small'
|
||||
>
|
||||
{t('测试')}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const dataSource = (() => {
|
||||
if (!hasChannel) return [];
|
||||
const start = (modelTablePage - 1) * MODEL_TABLE_PAGE_SIZE;
|
||||
const end = start + MODEL_TABLE_PAGE_SIZE;
|
||||
return filteredModels.slice(start, end).map((model) => ({
|
||||
model,
|
||||
key: model,
|
||||
}));
|
||||
})();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
hasChannel ? (
|
||||
<div className='flex flex-col gap-2 w-full'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Typography.Text
|
||||
strong
|
||||
className='!text-[var(--semi-color-text-0)] !text-base'
|
||||
>
|
||||
{currentTestChannel.name} {t('渠道的模型测试')}
|
||||
</Typography.Text>
|
||||
<Typography.Text type='tertiary' size='small'>
|
||||
{t('共')} {currentTestChannel.models.split(',').length}{' '}
|
||||
{t('个模型')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
visible={showModelTestModal}
|
||||
onCancel={handleCloseModal}
|
||||
footer={
|
||||
hasChannel ? (
|
||||
<div className='flex justify-end'>
|
||||
{isBatchTesting ? (
|
||||
<Button type='danger' onClick={handleCloseModal}>
|
||||
{t('停止测试')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button type='tertiary' onClick={handleCloseModal}>
|
||||
{t('取消')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={batchTestModels}
|
||||
loading={isBatchTesting}
|
||||
disabled={isBatchTesting}
|
||||
>
|
||||
{isBatchTesting
|
||||
? t('测试中...')
|
||||
: t('批量测试${count}个模型').replace(
|
||||
'${count}',
|
||||
filteredModels.length,
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
maskClosable={!isBatchTesting}
|
||||
className='!rounded-lg'
|
||||
size={isMobile ? 'full-width' : 'large'}
|
||||
>
|
||||
{hasChannel && (
|
||||
<div className='model-test-scroll'>
|
||||
{/* Endpoint toolbar */}
|
||||
<div className='flex flex-col sm:flex-row sm:items-center gap-2 w-full mb-2'>
|
||||
<div className='flex items-center gap-2 flex-1 min-w-0'>
|
||||
<Typography.Text strong className='shrink-0'>
|
||||
{t('端点类型')}:
|
||||
</Typography.Text>
|
||||
<Select
|
||||
value={selectedEndpointType}
|
||||
onChange={setSelectedEndpointType}
|
||||
optionList={endpointTypeOptions}
|
||||
className='!w-full min-w-0'
|
||||
placeholder={t('选择端点类型')}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex items-center justify-between sm:justify-end gap-2 shrink-0'>
|
||||
<Typography.Text strong className='shrink-0'>
|
||||
{t('流式')}:
|
||||
</Typography.Text>
|
||||
<Switch
|
||||
checked={isStreamTest}
|
||||
onChange={setIsStreamTest}
|
||||
size='small'
|
||||
disabled={streamToggleDisabled}
|
||||
aria-label={t('流式')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Banner
|
||||
type='info'
|
||||
closeIcon={null}
|
||||
icon={<IconInfoCircle />}
|
||||
className='!rounded-lg mb-2'
|
||||
description={t(
|
||||
'说明:本页测试为非流式请求;若渠道仅支持流式返回,可能出现测试失败,请以实际使用为准。',
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* 搜索与操作按钮 */}
|
||||
<div className='flex flex-col sm:flex-row sm:items-center gap-2 w-full mb-2'>
|
||||
<Input
|
||||
placeholder={t('搜索模型...')}
|
||||
value={modelSearchKeyword}
|
||||
onChange={(v) => {
|
||||
setModelSearchKeyword(v);
|
||||
setModelTablePage(1);
|
||||
}}
|
||||
className='!w-full sm:!flex-1'
|
||||
prefix={<IconSearch />}
|
||||
showClear
|
||||
/>
|
||||
|
||||
<div className='flex items-center justify-end gap-2'>
|
||||
<Button onClick={handleCopySelected}>{t('复制已选')}</Button>
|
||||
<Button type='tertiary' onClick={handleSelectSuccess}>
|
||||
{t('选择成功')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={dataSource}
|
||||
rowSelection={{
|
||||
selectedRowKeys: selectedModelKeys,
|
||||
onChange: (keys) => {
|
||||
if (allSelectingRef.current) {
|
||||
allSelectingRef.current = false;
|
||||
return;
|
||||
}
|
||||
setSelectedModelKeys(keys);
|
||||
},
|
||||
onSelectAll: (checked) => {
|
||||
allSelectingRef.current = true;
|
||||
setSelectedModelKeys(checked ? filteredModels : []);
|
||||
},
|
||||
}}
|
||||
pagination={{
|
||||
currentPage: modelTablePage,
|
||||
pageSize: MODEL_TABLE_PAGE_SIZE,
|
||||
total: filteredModels.length,
|
||||
showSizeChanger: false,
|
||||
onPageChange: (page) => setModelTablePage(page),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelTestModal;
|
||||
742
web/src/components/table/channels/modals/MultiKeyManageModal.jsx
Normal file
742
web/src/components/table/channels/modals/MultiKeyManageModal.jsx
Normal file
@@ -0,0 +1,742 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Modal,
|
||||
Button,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
Space,
|
||||
Tooltip,
|
||||
Popconfirm,
|
||||
Empty,
|
||||
Spin,
|
||||
Select,
|
||||
Row,
|
||||
Col,
|
||||
Badge,
|
||||
Progress,
|
||||
Card,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import {
|
||||
API,
|
||||
showError,
|
||||
showSuccess,
|
||||
timestamp2string,
|
||||
} from '../../../../helpers';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const MultiKeyManageModal = ({ visible, onCancel, channel, onRefresh }) => {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [keyStatusList, setKeyStatusList] = useState([]);
|
||||
const [operationLoading, setOperationLoading] = useState({});
|
||||
|
||||
// Pagination states
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
|
||||
// Statistics states
|
||||
const [enabledCount, setEnabledCount] = useState(0);
|
||||
const [manualDisabledCount, setManualDisabledCount] = useState(0);
|
||||
const [autoDisabledCount, setAutoDisabledCount] = useState(0);
|
||||
|
||||
// Filter states
|
||||
const [statusFilter, setStatusFilter] = useState(null); // null=all, 1=enabled, 2=manual_disabled, 3=auto_disabled
|
||||
|
||||
// Load key status data
|
||||
const loadKeyStatus = async (
|
||||
page = currentPage,
|
||||
size = pageSize,
|
||||
status = statusFilter,
|
||||
) => {
|
||||
if (!channel?.id) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const requestData = {
|
||||
channel_id: channel.id,
|
||||
action: 'get_key_status',
|
||||
page: page,
|
||||
page_size: size,
|
||||
};
|
||||
|
||||
// Add status filter if specified
|
||||
if (status !== null) {
|
||||
requestData.status = status;
|
||||
}
|
||||
|
||||
const res = await API.post('/api/channel/multi_key/manage', requestData);
|
||||
|
||||
if (res.data.success) {
|
||||
const data = res.data.data;
|
||||
setKeyStatusList(data.keys || []);
|
||||
setTotal(data.total || 0);
|
||||
setCurrentPage(data.page || 1);
|
||||
setPageSize(data.page_size || 10);
|
||||
setTotalPages(data.total_pages || 0);
|
||||
|
||||
// Update statistics (these are always the overall statistics)
|
||||
setEnabledCount(data.enabled_count || 0);
|
||||
setManualDisabledCount(data.manual_disabled_count || 0);
|
||||
setAutoDisabledCount(data.auto_disabled_count || 0);
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
showError(t('获取密钥状态失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Disable a specific key
|
||||
const handleDisableKey = async (keyIndex) => {
|
||||
const operationId = `disable_${keyIndex}`;
|
||||
setOperationLoading((prev) => ({ ...prev, [operationId]: true }));
|
||||
|
||||
try {
|
||||
const res = await API.post('/api/channel/multi_key/manage', {
|
||||
channel_id: channel.id,
|
||||
action: 'disable_key',
|
||||
key_index: keyIndex,
|
||||
});
|
||||
|
||||
if (res.data.success) {
|
||||
showSuccess(t('密钥已禁用'));
|
||||
await loadKeyStatus(currentPage, pageSize); // Reload current page
|
||||
onRefresh && onRefresh(); // Refresh parent component
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('禁用密钥失败'));
|
||||
} finally {
|
||||
setOperationLoading((prev) => ({ ...prev, [operationId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// Enable a specific key
|
||||
const handleEnableKey = async (keyIndex) => {
|
||||
const operationId = `enable_${keyIndex}`;
|
||||
setOperationLoading((prev) => ({ ...prev, [operationId]: true }));
|
||||
|
||||
try {
|
||||
const res = await API.post('/api/channel/multi_key/manage', {
|
||||
channel_id: channel.id,
|
||||
action: 'enable_key',
|
||||
key_index: keyIndex,
|
||||
});
|
||||
|
||||
if (res.data.success) {
|
||||
showSuccess(t('密钥已启用'));
|
||||
await loadKeyStatus(currentPage, pageSize); // Reload current page
|
||||
onRefresh && onRefresh(); // Refresh parent component
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('启用密钥失败'));
|
||||
} finally {
|
||||
setOperationLoading((prev) => ({ ...prev, [operationId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// Enable all disabled keys
|
||||
const handleEnableAll = async () => {
|
||||
setOperationLoading((prev) => ({ ...prev, enable_all: true }));
|
||||
|
||||
try {
|
||||
const res = await API.post('/api/channel/multi_key/manage', {
|
||||
channel_id: channel.id,
|
||||
action: 'enable_all_keys',
|
||||
});
|
||||
|
||||
if (res.data.success) {
|
||||
showSuccess(res.data.message || t('已启用所有密钥'));
|
||||
// Reset to first page after bulk operation
|
||||
setCurrentPage(1);
|
||||
await loadKeyStatus(1, pageSize);
|
||||
onRefresh && onRefresh(); // Refresh parent component
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('启用所有密钥失败'));
|
||||
} finally {
|
||||
setOperationLoading((prev) => ({ ...prev, enable_all: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// Disable all enabled keys
|
||||
const handleDisableAll = async () => {
|
||||
setOperationLoading((prev) => ({ ...prev, disable_all: true }));
|
||||
|
||||
try {
|
||||
const res = await API.post('/api/channel/multi_key/manage', {
|
||||
channel_id: channel.id,
|
||||
action: 'disable_all_keys',
|
||||
});
|
||||
|
||||
if (res.data.success) {
|
||||
showSuccess(res.data.message || t('已禁用所有密钥'));
|
||||
// Reset to first page after bulk operation
|
||||
setCurrentPage(1);
|
||||
await loadKeyStatus(1, pageSize);
|
||||
onRefresh && onRefresh(); // Refresh parent component
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('禁用所有密钥失败'));
|
||||
} finally {
|
||||
setOperationLoading((prev) => ({ ...prev, disable_all: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// Delete all disabled keys
|
||||
const handleDeleteDisabledKeys = async () => {
|
||||
setOperationLoading((prev) => ({ ...prev, delete_disabled: true }));
|
||||
|
||||
try {
|
||||
const res = await API.post('/api/channel/multi_key/manage', {
|
||||
channel_id: channel.id,
|
||||
action: 'delete_disabled_keys',
|
||||
});
|
||||
|
||||
if (res.data.success) {
|
||||
showSuccess(res.data.message);
|
||||
// Reset to first page after deletion as data structure might change
|
||||
setCurrentPage(1);
|
||||
await loadKeyStatus(1, pageSize);
|
||||
onRefresh && onRefresh(); // Refresh parent component
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('删除禁用密钥失败'));
|
||||
} finally {
|
||||
setOperationLoading((prev) => ({ ...prev, delete_disabled: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// Delete a specific key
|
||||
const handleDeleteKey = async (keyIndex) => {
|
||||
const operationId = `delete_${keyIndex}`;
|
||||
setOperationLoading((prev) => ({ ...prev, [operationId]: true }));
|
||||
|
||||
try {
|
||||
const res = await API.post('/api/channel/multi_key/manage', {
|
||||
channel_id: channel.id,
|
||||
action: 'delete_key',
|
||||
key_index: keyIndex,
|
||||
});
|
||||
|
||||
if (res.data.success) {
|
||||
showSuccess(t('密钥已删除'));
|
||||
await loadKeyStatus(currentPage, pageSize); // Reload current page
|
||||
onRefresh && onRefresh(); // Refresh parent component
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('删除密钥失败'));
|
||||
} finally {
|
||||
setOperationLoading((prev) => ({ ...prev, [operationId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// Handle page change
|
||||
const handlePageChange = (page) => {
|
||||
setCurrentPage(page);
|
||||
loadKeyStatus(page, pageSize);
|
||||
};
|
||||
|
||||
// Handle page size change
|
||||
const handlePageSizeChange = (size) => {
|
||||
setPageSize(size);
|
||||
setCurrentPage(1); // Reset to first page
|
||||
loadKeyStatus(1, size);
|
||||
};
|
||||
|
||||
// Handle status filter change
|
||||
const handleStatusFilterChange = (status) => {
|
||||
setStatusFilter(status);
|
||||
setCurrentPage(1); // Reset to first page when filter changes
|
||||
loadKeyStatus(1, pageSize, status);
|
||||
};
|
||||
|
||||
// Effect to load data when modal opens
|
||||
useEffect(() => {
|
||||
if (visible && channel?.id) {
|
||||
setCurrentPage(1); // Reset to first page when opening
|
||||
loadKeyStatus(1, pageSize);
|
||||
}
|
||||
}, [visible, channel?.id]);
|
||||
|
||||
// Reset pagination when modal closes
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setCurrentPage(1);
|
||||
setKeyStatusList([]);
|
||||
setTotal(0);
|
||||
setTotalPages(0);
|
||||
setEnabledCount(0);
|
||||
setManualDisabledCount(0);
|
||||
setAutoDisabledCount(0);
|
||||
setStatusFilter(null); // Reset filter
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
// Percentages for progress display
|
||||
const enabledPercent =
|
||||
total > 0 ? Math.round((enabledCount / total) * 100) : 0;
|
||||
const manualDisabledPercent =
|
||||
total > 0 ? Math.round((manualDisabledCount / total) * 100) : 0;
|
||||
const autoDisabledPercent =
|
||||
total > 0 ? Math.round((autoDisabledCount / total) * 100) : 0;
|
||||
|
||||
// 取消饼图:不再需要图表数据与配置
|
||||
|
||||
// Get status tag component
|
||||
const renderStatusTag = (status) => {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return (
|
||||
<Tag color='green' shape='circle' size='small'>
|
||||
{t('已启用')}
|
||||
</Tag>
|
||||
);
|
||||
case 2:
|
||||
return (
|
||||
<Tag color='red' shape='circle' size='small'>
|
||||
{t('已禁用')}
|
||||
</Tag>
|
||||
);
|
||||
case 3:
|
||||
return (
|
||||
<Tag color='orange' shape='circle' size='small'>
|
||||
{t('自动禁用')}
|
||||
</Tag>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Tag color='grey' shape='circle' size='small'>
|
||||
{t('未知状态')}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Table columns definition
|
||||
const columns = [
|
||||
{
|
||||
title: t('索引'),
|
||||
dataIndex: 'index',
|
||||
render: (text) => `#${text}`,
|
||||
},
|
||||
// {
|
||||
// title: t('密钥预览'),
|
||||
// dataIndex: 'key_preview',
|
||||
// render: (text) => (
|
||||
// <Text code style={{ fontSize: '12px' }}>
|
||||
// {text}
|
||||
// </Text>
|
||||
// ),
|
||||
// },
|
||||
{
|
||||
title: t('状态'),
|
||||
dataIndex: 'status',
|
||||
render: (status) => renderStatusTag(status),
|
||||
},
|
||||
{
|
||||
title: t('禁用原因'),
|
||||
dataIndex: 'reason',
|
||||
render: (reason, record) => {
|
||||
if (record.status === 1 || !reason) {
|
||||
return <Text type='quaternary'>-</Text>;
|
||||
}
|
||||
return (
|
||||
<Tooltip content={reason}>
|
||||
<Text style={{ maxWidth: '200px', display: 'block' }} ellipsis>
|
||||
{reason}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('禁用时间'),
|
||||
dataIndex: 'disabled_time',
|
||||
render: (time, record) => {
|
||||
if (record.status === 1 || !time) {
|
||||
return <Text type='quaternary'>-</Text>;
|
||||
}
|
||||
return (
|
||||
<Tooltip content={timestamp2string(time)}>
|
||||
<Text style={{ fontSize: '12px' }}>{timestamp2string(time)}</Text>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('操作'),
|
||||
key: 'action',
|
||||
fixed: 'right',
|
||||
width: 150,
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
{record.status === 1 ? (
|
||||
<Button
|
||||
type='danger'
|
||||
size='small'
|
||||
loading={operationLoading[`disable_${record.index}`]}
|
||||
onClick={() => handleDisableKey(record.index)}
|
||||
>
|
||||
{t('禁用')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type='primary'
|
||||
size='small'
|
||||
loading={operationLoading[`enable_${record.index}`]}
|
||||
onClick={() => handleEnableKey(record.index)}
|
||||
>
|
||||
{t('启用')}
|
||||
</Button>
|
||||
)}
|
||||
<Popconfirm
|
||||
title={t('确定要删除此密钥吗?')}
|
||||
content={t('此操作不可撤销,将永久删除该密钥')}
|
||||
onConfirm={() => handleDeleteKey(record.index)}
|
||||
okType={'danger'}
|
||||
position={'topRight'}
|
||||
>
|
||||
<Button
|
||||
type='danger'
|
||||
size='small'
|
||||
loading={operationLoading[`delete_${record.index}`]}
|
||||
>
|
||||
{t('删除')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<Space>
|
||||
<Text>{t('多密钥管理')}</Text>
|
||||
{channel?.name && (
|
||||
<Tag size='small' shape='circle' color='white'>
|
||||
{channel.name}
|
||||
</Tag>
|
||||
)}
|
||||
<Tag size='small' shape='circle' color='white'>
|
||||
{t('总密钥数')}: {total}
|
||||
</Tag>
|
||||
{channel?.channel_info?.multi_key_mode && (
|
||||
<Tag size='small' shape='circle' color='white'>
|
||||
{channel.channel_info.multi_key_mode === 'random'
|
||||
? t('随机模式')
|
||||
: t('轮询模式')}
|
||||
</Tag>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
visible={visible}
|
||||
onCancel={onCancel}
|
||||
width={900}
|
||||
footer={null}
|
||||
>
|
||||
<div className='flex flex-col mb-5'>
|
||||
{/* Stats & Mode */}
|
||||
<div
|
||||
className='rounded-xl p-4 mb-3'
|
||||
style={{
|
||||
background: 'var(--semi-color-bg-1)',
|
||||
border: '1px solid var(--semi-color-border)',
|
||||
}}
|
||||
>
|
||||
<Row gutter={16} align='middle'>
|
||||
<Col span={8}>
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--semi-color-bg-0)',
|
||||
border: '1px solid var(--semi-color-border)',
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
}}
|
||||
>
|
||||
<div className='flex items-center gap-2 mb-2'>
|
||||
<Badge dot type='success' />
|
||||
<Text type='tertiary'>{t('已启用')}</Text>
|
||||
</div>
|
||||
<div className='flex items-end gap-2 mb-2'>
|
||||
<Text
|
||||
style={{ fontSize: 18, fontWeight: 700, color: '#22c55e' }}
|
||||
>
|
||||
{enabledCount}
|
||||
</Text>
|
||||
<Text
|
||||
style={{ fontSize: 18, color: 'var(--semi-color-text-2)' }}
|
||||
>
|
||||
/ {total}
|
||||
</Text>
|
||||
</div>
|
||||
<Progress
|
||||
percent={enabledPercent}
|
||||
showInfo={false}
|
||||
size='small'
|
||||
stroke='#22c55e'
|
||||
style={{ height: 6, borderRadius: 999 }}
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--semi-color-bg-0)',
|
||||
border: '1px solid var(--semi-color-border)',
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
}}
|
||||
>
|
||||
<div className='flex items-center gap-2 mb-2'>
|
||||
<Badge dot type='danger' />
|
||||
<Text type='tertiary'>{t('手动禁用')}</Text>
|
||||
</div>
|
||||
<div className='flex items-end gap-2 mb-2'>
|
||||
<Text
|
||||
style={{ fontSize: 18, fontWeight: 700, color: '#ef4444' }}
|
||||
>
|
||||
{manualDisabledCount}
|
||||
</Text>
|
||||
<Text
|
||||
style={{ fontSize: 18, color: 'var(--semi-color-text-2)' }}
|
||||
>
|
||||
/ {total}
|
||||
</Text>
|
||||
</div>
|
||||
<Progress
|
||||
percent={manualDisabledPercent}
|
||||
showInfo={false}
|
||||
size='small'
|
||||
stroke='#ef4444'
|
||||
style={{ height: 6, borderRadius: 999 }}
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--semi-color-bg-0)',
|
||||
border: '1px solid var(--semi-color-border)',
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
}}
|
||||
>
|
||||
<div className='flex items-center gap-2 mb-2'>
|
||||
<Badge dot type='warning' />
|
||||
<Text type='tertiary'>{t('自动禁用')}</Text>
|
||||
</div>
|
||||
<div className='flex items-end gap-2 mb-2'>
|
||||
<Text
|
||||
style={{ fontSize: 18, fontWeight: 700, color: '#f59e0b' }}
|
||||
>
|
||||
{autoDisabledCount}
|
||||
</Text>
|
||||
<Text
|
||||
style={{ fontSize: 18, color: 'var(--semi-color-text-2)' }}
|
||||
>
|
||||
/ {total}
|
||||
</Text>
|
||||
</div>
|
||||
<Progress
|
||||
percent={autoDisabledPercent}
|
||||
showInfo={false}
|
||||
size='small'
|
||||
stroke='#f59e0b'
|
||||
style={{ height: 6, borderRadius: 999 }}
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className='flex-1 flex flex-col min-h-0'>
|
||||
<Spin spinning={loading}>
|
||||
<Card className='!rounded-xl'>
|
||||
<Table
|
||||
title={() => (
|
||||
<Row gutter={12} style={{ width: '100%' }}>
|
||||
<Col span={14}>
|
||||
<Row gutter={12} style={{ alignItems: 'center' }}>
|
||||
<Col>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onChange={handleStatusFilterChange}
|
||||
size='small'
|
||||
placeholder={t('全部状态')}
|
||||
>
|
||||
<Select.Option value={null}>
|
||||
{t('全部状态')}
|
||||
</Select.Option>
|
||||
<Select.Option value={1}>
|
||||
{t('已启用')}
|
||||
</Select.Option>
|
||||
<Select.Option value={2}>
|
||||
{t('手动禁用')}
|
||||
</Select.Option>
|
||||
<Select.Option value={3}>
|
||||
{t('自动禁用')}
|
||||
</Select.Option>
|
||||
</Select>
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
<Col
|
||||
span={10}
|
||||
style={{ display: 'flex', justifyContent: 'flex-end' }}
|
||||
>
|
||||
<Space>
|
||||
<Button
|
||||
size='small'
|
||||
type='tertiary'
|
||||
onClick={() => loadKeyStatus(currentPage, pageSize)}
|
||||
loading={loading}
|
||||
>
|
||||
{t('刷新')}
|
||||
</Button>
|
||||
{manualDisabledCount + autoDisabledCount > 0 && (
|
||||
<Popconfirm
|
||||
title={t('确定要启用所有密钥吗?')}
|
||||
onConfirm={handleEnableAll}
|
||||
position={'topRight'}
|
||||
>
|
||||
<Button
|
||||
size='small'
|
||||
type='primary'
|
||||
loading={operationLoading.enable_all}
|
||||
>
|
||||
{t('启用全部')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
{enabledCount > 0 && (
|
||||
<Popconfirm
|
||||
title={t('确定要禁用所有的密钥吗?')}
|
||||
onConfirm={handleDisableAll}
|
||||
okType={'danger'}
|
||||
position={'topRight'}
|
||||
>
|
||||
<Button
|
||||
size='small'
|
||||
type='danger'
|
||||
loading={operationLoading.disable_all}
|
||||
>
|
||||
{t('禁用全部')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
<Popconfirm
|
||||
title={t('确定要删除所有已自动禁用的密钥吗?')}
|
||||
content={t(
|
||||
'此操作不可撤销,将永久删除已自动禁用的密钥',
|
||||
)}
|
||||
onConfirm={handleDeleteDisabledKeys}
|
||||
okType={'danger'}
|
||||
position={'topRight'}
|
||||
>
|
||||
<Button
|
||||
size='small'
|
||||
type='warning'
|
||||
loading={operationLoading.delete_disabled}
|
||||
>
|
||||
{t('删除自动禁用密钥')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
columns={columns}
|
||||
dataSource={keyStatusList}
|
||||
pagination={{
|
||||
currentPage: currentPage,
|
||||
pageSize: pageSize,
|
||||
total: total,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
pageSizeOpts: [10, 20, 50, 100],
|
||||
onChange: (page, size) => {
|
||||
setCurrentPage(page);
|
||||
loadKeyStatus(page, size);
|
||||
},
|
||||
onShowSizeChange: (current, size) => {
|
||||
setCurrentPage(1);
|
||||
handlePageSizeChange(size);
|
||||
},
|
||||
}}
|
||||
size='small'
|
||||
bordered={false}
|
||||
rowKey='index'
|
||||
scroll={{ x: 'max-content' }}
|
||||
empty={
|
||||
<Empty
|
||||
image={
|
||||
<IllustrationNoResult
|
||||
style={{ width: 140, height: 140 }}
|
||||
/>
|
||||
}
|
||||
darkModeImage={
|
||||
<IllustrationNoResultDark
|
||||
style={{ width: 140, height: 140 }}
|
||||
/>
|
||||
}
|
||||
title={t('暂无密钥数据')}
|
||||
description={t('请检查渠道配置或刷新重试')}
|
||||
style={{ padding: 30 }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
</Spin>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default MultiKeyManageModal;
|
||||
778
web/src/components/table/channels/modals/OllamaModelModal.jsx
Normal file
778
web/src/components/table/channels/modals/OllamaModelModal.jsx
Normal file
@@ -0,0 +1,778 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Modal,
|
||||
Button,
|
||||
Typography,
|
||||
Card,
|
||||
List,
|
||||
Space,
|
||||
Input,
|
||||
Spin,
|
||||
Popconfirm,
|
||||
Tag,
|
||||
Empty,
|
||||
Row,
|
||||
Col,
|
||||
Progress,
|
||||
Checkbox,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconDownload,
|
||||
IconDelete,
|
||||
IconRefresh,
|
||||
IconSearch,
|
||||
IconPlus,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import {
|
||||
API,
|
||||
authHeader,
|
||||
getUserIdFromLocalStorage,
|
||||
showError,
|
||||
showSuccess,
|
||||
} from '../../../../helpers';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
const CHANNEL_TYPE_OLLAMA = 4;
|
||||
|
||||
const parseMaybeJSON = (value) => {
|
||||
if (!value) return null;
|
||||
if (typeof value === 'object') return value;
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const resolveOllamaBaseUrl = (info) => {
|
||||
if (!info) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const direct = typeof info.base_url === 'string' ? info.base_url.trim() : '';
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
const alt =
|
||||
typeof info.ollama_base_url === 'string' ? info.ollama_base_url.trim() : '';
|
||||
if (alt) {
|
||||
return alt;
|
||||
}
|
||||
|
||||
const parsed = parseMaybeJSON(info.other_info);
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
const candidate =
|
||||
(typeof parsed.base_url === 'string' && parsed.base_url.trim()) ||
|
||||
(typeof parsed.public_url === 'string' && parsed.public_url.trim()) ||
|
||||
(typeof parsed.api_url === 'string' && parsed.api_url.trim());
|
||||
if (candidate) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
const normalizeModels = (items) => {
|
||||
if (!Array.isArray(items)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return items
|
||||
.map((item) => {
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof item === 'string') {
|
||||
return {
|
||||
id: item,
|
||||
owned_by: 'ollama',
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof item === 'object') {
|
||||
const candidateId =
|
||||
item.id || item.ID || item.name || item.model || item.Model;
|
||||
if (!candidateId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const metadata = item.metadata || item.Metadata;
|
||||
const normalized = {
|
||||
...item,
|
||||
id: candidateId,
|
||||
owned_by: item.owned_by || item.ownedBy || 'ollama',
|
||||
};
|
||||
|
||||
if (typeof item.size === 'number' && !normalized.size) {
|
||||
normalized.size = item.size;
|
||||
}
|
||||
if (metadata && typeof metadata === 'object') {
|
||||
if (typeof metadata.size === 'number' && !normalized.size) {
|
||||
normalized.size = metadata.size;
|
||||
}
|
||||
if (!normalized.digest && typeof metadata.digest === 'string') {
|
||||
normalized.digest = metadata.digest;
|
||||
}
|
||||
if (
|
||||
!normalized.modified_at &&
|
||||
typeof metadata.modified_at === 'string'
|
||||
) {
|
||||
normalized.modified_at = metadata.modified_at;
|
||||
}
|
||||
if (metadata.details && !normalized.details) {
|
||||
normalized.details = metadata.details;
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
const OllamaModelModal = ({
|
||||
visible,
|
||||
onCancel,
|
||||
channelId,
|
||||
channelInfo,
|
||||
onModelsUpdate,
|
||||
onApplyModels,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [models, setModels] = useState([]);
|
||||
const [filteredModels, setFilteredModels] = useState([]);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [pullModelName, setPullModelName] = useState('');
|
||||
const [pullLoading, setPullLoading] = useState(false);
|
||||
const [pullProgress, setPullProgress] = useState(null);
|
||||
const [eventSource, setEventSource] = useState(null);
|
||||
const [selectedModelIds, setSelectedModelIds] = useState([]);
|
||||
|
||||
const handleApplyAllModels = () => {
|
||||
if (!onApplyModels || selectedModelIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
onApplyModels({ mode: 'append', modelIds: selectedModelIds });
|
||||
};
|
||||
|
||||
const handleToggleModel = (modelId, checked) => {
|
||||
if (!modelId) {
|
||||
return;
|
||||
}
|
||||
setSelectedModelIds((prev) => {
|
||||
if (checked) {
|
||||
if (prev.includes(modelId)) {
|
||||
return prev;
|
||||
}
|
||||
return [...prev, modelId];
|
||||
}
|
||||
return prev.filter((id) => id !== modelId);
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
setSelectedModelIds(models.map((item) => item?.id).filter(Boolean));
|
||||
};
|
||||
|
||||
const handleClearSelection = () => {
|
||||
setSelectedModelIds([]);
|
||||
};
|
||||
|
||||
// 获取模型列表
|
||||
const fetchModels = async () => {
|
||||
const channelType = Number(channelInfo?.type ?? CHANNEL_TYPE_OLLAMA);
|
||||
const shouldTryLiveFetch = channelType === CHANNEL_TYPE_OLLAMA;
|
||||
const resolvedBaseUrl = resolveOllamaBaseUrl(channelInfo);
|
||||
|
||||
setLoading(true);
|
||||
let liveFetchSucceeded = false;
|
||||
let fallbackSucceeded = false;
|
||||
let lastError = '';
|
||||
let nextModels = [];
|
||||
|
||||
try {
|
||||
if (shouldTryLiveFetch && resolvedBaseUrl) {
|
||||
try {
|
||||
const payload = {
|
||||
base_url: resolvedBaseUrl,
|
||||
type: CHANNEL_TYPE_OLLAMA,
|
||||
key: channelInfo?.key || '',
|
||||
};
|
||||
|
||||
const res = await API.post('/api/channel/fetch_models', payload, {
|
||||
skipErrorHandler: true,
|
||||
});
|
||||
|
||||
if (res?.data?.success) {
|
||||
nextModels = normalizeModels(res.data.data);
|
||||
liveFetchSucceeded = true;
|
||||
} else if (res?.data?.message) {
|
||||
lastError = res.data.message;
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error?.response?.data?.message || error.message;
|
||||
if (message) {
|
||||
lastError = message;
|
||||
}
|
||||
}
|
||||
} else if (shouldTryLiveFetch && !resolvedBaseUrl && !channelId) {
|
||||
lastError = t('请先填写 Ollama API 地址');
|
||||
}
|
||||
|
||||
if ((!liveFetchSucceeded || nextModels.length === 0) && channelId) {
|
||||
try {
|
||||
const res = await API.get(`/api/channel/fetch_models/${channelId}`, {
|
||||
skipErrorHandler: true,
|
||||
});
|
||||
|
||||
if (res?.data?.success) {
|
||||
nextModels = normalizeModels(res.data.data);
|
||||
fallbackSucceeded = true;
|
||||
lastError = '';
|
||||
} else if (res?.data?.message) {
|
||||
lastError = res.data.message;
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error?.response?.data?.message || error.message;
|
||||
if (message) {
|
||||
lastError = message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!liveFetchSucceeded && !fallbackSucceeded && lastError) {
|
||||
showError(`${t('获取模型列表失败')}: ${lastError}`);
|
||||
}
|
||||
|
||||
const normalized = nextModels;
|
||||
setModels(normalized);
|
||||
setFilteredModels(normalized);
|
||||
setSelectedModelIds((prev) => {
|
||||
if (!normalized || normalized.length === 0) {
|
||||
return [];
|
||||
}
|
||||
if (!prev || prev.length === 0) {
|
||||
return normalized.map((item) => item.id).filter(Boolean);
|
||||
}
|
||||
const available = prev.filter((id) =>
|
||||
normalized.some((item) => item.id === id),
|
||||
);
|
||||
return available.length > 0
|
||||
? available
|
||||
: normalized.map((item) => item.id).filter(Boolean);
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 拉取模型 (流式,支持进度)
|
||||
const pullModel = async () => {
|
||||
if (!pullModelName.trim()) {
|
||||
showError(t('请输入模型名称'));
|
||||
return;
|
||||
}
|
||||
|
||||
setPullLoading(true);
|
||||
setPullProgress({ status: 'starting', completed: 0, total: 0 });
|
||||
|
||||
let hasRefreshed = false;
|
||||
const refreshModels = async () => {
|
||||
if (hasRefreshed) return;
|
||||
hasRefreshed = true;
|
||||
await fetchModels();
|
||||
if (onModelsUpdate) {
|
||||
onModelsUpdate({ silent: true });
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
// 关闭之前的连接
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
setEventSource(null);
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const closable = {
|
||||
close: () => controller.abort(),
|
||||
};
|
||||
setEventSource(closable);
|
||||
|
||||
// 使用 fetch 请求 SSE 流
|
||||
const authHeaders = authHeader();
|
||||
const userId = getUserIdFromLocalStorage();
|
||||
const fetchHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'text/event-stream',
|
||||
'New-API-User': String(userId),
|
||||
...authHeaders,
|
||||
};
|
||||
|
||||
const response = await fetch('/api/channel/ollama/pull/stream', {
|
||||
method: 'POST',
|
||||
headers: fetchHeaders,
|
||||
body: JSON.stringify({
|
||||
channel_id: channelId,
|
||||
model_name: pullModelName.trim(),
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
// 读取 SSE 流
|
||||
const processStream = async () => {
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const eventData = line.substring(6);
|
||||
if (eventData === '[DONE]') {
|
||||
setPullLoading(false);
|
||||
setPullProgress(null);
|
||||
setEventSource(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = JSON.parse(eventData);
|
||||
|
||||
if (data.status) {
|
||||
// 处理进度数据
|
||||
setPullProgress(data);
|
||||
} else if (data.error) {
|
||||
// 处理错误
|
||||
showError(data.error);
|
||||
setPullProgress(null);
|
||||
setPullLoading(false);
|
||||
setEventSource(null);
|
||||
return;
|
||||
} else if (data.message) {
|
||||
// 处理成功消息
|
||||
showSuccess(data.message);
|
||||
setPullModelName('');
|
||||
setPullProgress(null);
|
||||
setPullLoading(false);
|
||||
setEventSource(null);
|
||||
await fetchModels();
|
||||
if (onModelsUpdate) {
|
||||
onModelsUpdate({ silent: true });
|
||||
}
|
||||
await refreshModels();
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse SSE data:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 正常结束流
|
||||
setPullLoading(false);
|
||||
setPullProgress(null);
|
||||
setEventSource(null);
|
||||
await refreshModels();
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
setPullProgress(null);
|
||||
setPullLoading(false);
|
||||
setEventSource(null);
|
||||
return;
|
||||
}
|
||||
console.error('Stream processing error:', error);
|
||||
showError(t('数据传输中断'));
|
||||
setPullProgress(null);
|
||||
setPullLoading(false);
|
||||
setEventSource(null);
|
||||
await refreshModels();
|
||||
}
|
||||
};
|
||||
|
||||
await processStream();
|
||||
} catch (error) {
|
||||
if (error?.name !== 'AbortError') {
|
||||
showError(t('模型拉取失败: {{error}}', { error: error.message }));
|
||||
}
|
||||
setPullLoading(false);
|
||||
setPullProgress(null);
|
||||
setEventSource(null);
|
||||
await refreshModels();
|
||||
}
|
||||
};
|
||||
|
||||
// 删除模型
|
||||
const deleteModel = async (modelName) => {
|
||||
try {
|
||||
const res = await API.delete('/api/channel/ollama/delete', {
|
||||
data: {
|
||||
channel_id: channelId,
|
||||
model_name: modelName,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.data.success) {
|
||||
showSuccess(t('模型删除成功'));
|
||||
await fetchModels(); // 重新获取模型列表
|
||||
if (onModelsUpdate) {
|
||||
onModelsUpdate({ silent: true }); // 通知父组件更新
|
||||
}
|
||||
} else {
|
||||
showError(res.data.message || t('模型删除失败'));
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('模型删除失败: {{error}}', { error: error.message }));
|
||||
}
|
||||
};
|
||||
|
||||
// 搜索过滤
|
||||
useEffect(() => {
|
||||
if (!searchValue) {
|
||||
setFilteredModels(models);
|
||||
} else {
|
||||
const filtered = models.filter((model) =>
|
||||
model.id.toLowerCase().includes(searchValue.toLowerCase()),
|
||||
);
|
||||
setFilteredModels(filtered);
|
||||
}
|
||||
}, [models, searchValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setSelectedModelIds([]);
|
||||
setPullModelName('');
|
||||
setPullProgress(null);
|
||||
setPullLoading(false);
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
// 组件加载时获取模型列表
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (channelId || Number(channelInfo?.type) === CHANNEL_TYPE_OLLAMA) {
|
||||
fetchModels();
|
||||
}
|
||||
}, [
|
||||
visible,
|
||||
channelId,
|
||||
channelInfo?.type,
|
||||
channelInfo?.base_url,
|
||||
channelInfo?.other_info,
|
||||
channelInfo?.ollama_base_url,
|
||||
]);
|
||||
|
||||
// 组件卸载时清理 EventSource
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
}
|
||||
};
|
||||
}, [eventSource]);
|
||||
|
||||
const formatModelSize = (size) => {
|
||||
if (!size) return '-';
|
||||
const gb = size / (1024 * 1024 * 1024);
|
||||
return gb >= 1
|
||||
? `${gb.toFixed(1)} GB`
|
||||
: `${(size / (1024 * 1024)).toFixed(0)} MB`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('Ollama 模型管理')}
|
||||
visible={visible}
|
||||
onCancel={onCancel}
|
||||
width={720}
|
||||
style={{ maxWidth: '95vw' }}
|
||||
footer={
|
||||
<Button theme='solid' type='primary' onClick={onCancel}>
|
||||
{t('关闭')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Space vertical spacing='medium' style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Text type='tertiary' size='small'>
|
||||
{channelInfo?.name ? `${channelInfo.name} - ` : ''}
|
||||
{t('管理 Ollama 模型的拉取和删除')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* 拉取新模型 */}
|
||||
<Card>
|
||||
<Title heading={6} className='m-0 mb-3'>
|
||||
{t('拉取新模型')}
|
||||
</Title>
|
||||
|
||||
<Row gutter={12} align='middle'>
|
||||
<Col span={16}>
|
||||
<Input
|
||||
placeholder={t('请输入模型名称,例如: llama3.2, qwen2.5:7b')}
|
||||
value={pullModelName}
|
||||
onChange={(value) => setPullModelName(value)}
|
||||
onEnterPress={pullModel}
|
||||
disabled={pullLoading}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Button
|
||||
theme='solid'
|
||||
type='primary'
|
||||
onClick={pullModel}
|
||||
loading={pullLoading}
|
||||
disabled={!pullModelName.trim()}
|
||||
icon={<IconDownload />}
|
||||
block
|
||||
>
|
||||
{pullLoading ? t('拉取中...') : t('拉取模型')}
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 进度条显示 */}
|
||||
{pullProgress &&
|
||||
(() => {
|
||||
const completedBytes = Number(pullProgress.completed) || 0;
|
||||
const totalBytes = Number(pullProgress.total) || 0;
|
||||
const hasTotal = Number.isFinite(totalBytes) && totalBytes > 0;
|
||||
const safePercent = hasTotal
|
||||
? Math.min(
|
||||
100,
|
||||
Math.max(
|
||||
0,
|
||||
Math.round((completedBytes / totalBytes) * 100),
|
||||
),
|
||||
)
|
||||
: null;
|
||||
const percentText =
|
||||
hasTotal && safePercent !== null
|
||||
? `${safePercent.toFixed(0)}%`
|
||||
: pullProgress.status || t('处理中');
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<div className='flex items-center justify-between mb-2'>
|
||||
<Text strong>{t('拉取进度')}</Text>
|
||||
<Text type='tertiary' size='small'>
|
||||
{percentText}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{hasTotal && safePercent !== null ? (
|
||||
<div>
|
||||
<Progress
|
||||
percent={safePercent}
|
||||
showInfo={false}
|
||||
stroke='#1890ff'
|
||||
size='small'
|
||||
/>
|
||||
<div className='flex justify-between mt-1'>
|
||||
<Text type='tertiary' size='small'>
|
||||
{(completedBytes / (1024 * 1024 * 1024)).toFixed(2)}{' '}
|
||||
GB
|
||||
</Text>
|
||||
<Text type='tertiary' size='small'>
|
||||
{(totalBytes / (1024 * 1024 * 1024)).toFixed(2)} GB
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex items-center gap-2 text-xs text-[var(--semi-color-text-2)]'>
|
||||
<Spin size='small' />
|
||||
<span>{t('准备中...')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
<Text type='tertiary' size='small' className='mt-2 block'>
|
||||
{t(
|
||||
'支持拉取 Ollama 官方模型库中的所有模型,拉取过程可能需要几分钟时间',
|
||||
)}
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
{/* 已有模型列表 */}
|
||||
<Card>
|
||||
<div className='flex items-center justify-between mb-3'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Title heading={6} className='m-0'>
|
||||
{t('已有模型')}
|
||||
</Title>
|
||||
{models.length > 0 ? (
|
||||
<Tag color='blue'>{models.length}</Tag>
|
||||
) : null}
|
||||
</div>
|
||||
<Space wrap>
|
||||
<Input
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('搜索模型...')}
|
||||
value={searchValue}
|
||||
onChange={(value) => setSearchValue(value)}
|
||||
style={{ width: 200 }}
|
||||
showClear
|
||||
/>
|
||||
<Button
|
||||
size='small'
|
||||
theme='light'
|
||||
onClick={handleSelectAll}
|
||||
disabled={models.length === 0}
|
||||
>
|
||||
{t('全选')}
|
||||
</Button>
|
||||
<Button
|
||||
size='small'
|
||||
theme='light'
|
||||
onClick={handleClearSelection}
|
||||
disabled={selectedModelIds.length === 0}
|
||||
>
|
||||
{t('清空')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='solid'
|
||||
type='primary'
|
||||
icon={<IconPlus />}
|
||||
onClick={handleApplyAllModels}
|
||||
disabled={selectedModelIds.length === 0}
|
||||
size='small'
|
||||
>
|
||||
{t('加入渠道')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='light'
|
||||
type='primary'
|
||||
onClick={fetchModels}
|
||||
loading={loading}
|
||||
icon={<IconRefresh />}
|
||||
size='small'
|
||||
>
|
||||
{t('刷新')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Spin spinning={loading}>
|
||||
{filteredModels.length === 0 ? (
|
||||
<Empty
|
||||
title={searchValue ? t('未找到匹配的模型') : t('暂无模型')}
|
||||
description={
|
||||
searchValue
|
||||
? t('请尝试其他搜索关键词')
|
||||
: t('您可以在上方拉取需要的模型')
|
||||
}
|
||||
style={{ padding: '40px 0' }}
|
||||
/>
|
||||
) : (
|
||||
<List
|
||||
dataSource={filteredModels}
|
||||
split
|
||||
renderItem={(model) => (
|
||||
<List.Item key={model.id}>
|
||||
<div className='flex items-center justify-between w-full'>
|
||||
<div className='flex items-center flex-1 min-w-0 gap-3'>
|
||||
<Checkbox
|
||||
checked={selectedModelIds.includes(model.id)}
|
||||
onChange={(checked) =>
|
||||
handleToggleModel(model.id, checked)
|
||||
}
|
||||
/>
|
||||
<div className='flex-1 min-w-0'>
|
||||
<Text strong className='block truncate'>
|
||||
{model.id}
|
||||
</Text>
|
||||
<div className='flex items-center space-x-2 mt-1'>
|
||||
<Tag color='cyan' size='small'>
|
||||
{model.owned_by || 'ollama'}
|
||||
</Tag>
|
||||
{model.size && (
|
||||
<Text type='tertiary' size='small'>
|
||||
{formatModelSize(model.size)}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex items-center space-x-2 ml-4'>
|
||||
<Popconfirm
|
||||
title={t('确认删除模型')}
|
||||
content={t(
|
||||
'删除后无法恢复,确定要删除模型 "{{name}}" 吗?',
|
||||
{ name: model.id },
|
||||
)}
|
||||
onConfirm={() => deleteModel(model.id)}
|
||||
okText={t('确认')}
|
||||
cancelText={t('取消')}
|
||||
>
|
||||
<Button
|
||||
theme='borderless'
|
||||
type='danger'
|
||||
size='small'
|
||||
icon={<IconDelete />}
|
||||
/>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Spin>
|
||||
</Card>
|
||||
</Space>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default OllamaModelModal;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,195 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
||||
import {
|
||||
Collapse,
|
||||
Empty,
|
||||
Input,
|
||||
Modal,
|
||||
Radio,
|
||||
Typography,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import { IconSearch } from '@douyinfe/semi-icons';
|
||||
import { getModelCategories } from '../../../../helpers/render';
|
||||
|
||||
const SingleModelSelectModal = ({
|
||||
visible,
|
||||
models = [],
|
||||
selected = '',
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const normalizeModelName = (model) => String(model ?? '').trim();
|
||||
const normalizedModels = useMemo(() => {
|
||||
const list = Array.isArray(models) ? models : [];
|
||||
return Array.from(new Set(list.map(normalizeModelName).filter(Boolean)));
|
||||
}, [models]);
|
||||
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [selectedModel, setSelectedModel] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setKeyword('');
|
||||
setSelectedModel(normalizeModelName(selected));
|
||||
}
|
||||
}, [visible, selected]);
|
||||
|
||||
const filteredModels = useMemo(() => {
|
||||
const lower = keyword.trim().toLowerCase();
|
||||
if (!lower) return normalizedModels;
|
||||
return normalizedModels.filter((m) => m.toLowerCase().includes(lower));
|
||||
}, [normalizedModels, keyword]);
|
||||
|
||||
const modelsByCategory = useMemo(() => {
|
||||
const categories = getModelCategories(t);
|
||||
const categorized = {};
|
||||
const uncategorized = [];
|
||||
|
||||
filteredModels.forEach((model) => {
|
||||
let foundCategory = false;
|
||||
for (const [key, category] of Object.entries(categories)) {
|
||||
if (key !== 'all' && category.filter({ model_name: model })) {
|
||||
if (!categorized[key]) {
|
||||
categorized[key] = {
|
||||
label: category.label,
|
||||
icon: category.icon,
|
||||
models: [],
|
||||
};
|
||||
}
|
||||
categorized[key].models.push(model);
|
||||
foundCategory = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!foundCategory) {
|
||||
uncategorized.push(model);
|
||||
}
|
||||
});
|
||||
|
||||
if (uncategorized.length > 0) {
|
||||
categorized.other = {
|
||||
label: t('其他'),
|
||||
icon: null,
|
||||
models: uncategorized,
|
||||
};
|
||||
}
|
||||
|
||||
return categorized;
|
||||
}, [filteredModels, t]);
|
||||
|
||||
const categoryEntries = useMemo(
|
||||
() => Object.entries(modelsByCategory),
|
||||
[modelsByCategory],
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
header={
|
||||
<div className='flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-4 py-4'>
|
||||
<Typography.Title heading={5} className='m-0'>
|
||||
{t('选择模型')}
|
||||
</Typography.Title>
|
||||
</div>
|
||||
}
|
||||
visible={visible}
|
||||
onOk={() => onConfirm?.(selectedModel)}
|
||||
onCancel={onCancel}
|
||||
okText={t('确定')}
|
||||
cancelText={t('取消')}
|
||||
okButtonProps={{ disabled: !selectedModel }}
|
||||
size={isMobile ? 'full-width' : 'large'}
|
||||
closeOnEsc
|
||||
maskClosable
|
||||
centered
|
||||
>
|
||||
<Input
|
||||
prefix={<IconSearch size={14} />}
|
||||
placeholder={t('搜索模型')}
|
||||
value={keyword}
|
||||
onChange={(v) => setKeyword(v)}
|
||||
showClear
|
||||
/>
|
||||
|
||||
<div style={{ maxHeight: 400, overflowY: 'auto', paddingRight: 8 }}>
|
||||
{filteredModels.length === 0 ? (
|
||||
<Empty
|
||||
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
|
||||
darkModeImage={
|
||||
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
|
||||
}
|
||||
description={t('暂无匹配模型')}
|
||||
style={{ padding: 30 }}
|
||||
/>
|
||||
) : (
|
||||
<Radio.Group
|
||||
className='w-full'
|
||||
style={{ width: '100%' }}
|
||||
value={selectedModel}
|
||||
onChange={(val) => {
|
||||
const next = val && val.target ? val.target.value : val;
|
||||
setSelectedModel(next);
|
||||
}}
|
||||
>
|
||||
<Collapse
|
||||
className='w-full'
|
||||
style={{ width: '100%' }}
|
||||
defaultActiveKey={[]}
|
||||
>
|
||||
{categoryEntries.map(([key, categoryData], index) => (
|
||||
<Collapse.Panel
|
||||
key={`${key}_${index}`}
|
||||
itemKey={`${key}_${index}`}
|
||||
header={
|
||||
<span className='flex items-center gap-2'>
|
||||
{categoryData.icon}
|
||||
<span>
|
||||
{categoryData.label} ({categoryData.models.length})
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<div className='grid grid-cols-2 gap-x-4'>
|
||||
{categoryData.models.map((model) => (
|
||||
<Radio key={model} value={model} className='my-1'>
|
||||
{model}
|
||||
</Radio>
|
||||
))}
|
||||
</div>
|
||||
</Collapse.Panel>
|
||||
))}
|
||||
</Collapse>
|
||||
</Radio.Group>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default SingleModelSelectModal;
|
||||
@@ -0,0 +1,41 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import RiskAcknowledgementModal from '../../../common/modals/RiskAcknowledgementModal';
|
||||
import {
|
||||
STATUS_CODE_RISK_I18N_KEYS,
|
||||
STATUS_CODE_RISK_CHECKLIST_KEYS,
|
||||
} from './statusCodeRiskGuard';
|
||||
|
||||
const StatusCodeRiskGuardModal = React.memo(function StatusCodeRiskGuardModal({
|
||||
visible,
|
||||
detailItems,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const checklist = useMemo(
|
||||
() => STATUS_CODE_RISK_CHECKLIST_KEYS.map((item) => t(item)),
|
||||
[t, i18n.language],
|
||||
);
|
||||
|
||||
return (
|
||||
<RiskAcknowledgementModal
|
||||
visible={visible}
|
||||
title={t(STATUS_CODE_RISK_I18N_KEYS.title)}
|
||||
markdownContent={t(STATUS_CODE_RISK_I18N_KEYS.markdown)}
|
||||
detailTitle={t(STATUS_CODE_RISK_I18N_KEYS.detailTitle)}
|
||||
detailItems={detailItems}
|
||||
checklist={checklist}
|
||||
inputPrompt={t(STATUS_CODE_RISK_I18N_KEYS.inputPrompt)}
|
||||
requiredText={t(STATUS_CODE_RISK_I18N_KEYS.confirmText)}
|
||||
inputPlaceholder={t(STATUS_CODE_RISK_I18N_KEYS.inputPlaceholder)}
|
||||
mismatchText={t(STATUS_CODE_RISK_I18N_KEYS.mismatchText)}
|
||||
cancelText={t('取消')}
|
||||
confirmText={t(STATUS_CODE_RISK_I18N_KEYS.confirmButton)}
|
||||
onCancel={onCancel}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default StatusCodeRiskGuardModal;
|
||||
132
web/src/components/table/channels/modals/statusCodeRiskGuard.js
Normal file
132
web/src/components/table/channels/modals/statusCodeRiskGuard.js
Normal file
@@ -0,0 +1,132 @@
|
||||
const NON_REDIRECTABLE_STATUS_CODES = new Set([504, 524]);
|
||||
|
||||
export const STATUS_CODE_RISK_I18N_KEYS = {
|
||||
title: '高危操作确认',
|
||||
detailTitle: '检测到以下高危状态码重定向规则',
|
||||
inputPrompt: '操作确认',
|
||||
confirmButton: '我确认开启高危重试',
|
||||
markdown: '高危状态码重试风险告知与免责声明Markdown',
|
||||
confirmText: '高危状态码重试风险确认输入文本',
|
||||
inputPlaceholder: '高危状态码重试风险输入框占位文案',
|
||||
mismatchText: '高危状态码重试风险输入不匹配提示',
|
||||
};
|
||||
|
||||
export const STATUS_CODE_RISK_CHECKLIST_KEYS = [
|
||||
'高危状态码重试风险确认项1',
|
||||
'高危状态码重试风险确认项2',
|
||||
'高危状态码重试风险确认项3',
|
||||
'高危状态码重试风险确认项4',
|
||||
];
|
||||
|
||||
function parseStatusCodeKey(rawKey) {
|
||||
if (typeof rawKey !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const normalized = rawKey.trim();
|
||||
if (!/^[1-5]\d{2}$/.test(normalized)) {
|
||||
return null;
|
||||
}
|
||||
return Number.parseInt(normalized, 10);
|
||||
}
|
||||
|
||||
function parseStatusCodeMappingTarget(rawValue) {
|
||||
if (typeof rawValue === 'number' && Number.isInteger(rawValue)) {
|
||||
return rawValue >= 100 && rawValue <= 599 ? rawValue : null;
|
||||
}
|
||||
if (typeof rawValue === 'string') {
|
||||
const normalized = rawValue.trim();
|
||||
if (!/^[1-5]\d{2}$/.test(normalized)) {
|
||||
return null;
|
||||
}
|
||||
const code = Number.parseInt(normalized, 10);
|
||||
return code >= 100 && code <= 599 ? code : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function collectInvalidStatusCodeEntries(statusCodeMappingStr) {
|
||||
if (
|
||||
typeof statusCodeMappingStr !== 'string' ||
|
||||
statusCodeMappingStr.trim() === ''
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(statusCodeMappingStr);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const invalid = [];
|
||||
for (const [rawKey, rawValue] of Object.entries(parsed)) {
|
||||
const fromCode = parseStatusCodeKey(rawKey);
|
||||
const toCode = parseStatusCodeMappingTarget(rawValue);
|
||||
if (fromCode === null || toCode === null) {
|
||||
invalid.push(`${rawKey} → ${rawValue}`);
|
||||
}
|
||||
}
|
||||
|
||||
return invalid;
|
||||
}
|
||||
|
||||
export function collectDisallowedStatusCodeRedirects(statusCodeMappingStr) {
|
||||
if (
|
||||
typeof statusCodeMappingStr !== 'string' ||
|
||||
statusCodeMappingStr.trim() === ''
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(statusCodeMappingStr);
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const riskyMappings = [];
|
||||
Object.entries(parsed).forEach(([rawFrom, rawTo]) => {
|
||||
const fromCode = parseStatusCodeKey(rawFrom);
|
||||
const toCode = parseStatusCodeMappingTarget(rawTo);
|
||||
if (fromCode === null || toCode === null) {
|
||||
return;
|
||||
}
|
||||
if (!NON_REDIRECTABLE_STATUS_CODES.has(fromCode)) {
|
||||
return;
|
||||
}
|
||||
if (fromCode === toCode) {
|
||||
return;
|
||||
}
|
||||
riskyMappings.push(`${fromCode} -> ${toCode}`);
|
||||
});
|
||||
|
||||
return Array.from(new Set(riskyMappings)).sort();
|
||||
}
|
||||
|
||||
export function collectNewDisallowedStatusCodeRedirects(
|
||||
originalStatusCodeMappingStr,
|
||||
currentStatusCodeMappingStr,
|
||||
) {
|
||||
const currentRisky = collectDisallowedStatusCodeRedirects(
|
||||
currentStatusCodeMappingStr,
|
||||
);
|
||||
if (currentRisky.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const originalRiskySet = new Set(
|
||||
collectDisallowedStatusCodeRedirects(originalStatusCodeMappingStr),
|
||||
);
|
||||
|
||||
return currentRisky.filter((mapping) => !originalRiskySet.has(mapping));
|
||||
}
|
||||
69
web/src/components/table/mj-logs/MjLogsActions.jsx
Normal file
69
web/src/components/table/mj-logs/MjLogsActions.jsx
Normal file
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Skeleton, Typography } from '@douyinfe/semi-ui';
|
||||
import { useMinimumLoadingTime } from '../../../hooks/common/useMinimumLoadingTime';
|
||||
import { IconEyeOpened } from '@douyinfe/semi-icons';
|
||||
import CompactModeToggle from '../../common/ui/CompactModeToggle';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const MjLogsActions = ({
|
||||
loading,
|
||||
showBanner,
|
||||
isAdminUser,
|
||||
compactMode,
|
||||
setCompactMode,
|
||||
t,
|
||||
}) => {
|
||||
const showSkeleton = useMinimumLoadingTime(loading);
|
||||
|
||||
const placeholder = (
|
||||
<div className='flex items-center mb-2 md:mb-0'>
|
||||
<IconEyeOpened className='mr-2' />
|
||||
<Skeleton.Title style={{ width: 300, height: 21, borderRadius: 6 }} />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full'>
|
||||
<Skeleton loading={showSkeleton} active placeholder={placeholder}>
|
||||
<div className='flex items-center mb-2 md:mb-0'>
|
||||
<IconEyeOpened className='mr-2' />
|
||||
<Text>
|
||||
{isAdminUser && showBanner
|
||||
? t(
|
||||
'当前未开启Midjourney回调,部分项目可能无法获得绘图结果,可在运营设置中开启。',
|
||||
)
|
||||
: t('Midjourney 任务记录')}
|
||||
</Text>
|
||||
</div>
|
||||
</Skeleton>
|
||||
|
||||
<CompactModeToggle
|
||||
compactMode={compactMode}
|
||||
setCompactMode={setCompactMode}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MjLogsActions;
|
||||
511
web/src/components/table/mj-logs/MjLogsColumnDefs.jsx
Normal file
511
web/src/components/table/mj-logs/MjLogsColumnDefs.jsx
Normal file
@@ -0,0 +1,511 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Button, Progress, Tag, Typography } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
Palette,
|
||||
ZoomIn,
|
||||
Shuffle,
|
||||
Move,
|
||||
FileText,
|
||||
Blend,
|
||||
Upload,
|
||||
Minimize2,
|
||||
RotateCcw,
|
||||
PaintBucket,
|
||||
Focus,
|
||||
Move3D,
|
||||
Monitor,
|
||||
UserCheck,
|
||||
HelpCircle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Copy,
|
||||
FileX,
|
||||
Pause,
|
||||
XCircle,
|
||||
Loader,
|
||||
AlertCircle,
|
||||
Hash,
|
||||
Video,
|
||||
} from 'lucide-react';
|
||||
|
||||
const colors = [
|
||||
'amber',
|
||||
'blue',
|
||||
'cyan',
|
||||
'green',
|
||||
'grey',
|
||||
'indigo',
|
||||
'light-blue',
|
||||
'lime',
|
||||
'orange',
|
||||
'pink',
|
||||
'purple',
|
||||
'red',
|
||||
'teal',
|
||||
'violet',
|
||||
'yellow',
|
||||
];
|
||||
|
||||
// Render functions
|
||||
function renderType(type, t) {
|
||||
switch (type) {
|
||||
case 'IMAGINE':
|
||||
return (
|
||||
<Tag color='blue' shape='circle' prefixIcon={<Palette size={14} />}>
|
||||
{t('绘图')}
|
||||
</Tag>
|
||||
);
|
||||
case 'UPSCALE':
|
||||
return (
|
||||
<Tag color='orange' shape='circle' prefixIcon={<ZoomIn size={14} />}>
|
||||
{t('放大')}
|
||||
</Tag>
|
||||
);
|
||||
case 'VIDEO':
|
||||
return (
|
||||
<Tag color='orange' shape='circle' prefixIcon={<Video size={14} />}>
|
||||
{t('视频')}
|
||||
</Tag>
|
||||
);
|
||||
case 'EDITS':
|
||||
return (
|
||||
<Tag color='orange' shape='circle' prefixIcon={<Video size={14} />}>
|
||||
{t('编辑')}
|
||||
</Tag>
|
||||
);
|
||||
case 'VARIATION':
|
||||
return (
|
||||
<Tag color='purple' shape='circle' prefixIcon={<Shuffle size={14} />}>
|
||||
{t('变换')}
|
||||
</Tag>
|
||||
);
|
||||
case 'HIGH_VARIATION':
|
||||
return (
|
||||
<Tag color='purple' shape='circle' prefixIcon={<Shuffle size={14} />}>
|
||||
{t('强变换')}
|
||||
</Tag>
|
||||
);
|
||||
case 'LOW_VARIATION':
|
||||
return (
|
||||
<Tag color='purple' shape='circle' prefixIcon={<Shuffle size={14} />}>
|
||||
{t('弱变换')}
|
||||
</Tag>
|
||||
);
|
||||
case 'PAN':
|
||||
return (
|
||||
<Tag color='cyan' shape='circle' prefixIcon={<Move size={14} />}>
|
||||
{t('平移')}
|
||||
</Tag>
|
||||
);
|
||||
case 'DESCRIBE':
|
||||
return (
|
||||
<Tag color='yellow' shape='circle' prefixIcon={<FileText size={14} />}>
|
||||
{t('图生文')}
|
||||
</Tag>
|
||||
);
|
||||
case 'BLEND':
|
||||
return (
|
||||
<Tag color='lime' shape='circle' prefixIcon={<Blend size={14} />}>
|
||||
{t('图混合')}
|
||||
</Tag>
|
||||
);
|
||||
case 'UPLOAD':
|
||||
return (
|
||||
<Tag color='blue' shape='circle' prefixIcon={<Upload size={14} />}>
|
||||
上传文件
|
||||
</Tag>
|
||||
);
|
||||
case 'SHORTEN':
|
||||
return (
|
||||
<Tag color='pink' shape='circle' prefixIcon={<Minimize2 size={14} />}>
|
||||
{t('缩词')}
|
||||
</Tag>
|
||||
);
|
||||
case 'REROLL':
|
||||
return (
|
||||
<Tag color='indigo' shape='circle' prefixIcon={<RotateCcw size={14} />}>
|
||||
{t('重绘')}
|
||||
</Tag>
|
||||
);
|
||||
case 'INPAINT':
|
||||
return (
|
||||
<Tag
|
||||
color='violet'
|
||||
shape='circle'
|
||||
prefixIcon={<PaintBucket size={14} />}
|
||||
>
|
||||
{t('局部重绘-提交')}
|
||||
</Tag>
|
||||
);
|
||||
case 'ZOOM':
|
||||
return (
|
||||
<Tag color='teal' shape='circle' prefixIcon={<Focus size={14} />}>
|
||||
{t('变焦')}
|
||||
</Tag>
|
||||
);
|
||||
case 'CUSTOM_ZOOM':
|
||||
return (
|
||||
<Tag color='teal' shape='circle' prefixIcon={<Move3D size={14} />}>
|
||||
{t('自定义变焦-提交')}
|
||||
</Tag>
|
||||
);
|
||||
case 'MODAL':
|
||||
return (
|
||||
<Tag color='green' shape='circle' prefixIcon={<Monitor size={14} />}>
|
||||
{t('窗口处理')}
|
||||
</Tag>
|
||||
);
|
||||
case 'SWAP_FACE':
|
||||
return (
|
||||
<Tag
|
||||
color='light-green'
|
||||
shape='circle'
|
||||
prefixIcon={<UserCheck size={14} />}
|
||||
>
|
||||
{t('换脸')}
|
||||
</Tag>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Tag color='white' shape='circle' prefixIcon={<HelpCircle size={14} />}>
|
||||
{t('未知')}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function renderCode(code, t) {
|
||||
switch (code) {
|
||||
case 1:
|
||||
return (
|
||||
<Tag
|
||||
color='green'
|
||||
shape='circle'
|
||||
prefixIcon={<CheckCircle size={14} />}
|
||||
>
|
||||
{t('已提交')}
|
||||
</Tag>
|
||||
);
|
||||
case 21:
|
||||
return (
|
||||
<Tag color='lime' shape='circle' prefixIcon={<Clock size={14} />}>
|
||||
{t('等待中')}
|
||||
</Tag>
|
||||
);
|
||||
case 22:
|
||||
return (
|
||||
<Tag color='orange' shape='circle' prefixIcon={<Copy size={14} />}>
|
||||
{t('重复提交')}
|
||||
</Tag>
|
||||
);
|
||||
case 0:
|
||||
return (
|
||||
<Tag color='yellow' shape='circle' prefixIcon={<FileX size={14} />}>
|
||||
{t('未提交')}
|
||||
</Tag>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Tag color='white' shape='circle' prefixIcon={<HelpCircle size={14} />}>
|
||||
{t('未知')}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function renderStatus(type, t) {
|
||||
switch (type) {
|
||||
case 'SUCCESS':
|
||||
return (
|
||||
<Tag
|
||||
color='green'
|
||||
shape='circle'
|
||||
prefixIcon={<CheckCircle size={14} />}
|
||||
>
|
||||
{t('成功')}
|
||||
</Tag>
|
||||
);
|
||||
case 'NOT_START':
|
||||
return (
|
||||
<Tag color='grey' shape='circle' prefixIcon={<Pause size={14} />}>
|
||||
{t('未启动')}
|
||||
</Tag>
|
||||
);
|
||||
case 'SUBMITTED':
|
||||
return (
|
||||
<Tag color='yellow' shape='circle' prefixIcon={<Clock size={14} />}>
|
||||
{t('队列中')}
|
||||
</Tag>
|
||||
);
|
||||
case 'IN_PROGRESS':
|
||||
return (
|
||||
<Tag color='blue' shape='circle' prefixIcon={<Loader size={14} />}>
|
||||
{t('执行中')}
|
||||
</Tag>
|
||||
);
|
||||
case 'FAILURE':
|
||||
return (
|
||||
<Tag color='red' shape='circle' prefixIcon={<XCircle size={14} />}>
|
||||
{t('失败')}
|
||||
</Tag>
|
||||
);
|
||||
case 'MODAL':
|
||||
return (
|
||||
<Tag
|
||||
color='yellow'
|
||||
shape='circle'
|
||||
prefixIcon={<AlertCircle size={14} />}
|
||||
>
|
||||
{t('窗口等待')}
|
||||
</Tag>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Tag color='white' shape='circle' prefixIcon={<HelpCircle size={14} />}>
|
||||
{t('未知')}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const renderTimestamp = (timestampInSeconds) => {
|
||||
const date = new Date(timestampInSeconds * 1000);
|
||||
const year = date.getFullYear();
|
||||
const month = ('0' + (date.getMonth() + 1)).slice(-2);
|
||||
const day = ('0' + date.getDate()).slice(-2);
|
||||
const hours = ('0' + date.getHours()).slice(-2);
|
||||
const minutes = ('0' + date.getMinutes()).slice(-2);
|
||||
const seconds = ('0' + date.getSeconds()).slice(-2);
|
||||
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||
};
|
||||
|
||||
function renderDuration(submit_time, finishTime, t) {
|
||||
if (!submit_time || !finishTime) return 'N/A';
|
||||
|
||||
const start = new Date(submit_time);
|
||||
const finish = new Date(finishTime);
|
||||
const durationMs = finish - start;
|
||||
const durationSec = (durationMs / 1000).toFixed(1);
|
||||
const color = durationSec > 60 ? 'red' : 'green';
|
||||
|
||||
return (
|
||||
<Tag color={color} shape='circle' prefixIcon={<Clock size={14} />}>
|
||||
{durationSec} {t('秒')}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
export const getMjLogsColumns = ({
|
||||
t,
|
||||
COLUMN_KEYS,
|
||||
copyText,
|
||||
openContentModal,
|
||||
openImageModal,
|
||||
isAdminUser,
|
||||
}) => {
|
||||
return [
|
||||
{
|
||||
key: COLUMN_KEYS.SUBMIT_TIME,
|
||||
title: t('提交时间'),
|
||||
dataIndex: 'submit_time',
|
||||
render: (text, record, index) => {
|
||||
return <div>{renderTimestamp(text / 1000)}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.DURATION,
|
||||
title: t('花费时间'),
|
||||
dataIndex: 'finish_time',
|
||||
render: (finish, record) => {
|
||||
return renderDuration(record.submit_time, finish, t);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.CHANNEL,
|
||||
title: t('渠道'),
|
||||
dataIndex: 'channel_id',
|
||||
render: (text, record, index) => {
|
||||
return isAdminUser ? (
|
||||
<div>
|
||||
<Tag
|
||||
color={colors[parseInt(text) % colors.length]}
|
||||
shape='circle'
|
||||
prefixIcon={<Hash size={14} />}
|
||||
onClick={() => {
|
||||
copyText(text);
|
||||
}}
|
||||
>
|
||||
{' '}
|
||||
{text}{' '}
|
||||
</Tag>
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.TYPE,
|
||||
title: t('类型'),
|
||||
dataIndex: 'action',
|
||||
render: (text, record, index) => {
|
||||
return <div>{renderType(text, t)}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.TASK_ID,
|
||||
title: t('任务ID'),
|
||||
dataIndex: 'mj_id',
|
||||
render: (text, record, index) => {
|
||||
return <div>{text}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.SUBMIT_RESULT,
|
||||
title: t('提交结果'),
|
||||
dataIndex: 'code',
|
||||
render: (text, record, index) => {
|
||||
return isAdminUser ? <div>{renderCode(text, t)}</div> : <></>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.TASK_STATUS,
|
||||
title: t('任务状态'),
|
||||
dataIndex: 'status',
|
||||
render: (text, record, index) => {
|
||||
return <div>{renderStatus(text, t)}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.PROGRESS,
|
||||
title: t('进度'),
|
||||
dataIndex: 'progress',
|
||||
render: (text, record, index) => {
|
||||
return (
|
||||
<div>
|
||||
{
|
||||
<Progress
|
||||
stroke={
|
||||
record.status === 'FAILURE'
|
||||
? 'var(--semi-color-warning)'
|
||||
: null
|
||||
}
|
||||
percent={text ? parseInt(text.replace('%', '')) : 0}
|
||||
showInfo={true}
|
||||
aria-label='drawing progress'
|
||||
style={{ minWidth: '160px' }}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.IMAGE,
|
||||
title: t('结果图片'),
|
||||
dataIndex: 'image_url',
|
||||
render: (text, record, index) => {
|
||||
if (!text) {
|
||||
return t('无');
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
size='small'
|
||||
onClick={() => {
|
||||
openImageModal(text);
|
||||
}}
|
||||
>
|
||||
{t('查看图片')}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.PROMPT,
|
||||
title: 'Prompt',
|
||||
dataIndex: 'prompt',
|
||||
render: (text, record, index) => {
|
||||
if (!text) {
|
||||
return t('无');
|
||||
}
|
||||
|
||||
return (
|
||||
<Typography.Text
|
||||
ellipsis={{ showTooltip: true }}
|
||||
style={{ width: 100 }}
|
||||
onClick={() => {
|
||||
openContentModal(text);
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</Typography.Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.PROMPT_EN,
|
||||
title: 'PromptEn',
|
||||
dataIndex: 'prompt_en',
|
||||
render: (text, record, index) => {
|
||||
if (!text) {
|
||||
return t('无');
|
||||
}
|
||||
|
||||
return (
|
||||
<Typography.Text
|
||||
ellipsis={{ showTooltip: true }}
|
||||
style={{ width: 100 }}
|
||||
onClick={() => {
|
||||
openContentModal(text);
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</Typography.Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.FAIL_REASON,
|
||||
title: t('失败原因'),
|
||||
dataIndex: 'fail_reason',
|
||||
fixed: 'right',
|
||||
render: (text, record, index) => {
|
||||
if (!text) {
|
||||
return t('无');
|
||||
}
|
||||
|
||||
return (
|
||||
<Typography.Text
|
||||
ellipsis={{ showTooltip: true }}
|
||||
style={{ width: 100 }}
|
||||
onClick={() => {
|
||||
openContentModal(text);
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</Typography.Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
130
web/src/components/table/mj-logs/MjLogsFilters.jsx
Normal file
130
web/src/components/table/mj-logs/MjLogsFilters.jsx
Normal file
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Button, Form } from '@douyinfe/semi-ui';
|
||||
import { IconSearch } from '@douyinfe/semi-icons';
|
||||
|
||||
import { DATE_RANGE_PRESETS } from '../../../constants/console.constants';
|
||||
|
||||
const MjLogsFilters = ({
|
||||
formInitValues,
|
||||
setFormApi,
|
||||
refresh,
|
||||
setShowColumnSelector,
|
||||
formApi,
|
||||
loading,
|
||||
isAdminUser,
|
||||
t,
|
||||
}) => {
|
||||
return (
|
||||
<Form
|
||||
initValues={formInitValues}
|
||||
getFormApi={(api) => setFormApi(api)}
|
||||
onSubmit={refresh}
|
||||
allowEmpty={true}
|
||||
autoComplete='off'
|
||||
layout='vertical'
|
||||
trigger='change'
|
||||
stopValidateWithError={false}
|
||||
>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-2'>
|
||||
{/* 时间选择器 */}
|
||||
<div className='col-span-1 lg:col-span-2'>
|
||||
<Form.DatePicker
|
||||
field='dateRange'
|
||||
className='w-full'
|
||||
type='dateTimeRange'
|
||||
placeholder={[t('开始时间'), t('结束时间')]}
|
||||
showClear
|
||||
pure
|
||||
size='small'
|
||||
presets={DATE_RANGE_PRESETS.map((preset) => ({
|
||||
text: t(preset.text),
|
||||
start: preset.start(),
|
||||
end: preset.end(),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 任务 ID */}
|
||||
<Form.Input
|
||||
field='mj_id'
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('任务 ID')}
|
||||
showClear
|
||||
pure
|
||||
size='small'
|
||||
/>
|
||||
|
||||
{/* 渠道 ID - 仅管理员可见 */}
|
||||
{isAdminUser && (
|
||||
<Form.Input
|
||||
field='channel_id'
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('渠道 ID')}
|
||||
showClear
|
||||
pure
|
||||
size='small'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮区域 */}
|
||||
<div className='flex justify-between items-center'>
|
||||
<div></div>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
type='tertiary'
|
||||
htmlType='submit'
|
||||
loading={loading}
|
||||
size='small'
|
||||
>
|
||||
{t('查询')}
|
||||
</Button>
|
||||
<Button
|
||||
type='tertiary'
|
||||
onClick={() => {
|
||||
if (formApi) {
|
||||
formApi.reset();
|
||||
setTimeout(() => {
|
||||
refresh();
|
||||
}, 100);
|
||||
}
|
||||
}}
|
||||
size='small'
|
||||
>
|
||||
{t('重置')}
|
||||
</Button>
|
||||
<Button
|
||||
type='tertiary'
|
||||
onClick={() => setShowColumnSelector(true)}
|
||||
size='small'
|
||||
>
|
||||
{t('列设置')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default MjLogsFilters;
|
||||
108
web/src/components/table/mj-logs/MjLogsTable.jsx
Normal file
108
web/src/components/table/mj-logs/MjLogsTable.jsx
Normal file
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { Empty } from '@douyinfe/semi-ui';
|
||||
import CardTable from '../../common/ui/CardTable';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import { getMjLogsColumns } from './MjLogsColumnDefs';
|
||||
|
||||
const MjLogsTable = (mjLogsData) => {
|
||||
const {
|
||||
logs,
|
||||
loading,
|
||||
activePage,
|
||||
pageSize,
|
||||
logCount,
|
||||
compactMode,
|
||||
visibleColumns,
|
||||
handlePageChange,
|
||||
handlePageSizeChange,
|
||||
copyText,
|
||||
openContentModal,
|
||||
openImageModal,
|
||||
isAdminUser,
|
||||
t,
|
||||
COLUMN_KEYS,
|
||||
} = mjLogsData;
|
||||
|
||||
// Get all columns
|
||||
const allColumns = useMemo(() => {
|
||||
return getMjLogsColumns({
|
||||
t,
|
||||
COLUMN_KEYS,
|
||||
copyText,
|
||||
openContentModal,
|
||||
openImageModal,
|
||||
isAdminUser,
|
||||
});
|
||||
}, [t, COLUMN_KEYS, copyText, openContentModal, openImageModal, isAdminUser]);
|
||||
|
||||
// Filter columns based on visibility settings
|
||||
const getVisibleColumns = () => {
|
||||
return allColumns.filter((column) => visibleColumns[column.key]);
|
||||
};
|
||||
|
||||
const visibleColumnsList = useMemo(() => {
|
||||
return getVisibleColumns();
|
||||
}, [visibleColumns, allColumns]);
|
||||
|
||||
const tableColumns = useMemo(() => {
|
||||
return compactMode
|
||||
? visibleColumnsList.map(({ fixed, ...rest }) => rest)
|
||||
: visibleColumnsList;
|
||||
}, [compactMode, visibleColumnsList]);
|
||||
|
||||
return (
|
||||
<CardTable
|
||||
columns={tableColumns}
|
||||
dataSource={logs}
|
||||
rowKey='key'
|
||||
loading={loading}
|
||||
scroll={compactMode ? undefined : { x: 'max-content' }}
|
||||
className='rounded-xl overflow-hidden'
|
||||
size='middle'
|
||||
empty={
|
||||
<Empty
|
||||
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
|
||||
darkModeImage={
|
||||
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
|
||||
}
|
||||
description={t('搜索无结果')}
|
||||
style={{ padding: 30 }}
|
||||
/>
|
||||
}
|
||||
pagination={{
|
||||
currentPage: activePage,
|
||||
pageSize: pageSize,
|
||||
total: logCount,
|
||||
pageSizeOptions: [10, 20, 50, 100],
|
||||
showSizeChanger: true,
|
||||
onPageSizeChange: handlePageSizeChange,
|
||||
onPageChange: handlePageChange,
|
||||
}}
|
||||
hidePagination={true}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default MjLogsTable;
|
||||
65
web/src/components/table/mj-logs/index.jsx
Normal file
65
web/src/components/table/mj-logs/index.jsx
Normal file
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Layout } from '@douyinfe/semi-ui';
|
||||
import CardPro from '../../common/ui/CardPro';
|
||||
import MjLogsTable from './MjLogsTable';
|
||||
import MjLogsActions from './MjLogsActions';
|
||||
import MjLogsFilters from './MjLogsFilters';
|
||||
import ColumnSelectorModal from './modals/ColumnSelectorModal';
|
||||
import ContentModal from './modals/ContentModal';
|
||||
import { useMjLogsData } from '../../../hooks/mj-logs/useMjLogsData';
|
||||
import { useIsMobile } from '../../../hooks/common/useIsMobile';
|
||||
import { createCardProPagination } from '../../../helpers/utils';
|
||||
|
||||
const MjLogsPage = () => {
|
||||
const mjLogsData = useMjLogsData();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Modals */}
|
||||
<ColumnSelectorModal {...mjLogsData} />
|
||||
<ContentModal {...mjLogsData} />
|
||||
|
||||
<Layout>
|
||||
<CardPro
|
||||
type='type2'
|
||||
statsArea={<MjLogsActions {...mjLogsData} />}
|
||||
searchArea={<MjLogsFilters {...mjLogsData} />}
|
||||
paginationArea={createCardProPagination({
|
||||
currentPage: mjLogsData.activePage,
|
||||
pageSize: mjLogsData.pageSize,
|
||||
total: mjLogsData.logCount,
|
||||
onPageChange: mjLogsData.handlePageChange,
|
||||
onPageSizeChange: mjLogsData.handlePageSizeChange,
|
||||
isMobile: isMobile,
|
||||
t: mjLogsData.t,
|
||||
})}
|
||||
t={mjLogsData.t}
|
||||
>
|
||||
<MjLogsTable {...mjLogsData} />
|
||||
</CardPro>
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MjLogsPage;
|
||||
109
web/src/components/table/mj-logs/modals/ColumnSelectorModal.jsx
Normal file
109
web/src/components/table/mj-logs/modals/ColumnSelectorModal.jsx
Normal file
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Modal, Button, Checkbox } from '@douyinfe/semi-ui';
|
||||
import { getMjLogsColumns } from '../MjLogsColumnDefs';
|
||||
|
||||
const ColumnSelectorModal = ({
|
||||
showColumnSelector,
|
||||
setShowColumnSelector,
|
||||
visibleColumns,
|
||||
handleColumnVisibilityChange,
|
||||
handleSelectAll,
|
||||
initDefaultColumns,
|
||||
COLUMN_KEYS,
|
||||
isAdminUser,
|
||||
copyText,
|
||||
openContentModal,
|
||||
openImageModal,
|
||||
t,
|
||||
}) => {
|
||||
// Get all columns for display in selector
|
||||
const allColumns = getMjLogsColumns({
|
||||
t,
|
||||
COLUMN_KEYS,
|
||||
copyText,
|
||||
openContentModal,
|
||||
openImageModal,
|
||||
isAdminUser,
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('列设置')}
|
||||
visible={showColumnSelector}
|
||||
onCancel={() => setShowColumnSelector(false)}
|
||||
footer={
|
||||
<div className='flex justify-end'>
|
||||
<Button onClick={() => initDefaultColumns()}>{t('重置')}</Button>
|
||||
<Button onClick={() => setShowColumnSelector(false)}>
|
||||
{t('取消')}
|
||||
</Button>
|
||||
<Button onClick={() => setShowColumnSelector(false)}>
|
||||
{t('确定')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<Checkbox
|
||||
checked={Object.values(visibleColumns).every((v) => v === true)}
|
||||
indeterminate={
|
||||
Object.values(visibleColumns).some((v) => v === true) &&
|
||||
!Object.values(visibleColumns).every((v) => v === true)
|
||||
}
|
||||
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||
>
|
||||
{t('全选')}
|
||||
</Checkbox>
|
||||
</div>
|
||||
<div
|
||||
className='flex flex-wrap max-h-96 overflow-y-auto rounded-lg p-4'
|
||||
style={{ border: '1px solid var(--semi-color-border)' }}
|
||||
>
|
||||
{allColumns.map((column) => {
|
||||
// Skip admin-only columns for non-admin users
|
||||
if (
|
||||
!isAdminUser &&
|
||||
(column.key === COLUMN_KEYS.CHANNEL ||
|
||||
column.key === COLUMN_KEYS.SUBMIT_RESULT)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={column.key} className='w-1/2 mb-4 pr-2'>
|
||||
<Checkbox
|
||||
checked={!!visibleColumns[column.key]}
|
||||
onChange={(e) =>
|
||||
handleColumnVisibilityChange(column.key, e.target.checked)
|
||||
}
|
||||
>
|
||||
{column.title}
|
||||
</Checkbox>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ColumnSelectorModal;
|
||||
55
web/src/components/table/mj-logs/modals/ContentModal.jsx
Normal file
55
web/src/components/table/mj-logs/modals/ContentModal.jsx
Normal file
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Modal, ImagePreview } from '@douyinfe/semi-ui';
|
||||
|
||||
const ContentModal = ({
|
||||
isModalOpen,
|
||||
setIsModalOpen,
|
||||
modalContent,
|
||||
isModalOpenurl,
|
||||
setIsModalOpenurl,
|
||||
modalImageUrl,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{/* Text Content Modal */}
|
||||
<Modal
|
||||
visible={isModalOpen}
|
||||
onOk={() => setIsModalOpen(false)}
|
||||
onCancel={() => setIsModalOpen(false)}
|
||||
closable={null}
|
||||
bodyStyle={{ height: '400px', overflow: 'auto' }}
|
||||
width={800}
|
||||
>
|
||||
<p style={{ whiteSpace: 'pre-line' }}>{modalContent}</p>
|
||||
</Modal>
|
||||
|
||||
{/* Image Preview Modal */}
|
||||
<ImagePreview
|
||||
src={modalImageUrl}
|
||||
visible={isModalOpenurl}
|
||||
onVisibleChange={(visible) => setIsModalOpenurl(visible)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContentModal;
|
||||
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Button, Popconfirm } from '@douyinfe/semi-ui';
|
||||
import CompactModeToggle from '../../common/ui/CompactModeToggle';
|
||||
|
||||
const DeploymentsActions = ({
|
||||
selectedKeys,
|
||||
setSelectedKeys,
|
||||
setEditingDeployment,
|
||||
setShowEdit,
|
||||
batchDeleteDeployments,
|
||||
batchOperationsEnabled = true,
|
||||
compactMode,
|
||||
setCompactMode,
|
||||
showCreateModal,
|
||||
setShowCreateModal,
|
||||
t,
|
||||
}) => {
|
||||
const hasSelected = batchOperationsEnabled && selectedKeys.length > 0;
|
||||
|
||||
const handleAddDeployment = () => {
|
||||
if (setShowCreateModal) {
|
||||
setShowCreateModal(true);
|
||||
} else {
|
||||
// Fallback to old behavior if setShowCreateModal is not provided
|
||||
setEditingDeployment({ id: undefined });
|
||||
setShowEdit(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchDelete = () => {
|
||||
batchDeleteDeployments();
|
||||
};
|
||||
|
||||
const handleDeselectAll = () => {
|
||||
setSelectedKeys([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='flex flex-wrap gap-2 w-full md:w-auto order-2 md:order-1'>
|
||||
<Button
|
||||
type='primary'
|
||||
className='flex-1 md:flex-initial'
|
||||
onClick={handleAddDeployment}
|
||||
size='small'
|
||||
>
|
||||
{t('新建容器')}
|
||||
</Button>
|
||||
|
||||
{hasSelected && (
|
||||
<>
|
||||
<Popconfirm
|
||||
title={t('确认删除')}
|
||||
content={`${t('确定要删除选中的')} ${selectedKeys.length} ${t('个部署吗?此操作不可逆。')}`}
|
||||
okText={t('删除')}
|
||||
cancelText={t('取消')}
|
||||
okType='danger'
|
||||
onConfirm={handleBatchDelete}
|
||||
>
|
||||
<Button
|
||||
type='danger'
|
||||
className='flex-1 md:flex-initial'
|
||||
disabled={selectedKeys.length === 0}
|
||||
size='small'
|
||||
>
|
||||
{t('批量删除')} ({selectedKeys.length})
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
|
||||
<Button
|
||||
type='tertiary'
|
||||
className='flex-1 md:flex-initial'
|
||||
onClick={handleDeselectAll}
|
||||
size='small'
|
||||
>
|
||||
{t('取消选择')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Compact Mode */}
|
||||
<CompactModeToggle
|
||||
compactMode={compactMode}
|
||||
setCompactMode={setCompactMode}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeploymentsActions;
|
||||
@@ -0,0 +1,702 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Button, Dropdown, Tag, Typography } from '@douyinfe/semi-ui';
|
||||
import { timestamp2string, showSuccess, showError } from '../../../helpers';
|
||||
import { IconMore } from '@douyinfe/semi-icons';
|
||||
import {
|
||||
FaPlay,
|
||||
FaTrash,
|
||||
FaServer,
|
||||
FaMemory,
|
||||
FaMicrochip,
|
||||
FaCheckCircle,
|
||||
FaSpinner,
|
||||
FaClock,
|
||||
FaExclamationCircle,
|
||||
FaBan,
|
||||
FaTerminal,
|
||||
FaPlus,
|
||||
FaCog,
|
||||
FaInfoCircle,
|
||||
FaLink,
|
||||
FaStop,
|
||||
FaHourglassHalf,
|
||||
FaGlobe,
|
||||
} from 'react-icons/fa';
|
||||
|
||||
const normalizeStatus = (status) =>
|
||||
typeof status === 'string' ? status.trim().toLowerCase() : '';
|
||||
|
||||
const STATUS_TAG_CONFIG = {
|
||||
running: {
|
||||
color: 'green',
|
||||
labelKey: '运行中',
|
||||
icon: <FaPlay size={12} className='text-green-600' />,
|
||||
},
|
||||
deploying: {
|
||||
color: 'blue',
|
||||
labelKey: '部署中',
|
||||
icon: <FaSpinner size={12} className='text-blue-600' />,
|
||||
},
|
||||
pending: {
|
||||
color: 'orange',
|
||||
labelKey: '待部署',
|
||||
icon: <FaClock size={12} className='text-orange-600' />,
|
||||
},
|
||||
stopped: {
|
||||
color: 'grey',
|
||||
labelKey: '已停止',
|
||||
icon: <FaStop size={12} className='text-gray-500' />,
|
||||
},
|
||||
error: {
|
||||
color: 'red',
|
||||
labelKey: '错误',
|
||||
icon: <FaExclamationCircle size={12} className='text-red-500' />,
|
||||
},
|
||||
failed: {
|
||||
color: 'red',
|
||||
labelKey: '失败',
|
||||
icon: <FaExclamationCircle size={12} className='text-red-500' />,
|
||||
},
|
||||
destroyed: {
|
||||
color: 'red',
|
||||
labelKey: '已销毁',
|
||||
icon: <FaBan size={12} className='text-red-500' />,
|
||||
},
|
||||
completed: {
|
||||
color: 'green',
|
||||
labelKey: '已完成',
|
||||
icon: <FaCheckCircle size={12} className='text-green-600' />,
|
||||
},
|
||||
'deployment requested': {
|
||||
color: 'blue',
|
||||
labelKey: '部署请求中',
|
||||
icon: <FaSpinner size={12} className='text-blue-600' />,
|
||||
},
|
||||
'termination requested': {
|
||||
color: 'orange',
|
||||
labelKey: '终止请求中',
|
||||
icon: <FaClock size={12} className='text-orange-600' />,
|
||||
},
|
||||
};
|
||||
|
||||
const DEFAULT_STATUS_CONFIG = {
|
||||
color: 'grey',
|
||||
labelKey: null,
|
||||
icon: <FaInfoCircle size={12} className='text-gray-500' />,
|
||||
};
|
||||
|
||||
const parsePercentValue = (value) => {
|
||||
if (value === null || value === undefined) return null;
|
||||
if (typeof value === 'string') {
|
||||
const parsed = parseFloat(value.replace(/[^0-9.+-]/g, ''));
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const clampPercent = (value) => {
|
||||
if (value === null || value === undefined) return null;
|
||||
return Math.min(100, Math.max(0, Math.round(value)));
|
||||
};
|
||||
|
||||
const formatRemainingMinutes = (minutes, t) => {
|
||||
if (minutes === null || minutes === undefined) return null;
|
||||
const numeric = Number(minutes);
|
||||
if (!Number.isFinite(numeric)) return null;
|
||||
const totalMinutes = Math.max(0, Math.round(numeric));
|
||||
const days = Math.floor(totalMinutes / 1440);
|
||||
const hours = Math.floor((totalMinutes % 1440) / 60);
|
||||
const mins = totalMinutes % 60;
|
||||
const parts = [];
|
||||
|
||||
if (days > 0) {
|
||||
parts.push(`${days}${t('天')}`);
|
||||
}
|
||||
if (hours > 0) {
|
||||
parts.push(`${hours}${t('小时')}`);
|
||||
}
|
||||
if (parts.length === 0 || mins > 0) {
|
||||
parts.push(`${mins}${t('分钟')}`);
|
||||
}
|
||||
|
||||
return parts.join(' ');
|
||||
};
|
||||
|
||||
const getRemainingTheme = (percentRemaining) => {
|
||||
if (percentRemaining === null) {
|
||||
return {
|
||||
iconColor: 'var(--semi-color-primary)',
|
||||
tagColor: 'blue',
|
||||
textColor: 'var(--semi-color-text-2)',
|
||||
};
|
||||
}
|
||||
|
||||
if (percentRemaining <= 10) {
|
||||
return {
|
||||
iconColor: '#ff5a5f',
|
||||
tagColor: 'red',
|
||||
textColor: '#ff5a5f',
|
||||
};
|
||||
}
|
||||
|
||||
if (percentRemaining <= 30) {
|
||||
return {
|
||||
iconColor: '#ffb400',
|
||||
tagColor: 'orange',
|
||||
textColor: '#ffb400',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
iconColor: '#2ecc71',
|
||||
tagColor: 'green',
|
||||
textColor: '#2ecc71',
|
||||
};
|
||||
};
|
||||
|
||||
const renderStatus = (status, t) => {
|
||||
const normalizedStatus = normalizeStatus(status);
|
||||
const config = STATUS_TAG_CONFIG[normalizedStatus] || DEFAULT_STATUS_CONFIG;
|
||||
const statusText = typeof status === 'string' ? status : '';
|
||||
const labelText = config.labelKey
|
||||
? t(config.labelKey)
|
||||
: statusText || t('未知状态');
|
||||
|
||||
return (
|
||||
<Tag
|
||||
color={config.color}
|
||||
shape='circle'
|
||||
size='small'
|
||||
prefixIcon={config.icon}
|
||||
>
|
||||
{labelText}
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
|
||||
// Container Name Cell Component - to properly handle React hooks
|
||||
const ContainerNameCell = ({ text, record, t }) => {
|
||||
const handleCopyId = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(record.id);
|
||||
showSuccess(t('已复制 ID 到剪贴板'));
|
||||
} catch (err) {
|
||||
showError(t('复制失败'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-1'>
|
||||
<Typography.Text strong className='text-base'>
|
||||
{text}
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
type='secondary'
|
||||
size='small'
|
||||
className='text-xs cursor-pointer hover:text-blue-600 transition-colors select-all'
|
||||
onClick={handleCopyId}
|
||||
title={t('点击复制ID')}
|
||||
>
|
||||
ID: {record.id}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Render resource configuration
|
||||
const renderResourceConfig = (resource, t) => {
|
||||
if (!resource) return '-';
|
||||
|
||||
const { cpu, memory, gpu } = resource;
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-1'>
|
||||
{cpu && (
|
||||
<div className='flex items-center gap-1 text-xs'>
|
||||
<FaMicrochip className='text-blue-500' />
|
||||
<span>CPU: {cpu}</span>
|
||||
</div>
|
||||
)}
|
||||
{memory && (
|
||||
<div className='flex items-center gap-1 text-xs'>
|
||||
<FaMemory className='text-green-500' />
|
||||
<span>内存: {memory}</span>
|
||||
</div>
|
||||
)}
|
||||
{gpu && (
|
||||
<div className='flex items-center gap-1 text-xs'>
|
||||
<FaServer className='text-purple-500' />
|
||||
<span>GPU: {gpu}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Render instance count with status indicator
|
||||
const renderInstanceCount = (count, record, t) => {
|
||||
const normalizedStatus = normalizeStatus(record?.status);
|
||||
const statusConfig = STATUS_TAG_CONFIG[normalizedStatus];
|
||||
const countColor = statusConfig?.color ?? 'grey';
|
||||
|
||||
return (
|
||||
<Tag color={countColor} size='small' shape='circle'>
|
||||
{count || 0} {t('个实例')}
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
|
||||
// Main function to get all deployment columns
|
||||
export const getDeploymentsColumns = ({
|
||||
t,
|
||||
COLUMN_KEYS,
|
||||
startDeployment,
|
||||
restartDeployment,
|
||||
deleteDeployment,
|
||||
setEditingDeployment,
|
||||
setShowEdit,
|
||||
refresh,
|
||||
activePage,
|
||||
deployments,
|
||||
// New handlers for enhanced operations
|
||||
onViewLogs,
|
||||
onExtendDuration,
|
||||
onViewDetails,
|
||||
onUpdateConfig,
|
||||
onSyncToChannel,
|
||||
}) => {
|
||||
const columns = [
|
||||
{
|
||||
title: t('容器名称'),
|
||||
dataIndex: 'container_name',
|
||||
key: COLUMN_KEYS.container_name,
|
||||
width: 300,
|
||||
ellipsis: true,
|
||||
render: (text, record) => (
|
||||
<ContainerNameCell text={text} record={record} t={t} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('状态'),
|
||||
dataIndex: 'status',
|
||||
key: COLUMN_KEYS.status,
|
||||
width: 140,
|
||||
render: (status) => (
|
||||
<div className='flex items-center gap-2'>{renderStatus(status, t)}</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('服务商'),
|
||||
dataIndex: 'provider',
|
||||
key: COLUMN_KEYS.provider,
|
||||
width: 140,
|
||||
render: (provider) =>
|
||||
provider ? (
|
||||
<div
|
||||
className='flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide'
|
||||
style={{
|
||||
borderColor: 'rgba(59, 130, 246, 0.4)',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.08)',
|
||||
color: '#2563eb',
|
||||
}}
|
||||
>
|
||||
<FaGlobe className='text-[11px]' />
|
||||
<span>{provider}</span>
|
||||
</div>
|
||||
) : (
|
||||
<Typography.Text
|
||||
type='tertiary'
|
||||
size='small'
|
||||
className='text-xs text-gray-500'
|
||||
>
|
||||
{t('暂无')}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('剩余时间'),
|
||||
dataIndex: 'time_remaining',
|
||||
key: COLUMN_KEYS.time_remaining,
|
||||
width: 200,
|
||||
render: (text, record) => {
|
||||
const normalizedStatus = normalizeStatus(record?.status);
|
||||
const percentUsedRaw = parsePercentValue(record?.completed_percent);
|
||||
const percentUsed = clampPercent(percentUsedRaw);
|
||||
const percentRemaining =
|
||||
percentUsed === null ? null : clampPercent(100 - percentUsed);
|
||||
const theme = getRemainingTheme(percentRemaining);
|
||||
const statusDisplayMap = {
|
||||
completed: t('已完成'),
|
||||
destroyed: t('已销毁'),
|
||||
failed: t('失败'),
|
||||
error: t('失败'),
|
||||
stopped: t('已停止'),
|
||||
pending: t('待部署'),
|
||||
deploying: t('部署中'),
|
||||
'deployment requested': t('部署请求中'),
|
||||
'termination requested': t('终止中'),
|
||||
};
|
||||
const statusOverride = statusDisplayMap[normalizedStatus];
|
||||
const baseTimeDisplay =
|
||||
text && String(text).trim() !== '' ? text : t('计算中');
|
||||
const timeDisplay = baseTimeDisplay;
|
||||
const humanReadable = formatRemainingMinutes(
|
||||
record.compute_minutes_remaining,
|
||||
t,
|
||||
);
|
||||
const showProgress = !statusOverride && normalizedStatus === 'running';
|
||||
const showExtraInfo = Boolean(humanReadable || percentUsed !== null);
|
||||
const showRemainingMeta =
|
||||
record.compute_minutes_remaining !== undefined &&
|
||||
record.compute_minutes_remaining !== null &&
|
||||
percentRemaining !== null;
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-1 leading-tight text-xs'>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<FaHourglassHalf
|
||||
className='text-sm'
|
||||
style={{ color: theme.iconColor }}
|
||||
/>
|
||||
<Typography.Text className='text-sm font-medium text-[var(--semi-color-text-0)]'>
|
||||
{timeDisplay}
|
||||
</Typography.Text>
|
||||
{showProgress && percentRemaining !== null ? (
|
||||
<Tag size='small' color={theme.tagColor}>
|
||||
{percentRemaining}%
|
||||
</Tag>
|
||||
) : statusOverride ? (
|
||||
<Tag size='small' color='grey'>
|
||||
{statusOverride}
|
||||
</Tag>
|
||||
) : null}
|
||||
</div>
|
||||
{showExtraInfo && (
|
||||
<div className='flex items-center gap-3 text-[var(--semi-color-text-2)]'>
|
||||
{humanReadable && (
|
||||
<span className='flex items-center gap-1'>
|
||||
<FaClock className='text-[11px]' />
|
||||
{t('约')} {humanReadable}
|
||||
</span>
|
||||
)}
|
||||
{percentUsed !== null && (
|
||||
<span className='flex items-center gap-1'>
|
||||
<FaCheckCircle className='text-[11px]' />
|
||||
{t('已用')} {percentUsed}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{showProgress && showRemainingMeta && (
|
||||
<div className='text-[10px]' style={{ color: theme.textColor }}>
|
||||
{t('剩余')} {record.compute_minutes_remaining} {t('分钟')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('硬件配置'),
|
||||
dataIndex: 'hardware_info',
|
||||
key: COLUMN_KEYS.hardware_info,
|
||||
width: 220,
|
||||
ellipsis: true,
|
||||
render: (text, record) => (
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='flex items-center gap-1 px-2 py-1 bg-green-50 border border-green-200 rounded-md'>
|
||||
<FaServer className='text-green-600 text-xs' />
|
||||
<span className='text-xs font-medium text-green-700'>
|
||||
{record.hardware_name}
|
||||
</span>
|
||||
</div>
|
||||
<span className='text-xs text-gray-500 font-medium'>
|
||||
x{record.hardware_quantity}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('创建时间'),
|
||||
dataIndex: 'created_at',
|
||||
key: COLUMN_KEYS.created_at,
|
||||
width: 150,
|
||||
render: (text) => (
|
||||
<span className='text-sm text-gray-600'>{timestamp2string(text)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('操作'),
|
||||
key: COLUMN_KEYS.actions,
|
||||
fixed: 'right',
|
||||
width: 120,
|
||||
render: (_, record) => {
|
||||
const { status, id } = record;
|
||||
const normalizedStatus = normalizeStatus(status);
|
||||
const isEnded =
|
||||
normalizedStatus === 'completed' || normalizedStatus === 'destroyed';
|
||||
|
||||
const handleDelete = () => {
|
||||
// Use enhanced confirmation dialog
|
||||
onUpdateConfig?.(record, 'delete');
|
||||
};
|
||||
|
||||
// Get primary action based on status
|
||||
const getPrimaryAction = () => {
|
||||
switch (normalizedStatus) {
|
||||
case 'running':
|
||||
return {
|
||||
icon: <FaInfoCircle className='text-xs' />,
|
||||
text: t('查看详情'),
|
||||
onClick: () => onViewDetails?.(record),
|
||||
type: 'secondary',
|
||||
theme: 'borderless',
|
||||
};
|
||||
case 'failed':
|
||||
case 'error':
|
||||
return {
|
||||
icon: <FaPlay className='text-xs' />,
|
||||
text: t('重试'),
|
||||
onClick: () => startDeployment(id),
|
||||
type: 'primary',
|
||||
theme: 'solid',
|
||||
};
|
||||
case 'stopped':
|
||||
return {
|
||||
icon: <FaPlay className='text-xs' />,
|
||||
text: t('启动'),
|
||||
onClick: () => startDeployment(id),
|
||||
type: 'primary',
|
||||
theme: 'solid',
|
||||
};
|
||||
case 'deployment requested':
|
||||
case 'deploying':
|
||||
return {
|
||||
icon: <FaClock className='text-xs' />,
|
||||
text: t('部署中'),
|
||||
onClick: () => {},
|
||||
type: 'secondary',
|
||||
theme: 'light',
|
||||
disabled: true,
|
||||
};
|
||||
case 'pending':
|
||||
return {
|
||||
icon: <FaClock className='text-xs' />,
|
||||
text: t('待部署'),
|
||||
onClick: () => {},
|
||||
type: 'secondary',
|
||||
theme: 'light',
|
||||
disabled: true,
|
||||
};
|
||||
case 'termination requested':
|
||||
return {
|
||||
icon: <FaClock className='text-xs' />,
|
||||
text: t('终止中'),
|
||||
onClick: () => {},
|
||||
type: 'secondary',
|
||||
theme: 'light',
|
||||
disabled: true,
|
||||
};
|
||||
case 'completed':
|
||||
case 'destroyed':
|
||||
default:
|
||||
return {
|
||||
icon: <FaInfoCircle className='text-xs' />,
|
||||
text: t('已结束'),
|
||||
onClick: () => {},
|
||||
type: 'tertiary',
|
||||
theme: 'borderless',
|
||||
disabled: true,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const primaryAction = getPrimaryAction();
|
||||
const primaryTheme = primaryAction.theme || 'solid';
|
||||
const primaryType = primaryAction.type || 'primary';
|
||||
|
||||
if (isEnded) {
|
||||
return (
|
||||
<div className='flex w-full items-center justify-start gap-1 pr-2'>
|
||||
<Button
|
||||
size='small'
|
||||
type='tertiary'
|
||||
theme='borderless'
|
||||
onClick={() => onViewDetails?.(record)}
|
||||
icon={<FaInfoCircle className='text-xs' />}
|
||||
>
|
||||
{t('查看详情')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// All actions dropdown with enhanced operations
|
||||
const dropdownItems = [
|
||||
<Dropdown.Item
|
||||
key='details'
|
||||
onClick={() => onViewDetails?.(record)}
|
||||
icon={<FaInfoCircle />}
|
||||
>
|
||||
{t('查看详情')}
|
||||
</Dropdown.Item>,
|
||||
];
|
||||
|
||||
if (!isEnded) {
|
||||
dropdownItems.push(
|
||||
<Dropdown.Item
|
||||
key='logs'
|
||||
onClick={() => onViewLogs?.(record)}
|
||||
icon={<FaTerminal />}
|
||||
>
|
||||
{t('查看日志')}
|
||||
</Dropdown.Item>,
|
||||
);
|
||||
}
|
||||
|
||||
const managementItems = [];
|
||||
if (normalizedStatus === 'running') {
|
||||
if (onSyncToChannel) {
|
||||
managementItems.push(
|
||||
<Dropdown.Item
|
||||
key='sync-channel'
|
||||
onClick={() => onSyncToChannel(record)}
|
||||
icon={<FaLink />}
|
||||
>
|
||||
{t('同步到渠道')}
|
||||
</Dropdown.Item>,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (normalizedStatus === 'failed' || normalizedStatus === 'error') {
|
||||
managementItems.push(
|
||||
<Dropdown.Item
|
||||
key='retry'
|
||||
onClick={() => startDeployment(id)}
|
||||
icon={<FaPlay />}
|
||||
>
|
||||
{t('重试')}
|
||||
</Dropdown.Item>,
|
||||
);
|
||||
}
|
||||
if (normalizedStatus === 'stopped') {
|
||||
managementItems.push(
|
||||
<Dropdown.Item
|
||||
key='start'
|
||||
onClick={() => startDeployment(id)}
|
||||
icon={<FaPlay />}
|
||||
>
|
||||
{t('启动')}
|
||||
</Dropdown.Item>,
|
||||
);
|
||||
}
|
||||
|
||||
if (managementItems.length > 0) {
|
||||
dropdownItems.push(<Dropdown.Divider key='management-divider' />);
|
||||
dropdownItems.push(...managementItems);
|
||||
}
|
||||
|
||||
const configItems = [];
|
||||
if (
|
||||
!isEnded &&
|
||||
(normalizedStatus === 'running' ||
|
||||
normalizedStatus === 'deployment requested')
|
||||
) {
|
||||
configItems.push(
|
||||
<Dropdown.Item
|
||||
key='extend'
|
||||
onClick={() => onExtendDuration?.(record)}
|
||||
icon={<FaPlus />}
|
||||
>
|
||||
{t('延长时长')}
|
||||
</Dropdown.Item>,
|
||||
);
|
||||
}
|
||||
// if (!isEnded && normalizedStatus === 'running') {
|
||||
// configItems.push(
|
||||
// <Dropdown.Item key="update-config" onClick={() => onUpdateConfig?.(record)} icon={<FaCog />}>
|
||||
// {t('更新配置')}
|
||||
// </Dropdown.Item>,
|
||||
// );
|
||||
// }
|
||||
|
||||
if (configItems.length > 0) {
|
||||
dropdownItems.push(<Dropdown.Divider key='config-divider' />);
|
||||
dropdownItems.push(...configItems);
|
||||
}
|
||||
if (!isEnded) {
|
||||
dropdownItems.push(<Dropdown.Divider key='danger-divider' />);
|
||||
dropdownItems.push(
|
||||
<Dropdown.Item
|
||||
key='delete'
|
||||
type='danger'
|
||||
onClick={handleDelete}
|
||||
icon={<FaTrash />}
|
||||
>
|
||||
{t('销毁容器')}
|
||||
</Dropdown.Item>,
|
||||
);
|
||||
}
|
||||
|
||||
const allActions = <Dropdown.Menu>{dropdownItems}</Dropdown.Menu>;
|
||||
const hasDropdown = dropdownItems.length > 0;
|
||||
|
||||
return (
|
||||
<div className='flex w-full items-center justify-start gap-1 pr-2'>
|
||||
<Button
|
||||
size='small'
|
||||
theme={primaryTheme}
|
||||
type={primaryType}
|
||||
icon={primaryAction.icon}
|
||||
onClick={primaryAction.onClick}
|
||||
className='px-2 text-xs'
|
||||
disabled={primaryAction.disabled}
|
||||
>
|
||||
{primaryAction.text}
|
||||
</Button>
|
||||
|
||||
{hasDropdown && (
|
||||
<Dropdown
|
||||
trigger='click'
|
||||
position='bottomRight'
|
||||
render={allActions}
|
||||
>
|
||||
<Button
|
||||
size='small'
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
icon={<IconMore />}
|
||||
className='px-1'
|
||||
/>
|
||||
</Dropdown>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return columns;
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useRef } from 'react';
|
||||
import { Form, Button } from '@douyinfe/semi-ui';
|
||||
import { IconSearch, IconRefresh } from '@douyinfe/semi-icons';
|
||||
|
||||
const DeploymentsFilters = ({
|
||||
formInitValues,
|
||||
setFormApi,
|
||||
searchDeployments,
|
||||
loading,
|
||||
searching,
|
||||
setShowColumnSelector,
|
||||
t,
|
||||
}) => {
|
||||
const formApiRef = useRef(null);
|
||||
|
||||
const handleSubmit = (values) => {
|
||||
searchDeployments(values);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (!formApiRef.current) return;
|
||||
formApiRef.current.reset();
|
||||
setTimeout(() => {
|
||||
formApiRef.current.submitForm();
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const statusOptions = [
|
||||
{ label: t('全部状态'), value: '' },
|
||||
{ label: t('运行中'), value: 'running' },
|
||||
{ label: t('已完成'), value: 'completed' },
|
||||
{ label: t('失败'), value: 'failed' },
|
||||
{ label: t('部署请求中'), value: 'deployment requested' },
|
||||
{ label: t('终止请求中'), value: 'termination requested' },
|
||||
{ label: t('已销毁'), value: 'destroyed' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Form
|
||||
layout='horizontal'
|
||||
onSubmit={handleSubmit}
|
||||
initValues={formInitValues}
|
||||
getFormApi={(formApi) => {
|
||||
setFormApi(formApi);
|
||||
formApiRef.current = formApi;
|
||||
}}
|
||||
className='w-full md:w-auto order-1 md:order-2'
|
||||
>
|
||||
<div className='flex flex-col md:flex-row items-center gap-2 w-full md:w-auto'>
|
||||
<div className='w-full md:w-64'>
|
||||
<Form.Input
|
||||
field='searchKeyword'
|
||||
placeholder={t('搜索部署名称')}
|
||||
prefix={<IconSearch />}
|
||||
showClear
|
||||
size='small'
|
||||
pure
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='w-full md:w-48'>
|
||||
<Form.Select
|
||||
field='searchStatus'
|
||||
placeholder={t('选择状态')}
|
||||
optionList={statusOptions}
|
||||
className='w-full'
|
||||
showClear
|
||||
size='small'
|
||||
pure
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex gap-2 w-full md:w-auto'>
|
||||
<Button
|
||||
htmlType='submit'
|
||||
type='tertiary'
|
||||
icon={<IconSearch />}
|
||||
loading={searching}
|
||||
disabled={loading}
|
||||
size='small'
|
||||
className='flex-1 md:flex-initial md:w-auto'
|
||||
>
|
||||
{t('查询')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type='tertiary'
|
||||
icon={<IconRefresh />}
|
||||
onClick={handleReset}
|
||||
disabled={loading || searching}
|
||||
size='small'
|
||||
className='flex-1 md:flex-initial md:w-auto'
|
||||
>
|
||||
{t('重置')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type='tertiary'
|
||||
onClick={() => setShowColumnSelector(true)}
|
||||
size='small'
|
||||
className='flex-1 md:flex-initial md:w-auto'
|
||||
>
|
||||
{t('列设置')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeploymentsFilters;
|
||||
247
web/src/components/table/model-deployments/DeploymentsTable.jsx
Normal file
247
web/src/components/table/model-deployments/DeploymentsTable.jsx
Normal file
@@ -0,0 +1,247 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Empty } from '@douyinfe/semi-ui';
|
||||
import CardTable from '../../common/ui/CardTable';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import { getDeploymentsColumns } from './DeploymentsColumnDefs';
|
||||
|
||||
// Import all the new modals
|
||||
import ViewLogsModal from './modals/ViewLogsModal';
|
||||
import ExtendDurationModal from './modals/ExtendDurationModal';
|
||||
import ViewDetailsModal from './modals/ViewDetailsModal';
|
||||
import UpdateConfigModal from './modals/UpdateConfigModal';
|
||||
import ConfirmationDialog from './modals/ConfirmationDialog';
|
||||
|
||||
const DeploymentsTable = (deploymentsData) => {
|
||||
const {
|
||||
deployments,
|
||||
loading,
|
||||
searching,
|
||||
activePage,
|
||||
pageSize,
|
||||
deploymentCount,
|
||||
compactMode,
|
||||
visibleColumns,
|
||||
rowSelection,
|
||||
batchOperationsEnabled = true,
|
||||
handlePageChange,
|
||||
handlePageSizeChange,
|
||||
handleRow,
|
||||
t,
|
||||
COLUMN_KEYS,
|
||||
// Column functions and data
|
||||
startDeployment,
|
||||
restartDeployment,
|
||||
deleteDeployment,
|
||||
syncDeploymentToChannel,
|
||||
setEditingDeployment,
|
||||
setShowEdit,
|
||||
refresh,
|
||||
} = deploymentsData;
|
||||
|
||||
// Modal states
|
||||
const [selectedDeployment, setSelectedDeployment] = useState(null);
|
||||
const [showLogsModal, setShowLogsModal] = useState(false);
|
||||
const [showExtendModal, setShowExtendModal] = useState(false);
|
||||
const [showDetailsModal, setShowDetailsModal] = useState(false);
|
||||
const [showConfigModal, setShowConfigModal] = useState(false);
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
const [confirmOperation, setConfirmOperation] = useState('delete');
|
||||
|
||||
// Enhanced modal handlers
|
||||
const handleViewLogs = (deployment) => {
|
||||
setSelectedDeployment(deployment);
|
||||
setShowLogsModal(true);
|
||||
};
|
||||
|
||||
const handleExtendDuration = (deployment) => {
|
||||
setSelectedDeployment(deployment);
|
||||
setShowExtendModal(true);
|
||||
};
|
||||
|
||||
const handleViewDetails = (deployment) => {
|
||||
setSelectedDeployment(deployment);
|
||||
setShowDetailsModal(true);
|
||||
};
|
||||
|
||||
const handleUpdateConfig = (deployment, operation = 'update') => {
|
||||
setSelectedDeployment(deployment);
|
||||
if (operation === 'delete' || operation === 'destroy') {
|
||||
setConfirmOperation(operation);
|
||||
setShowConfirmDialog(true);
|
||||
} else {
|
||||
setShowConfigModal(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmAction = () => {
|
||||
if (
|
||||
selectedDeployment &&
|
||||
(confirmOperation === 'delete' || confirmOperation === 'destroy')
|
||||
) {
|
||||
deleteDeployment(selectedDeployment.id);
|
||||
}
|
||||
setShowConfirmDialog(false);
|
||||
setSelectedDeployment(null);
|
||||
};
|
||||
|
||||
const handleModalSuccess = (updatedDeployment) => {
|
||||
// Refresh the deployments list
|
||||
refresh?.();
|
||||
};
|
||||
|
||||
// Get all columns
|
||||
const allColumns = useMemo(() => {
|
||||
return getDeploymentsColumns({
|
||||
t,
|
||||
COLUMN_KEYS,
|
||||
startDeployment,
|
||||
restartDeployment,
|
||||
deleteDeployment,
|
||||
setEditingDeployment,
|
||||
setShowEdit,
|
||||
refresh,
|
||||
activePage,
|
||||
deployments,
|
||||
// Enhanced handlers
|
||||
onViewLogs: handleViewLogs,
|
||||
onExtendDuration: handleExtendDuration,
|
||||
onViewDetails: handleViewDetails,
|
||||
onUpdateConfig: handleUpdateConfig,
|
||||
onSyncToChannel: syncDeploymentToChannel,
|
||||
});
|
||||
}, [
|
||||
t,
|
||||
COLUMN_KEYS,
|
||||
startDeployment,
|
||||
restartDeployment,
|
||||
deleteDeployment,
|
||||
syncDeploymentToChannel,
|
||||
setEditingDeployment,
|
||||
setShowEdit,
|
||||
refresh,
|
||||
activePage,
|
||||
deployments,
|
||||
]);
|
||||
|
||||
// Filter columns based on visibility settings
|
||||
const getVisibleColumns = () => {
|
||||
return allColumns.filter((column) => visibleColumns[column.key]);
|
||||
};
|
||||
|
||||
const visibleColumnsList = useMemo(() => {
|
||||
return getVisibleColumns();
|
||||
}, [visibleColumns, allColumns]);
|
||||
|
||||
const tableColumns = useMemo(() => {
|
||||
if (compactMode) {
|
||||
// In compact mode, remove fixed columns and adjust widths
|
||||
return visibleColumnsList.map(({ fixed, width, ...rest }) => ({
|
||||
...rest,
|
||||
width: width ? Math.max(width * 0.8, 80) : undefined, // Reduce width by 20% but keep minimum
|
||||
}));
|
||||
}
|
||||
return visibleColumnsList;
|
||||
}, [compactMode, visibleColumnsList]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardTable
|
||||
columns={tableColumns}
|
||||
dataSource={deployments}
|
||||
scroll={compactMode ? { x: 800 } : { x: 1200 }}
|
||||
pagination={{
|
||||
currentPage: activePage,
|
||||
pageSize: pageSize,
|
||||
total: deploymentCount,
|
||||
pageSizeOpts: [10, 20, 50, 100],
|
||||
showSizeChanger: true,
|
||||
onPageSizeChange: handlePageSizeChange,
|
||||
onPageChange: handlePageChange,
|
||||
}}
|
||||
hidePagination={true}
|
||||
expandAllRows={false}
|
||||
onRow={handleRow}
|
||||
rowSelection={batchOperationsEnabled ? rowSelection : undefined}
|
||||
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'
|
||||
size='middle'
|
||||
loading={loading || searching}
|
||||
/>
|
||||
|
||||
{/* Enhanced Modals */}
|
||||
<ViewLogsModal
|
||||
visible={showLogsModal}
|
||||
onCancel={() => setShowLogsModal(false)}
|
||||
deployment={selectedDeployment}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<ExtendDurationModal
|
||||
visible={showExtendModal}
|
||||
onCancel={() => setShowExtendModal(false)}
|
||||
deployment={selectedDeployment}
|
||||
onSuccess={handleModalSuccess}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<ViewDetailsModal
|
||||
visible={showDetailsModal}
|
||||
onCancel={() => setShowDetailsModal(false)}
|
||||
deployment={selectedDeployment}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<UpdateConfigModal
|
||||
visible={showConfigModal}
|
||||
onCancel={() => setShowConfigModal(false)}
|
||||
deployment={selectedDeployment}
|
||||
onSuccess={handleModalSuccess}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<ConfirmationDialog
|
||||
visible={showConfirmDialog}
|
||||
onCancel={() => setShowConfirmDialog(false)}
|
||||
onConfirm={handleConfirmAction}
|
||||
title={t('确认操作')}
|
||||
type='danger'
|
||||
deployment={selectedDeployment}
|
||||
operation={confirmOperation}
|
||||
t={t}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeploymentsTable;
|
||||
152
web/src/components/table/model-deployments/index.jsx
Normal file
152
web/src/components/table/model-deployments/index.jsx
Normal file
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import CardPro from '../../common/ui/CardPro';
|
||||
import DeploymentsTable from './DeploymentsTable';
|
||||
import DeploymentsActions from './DeploymentsActions';
|
||||
import DeploymentsFilters from './DeploymentsFilters';
|
||||
import EditDeploymentModal from './modals/EditDeploymentModal';
|
||||
import CreateDeploymentModal from './modals/CreateDeploymentModal';
|
||||
import ColumnSelectorModal from './modals/ColumnSelectorModal';
|
||||
import { useDeploymentsData } from '../../../hooks/model-deployments/useDeploymentsData';
|
||||
import { useIsMobile } from '../../../hooks/common/useIsMobile';
|
||||
import { createCardProPagination } from '../../../helpers/utils';
|
||||
|
||||
const DeploymentsPage = () => {
|
||||
const deploymentsData = useDeploymentsData();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// Create deployment modal state
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const batchOperationsEnabled = false;
|
||||
|
||||
const {
|
||||
// Edit state
|
||||
showEdit,
|
||||
editingDeployment,
|
||||
closeEdit,
|
||||
refresh,
|
||||
|
||||
// Actions state
|
||||
selectedKeys,
|
||||
setSelectedKeys,
|
||||
setEditingDeployment,
|
||||
setShowEdit,
|
||||
batchDeleteDeployments,
|
||||
|
||||
// Filters state
|
||||
formInitValues,
|
||||
setFormApi,
|
||||
searchDeployments,
|
||||
loading,
|
||||
searching,
|
||||
|
||||
// Column visibility
|
||||
showColumnSelector,
|
||||
setShowColumnSelector,
|
||||
visibleColumns,
|
||||
setVisibleColumns,
|
||||
COLUMN_KEYS,
|
||||
|
||||
// Description state
|
||||
compactMode,
|
||||
setCompactMode,
|
||||
|
||||
// Translation
|
||||
t,
|
||||
} = deploymentsData;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Modals */}
|
||||
<EditDeploymentModal
|
||||
refresh={refresh}
|
||||
editingDeployment={editingDeployment}
|
||||
visible={showEdit}
|
||||
handleClose={closeEdit}
|
||||
/>
|
||||
|
||||
<CreateDeploymentModal
|
||||
visible={showCreateModal}
|
||||
onCancel={() => setShowCreateModal(false)}
|
||||
onSuccess={refresh}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<ColumnSelectorModal
|
||||
visible={showColumnSelector}
|
||||
onCancel={() => setShowColumnSelector(false)}
|
||||
visibleColumns={visibleColumns}
|
||||
onVisibleColumnsChange={setVisibleColumns}
|
||||
columnKeys={COLUMN_KEYS}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
{/* Main Content */}
|
||||
<CardPro
|
||||
type='type3'
|
||||
actionsArea={
|
||||
<div className='flex flex-col md:flex-row justify-between items-center gap-2 w-full'>
|
||||
<DeploymentsActions
|
||||
selectedKeys={selectedKeys}
|
||||
setSelectedKeys={setSelectedKeys}
|
||||
setEditingDeployment={setEditingDeployment}
|
||||
setShowEdit={setShowEdit}
|
||||
batchDeleteDeployments={batchDeleteDeployments}
|
||||
batchOperationsEnabled={batchOperationsEnabled}
|
||||
compactMode={compactMode}
|
||||
setCompactMode={setCompactMode}
|
||||
showCreateModal={showCreateModal}
|
||||
setShowCreateModal={setShowCreateModal}
|
||||
setShowColumnSelector={setShowColumnSelector}
|
||||
t={t}
|
||||
/>
|
||||
<DeploymentsFilters
|
||||
formInitValues={formInitValues}
|
||||
setFormApi={setFormApi}
|
||||
searchDeployments={searchDeployments}
|
||||
loading={loading}
|
||||
searching={searching}
|
||||
setShowColumnSelector={setShowColumnSelector}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
paginationArea={createCardProPagination({
|
||||
currentPage: deploymentsData.activePage,
|
||||
pageSize: deploymentsData.pageSize,
|
||||
total: deploymentsData.deploymentCount,
|
||||
onPageChange: deploymentsData.handlePageChange,
|
||||
onPageSizeChange: deploymentsData.handlePageSizeChange,
|
||||
isMobile: isMobile,
|
||||
t: deploymentsData.t,
|
||||
})}
|
||||
t={deploymentsData.t}
|
||||
>
|
||||
<DeploymentsTable
|
||||
{...deploymentsData}
|
||||
batchOperationsEnabled={batchOperationsEnabled}
|
||||
/>
|
||||
</CardPro>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeploymentsPage;
|
||||
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { Modal, Button, Checkbox } from '@douyinfe/semi-ui';
|
||||
|
||||
const ColumnSelectorModal = ({
|
||||
visible,
|
||||
onCancel,
|
||||
visibleColumns,
|
||||
onVisibleColumnsChange,
|
||||
columnKeys,
|
||||
t,
|
||||
}) => {
|
||||
const columnOptions = useMemo(
|
||||
() => [
|
||||
{ key: columnKeys.container_name, label: t('容器名称'), required: true },
|
||||
{ key: columnKeys.status, label: t('状态') },
|
||||
{ key: columnKeys.time_remaining, label: t('剩余时间') },
|
||||
{ key: columnKeys.hardware_info, label: t('硬件配置') },
|
||||
{ key: columnKeys.created_at, label: t('创建时间') },
|
||||
{ key: columnKeys.actions, label: t('操作'), required: true },
|
||||
],
|
||||
[columnKeys, t],
|
||||
);
|
||||
|
||||
const handleColumnVisibilityChange = (key, checked) => {
|
||||
const column = columnOptions.find((option) => option.key === key);
|
||||
if (column?.required) return;
|
||||
onVisibleColumnsChange({
|
||||
...visibleColumns,
|
||||
[key]: checked,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectAll = (checked) => {
|
||||
const updated = { ...visibleColumns };
|
||||
columnOptions.forEach(({ key, required }) => {
|
||||
updated[key] = required ? true : checked;
|
||||
});
|
||||
onVisibleColumnsChange(updated);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
const defaults = columnOptions.reduce((acc, { key }) => {
|
||||
acc[key] = true;
|
||||
return acc;
|
||||
}, {});
|
||||
onVisibleColumnsChange({
|
||||
...visibleColumns,
|
||||
...defaults,
|
||||
});
|
||||
};
|
||||
|
||||
const allSelected = columnOptions.every(
|
||||
({ key, required }) => required || visibleColumns[key],
|
||||
);
|
||||
const indeterminate =
|
||||
columnOptions.some(
|
||||
({ key, required }) => !required && visibleColumns[key],
|
||||
) && !allSelected;
|
||||
|
||||
const handleConfirm = () => onCancel();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('列设置')}
|
||||
visible={visible}
|
||||
onCancel={onCancel}
|
||||
footer={
|
||||
<div className='flex justify-end gap-2'>
|
||||
<Button onClick={handleReset}>{t('重置')}</Button>
|
||||
<Button onClick={onCancel}>{t('取消')}</Button>
|
||||
<Button type='primary' onClick={handleConfirm}>
|
||||
{t('确定')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
indeterminate={indeterminate}
|
||||
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||
>
|
||||
{t('全选')}
|
||||
</Checkbox>
|
||||
</div>
|
||||
<div
|
||||
className='flex flex-wrap max-h-96 overflow-y-auto rounded-lg p-4'
|
||||
style={{ border: '1px solid var(--semi-color-border)' }}
|
||||
>
|
||||
{columnOptions.map(({ key, label, required }) => (
|
||||
<div key={key} className='w-1/2 mb-4 pr-2'>
|
||||
<Checkbox
|
||||
checked={!!visibleColumns[key]}
|
||||
disabled={required}
|
||||
onChange={(e) =>
|
||||
handleColumnVisibilityChange(key, e.target.checked)
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</Checkbox>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ColumnSelectorModal;
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, Typography, Input } from '@douyinfe/semi-ui';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const ConfirmationDialog = ({
|
||||
visible,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
title,
|
||||
type = 'danger',
|
||||
deployment,
|
||||
t,
|
||||
loading = false,
|
||||
}) => {
|
||||
const [confirmText, setConfirmText] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setConfirmText('');
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const requiredText = deployment?.container_name || deployment?.id || '';
|
||||
const isConfirmed = Boolean(requiredText) && confirmText === requiredText;
|
||||
|
||||
const handleCancel = () => {
|
||||
setConfirmText('');
|
||||
onCancel();
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (isConfirmed) {
|
||||
onConfirm();
|
||||
handleCancel();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={title}
|
||||
visible={visible}
|
||||
onCancel={handleCancel}
|
||||
onOk={handleConfirm}
|
||||
okText={t('确认')}
|
||||
cancelText={t('取消')}
|
||||
okButtonProps={{
|
||||
disabled: !isConfirmed,
|
||||
type: type === 'danger' ? 'danger' : 'primary',
|
||||
loading,
|
||||
}}
|
||||
width={480}
|
||||
>
|
||||
<div className='space-y-4'>
|
||||
<Text type='danger' strong>
|
||||
{t('此操作具有风险,请确认要继续执行')}。
|
||||
</Text>
|
||||
<Text>
|
||||
{t('请输入部署名称以完成二次确认')}:
|
||||
<Text code className='ml-1'>
|
||||
{requiredText || t('未知部署')}
|
||||
</Text>
|
||||
</Text>
|
||||
<Input
|
||||
value={confirmText}
|
||||
onChange={setConfirmText}
|
||||
placeholder={t('再次输入部署名称')}
|
||||
autoFocus
|
||||
/>
|
||||
{!isConfirmed && confirmText && (
|
||||
<Text type='danger' size='small'>
|
||||
{t('部署名称不匹配,请检查后重新输入')}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfirmationDialog;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,241 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
SideSheet,
|
||||
Form,
|
||||
Button,
|
||||
Space,
|
||||
Spin,
|
||||
Typography,
|
||||
Card,
|
||||
InputNumber,
|
||||
Select,
|
||||
Input,
|
||||
Row,
|
||||
Col,
|
||||
Divider,
|
||||
Tag,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { Save, X, Server } from 'lucide-react';
|
||||
import { API, showError, showSuccess } from '../../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
const EditDeploymentModal = ({
|
||||
refresh,
|
||||
editingDeployment,
|
||||
visible,
|
||||
handleClose,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsMobile();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [models, setModels] = useState([]);
|
||||
const [loadingModels, setLoadingModels] = useState(false);
|
||||
const formRef = useRef();
|
||||
|
||||
const isEdit = Boolean(editingDeployment?.id);
|
||||
const title = t('重命名部署');
|
||||
|
||||
// Resource configuration options
|
||||
const cpuOptions = [
|
||||
{ label: '0.5 Core', value: '0.5' },
|
||||
{ label: '1 Core', value: '1' },
|
||||
{ label: '2 Cores', value: '2' },
|
||||
{ label: '4 Cores', value: '4' },
|
||||
{ label: '8 Cores', value: '8' },
|
||||
];
|
||||
|
||||
const memoryOptions = [
|
||||
{ label: '1GB', value: '1Gi' },
|
||||
{ label: '2GB', value: '2Gi' },
|
||||
{ label: '4GB', value: '4Gi' },
|
||||
{ label: '8GB', value: '8Gi' },
|
||||
{ label: '16GB', value: '16Gi' },
|
||||
{ label: '32GB', value: '32Gi' },
|
||||
];
|
||||
|
||||
const gpuOptions = [
|
||||
{ label: t('无GPU'), value: '' },
|
||||
{ label: '1 GPU', value: '1' },
|
||||
{ label: '2 GPUs', value: '2' },
|
||||
{ label: '4 GPUs', value: '4' },
|
||||
];
|
||||
|
||||
// Load available models
|
||||
const loadModels = async () => {
|
||||
setLoadingModels(true);
|
||||
try {
|
||||
const res = await API.get('/api/models/?page_size=1000');
|
||||
if (res.data.success) {
|
||||
const items = res.data.data.items || res.data.data || [];
|
||||
const modelOptions = items.map((model) => ({
|
||||
label: `${model.model_name} (${model.vendor?.name || 'Unknown'})`,
|
||||
value: model.model_name,
|
||||
model_id: model.id,
|
||||
}));
|
||||
setModels(modelOptions);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load models:', error);
|
||||
showError(t('加载模型列表失败'));
|
||||
}
|
||||
setLoadingModels(false);
|
||||
};
|
||||
|
||||
// Form submission
|
||||
const handleSubmit = async (values) => {
|
||||
if (!isEdit || !editingDeployment?.id) {
|
||||
showError(t('无效的部署信息'));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// Only handle name update for now
|
||||
const res = await API.put(
|
||||
`/api/deployments/${editingDeployment.id}/name`,
|
||||
{
|
||||
name: values.deployment_name,
|
||||
},
|
||||
);
|
||||
|
||||
if (res.data.success) {
|
||||
showSuccess(t('部署名称更新成功'));
|
||||
handleClose();
|
||||
refresh();
|
||||
} else {
|
||||
showError(res.data.message || t('更新失败'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Submit error:', error);
|
||||
showError(t('更新失败,请检查输入信息'));
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
// Load models when modal opens
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
loadModels();
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
// Set form values when editing
|
||||
useEffect(() => {
|
||||
if (formRef.current && editingDeployment && visible && isEdit) {
|
||||
formRef.current.setValues({
|
||||
deployment_name: editingDeployment.deployment_name || '',
|
||||
});
|
||||
}
|
||||
}, [editingDeployment, visible, isEdit]);
|
||||
|
||||
return (
|
||||
<SideSheet
|
||||
title={
|
||||
<div className='flex items-center gap-2'>
|
||||
<Server size={20} />
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
}
|
||||
visible={visible}
|
||||
onCancel={handleClose}
|
||||
width={isMobile ? '100%' : 600}
|
||||
bodyStyle={{ padding: 0 }}
|
||||
maskClosable={false}
|
||||
closeOnEsc={true}
|
||||
>
|
||||
<div className='p-6 h-full overflow-auto'>
|
||||
<Spin spinning={loading} style={{ width: '100%' }}>
|
||||
<Form
|
||||
ref={formRef}
|
||||
onSubmit={handleSubmit}
|
||||
labelPosition='top'
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Card>
|
||||
<Title heading={5} style={{ marginBottom: 16 }}>
|
||||
{t('修改部署名称')}
|
||||
</Title>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={24}>
|
||||
<Form.Input
|
||||
field='deployment_name'
|
||||
label={t('部署名称')}
|
||||
placeholder={t('请输入新的部署名称')}
|
||||
rules={[
|
||||
{ required: true, message: t('请输入部署名称') },
|
||||
{
|
||||
pattern: /^[a-zA-Z0-9-_\u4e00-\u9fa5]+$/,
|
||||
message: t(
|
||||
'部署名称只能包含字母、数字、横线、下划线和中文',
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{isEdit && (
|
||||
<div className='mt-4 p-3 bg-gray-50 rounded'>
|
||||
<Text type='secondary'>{t('部署ID')}: </Text>
|
||||
<Text code>{editingDeployment.id}</Text>
|
||||
<br />
|
||||
<Text type='secondary'>{t('当前状态')}: </Text>
|
||||
<Tag
|
||||
color={
|
||||
editingDeployment.status === 'running' ? 'green' : 'grey'
|
||||
}
|
||||
>
|
||||
{editingDeployment.status}
|
||||
</Tag>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</Form>
|
||||
</Spin>
|
||||
</div>
|
||||
|
||||
<div className='p-4 border-t border-gray-200 bg-gray-50 flex justify-end'>
|
||||
<Space>
|
||||
<Button theme='outline' onClick={handleClose} disabled={loading}>
|
||||
<X size={16} className='mr-1' />
|
||||
{t('取消')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='solid'
|
||||
type='primary'
|
||||
loading={loading}
|
||||
onClick={() => formRef.current?.submitForm()}
|
||||
>
|
||||
<Save size={16} className='mr-1' />
|
||||
{isEdit ? t('更新') : t('创建')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</SideSheet>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditDeploymentModal;
|
||||
@@ -0,0 +1,542 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Form,
|
||||
InputNumber,
|
||||
Typography,
|
||||
Card,
|
||||
Space,
|
||||
Divider,
|
||||
Button,
|
||||
Tag,
|
||||
Banner,
|
||||
Spin,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
FaClock,
|
||||
FaCalculator,
|
||||
FaInfoCircle,
|
||||
FaExclamationTriangle,
|
||||
} from 'react-icons/fa';
|
||||
import { API, showError, showSuccess } from '../../../../helpers';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const ExtendDurationModal = ({
|
||||
visible,
|
||||
onCancel,
|
||||
deployment,
|
||||
onSuccess,
|
||||
t,
|
||||
}) => {
|
||||
const formRef = useRef(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [durationHours, setDurationHours] = useState(1);
|
||||
const [costLoading, setCostLoading] = useState(false);
|
||||
const [priceEstimation, setPriceEstimation] = useState(null);
|
||||
const [priceError, setPriceError] = useState(null);
|
||||
const [detailsLoading, setDetailsLoading] = useState(false);
|
||||
const [deploymentDetails, setDeploymentDetails] = useState(null);
|
||||
const costRequestIdRef = useRef(0);
|
||||
|
||||
const resetState = () => {
|
||||
costRequestIdRef.current += 1;
|
||||
setDurationHours(1);
|
||||
setPriceEstimation(null);
|
||||
setPriceError(null);
|
||||
setDeploymentDetails(null);
|
||||
setCostLoading(false);
|
||||
};
|
||||
|
||||
const fetchDeploymentDetails = async (deploymentId) => {
|
||||
setDetailsLoading(true);
|
||||
try {
|
||||
const response = await API.get(`/api/deployments/${deploymentId}`);
|
||||
if (response.data.success) {
|
||||
const details = response.data.data;
|
||||
setDeploymentDetails(details);
|
||||
setPriceError(null);
|
||||
return details;
|
||||
}
|
||||
|
||||
const message = response.data.message || '';
|
||||
const errorMessage = t('获取详情失败') + (message ? `: ${message}` : '');
|
||||
showError(errorMessage);
|
||||
setDeploymentDetails(null);
|
||||
setPriceEstimation(null);
|
||||
setPriceError(errorMessage);
|
||||
return null;
|
||||
} catch (error) {
|
||||
const message = error?.response?.data?.message || error.message || '';
|
||||
const errorMessage = t('获取详情失败') + (message ? `: ${message}` : '');
|
||||
showError(errorMessage);
|
||||
setDeploymentDetails(null);
|
||||
setPriceEstimation(null);
|
||||
setPriceError(errorMessage);
|
||||
return null;
|
||||
} finally {
|
||||
setDetailsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const calculatePrice = async (hours, details) => {
|
||||
if (!visible || !details) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sanitizedHours = Number.isFinite(hours) ? Math.round(hours) : 0;
|
||||
if (sanitizedHours <= 0) {
|
||||
setPriceEstimation(null);
|
||||
setPriceError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const hardwareId = Number(details?.hardware_id) || 0;
|
||||
const totalGPUs = Number(details?.total_gpus) || 0;
|
||||
const totalContainers = Number(details?.total_containers) || 0;
|
||||
const baseGpusPerContainer = Number(details?.gpus_per_container) || 0;
|
||||
const resolvedGpusPerContainer =
|
||||
baseGpusPerContainer > 0
|
||||
? baseGpusPerContainer
|
||||
: totalContainers > 0 && totalGPUs > 0
|
||||
? Math.max(1, Math.round(totalGPUs / totalContainers))
|
||||
: 0;
|
||||
const resolvedReplicaCount =
|
||||
totalContainers > 0
|
||||
? totalContainers
|
||||
: resolvedGpusPerContainer > 0 && totalGPUs > 0
|
||||
? Math.max(1, Math.round(totalGPUs / resolvedGpusPerContainer))
|
||||
: 0;
|
||||
const locationIds = Array.isArray(details?.locations)
|
||||
? details.locations
|
||||
.map((location) =>
|
||||
Number(
|
||||
location?.id ?? location?.location_id ?? location?.locationId,
|
||||
),
|
||||
)
|
||||
.filter((id) => Number.isInteger(id) && id > 0)
|
||||
: [];
|
||||
|
||||
if (
|
||||
hardwareId <= 0 ||
|
||||
resolvedGpusPerContainer <= 0 ||
|
||||
resolvedReplicaCount <= 0 ||
|
||||
locationIds.length === 0
|
||||
) {
|
||||
setPriceEstimation(null);
|
||||
setPriceError(t('价格计算失败'));
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = Date.now();
|
||||
costRequestIdRef.current = requestId;
|
||||
setCostLoading(true);
|
||||
setPriceError(null);
|
||||
|
||||
const payload = {
|
||||
location_ids: locationIds,
|
||||
hardware_id: hardwareId,
|
||||
gpus_per_container: resolvedGpusPerContainer,
|
||||
duration_hours: sanitizedHours,
|
||||
replica_count: resolvedReplicaCount,
|
||||
currency: 'usdc',
|
||||
duration_type: 'hour',
|
||||
duration_qty: sanitizedHours,
|
||||
hardware_qty: resolvedGpusPerContainer,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await API.post(
|
||||
'/api/deployments/price-estimation',
|
||||
payload,
|
||||
);
|
||||
|
||||
if (costRequestIdRef.current !== requestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.data.success) {
|
||||
setPriceEstimation(response.data.data);
|
||||
} else {
|
||||
const message = response.data.message || '';
|
||||
setPriceEstimation(null);
|
||||
setPriceError(t('价格计算失败') + (message ? `: ${message}` : ''));
|
||||
}
|
||||
} catch (error) {
|
||||
if (costRequestIdRef.current !== requestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = error?.response?.data?.message || error.message || '';
|
||||
setPriceEstimation(null);
|
||||
setPriceError(t('价格计算失败') + (message ? `: ${message}` : ''));
|
||||
} finally {
|
||||
if (costRequestIdRef.current === requestId) {
|
||||
setCostLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && deployment?.id) {
|
||||
resetState();
|
||||
if (formRef.current) {
|
||||
formRef.current.setValue('duration_hours', 1);
|
||||
}
|
||||
fetchDeploymentDetails(deployment.id);
|
||||
}
|
||||
if (!visible) {
|
||||
resetState();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [visible, deployment?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
return;
|
||||
}
|
||||
if (!deploymentDetails) {
|
||||
return;
|
||||
}
|
||||
calculatePrice(durationHours, deploymentDetails);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [durationHours, deploymentDetails, visible]);
|
||||
|
||||
const handleExtend = async () => {
|
||||
try {
|
||||
if (formRef.current) {
|
||||
await formRef.current.validate();
|
||||
}
|
||||
setLoading(true);
|
||||
|
||||
const response = await API.post(
|
||||
`/api/deployments/${deployment.id}/extend`,
|
||||
{
|
||||
duration_hours: Math.round(durationHours),
|
||||
},
|
||||
);
|
||||
|
||||
if (response.data.success) {
|
||||
showSuccess(t('容器时长延长成功'));
|
||||
onSuccess?.(response.data.data);
|
||||
handleCancel();
|
||||
}
|
||||
} catch (error) {
|
||||
showError(
|
||||
t('延长时长失败') +
|
||||
': ' +
|
||||
(error?.response?.data?.message || error.message),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (formRef.current) {
|
||||
formRef.current.reset();
|
||||
}
|
||||
resetState();
|
||||
onCancel();
|
||||
};
|
||||
|
||||
const currentRemainingTime = deployment?.time_remaining || '0分钟';
|
||||
const newTotalTime = `${currentRemainingTime} + ${durationHours}${t('小时')}`;
|
||||
|
||||
const priceData = priceEstimation || {};
|
||||
const breakdown = priceData.price_breakdown || priceData.PriceBreakdown || {};
|
||||
const currencyLabel = (priceData.currency || priceData.Currency || 'USDC')
|
||||
.toString()
|
||||
.toUpperCase();
|
||||
|
||||
const estimatedTotalCost =
|
||||
typeof priceData.estimated_cost === 'number'
|
||||
? priceData.estimated_cost
|
||||
: typeof priceData.EstimatedCost === 'number'
|
||||
? priceData.EstimatedCost
|
||||
: typeof breakdown.total_cost === 'number'
|
||||
? breakdown.total_cost
|
||||
: breakdown.TotalCost;
|
||||
const hourlyRate =
|
||||
typeof breakdown.hourly_rate === 'number'
|
||||
? breakdown.hourly_rate
|
||||
: breakdown.HourlyRate;
|
||||
const computeCost =
|
||||
typeof breakdown.compute_cost === 'number'
|
||||
? breakdown.compute_cost
|
||||
: breakdown.ComputeCost;
|
||||
|
||||
const resolvedHardwareName =
|
||||
deploymentDetails?.hardware_name || deployment?.hardware_name || '--';
|
||||
const gpuCount =
|
||||
deploymentDetails?.total_gpus || deployment?.hardware_quantity || 0;
|
||||
const containers = deploymentDetails?.total_containers || 0;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<div className='flex items-center gap-2'>
|
||||
<FaClock className='text-blue-500' />
|
||||
<span>{t('延长容器时长')}</span>
|
||||
</div>
|
||||
}
|
||||
visible={visible}
|
||||
onCancel={handleCancel}
|
||||
onOk={handleExtend}
|
||||
okText={t('确认延长')}
|
||||
cancelText={t('取消')}
|
||||
confirmLoading={loading}
|
||||
okButtonProps={{
|
||||
disabled:
|
||||
!deployment?.id ||
|
||||
detailsLoading ||
|
||||
!durationHours ||
|
||||
durationHours < 1,
|
||||
}}
|
||||
width={600}
|
||||
className='extend-duration-modal'
|
||||
>
|
||||
<div className='space-y-4'>
|
||||
<Card className='border-0 bg-gray-50'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div>
|
||||
<Text strong className='text-base'>
|
||||
{deployment?.container_name || deployment?.deployment_name}
|
||||
</Text>
|
||||
<div className='mt-1'>
|
||||
<Text type='secondary' size='small'>
|
||||
ID: {deployment?.id}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
<div className='text-right'>
|
||||
<div className='flex items-center gap-2 mb-1'>
|
||||
<Tag color='blue' size='small'>
|
||||
{resolvedHardwareName}
|
||||
{gpuCount ? ` x${gpuCount}` : ''}
|
||||
</Tag>
|
||||
</div>
|
||||
<Text size='small' type='secondary'>
|
||||
{t('当前剩余')}: <Text strong>{currentRemainingTime}</Text>
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Banner
|
||||
type='warning'
|
||||
icon={<FaExclamationTriangle />}
|
||||
title={t('重要提醒')}
|
||||
description={
|
||||
<div className='space-y-2'>
|
||||
<p>
|
||||
{t('延长容器时长将会产生额外费用,请确认您有足够的账户余额。')}
|
||||
</p>
|
||||
<p>{t('延长操作一旦确认无法撤销,费用将立即扣除。')}</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<Form
|
||||
getFormApi={(api) => (formRef.current = api)}
|
||||
layout='vertical'
|
||||
onValueChange={(values) => {
|
||||
if (values.duration_hours !== undefined) {
|
||||
const numericValue = Number(values.duration_hours);
|
||||
setDurationHours(
|
||||
Number.isFinite(numericValue) ? numericValue : 0,
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Form.InputNumber
|
||||
field='duration_hours'
|
||||
label={t('延长时长(小时)')}
|
||||
placeholder={t('请输入要延长的小时数')}
|
||||
min={1}
|
||||
max={720}
|
||||
step={1}
|
||||
initValue={1}
|
||||
style={{ width: '100%' }}
|
||||
suffix={t('小时')}
|
||||
rules={[
|
||||
{ required: true, message: t('请输入延长时长') },
|
||||
{
|
||||
type: 'number',
|
||||
min: 1,
|
||||
message: t('延长时长至少为1小时'),
|
||||
},
|
||||
{
|
||||
type: 'number',
|
||||
max: 720,
|
||||
message: t('延长时长不能超过720小时(30天)'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Form>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Text size='small' type='secondary'>
|
||||
{t('快速选择')}:
|
||||
</Text>
|
||||
<Space wrap>
|
||||
{[1, 2, 6, 12, 24, 48, 72, 168].map((hours) => (
|
||||
<Button
|
||||
key={hours}
|
||||
size='small'
|
||||
theme={durationHours === hours ? 'solid' : 'borderless'}
|
||||
type={durationHours === hours ? 'primary' : 'secondary'}
|
||||
onClick={() => {
|
||||
setDurationHours(hours);
|
||||
if (formRef.current) {
|
||||
formRef.current.setValue('duration_hours', hours);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{hours < 24
|
||||
? `${hours}${t('小时')}`
|
||||
: `${hours / 24}${t('天')}`}
|
||||
</Button>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Card
|
||||
title={
|
||||
<div className='flex items-center gap-2'>
|
||||
<FaCalculator className='text-green-500' />
|
||||
<span>{t('费用预估')}</span>
|
||||
</div>
|
||||
}
|
||||
className='border border-green-200'
|
||||
>
|
||||
{priceEstimation ? (
|
||||
<div className='space-y-3'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Text>{t('延长时长')}:</Text>
|
||||
<Text strong>
|
||||
{Math.round(durationHours)} {t('小时')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between'>
|
||||
<Text>{t('硬件配置')}:</Text>
|
||||
<Text strong>
|
||||
{resolvedHardwareName}
|
||||
{gpuCount ? ` x${gpuCount}` : ''}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{containers ? (
|
||||
<div className='flex items-center justify-between'>
|
||||
<Text>{t('容器数量')}:</Text>
|
||||
<Text strong>{containers}</Text>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className='flex items-center justify-between'>
|
||||
<Text>{t('单GPU小时费率')}:</Text>
|
||||
<Text strong>
|
||||
{typeof hourlyRate === 'number'
|
||||
? `${hourlyRate.toFixed(4)} ${currencyLabel}`
|
||||
: '--'}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{typeof computeCost === 'number' && (
|
||||
<div className='flex items-center justify-between'>
|
||||
<Text>{t('计算成本')}:</Text>
|
||||
<Text strong>
|
||||
{computeCost.toFixed(4)} {currencyLabel}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Divider margin='12px' />
|
||||
|
||||
<div className='flex items-center justify-between'>
|
||||
<Text strong className='text-lg'>
|
||||
{t('预估总费用')}:
|
||||
</Text>
|
||||
<Text strong className='text-lg text-green-600'>
|
||||
{typeof estimatedTotalCost === 'number'
|
||||
? `${estimatedTotalCost.toFixed(4)} ${currencyLabel}`
|
||||
: '--'}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className='bg-blue-50 p-3 rounded-lg'>
|
||||
<div className='flex items-start gap-2'>
|
||||
<FaInfoCircle className='text-blue-500 mt-0.5' />
|
||||
<div>
|
||||
<Text size='small' type='secondary'>
|
||||
{t('延长后总时长')}: <Text strong>{newTotalTime}</Text>
|
||||
</Text>
|
||||
<br />
|
||||
<Text size='small' type='secondary'>
|
||||
{t('预估费用仅供参考,实际费用可能略有差异')}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='text-center text-gray-500 py-4'>
|
||||
{costLoading ? (
|
||||
<Space align='center' className='justify-center'>
|
||||
<Spin size='small' />
|
||||
<Text type='secondary'>{t('计算费用中...')}</Text>
|
||||
</Space>
|
||||
) : priceError ? (
|
||||
<Text type='danger'>{priceError}</Text>
|
||||
) : deploymentDetails ? (
|
||||
<Text type='secondary'>{t('请输入延长时长')}</Text>
|
||||
) : (
|
||||
<Text type='secondary'>{t('加载详情中...')}</Text>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<div className='bg-red-50 border border-red-200 rounded-lg p-3'>
|
||||
<div className='flex items-start gap-2'>
|
||||
<FaExclamationTriangle className='text-red-500 mt-0.5' />
|
||||
<div>
|
||||
<Text strong className='text-red-700'>
|
||||
{t('确认延长容器时长')}
|
||||
</Text>
|
||||
<div className='mt-1'>
|
||||
<Text size='small' className='text-red-600'>
|
||||
{t('点击"确认延长"后将立即扣除费用并延长容器运行时间')}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExtendDurationModal;
|
||||
@@ -0,0 +1,497 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Typography,
|
||||
Card,
|
||||
Space,
|
||||
Divider,
|
||||
Button,
|
||||
Banner,
|
||||
Tag,
|
||||
Collapse,
|
||||
TextArea,
|
||||
Switch,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
FaCog,
|
||||
FaDocker,
|
||||
FaKey,
|
||||
FaTerminal,
|
||||
FaNetworkWired,
|
||||
FaExclamationTriangle,
|
||||
FaPlus,
|
||||
FaMinus,
|
||||
} from 'react-icons/fa';
|
||||
import { API, showError, showSuccess } from '../../../../helpers';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
const UpdateConfigModal = ({ visible, onCancel, deployment, onSuccess, t }) => {
|
||||
const formRef = useRef(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [envVars, setEnvVars] = useState([]);
|
||||
const [secretEnvVars, setSecretEnvVars] = useState([]);
|
||||
|
||||
// Initialize form data when modal opens
|
||||
useEffect(() => {
|
||||
if (visible && deployment) {
|
||||
// Set initial form values based on deployment data
|
||||
const initialValues = {
|
||||
image_url: deployment.container_config?.image_url || '',
|
||||
traffic_port: deployment.container_config?.traffic_port || null,
|
||||
entrypoint: deployment.container_config?.entrypoint?.join(' ') || '',
|
||||
registry_username: '',
|
||||
registry_secret: '',
|
||||
command: '',
|
||||
};
|
||||
|
||||
if (formRef.current) {
|
||||
formRef.current.setValues(initialValues);
|
||||
}
|
||||
|
||||
// Initialize environment variables
|
||||
const envVarsList = deployment.container_config?.env_variables
|
||||
? Object.entries(deployment.container_config.env_variables).map(
|
||||
([key, value]) => ({
|
||||
key,
|
||||
value: String(value),
|
||||
}),
|
||||
)
|
||||
: [];
|
||||
|
||||
setEnvVars(envVarsList);
|
||||
setSecretEnvVars([]);
|
||||
}
|
||||
}, [visible, deployment]);
|
||||
|
||||
const handleUpdate = async () => {
|
||||
try {
|
||||
const formValues = formRef.current
|
||||
? await formRef.current.validate()
|
||||
: {};
|
||||
setLoading(true);
|
||||
|
||||
// Prepare the update payload
|
||||
const payload = {};
|
||||
|
||||
if (formValues.image_url) payload.image_url = formValues.image_url;
|
||||
if (formValues.traffic_port)
|
||||
payload.traffic_port = formValues.traffic_port;
|
||||
if (formValues.registry_username)
|
||||
payload.registry_username = formValues.registry_username;
|
||||
if (formValues.registry_secret)
|
||||
payload.registry_secret = formValues.registry_secret;
|
||||
if (formValues.command) payload.command = formValues.command;
|
||||
|
||||
// Process entrypoint
|
||||
if (formValues.entrypoint) {
|
||||
payload.entrypoint = formValues.entrypoint
|
||||
.split(' ')
|
||||
.filter((cmd) => cmd.trim());
|
||||
}
|
||||
|
||||
// Process environment variables
|
||||
if (envVars.length > 0) {
|
||||
payload.env_variables = envVars.reduce((acc, env) => {
|
||||
if (env.key && env.value !== undefined) {
|
||||
acc[env.key] = env.value;
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
// Process secret environment variables
|
||||
if (secretEnvVars.length > 0) {
|
||||
payload.secret_env_variables = secretEnvVars.reduce((acc, env) => {
|
||||
if (env.key && env.value !== undefined) {
|
||||
acc[env.key] = env.value;
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
const response = await API.put(
|
||||
`/api/deployments/${deployment.id}`,
|
||||
payload,
|
||||
);
|
||||
|
||||
if (response.data.success) {
|
||||
showSuccess(t('容器配置更新成功'));
|
||||
onSuccess?.(response.data.data);
|
||||
handleCancel();
|
||||
}
|
||||
} catch (error) {
|
||||
showError(
|
||||
t('更新配置失败') +
|
||||
': ' +
|
||||
(error.response?.data?.message || error.message),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (formRef.current) {
|
||||
formRef.current.reset();
|
||||
}
|
||||
setEnvVars([]);
|
||||
setSecretEnvVars([]);
|
||||
onCancel();
|
||||
};
|
||||
|
||||
const addEnvVar = () => {
|
||||
setEnvVars([...envVars, { key: '', value: '' }]);
|
||||
};
|
||||
|
||||
const removeEnvVar = (index) => {
|
||||
const newEnvVars = envVars.filter((_, i) => i !== index);
|
||||
setEnvVars(newEnvVars);
|
||||
};
|
||||
|
||||
const updateEnvVar = (index, field, value) => {
|
||||
const newEnvVars = [...envVars];
|
||||
newEnvVars[index][field] = value;
|
||||
setEnvVars(newEnvVars);
|
||||
};
|
||||
|
||||
const addSecretEnvVar = () => {
|
||||
setSecretEnvVars([...secretEnvVars, { key: '', value: '' }]);
|
||||
};
|
||||
|
||||
const removeSecretEnvVar = (index) => {
|
||||
const newSecretEnvVars = secretEnvVars.filter((_, i) => i !== index);
|
||||
setSecretEnvVars(newSecretEnvVars);
|
||||
};
|
||||
|
||||
const updateSecretEnvVar = (index, field, value) => {
|
||||
const newSecretEnvVars = [...secretEnvVars];
|
||||
newSecretEnvVars[index][field] = value;
|
||||
setSecretEnvVars(newSecretEnvVars);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<div className='flex items-center gap-2'>
|
||||
<FaCog className='text-blue-500' />
|
||||
<span>{t('更新容器配置')}</span>
|
||||
</div>
|
||||
}
|
||||
visible={visible}
|
||||
onCancel={handleCancel}
|
||||
onOk={handleUpdate}
|
||||
okText={t('更新配置')}
|
||||
cancelText={t('取消')}
|
||||
confirmLoading={loading}
|
||||
width={700}
|
||||
className='update-config-modal'
|
||||
>
|
||||
<div className='space-y-4 max-h-[600px] overflow-y-auto'>
|
||||
{/* Container Info */}
|
||||
<Card className='border-0 bg-gray-50'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div>
|
||||
<Text strong className='text-base'>
|
||||
{deployment?.container_name}
|
||||
</Text>
|
||||
<div className='mt-1'>
|
||||
<Text type='secondary' size='small'>
|
||||
ID: {deployment?.id}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
<Tag color='blue'>{deployment?.status}</Tag>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Warning Banner */}
|
||||
<Banner
|
||||
type='warning'
|
||||
icon={<FaExclamationTriangle />}
|
||||
title={t('重要提醒')}
|
||||
description={
|
||||
<div className='space-y-2'>
|
||||
<p>
|
||||
{t(
|
||||
'更新容器配置可能会导致容器重启,请确保在合适的时间进行此操作。',
|
||||
)}
|
||||
</p>
|
||||
<p>{t('某些配置更改可能需要几分钟才能生效。')}</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<Form getFormApi={(api) => (formRef.current = api)} layout='vertical'>
|
||||
<Collapse defaultActiveKey={['docker']}>
|
||||
{/* Docker Configuration */}
|
||||
<Collapse.Panel
|
||||
header={
|
||||
<div className='flex items-center gap-2'>
|
||||
<FaDocker className='text-blue-600' />
|
||||
<span>{t('镜像配置')}</span>
|
||||
</div>
|
||||
}
|
||||
itemKey='docker'
|
||||
>
|
||||
<div className='space-y-4'>
|
||||
<Form.Input
|
||||
field='image_url'
|
||||
label={t('镜像地址')}
|
||||
placeholder={t('例如: nginx:latest')}
|
||||
rules={[
|
||||
{
|
||||
type: 'string',
|
||||
message: t('请输入有效的镜像地址'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Form.Input
|
||||
field='registry_username'
|
||||
label={t('镜像仓库用户名')}
|
||||
placeholder={t('如果镜像为私有,请填写用户名')}
|
||||
/>
|
||||
|
||||
<Form.Input
|
||||
field='registry_secret'
|
||||
label={t('镜像仓库密码')}
|
||||
mode='password'
|
||||
placeholder={t('如果镜像为私有,请填写密码或Token')}
|
||||
/>
|
||||
</div>
|
||||
</Collapse.Panel>
|
||||
|
||||
{/* Network Configuration */}
|
||||
<Collapse.Panel
|
||||
header={
|
||||
<div className='flex items-center gap-2'>
|
||||
<FaNetworkWired className='text-green-600' />
|
||||
<span>{t('网络配置')}</span>
|
||||
</div>
|
||||
}
|
||||
itemKey='network'
|
||||
>
|
||||
<Form.InputNumber
|
||||
field='traffic_port'
|
||||
label={t('流量端口')}
|
||||
placeholder={t('容器对外暴露的端口')}
|
||||
min={1}
|
||||
max={65535}
|
||||
style={{ width: '100%' }}
|
||||
rules={[
|
||||
{
|
||||
type: 'number',
|
||||
min: 1,
|
||||
max: 65535,
|
||||
message: t('端口号必须在1-65535之间'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Collapse.Panel>
|
||||
|
||||
{/* Startup Configuration */}
|
||||
<Collapse.Panel
|
||||
header={
|
||||
<div className='flex items-center gap-2'>
|
||||
<FaTerminal className='text-purple-600' />
|
||||
<span>{t('启动配置')}</span>
|
||||
</div>
|
||||
}
|
||||
itemKey='startup'
|
||||
>
|
||||
<div className='space-y-4'>
|
||||
<Form.Input
|
||||
field='entrypoint'
|
||||
label={t('启动命令 (Entrypoint)')}
|
||||
placeholder={t('例如: /bin/bash -c "python app.py"')}
|
||||
helpText={t('多个命令用空格分隔')}
|
||||
/>
|
||||
|
||||
<Form.Input
|
||||
field='command'
|
||||
label={t('运行命令 (Command)')}
|
||||
placeholder={t('容器启动后执行的命令')}
|
||||
/>
|
||||
</div>
|
||||
</Collapse.Panel>
|
||||
|
||||
{/* Environment Variables */}
|
||||
<Collapse.Panel
|
||||
header={
|
||||
<div className='flex items-center gap-2'>
|
||||
<FaKey className='text-orange-600' />
|
||||
<span>{t('环境变量')}</span>
|
||||
<Tag size='small'>{envVars.length}</Tag>
|
||||
</div>
|
||||
}
|
||||
itemKey='env'
|
||||
>
|
||||
<div className='space-y-4'>
|
||||
{/* Regular Environment Variables */}
|
||||
<div>
|
||||
<div className='flex items-center justify-between mb-3'>
|
||||
<Text strong>{t('普通环境变量')}</Text>
|
||||
<Button
|
||||
size='small'
|
||||
icon={<FaPlus />}
|
||||
onClick={addEnvVar}
|
||||
theme='borderless'
|
||||
type='primary'
|
||||
>
|
||||
{t('添加')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{envVars.map((envVar, index) => (
|
||||
<div key={index} className='flex items-end gap-2 mb-2'>
|
||||
<Input
|
||||
placeholder={t('变量名')}
|
||||
value={envVar.key}
|
||||
onChange={(value) => updateEnvVar(index, 'key', value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Text>=</Text>
|
||||
<Input
|
||||
placeholder={t('变量值')}
|
||||
value={envVar.value}
|
||||
onChange={(value) =>
|
||||
updateEnvVar(index, 'value', value)
|
||||
}
|
||||
style={{ flex: 2 }}
|
||||
/>
|
||||
<Button
|
||||
size='small'
|
||||
icon={<FaMinus />}
|
||||
onClick={() => removeEnvVar(index)}
|
||||
theme='borderless'
|
||||
type='danger'
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{envVars.length === 0 && (
|
||||
<div className='text-center text-gray-500 py-4 border-2 border-dashed border-gray-300 rounded-lg'>
|
||||
<Text type='secondary'>{t('暂无环境变量')}</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Secret Environment Variables */}
|
||||
<div>
|
||||
<div className='flex items-center justify-between mb-3'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Text strong>{t('机密环境变量')}</Text>
|
||||
<Tag size='small' type='danger'>
|
||||
{t('加密存储')}
|
||||
</Tag>
|
||||
</div>
|
||||
<Button
|
||||
size='small'
|
||||
icon={<FaPlus />}
|
||||
onClick={addSecretEnvVar}
|
||||
theme='borderless'
|
||||
type='danger'
|
||||
>
|
||||
{t('添加')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{secretEnvVars.map((envVar, index) => (
|
||||
<div key={index} className='flex items-end gap-2 mb-2'>
|
||||
<Input
|
||||
placeholder={t('变量名')}
|
||||
value={envVar.key}
|
||||
onChange={(value) =>
|
||||
updateSecretEnvVar(index, 'key', value)
|
||||
}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Text>=</Text>
|
||||
<Input
|
||||
mode='password'
|
||||
placeholder={t('变量值')}
|
||||
value={envVar.value}
|
||||
onChange={(value) =>
|
||||
updateSecretEnvVar(index, 'value', value)
|
||||
}
|
||||
style={{ flex: 2 }}
|
||||
/>
|
||||
<Button
|
||||
size='small'
|
||||
icon={<FaMinus />}
|
||||
onClick={() => removeSecretEnvVar(index)}
|
||||
theme='borderless'
|
||||
type='danger'
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{secretEnvVars.length === 0 && (
|
||||
<div className='text-center text-gray-500 py-4 border-2 border-dashed border-red-200 rounded-lg bg-red-50'>
|
||||
<Text type='secondary'>{t('暂无机密环境变量')}</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Banner
|
||||
type='info'
|
||||
title={t('机密环境变量说明')}
|
||||
description={t(
|
||||
'机密环境变量将被加密存储,适用于存储密码、API密钥等敏感信息。',
|
||||
)}
|
||||
size='small'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
</Form>
|
||||
|
||||
{/* Final Warning */}
|
||||
<div className='bg-yellow-50 border border-yellow-200 rounded-lg p-3'>
|
||||
<div className='flex items-start gap-2'>
|
||||
<FaExclamationTriangle className='text-yellow-600 mt-0.5' />
|
||||
<div>
|
||||
<Text strong className='text-yellow-800'>
|
||||
{t('配置更新确认')}
|
||||
</Text>
|
||||
<div className='mt-1'>
|
||||
<Text size='small' className='text-yellow-700'>
|
||||
{t(
|
||||
'更新配置后,容器可能需要重启以应用新的设置。请确保您了解这些更改的影响。',
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpdateConfigModal;
|
||||
@@ -0,0 +1,601 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Typography,
|
||||
Card,
|
||||
Tag,
|
||||
Progress,
|
||||
Descriptions,
|
||||
Spin,
|
||||
Empty,
|
||||
Button,
|
||||
Badge,
|
||||
Tooltip,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
FaInfoCircle,
|
||||
FaServer,
|
||||
FaClock,
|
||||
FaMapMarkerAlt,
|
||||
FaDocker,
|
||||
FaMoneyBillWave,
|
||||
FaChartLine,
|
||||
FaCopy,
|
||||
FaLink,
|
||||
} from 'react-icons/fa';
|
||||
import { IconRefresh } from '@douyinfe/semi-icons';
|
||||
import {
|
||||
API,
|
||||
showError,
|
||||
showSuccess,
|
||||
timestamp2string,
|
||||
} from '../../../../helpers';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
const ViewDetailsModal = ({ visible, onCancel, deployment, t }) => {
|
||||
const [details, setDetails] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [containers, setContainers] = useState([]);
|
||||
const [containersLoading, setContainersLoading] = useState(false);
|
||||
|
||||
const fetchDetails = async () => {
|
||||
if (!deployment?.id) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await API.get(`/api/deployments/${deployment.id}`);
|
||||
if (response.data.success) {
|
||||
setDetails(response.data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(
|
||||
t('获取详情失败') +
|
||||
': ' +
|
||||
(error.response?.data?.message || error.message),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchContainers = async () => {
|
||||
if (!deployment?.id) return;
|
||||
|
||||
setContainersLoading(true);
|
||||
try {
|
||||
const response = await API.get(
|
||||
`/api/deployments/${deployment.id}/containers`,
|
||||
);
|
||||
if (response.data.success) {
|
||||
setContainers(response.data.data?.containers || []);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(
|
||||
t('获取容器信息失败') +
|
||||
': ' +
|
||||
(error.response?.data?.message || error.message),
|
||||
);
|
||||
} finally {
|
||||
setContainersLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && deployment?.id) {
|
||||
fetchDetails();
|
||||
fetchContainers();
|
||||
} else if (!visible) {
|
||||
setDetails(null);
|
||||
setContainers([]);
|
||||
}
|
||||
}, [visible, deployment?.id]);
|
||||
|
||||
const handleCopyId = () => {
|
||||
navigator.clipboard.writeText(deployment?.id);
|
||||
showSuccess(t('已复制 ID 到剪贴板'));
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
fetchDetails();
|
||||
fetchContainers();
|
||||
};
|
||||
|
||||
const getStatusConfig = (status) => {
|
||||
const statusConfig = {
|
||||
running: { color: 'green', text: '运行中', icon: '🟢' },
|
||||
completed: { color: 'green', text: '已完成', icon: '✅' },
|
||||
'deployment requested': { color: 'blue', text: '部署请求中', icon: '🔄' },
|
||||
'termination requested': {
|
||||
color: 'orange',
|
||||
text: '终止请求中',
|
||||
icon: '⏸️',
|
||||
},
|
||||
destroyed: { color: 'red', text: '已销毁', icon: '🔴' },
|
||||
failed: { color: 'red', text: '失败', icon: '❌' },
|
||||
};
|
||||
return statusConfig[status] || { color: 'grey', text: status, icon: '❓' };
|
||||
};
|
||||
|
||||
const statusConfig = getStatusConfig(deployment?.status);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<div className='flex items-center gap-2'>
|
||||
<FaInfoCircle className='text-blue-500' />
|
||||
<span>{t('容器详情')}</span>
|
||||
</div>
|
||||
}
|
||||
visible={visible}
|
||||
onCancel={onCancel}
|
||||
footer={
|
||||
<div className='flex justify-between'>
|
||||
<Button
|
||||
icon={<IconRefresh />}
|
||||
onClick={handleRefresh}
|
||||
loading={loading || containersLoading}
|
||||
theme='borderless'
|
||||
>
|
||||
{t('刷新')}
|
||||
</Button>
|
||||
<Button onClick={onCancel}>{t('关闭')}</Button>
|
||||
</div>
|
||||
}
|
||||
width={800}
|
||||
className='deployment-details-modal'
|
||||
>
|
||||
{loading && !details ? (
|
||||
<div className='flex items-center justify-center py-12'>
|
||||
<Spin size='large' tip={t('加载详情中...')} />
|
||||
</div>
|
||||
) : details ? (
|
||||
<div className='space-y-4 max-h-[600px] overflow-y-auto'>
|
||||
{/* Basic Info */}
|
||||
<Card
|
||||
title={
|
||||
<div className='flex items-center gap-2'>
|
||||
<FaServer className='text-blue-500' />
|
||||
<span>{t('基本信息')}</span>
|
||||
</div>
|
||||
}
|
||||
className='border-0 shadow-sm'
|
||||
>
|
||||
<Descriptions
|
||||
data={[
|
||||
{
|
||||
key: t('容器名称'),
|
||||
value: (
|
||||
<div className='flex items-center gap-2'>
|
||||
<Text strong className='text-base'>
|
||||
{details.deployment_name || details.id}
|
||||
</Text>
|
||||
<Button
|
||||
size='small'
|
||||
theme='borderless'
|
||||
icon={<FaCopy />}
|
||||
onClick={handleCopyId}
|
||||
className='opacity-70 hover:opacity-100'
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: t('容器ID'),
|
||||
value: (
|
||||
<Text type='secondary' className='font-mono text-sm'>
|
||||
{details.id}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: t('状态'),
|
||||
value: (
|
||||
<div className='flex items-center gap-2'>
|
||||
<span>{statusConfig.icon}</span>
|
||||
<Tag color={statusConfig.color}>
|
||||
{t(statusConfig.text)}
|
||||
</Tag>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: t('创建时间'),
|
||||
value: timestamp2string(details.created_at),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Hardware & Performance */}
|
||||
<Card
|
||||
title={
|
||||
<div className='flex items-center gap-2'>
|
||||
<FaChartLine className='text-green-500' />
|
||||
<span>{t('硬件与性能')}</span>
|
||||
</div>
|
||||
}
|
||||
className='border-0 shadow-sm'
|
||||
>
|
||||
<div className='space-y-4'>
|
||||
<Descriptions
|
||||
data={[
|
||||
{
|
||||
key: t('硬件类型'),
|
||||
value: (
|
||||
<div className='flex items-center gap-2'>
|
||||
<Tag color='blue'>{details.brand_name}</Tag>
|
||||
<Text strong>{details.hardware_name}</Text>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: t('GPU数量'),
|
||||
value: (
|
||||
<div className='flex items-center gap-2'>
|
||||
<Badge
|
||||
count={details.total_gpus}
|
||||
theme='solid'
|
||||
type='primary'
|
||||
>
|
||||
<FaServer className='text-purple-500' />
|
||||
</Badge>
|
||||
<Text>
|
||||
{t('总计')} {details.total_gpus} {t('个GPU')}
|
||||
</Text>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: t('容器配置'),
|
||||
value: (
|
||||
<div className='space-y-1'>
|
||||
<div>
|
||||
{t('每容器GPU数')}: {details.gpus_per_container}
|
||||
</div>
|
||||
<div>
|
||||
{t('容器总数')}: {details.total_containers}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Text strong>{t('完成进度')}</Text>
|
||||
<Text>{details.completed_percent}%</Text>
|
||||
</div>
|
||||
<Progress
|
||||
percent={details.completed_percent}
|
||||
status={
|
||||
details.completed_percent === 100 ? 'success' : 'normal'
|
||||
}
|
||||
strokeWidth={8}
|
||||
showInfo={false}
|
||||
/>
|
||||
<div className='flex justify-between text-xs text-gray-500'>
|
||||
<span>
|
||||
{t('已服务')}: {details.compute_minutes_served} {t('分钟')}
|
||||
</span>
|
||||
<span>
|
||||
{t('剩余')}: {details.compute_minutes_remaining} {t('分钟')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Container Configuration */}
|
||||
{details.container_config && (
|
||||
<Card
|
||||
title={
|
||||
<div className='flex items-center gap-2'>
|
||||
<FaDocker className='text-blue-600' />
|
||||
<span>{t('容器配置')}</span>
|
||||
</div>
|
||||
}
|
||||
className='border-0 shadow-sm'
|
||||
>
|
||||
<div className='space-y-3'>
|
||||
<Descriptions
|
||||
data={[
|
||||
{
|
||||
key: t('镜像地址'),
|
||||
value: (
|
||||
<Text className='font-mono text-sm break-all'>
|
||||
{details.container_config.image_url || 'N/A'}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: t('流量端口'),
|
||||
value: details.container_config.traffic_port || 'N/A',
|
||||
},
|
||||
{
|
||||
key: t('启动命令'),
|
||||
value: (
|
||||
<Text className='font-mono text-sm'>
|
||||
{details.container_config.entrypoint
|
||||
? details.container_config.entrypoint.join(' ')
|
||||
: 'N/A'}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Environment Variables */}
|
||||
{details.container_config.env_variables &&
|
||||
Object.keys(details.container_config.env_variables).length >
|
||||
0 && (
|
||||
<div className='mt-4'>
|
||||
<Text strong className='block mb-2'>
|
||||
{t('环境变量')}:
|
||||
</Text>
|
||||
<div className='bg-gray-50 p-3 rounded-lg max-h-32 overflow-y-auto'>
|
||||
{Object.entries(
|
||||
details.container_config.env_variables,
|
||||
).map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className='flex gap-2 text-sm font-mono mb-1'
|
||||
>
|
||||
<span className='text-blue-600 font-medium'>
|
||||
{key}=
|
||||
</span>
|
||||
<span className='text-gray-700 break-all'>
|
||||
{String(value)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Containers List */}
|
||||
<Card
|
||||
title={
|
||||
<div className='flex items-center gap-2'>
|
||||
<FaServer className='text-indigo-500' />
|
||||
<span>{t('容器实例')}</span>
|
||||
</div>
|
||||
}
|
||||
className='border-0 shadow-sm'
|
||||
>
|
||||
{containersLoading ? (
|
||||
<div className='flex items-center justify-center py-6'>
|
||||
<Spin tip={t('加载容器信息中...')} />
|
||||
</div>
|
||||
) : containers.length === 0 ? (
|
||||
<Empty
|
||||
description={t('暂无容器信息')}
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
/>
|
||||
) : (
|
||||
<div className='space-y-3'>
|
||||
{containers.map((ctr) => (
|
||||
<Card
|
||||
key={ctr.container_id}
|
||||
className='bg-gray-50 border border-gray-100'
|
||||
bodyStyle={{ padding: '12px 16px' }}
|
||||
>
|
||||
<div className='flex flex-wrap items-center justify-between gap-3'>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<Text strong className='font-mono text-sm'>
|
||||
{ctr.container_id}
|
||||
</Text>
|
||||
<Text size='small' type='secondary'>
|
||||
{t('设备')} {ctr.device_id || '--'} · {t('状态')}{' '}
|
||||
{ctr.status || '--'}
|
||||
</Text>
|
||||
<Text size='small' type='secondary'>
|
||||
{t('创建时间')}:{' '}
|
||||
{ctr.created_at
|
||||
? timestamp2string(ctr.created_at)
|
||||
: '--'}
|
||||
</Text>
|
||||
</div>
|
||||
<div className='flex flex-col items-end gap-2'>
|
||||
<Tag color='blue' size='small'>
|
||||
{t('GPU/容器')}: {ctr.gpus_per_container ?? '--'}
|
||||
</Tag>
|
||||
{ctr.public_url && (
|
||||
<Tooltip content={ctr.public_url}>
|
||||
<Button
|
||||
icon={<FaLink />}
|
||||
size='small'
|
||||
theme='light'
|
||||
onClick={() =>
|
||||
window.open(
|
||||
ctr.public_url,
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('访问容器')}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ctr.events && ctr.events.length > 0 && (
|
||||
<div className='mt-3 bg-white rounded-md border border-gray-100 p-3'>
|
||||
<Text
|
||||
size='small'
|
||||
type='secondary'
|
||||
className='block mb-2'
|
||||
>
|
||||
{t('最近事件')}
|
||||
</Text>
|
||||
<div className='space-y-2 max-h-32 overflow-y-auto'>
|
||||
{ctr.events.map((event, index) => (
|
||||
<div
|
||||
key={`${ctr.container_id}-${event.time}-${index}`}
|
||||
className='flex gap-3 text-xs font-mono'
|
||||
>
|
||||
<span className='text-gray-500 min-w-[140px]'>
|
||||
{event.time
|
||||
? timestamp2string(event.time)
|
||||
: '--'}
|
||||
</span>
|
||||
<span className='text-gray-700 break-all flex-1'>
|
||||
{event.message || '--'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Location Information */}
|
||||
{details.locations && details.locations.length > 0 && (
|
||||
<Card
|
||||
title={
|
||||
<div className='flex items-center gap-2'>
|
||||
<FaMapMarkerAlt className='text-orange-500' />
|
||||
<span>{t('部署位置')}</span>
|
||||
</div>
|
||||
}
|
||||
className='border-0 shadow-sm'
|
||||
>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{details.locations.map((location) => (
|
||||
<Tag key={location.id} color='orange' size='large'>
|
||||
<div className='flex items-center gap-1'>
|
||||
<span>🌍</span>
|
||||
<span>
|
||||
{location.name} ({location.iso2})
|
||||
</span>
|
||||
</div>
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Cost Information */}
|
||||
<Card
|
||||
title={
|
||||
<div className='flex items-center gap-2'>
|
||||
<FaMoneyBillWave className='text-green-500' />
|
||||
<span>{t('费用信息')}</span>
|
||||
</div>
|
||||
}
|
||||
className='border-0 shadow-sm'
|
||||
>
|
||||
<div className='space-y-3'>
|
||||
<div className='flex items-center justify-between p-3 bg-green-50 rounded-lg'>
|
||||
<Text>{t('已支付金额')}</Text>
|
||||
<Text strong className='text-lg text-green-600'>
|
||||
$
|
||||
{details.amount_paid
|
||||
? details.amount_paid.toFixed(2)
|
||||
: '0.00'}{' '}
|
||||
USDC
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-2 gap-4 text-sm'>
|
||||
<div className='flex justify-between'>
|
||||
<Text type='secondary'>{t('计费开始')}:</Text>
|
||||
<Text>
|
||||
{details.started_at
|
||||
? timestamp2string(details.started_at)
|
||||
: 'N/A'}
|
||||
</Text>
|
||||
</div>
|
||||
<div className='flex justify-between'>
|
||||
<Text type='secondary'>{t('预计结束')}:</Text>
|
||||
<Text>
|
||||
{details.finished_at
|
||||
? timestamp2string(details.finished_at)
|
||||
: 'N/A'}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Time Information */}
|
||||
<Card
|
||||
title={
|
||||
<div className='flex items-center gap-2'>
|
||||
<FaClock className='text-purple-500' />
|
||||
<span>{t('时间信息')}</span>
|
||||
</div>
|
||||
}
|
||||
className='border-0 shadow-sm'
|
||||
>
|
||||
<div className='grid grid-cols-1 md:grid-cols-2 gap-4'>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Text type='secondary'>{t('已运行时间')}:</Text>
|
||||
<Text strong>
|
||||
{Math.floor(details.compute_minutes_served / 60)}h{' '}
|
||||
{details.compute_minutes_served % 60}m
|
||||
</Text>
|
||||
</div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Text type='secondary'>{t('剩余时间')}:</Text>
|
||||
<Text strong className='text-orange-600'>
|
||||
{Math.floor(details.compute_minutes_remaining / 60)}h{' '}
|
||||
{details.compute_minutes_remaining % 60}m
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Text type='secondary'>{t('创建时间')}:</Text>
|
||||
<Text>{timestamp2string(details.created_at)}</Text>
|
||||
</div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Text type='secondary'>{t('最后更新')}:</Text>
|
||||
<Text>{timestamp2string(details.updated_at)}</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
) : (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={t('无法获取容器详情')}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ViewDetailsModal;
|
||||
@@ -0,0 +1,723 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Button,
|
||||
Typography,
|
||||
Select,
|
||||
Input,
|
||||
Space,
|
||||
Spin,
|
||||
Card,
|
||||
Tag,
|
||||
Empty,
|
||||
Switch,
|
||||
Divider,
|
||||
Tooltip,
|
||||
Radio,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
FaCopy,
|
||||
FaSearch,
|
||||
FaClock,
|
||||
FaTerminal,
|
||||
FaServer,
|
||||
FaInfoCircle,
|
||||
FaLink,
|
||||
} from 'react-icons/fa';
|
||||
import { IconRefresh, IconDownload } from '@douyinfe/semi-icons';
|
||||
import {
|
||||
API,
|
||||
showError,
|
||||
showSuccess,
|
||||
copy,
|
||||
timestamp2string,
|
||||
} from '../../../../helpers';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const ALL_CONTAINERS = '__all__';
|
||||
|
||||
const ViewLogsModal = ({ visible, onCancel, deployment, t }) => {
|
||||
const [logLines, setLogLines] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [following, setFollowing] = useState(false);
|
||||
const [containers, setContainers] = useState([]);
|
||||
const [containersLoading, setContainersLoading] = useState(false);
|
||||
const [selectedContainerId, setSelectedContainerId] =
|
||||
useState(ALL_CONTAINERS);
|
||||
const [containerDetails, setContainerDetails] = useState(null);
|
||||
const [containerDetailsLoading, setContainerDetailsLoading] = useState(false);
|
||||
const [streamFilter, setStreamFilter] = useState('stdout');
|
||||
const [lastUpdatedAt, setLastUpdatedAt] = useState(null);
|
||||
|
||||
const logContainerRef = useRef(null);
|
||||
const autoRefreshRef = useRef(null);
|
||||
|
||||
// Auto scroll to bottom when new logs arrive
|
||||
const scrollToBottom = () => {
|
||||
if (logContainerRef.current) {
|
||||
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
|
||||
}
|
||||
};
|
||||
|
||||
const resolveStreamValue = (value) => {
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
if (value && typeof value.value === 'string') {
|
||||
return value.value;
|
||||
}
|
||||
if (value && value.target && typeof value.target.value === 'string') {
|
||||
return value.target.value;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const handleStreamChange = (value) => {
|
||||
const next = resolveStreamValue(value) || 'stdout';
|
||||
setStreamFilter(next);
|
||||
};
|
||||
|
||||
const fetchLogs = async (containerIdOverride = undefined) => {
|
||||
if (!deployment?.id) return;
|
||||
|
||||
const containerId =
|
||||
typeof containerIdOverride === 'string'
|
||||
? containerIdOverride
|
||||
: selectedContainerId;
|
||||
|
||||
if (!containerId || containerId === ALL_CONTAINERS) {
|
||||
setLogLines([]);
|
||||
setLastUpdatedAt(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.append('container_id', containerId);
|
||||
|
||||
const streamValue = resolveStreamValue(streamFilter) || 'stdout';
|
||||
if (streamValue && streamValue !== 'all') {
|
||||
params.append('stream', streamValue);
|
||||
}
|
||||
if (following) params.append('follow', 'true');
|
||||
|
||||
const response = await API.get(
|
||||
`/api/deployments/${deployment.id}/logs?${params}`,
|
||||
);
|
||||
|
||||
if (response.data.success) {
|
||||
const rawContent =
|
||||
typeof response.data.data === 'string' ? response.data.data : '';
|
||||
const normalized = rawContent.replace(/\r\n?/g, '\n');
|
||||
const lines = normalized ? normalized.split('\n') : [];
|
||||
|
||||
setLogLines(lines);
|
||||
setLastUpdatedAt(new Date());
|
||||
|
||||
setTimeout(scrollToBottom, 100);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(
|
||||
t('获取日志失败') +
|
||||
': ' +
|
||||
(error.response?.data?.message || error.message),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchContainers = async () => {
|
||||
if (!deployment?.id) return;
|
||||
|
||||
setContainersLoading(true);
|
||||
try {
|
||||
const response = await API.get(
|
||||
`/api/deployments/${deployment.id}/containers`,
|
||||
);
|
||||
|
||||
if (response.data.success) {
|
||||
const list = response.data.data?.containers || [];
|
||||
setContainers(list);
|
||||
|
||||
setSelectedContainerId((current) => {
|
||||
if (
|
||||
current !== ALL_CONTAINERS &&
|
||||
list.some((item) => item.container_id === current)
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return list.length > 0 ? list[0].container_id : ALL_CONTAINERS;
|
||||
});
|
||||
|
||||
if (list.length === 0) {
|
||||
setContainerDetails(null);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
showError(
|
||||
t('获取容器列表失败') +
|
||||
': ' +
|
||||
(error.response?.data?.message || error.message),
|
||||
);
|
||||
} finally {
|
||||
setContainersLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchContainerDetails = async (containerId) => {
|
||||
if (!deployment?.id || !containerId || containerId === ALL_CONTAINERS) {
|
||||
setContainerDetails(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setContainerDetailsLoading(true);
|
||||
try {
|
||||
const response = await API.get(
|
||||
`/api/deployments/${deployment.id}/containers/${containerId}`,
|
||||
);
|
||||
|
||||
if (response.data.success) {
|
||||
setContainerDetails(response.data.data || null);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(
|
||||
t('获取容器详情失败') +
|
||||
': ' +
|
||||
(error.response?.data?.message || error.message),
|
||||
);
|
||||
} finally {
|
||||
setContainerDetailsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContainerChange = (value) => {
|
||||
const newValue = value || ALL_CONTAINERS;
|
||||
setSelectedContainerId(newValue);
|
||||
setLogLines([]);
|
||||
setLastUpdatedAt(null);
|
||||
};
|
||||
|
||||
const refreshContainerDetails = () => {
|
||||
if (selectedContainerId && selectedContainerId !== ALL_CONTAINERS) {
|
||||
fetchContainerDetails(selectedContainerId);
|
||||
}
|
||||
};
|
||||
|
||||
const renderContainerStatusTag = (status) => {
|
||||
if (!status) {
|
||||
return (
|
||||
<Tag color='grey' size='small'>
|
||||
{t('未知状态')}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
const normalized =
|
||||
typeof status === 'string' ? status.trim().toLowerCase() : '';
|
||||
const statusMap = {
|
||||
running: { color: 'green', label: '运行中' },
|
||||
pending: { color: 'orange', label: '准备中' },
|
||||
deployed: { color: 'blue', label: '已部署' },
|
||||
failed: { color: 'red', label: '失败' },
|
||||
destroyed: { color: 'red', label: '已销毁' },
|
||||
stopping: { color: 'orange', label: '停止中' },
|
||||
terminated: { color: 'grey', label: '已终止' },
|
||||
};
|
||||
|
||||
const config = statusMap[normalized] || { color: 'grey', label: status };
|
||||
|
||||
return (
|
||||
<Tag color={config.color} size='small'>
|
||||
{t(config.label)}
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
|
||||
const currentContainer =
|
||||
selectedContainerId !== ALL_CONTAINERS
|
||||
? containers.find((ctr) => ctr.container_id === selectedContainerId)
|
||||
: null;
|
||||
|
||||
const refreshLogs = () => {
|
||||
if (selectedContainerId && selectedContainerId !== ALL_CONTAINERS) {
|
||||
fetchContainerDetails(selectedContainerId);
|
||||
}
|
||||
fetchLogs();
|
||||
};
|
||||
|
||||
const downloadLogs = () => {
|
||||
const sourceLogs = filteredLogs.length > 0 ? filteredLogs : logLines;
|
||||
if (sourceLogs.length === 0) {
|
||||
showError(t('暂无日志可下载'));
|
||||
return;
|
||||
}
|
||||
const logText = sourceLogs.join('\n');
|
||||
|
||||
const blob = new Blob([logText], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
const safeContainerId =
|
||||
selectedContainerId && selectedContainerId !== ALL_CONTAINERS
|
||||
? selectedContainerId.replace(/[^a-zA-Z0-9_-]/g, '-')
|
||||
: '';
|
||||
const fileName = safeContainerId
|
||||
? `deployment-${deployment.id}-container-${safeContainerId}-logs.txt`
|
||||
: `deployment-${deployment.id}-logs.txt`;
|
||||
a.download = fileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
showSuccess(t('日志已下载'));
|
||||
};
|
||||
|
||||
const copyAllLogs = async () => {
|
||||
const sourceLogs = filteredLogs.length > 0 ? filteredLogs : logLines;
|
||||
if (sourceLogs.length === 0) {
|
||||
showError(t('暂无日志可复制'));
|
||||
return;
|
||||
}
|
||||
const logText = sourceLogs.join('\n');
|
||||
|
||||
const copied = await copy(logText);
|
||||
if (copied) {
|
||||
showSuccess(t('日志已复制到剪贴板'));
|
||||
} else {
|
||||
showError(t('复制失败,请手动选择文本复制'));
|
||||
}
|
||||
};
|
||||
|
||||
// Auto refresh functionality
|
||||
useEffect(() => {
|
||||
if (autoRefresh && visible) {
|
||||
autoRefreshRef.current = setInterval(() => {
|
||||
fetchLogs();
|
||||
}, 5000);
|
||||
} else {
|
||||
if (autoRefreshRef.current) {
|
||||
clearInterval(autoRefreshRef.current);
|
||||
autoRefreshRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (autoRefreshRef.current) {
|
||||
clearInterval(autoRefreshRef.current);
|
||||
}
|
||||
};
|
||||
}, [autoRefresh, visible, selectedContainerId, streamFilter, following]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && deployment?.id) {
|
||||
fetchContainers();
|
||||
} else if (!visible) {
|
||||
setContainers([]);
|
||||
setSelectedContainerId(ALL_CONTAINERS);
|
||||
setContainerDetails(null);
|
||||
setStreamFilter('stdout');
|
||||
setLogLines([]);
|
||||
setLastUpdatedAt(null);
|
||||
}
|
||||
}, [visible, deployment?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setStreamFilter('stdout');
|
||||
}
|
||||
}, [selectedContainerId, visible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && deployment?.id) {
|
||||
fetchContainerDetails(selectedContainerId);
|
||||
}
|
||||
}, [visible, deployment?.id, selectedContainerId]);
|
||||
|
||||
// Initial load and cleanup
|
||||
useEffect(() => {
|
||||
if (visible && deployment?.id) {
|
||||
fetchLogs();
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (autoRefreshRef.current) {
|
||||
clearInterval(autoRefreshRef.current);
|
||||
}
|
||||
};
|
||||
}, [visible, deployment?.id, streamFilter, selectedContainerId, following]);
|
||||
|
||||
// Filter logs based on search term
|
||||
const filteredLogs = logLines
|
||||
.map((line) => line ?? '')
|
||||
.filter(
|
||||
(line) =>
|
||||
!searchTerm || line.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
const renderLogEntry = (line, index) => (
|
||||
<div
|
||||
key={`${index}-${line.slice(0, 20)}`}
|
||||
className='py-1 px-3 hover:bg-gray-50 font-mono text-sm border-b border-gray-100 whitespace-pre-wrap break-words'
|
||||
>
|
||||
{line}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<div className='flex items-center gap-2'>
|
||||
<FaTerminal className='text-blue-500' />
|
||||
<span>{t('容器日志')}</span>
|
||||
<Text type='secondary' size='small'>
|
||||
- {deployment?.container_name || deployment?.id}
|
||||
</Text>
|
||||
</div>
|
||||
}
|
||||
visible={visible}
|
||||
onCancel={onCancel}
|
||||
footer={null}
|
||||
width={1000}
|
||||
height={700}
|
||||
className='logs-modal'
|
||||
style={{ top: 20 }}
|
||||
>
|
||||
<div className='flex flex-col h-full max-h-[600px]'>
|
||||
{/* Controls */}
|
||||
<Card className='mb-4 border-0 shadow-sm'>
|
||||
<div className='flex items-center justify-between flex-wrap gap-3'>
|
||||
<Space wrap>
|
||||
<Select
|
||||
prefix={<FaServer />}
|
||||
placeholder={t('选择容器')}
|
||||
value={selectedContainerId}
|
||||
onChange={handleContainerChange}
|
||||
style={{ width: 240 }}
|
||||
size='small'
|
||||
loading={containersLoading}
|
||||
dropdownStyle={{ maxHeight: 320, overflowY: 'auto' }}
|
||||
>
|
||||
<Select.Option value={ALL_CONTAINERS}>
|
||||
{t('全部容器')}
|
||||
</Select.Option>
|
||||
{containers.map((ctr) => (
|
||||
<Select.Option
|
||||
key={ctr.container_id}
|
||||
value={ctr.container_id}
|
||||
>
|
||||
<div className='flex flex-col'>
|
||||
<span className='font-mono text-xs'>
|
||||
{ctr.container_id}
|
||||
</span>
|
||||
<span className='text-xs text-gray-500'>
|
||||
{ctr.brand_name || 'IO.NET'}
|
||||
{ctr.hardware ? ` · ${ctr.hardware}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<Input
|
||||
prefix={<FaSearch />}
|
||||
placeholder={t('搜索日志内容')}
|
||||
value={searchTerm}
|
||||
onChange={setSearchTerm}
|
||||
style={{ width: 200 }}
|
||||
size='small'
|
||||
/>
|
||||
|
||||
<Space align='center' className='ml-2'>
|
||||
<Text size='small' type='secondary'>
|
||||
{t('日志流')}
|
||||
</Text>
|
||||
<Radio.Group
|
||||
type='button'
|
||||
size='small'
|
||||
value={streamFilter}
|
||||
onChange={handleStreamChange}
|
||||
>
|
||||
<Radio value='stdout'>STDOUT</Radio>
|
||||
<Radio value='stderr'>STDERR</Radio>
|
||||
</Radio.Group>
|
||||
</Space>
|
||||
|
||||
<div className='flex items-center gap-2'>
|
||||
<Switch
|
||||
checked={autoRefresh}
|
||||
onChange={setAutoRefresh}
|
||||
size='small'
|
||||
/>
|
||||
<Text size='small'>{t('自动刷新')}</Text>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-2'>
|
||||
<Switch
|
||||
checked={following}
|
||||
onChange={setFollowing}
|
||||
size='small'
|
||||
/>
|
||||
<Text size='small'>{t('跟随日志')}</Text>
|
||||
</div>
|
||||
</Space>
|
||||
|
||||
<Space>
|
||||
<Tooltip content={t('刷新日志')}>
|
||||
<Button
|
||||
icon={<IconRefresh />}
|
||||
onClick={refreshLogs}
|
||||
loading={loading}
|
||||
size='small'
|
||||
theme='borderless'
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content={t('复制日志')}>
|
||||
<Button
|
||||
icon={<FaCopy />}
|
||||
onClick={copyAllLogs}
|
||||
size='small'
|
||||
theme='borderless'
|
||||
disabled={logLines.length === 0}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content={t('下载日志')}>
|
||||
<Button
|
||||
icon={<IconDownload />}
|
||||
onClick={downloadLogs}
|
||||
size='small'
|
||||
theme='borderless'
|
||||
disabled={logLines.length === 0}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* Status Info */}
|
||||
<Divider margin='12px' />
|
||||
<div className='flex items-center justify-between'>
|
||||
<Space size='large'>
|
||||
<Text size='small' type='secondary'>
|
||||
{t('共 {{count}} 条日志', { count: logLines.length })}
|
||||
</Text>
|
||||
{searchTerm && (
|
||||
<Text size='small' type='secondary'>
|
||||
{t('(筛选后显示 {{count}} 条)', {
|
||||
count: filteredLogs.length,
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
{autoRefresh && (
|
||||
<Tag color='green' size='small'>
|
||||
<FaClock className='mr-1' />
|
||||
{t('自动刷新中')}
|
||||
</Tag>
|
||||
)}
|
||||
</Space>
|
||||
|
||||
<Text size='small' type='secondary'>
|
||||
{t('状态')}: {deployment?.status || 'unknown'}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{selectedContainerId !== ALL_CONTAINERS && (
|
||||
<>
|
||||
<Divider margin='12px' />
|
||||
<div className='flex flex-col gap-3'>
|
||||
<div className='flex items-center justify-between flex-wrap gap-2'>
|
||||
<Space>
|
||||
<Tag color='blue' size='small'>
|
||||
{t('容器')}
|
||||
</Tag>
|
||||
<Text className='font-mono text-xs'>
|
||||
{selectedContainerId}
|
||||
</Text>
|
||||
{renderContainerStatusTag(
|
||||
containerDetails?.status || currentContainer?.status,
|
||||
)}
|
||||
</Space>
|
||||
|
||||
<Space>
|
||||
{containerDetails?.public_url && (
|
||||
<Tooltip content={containerDetails.public_url}>
|
||||
<Button
|
||||
icon={<FaLink />}
|
||||
size='small'
|
||||
theme='borderless'
|
||||
onClick={() =>
|
||||
window.open(containerDetails.public_url, '_blank')
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip content={t('刷新容器信息')}>
|
||||
<Button
|
||||
icon={<IconRefresh />}
|
||||
onClick={refreshContainerDetails}
|
||||
size='small'
|
||||
theme='borderless'
|
||||
loading={containerDetailsLoading}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{containerDetailsLoading ? (
|
||||
<div className='flex items-center justify-center py-6'>
|
||||
<Spin tip={t('加载容器详情中...')} />
|
||||
</div>
|
||||
) : containerDetails ? (
|
||||
<div className='grid gap-4 md:grid-cols-2 text-sm'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<FaInfoCircle className='text-blue-500' />
|
||||
<Text type='secondary'>{t('硬件')}</Text>
|
||||
<Text>
|
||||
{containerDetails?.brand_name ||
|
||||
currentContainer?.brand_name ||
|
||||
t('未知品牌')}
|
||||
{containerDetails?.hardware ||
|
||||
currentContainer?.hardware
|
||||
? ` · ${containerDetails?.hardware || currentContainer?.hardware}`
|
||||
: ''}
|
||||
</Text>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<FaServer className='text-purple-500' />
|
||||
<Text type='secondary'>{t('GPU/容器')}</Text>
|
||||
<Text>
|
||||
{containerDetails?.gpus_per_container ??
|
||||
currentContainer?.gpus_per_container ??
|
||||
0}
|
||||
</Text>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<FaClock className='text-orange-500' />
|
||||
<Text type='secondary'>{t('创建时间')}</Text>
|
||||
<Text>
|
||||
{containerDetails?.created_at
|
||||
? timestamp2string(containerDetails.created_at)
|
||||
: currentContainer?.created_at
|
||||
? timestamp2string(currentContainer.created_at)
|
||||
: t('未知')}
|
||||
</Text>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<FaInfoCircle className='text-green-500' />
|
||||
<Text type='secondary'>{t('运行时长')}</Text>
|
||||
<Text>
|
||||
{containerDetails?.uptime_percent ??
|
||||
currentContainer?.uptime_percent ??
|
||||
0}
|
||||
%
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Text size='small' type='secondary'>
|
||||
{t('暂无容器详情')}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{containerDetails?.events &&
|
||||
containerDetails.events.length > 0 && (
|
||||
<div className='bg-gray-50 rounded-lg p-3'>
|
||||
<Text size='small' type='secondary'>
|
||||
{t('最近事件')}
|
||||
</Text>
|
||||
<div className='mt-2 space-y-2 max-h-32 overflow-y-auto'>
|
||||
{containerDetails.events
|
||||
.slice(0, 5)
|
||||
.map((event, index) => (
|
||||
<div
|
||||
key={`${event.time}-${index}`}
|
||||
className='flex gap-3 text-xs font-mono'
|
||||
>
|
||||
<span className='text-gray-500'>
|
||||
{event.time
|
||||
? timestamp2string(event.time)
|
||||
: '--'}
|
||||
</span>
|
||||
<span className='text-gray-700 break-all flex-1'>
|
||||
{event.message}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Log Content */}
|
||||
<div className='flex-1 flex flex-col border rounded-lg bg-gray-50 overflow-hidden'>
|
||||
<div
|
||||
ref={logContainerRef}
|
||||
className='flex-1 overflow-y-auto bg-white'
|
||||
style={{ maxHeight: '400px' }}
|
||||
>
|
||||
{loading && logLines.length === 0 ? (
|
||||
<div className='flex items-center justify-center p-8'>
|
||||
<Spin tip={t('加载日志中...')} />
|
||||
</div>
|
||||
) : filteredLogs.length === 0 ? (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={
|
||||
searchTerm ? t('没有匹配的日志条目') : t('暂无日志')
|
||||
}
|
||||
style={{ padding: '60px 20px' }}
|
||||
/>
|
||||
) : (
|
||||
<div>
|
||||
{filteredLogs.map((log, index) => renderLogEntry(log, index))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer status */}
|
||||
{logLines.length > 0 && (
|
||||
<div className='flex items-center justify-between px-3 py-2 bg-gray-50 border-t text-xs text-gray-500'>
|
||||
<span>{following ? t('正在跟随最新日志') : t('日志已加载')}</span>
|
||||
<span>
|
||||
{t('最后更新')}:{' '}
|
||||
{lastUpdatedAt ? lastUpdatedAt.toLocaleTimeString() : '--'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ViewLogsModal;
|
||||
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import SelectableButtonGroup from '../../../common/ui/SelectableButtonGroup';
|
||||
|
||||
const PricingDisplaySettings = ({
|
||||
showWithRecharge,
|
||||
setShowWithRecharge,
|
||||
currency,
|
||||
setCurrency,
|
||||
siteDisplayType,
|
||||
showRatio,
|
||||
setShowRatio,
|
||||
viewMode,
|
||||
setViewMode,
|
||||
tokenUnit,
|
||||
setTokenUnit,
|
||||
loading = false,
|
||||
t,
|
||||
}) => {
|
||||
const supportsCurrencyDisplay = siteDisplayType !== 'TOKENS';
|
||||
|
||||
const items = [
|
||||
...(supportsCurrencyDisplay
|
||||
? [
|
||||
{
|
||||
value: 'recharge',
|
||||
label: t('充值价格显示'),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
value: 'ratio',
|
||||
label: t('显示倍率'),
|
||||
},
|
||||
{
|
||||
value: 'tableView',
|
||||
label: t('表格视图'),
|
||||
},
|
||||
{
|
||||
value: 'tokenUnit',
|
||||
label: t('按K显示单位'),
|
||||
},
|
||||
];
|
||||
|
||||
const currencyItems = [
|
||||
{ value: 'USD', label: 'USD ($)' },
|
||||
{ value: 'CNY', label: 'CNY (¥)' },
|
||||
{ value: 'CUSTOM', label: t('自定义货币') },
|
||||
];
|
||||
|
||||
const handleChange = (value) => {
|
||||
switch (value) {
|
||||
case 'recharge':
|
||||
setShowWithRecharge(!showWithRecharge);
|
||||
break;
|
||||
case 'ratio':
|
||||
setShowRatio(!showRatio);
|
||||
break;
|
||||
case 'tableView':
|
||||
setViewMode(viewMode === 'table' ? 'card' : 'table');
|
||||
break;
|
||||
case 'tokenUnit':
|
||||
setTokenUnit(tokenUnit === 'K' ? 'M' : 'K');
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const getActiveValues = () => {
|
||||
const activeValues = [];
|
||||
if (supportsCurrencyDisplay && showWithRecharge) activeValues.push('recharge');
|
||||
if (showRatio) activeValues.push('ratio');
|
||||
if (viewMode === 'table') activeValues.push('tableView');
|
||||
if (tokenUnit === 'K') activeValues.push('tokenUnit');
|
||||
return activeValues;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SelectableButtonGroup
|
||||
title={t('显示设置')}
|
||||
items={items}
|
||||
activeValue={getActiveValues()}
|
||||
onChange={handleChange}
|
||||
withCheckbox
|
||||
collapsible={false}
|
||||
loading={loading}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
{supportsCurrencyDisplay && showWithRecharge && (
|
||||
<SelectableButtonGroup
|
||||
title={t('货币单位')}
|
||||
items={currencyItems}
|
||||
activeValue={currency}
|
||||
onChange={setCurrency}
|
||||
collapsible={false}
|
||||
loading={loading}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PricingDisplaySettings;
|
||||
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import SelectableButtonGroup from '../../../common/ui/SelectableButtonGroup';
|
||||
|
||||
/**
|
||||
* 端点类型筛选组件
|
||||
* @param {string|'all'} filterEndpointType 当前值
|
||||
* @param {Function} setFilterEndpointType setter
|
||||
* @param {Array} models 模型列表
|
||||
* @param {boolean} loading 是否加载中
|
||||
* @param {Function} t i18n
|
||||
*/
|
||||
const PricingEndpointTypes = ({
|
||||
filterEndpointType,
|
||||
setFilterEndpointType,
|
||||
models = [],
|
||||
allModels = [],
|
||||
loading = false,
|
||||
t,
|
||||
}) => {
|
||||
// 获取系统中所有端点类型(基于 allModels,如果未提供则退化为 models)
|
||||
const getAllEndpointTypes = () => {
|
||||
const endpointTypes = new Set();
|
||||
(allModels.length > 0 ? allModels : models).forEach((model) => {
|
||||
if (
|
||||
model.supported_endpoint_types &&
|
||||
Array.isArray(model.supported_endpoint_types)
|
||||
) {
|
||||
model.supported_endpoint_types.forEach((endpoint) => {
|
||||
endpointTypes.add(endpoint);
|
||||
});
|
||||
}
|
||||
});
|
||||
return Array.from(endpointTypes).sort();
|
||||
};
|
||||
|
||||
// 计算每个端点类型的模型数量
|
||||
const getEndpointTypeCount = (endpointType) => {
|
||||
if (endpointType === 'all') {
|
||||
return models.length;
|
||||
}
|
||||
return models.filter(
|
||||
(model) =>
|
||||
model.supported_endpoint_types &&
|
||||
model.supported_endpoint_types.includes(endpointType),
|
||||
).length;
|
||||
};
|
||||
|
||||
// 端点类型显示名称映射
|
||||
const getEndpointTypeLabel = (endpointType) => {
|
||||
return endpointType;
|
||||
};
|
||||
|
||||
const availableEndpointTypes = getAllEndpointTypes();
|
||||
|
||||
const items = [
|
||||
{
|
||||
value: 'all',
|
||||
label: t('全部端点'),
|
||||
tagCount: getEndpointTypeCount('all'),
|
||||
},
|
||||
...availableEndpointTypes.map((endpointType) => {
|
||||
const count = getEndpointTypeCount(endpointType);
|
||||
return {
|
||||
value: endpointType,
|
||||
label: getEndpointTypeLabel(endpointType),
|
||||
tagCount: count,
|
||||
};
|
||||
}),
|
||||
];
|
||||
|
||||
return (
|
||||
<SelectableButtonGroup
|
||||
title={t('端点类型')}
|
||||
items={items}
|
||||
activeValue={filterEndpointType}
|
||||
onChange={setFilterEndpointType}
|
||||
loading={loading}
|
||||
variant='green'
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default PricingEndpointTypes;
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import SelectableButtonGroup from '../../../common/ui/SelectableButtonGroup';
|
||||
|
||||
/**
|
||||
* 分组筛选组件
|
||||
* @param {string} filterGroup 当前选中的分组,'all' 表示不过滤
|
||||
* @param {Function} setFilterGroup 设置选中分组
|
||||
* @param {Record<string, any>} usableGroup 后端返回的可用分组对象
|
||||
* @param {Record<string, number>} groupRatio 分组倍率对象
|
||||
* @param {Array} models 模型列表
|
||||
* @param {boolean} loading 是否加载中
|
||||
* @param {Function} t i18n
|
||||
*/
|
||||
const PricingGroups = ({
|
||||
filterGroup,
|
||||
setFilterGroup,
|
||||
usableGroup = {},
|
||||
groupRatio = {},
|
||||
models = [],
|
||||
loading = false,
|
||||
t,
|
||||
}) => {
|
||||
const groups = [
|
||||
'all',
|
||||
...Object.keys(usableGroup).filter((key) => key !== ''),
|
||||
];
|
||||
|
||||
const items = groups.map((g) => {
|
||||
const modelCount =
|
||||
g === 'all'
|
||||
? models.length
|
||||
: models.filter((m) => m.enable_groups && m.enable_groups.includes(g))
|
||||
.length;
|
||||
let ratioDisplay = '';
|
||||
if (g === 'all') {
|
||||
// ratioDisplay = t('全部');
|
||||
} else {
|
||||
const ratio = groupRatio[g];
|
||||
if (ratio !== undefined && ratio !== null) {
|
||||
ratioDisplay = `${ratio}x`;
|
||||
} else {
|
||||
ratioDisplay = '1x';
|
||||
}
|
||||
}
|
||||
return {
|
||||
value: g,
|
||||
label: g === 'all' ? t('全部分组') : g,
|
||||
tagCount: ratioDisplay,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<SelectableButtonGroup
|
||||
title={t('可用令牌分组')}
|
||||
items={items}
|
||||
activeValue={filterGroup}
|
||||
onChange={setFilterGroup}
|
||||
loading={loading}
|
||||
variant='teal'
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default PricingGroups;
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import SelectableButtonGroup from '../../../common/ui/SelectableButtonGroup';
|
||||
|
||||
/**
|
||||
* 计费类型筛选组件
|
||||
* @param {string|'all'|0|1} filterQuotaType 当前值
|
||||
* @param {Function} setFilterQuotaType setter
|
||||
* @param {Array} models 模型列表
|
||||
* @param {boolean} loading 是否加载中
|
||||
* @param {Function} t i18n
|
||||
*/
|
||||
const PricingQuotaTypes = ({
|
||||
filterQuotaType,
|
||||
setFilterQuotaType,
|
||||
models = [],
|
||||
loading = false,
|
||||
t,
|
||||
}) => {
|
||||
const qtyCount = (type) =>
|
||||
models.filter((m) => (type === 'all' ? true : m.quota_type === type))
|
||||
.length;
|
||||
|
||||
const items = [
|
||||
{ value: 'all', label: t('全部类型'), tagCount: qtyCount('all') },
|
||||
{ value: 0, label: t('按量计费'), tagCount: qtyCount(0) },
|
||||
{ value: 1, label: t('按次计费'), tagCount: qtyCount(1) },
|
||||
];
|
||||
|
||||
return (
|
||||
<SelectableButtonGroup
|
||||
title={t('计费类型')}
|
||||
items={items}
|
||||
activeValue={filterQuotaType}
|
||||
onChange={setFilterQuotaType}
|
||||
loading={loading}
|
||||
variant='amber'
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default PricingQuotaTypes;
|
||||
109
web/src/components/table/model-pricing/filter/PricingTags.jsx
Normal file
109
web/src/components/table/model-pricing/filter/PricingTags.jsx
Normal file
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import SelectableButtonGroup from '../../../common/ui/SelectableButtonGroup';
|
||||
|
||||
/**
|
||||
* 模型标签筛选组件
|
||||
* @param {string|'all'} filterTag 当前选中的标签
|
||||
* @param {Function} setFilterTag setter
|
||||
* @param {Array} models 当前过滤后模型列表(用于计数)
|
||||
* @param {Array} allModels 所有模型列表(用于获取所有标签)
|
||||
* @param {boolean} loading 是否加载中
|
||||
* @param {Function} t i18n
|
||||
*/
|
||||
const PricingTags = ({
|
||||
filterTag,
|
||||
setFilterTag,
|
||||
models = [],
|
||||
allModels = [],
|
||||
loading = false,
|
||||
t,
|
||||
}) => {
|
||||
// 提取系统所有标签
|
||||
const getAllTags = React.useMemo(() => {
|
||||
const tagSet = new Set();
|
||||
|
||||
(allModels.length > 0 ? allModels : models).forEach((model) => {
|
||||
if (model.tags) {
|
||||
model.tags
|
||||
.split(/[,;|]+/) // 逗号、分号或竖线(保留空格,允许多词标签如 "open weights")
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean)
|
||||
.forEach((tag) => tagSet.add(tag.toLowerCase()));
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(tagSet).sort((a, b) => a.localeCompare(b));
|
||||
}, [allModels, models]);
|
||||
|
||||
// 计算标签对应的模型数量
|
||||
const getTagCount = React.useCallback(
|
||||
(tag) => {
|
||||
if (tag === 'all') return models.length;
|
||||
|
||||
const tagLower = tag.toLowerCase();
|
||||
return models.filter((model) => {
|
||||
if (!model.tags) return false;
|
||||
return model.tags
|
||||
.toLowerCase()
|
||||
.split(/[,;|]+/)
|
||||
.map((tg) => tg.trim())
|
||||
.includes(tagLower);
|
||||
}).length;
|
||||
},
|
||||
[models],
|
||||
);
|
||||
|
||||
const items = React.useMemo(() => {
|
||||
const result = [
|
||||
{
|
||||
value: 'all',
|
||||
label: t('全部标签'),
|
||||
tagCount: getTagCount('all'),
|
||||
},
|
||||
];
|
||||
|
||||
getAllTags.forEach((tag) => {
|
||||
const count = getTagCount(tag);
|
||||
result.push({
|
||||
value: tag,
|
||||
label: tag,
|
||||
tagCount: count,
|
||||
});
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [getAllTags, getTagCount, t, models.length]);
|
||||
|
||||
return (
|
||||
<SelectableButtonGroup
|
||||
title={t('标签')}
|
||||
items={items}
|
||||
activeValue={filterTag}
|
||||
onChange={setFilterTag}
|
||||
loading={loading}
|
||||
variant='rose'
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default PricingTags;
|
||||
127
web/src/components/table/model-pricing/filter/PricingVendors.jsx
Normal file
127
web/src/components/table/model-pricing/filter/PricingVendors.jsx
Normal file
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import SelectableButtonGroup from '../../../common/ui/SelectableButtonGroup';
|
||||
import { getLobeHubIcon } from '../../../../helpers';
|
||||
|
||||
/**
|
||||
* 供应商筛选组件
|
||||
* @param {string|'all'} filterVendor 当前值
|
||||
* @param {Function} setFilterVendor setter
|
||||
* @param {Array} models 模型列表
|
||||
* @param {Array} allModels 所有模型列表(用于获取全部供应商)
|
||||
* @param {boolean} loading 是否加载中
|
||||
* @param {Function} t i18n
|
||||
*/
|
||||
const PricingVendors = ({
|
||||
filterVendor,
|
||||
setFilterVendor,
|
||||
models = [],
|
||||
allModels = [],
|
||||
loading = false,
|
||||
t,
|
||||
}) => {
|
||||
// 获取系统中所有供应商(基于 allModels,如果未提供则退化为 models)
|
||||
const getAllVendors = React.useMemo(() => {
|
||||
const vendors = new Set();
|
||||
const vendorIcons = new Map();
|
||||
let hasUnknownVendor = false;
|
||||
|
||||
(allModels.length > 0 ? allModels : models).forEach((model) => {
|
||||
if (model.vendor_name) {
|
||||
vendors.add(model.vendor_name);
|
||||
if (model.vendor_icon && !vendorIcons.has(model.vendor_name)) {
|
||||
vendorIcons.set(model.vendor_name, model.vendor_icon);
|
||||
}
|
||||
} else {
|
||||
hasUnknownVendor = true;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
vendors: Array.from(vendors).sort(),
|
||||
vendorIcons,
|
||||
hasUnknownVendor,
|
||||
};
|
||||
}, [allModels, models]);
|
||||
|
||||
// 计算每个供应商的模型数量(基于当前过滤后的 models)
|
||||
const getVendorCount = React.useCallback(
|
||||
(vendor) => {
|
||||
if (vendor === 'all') {
|
||||
return models.length;
|
||||
}
|
||||
if (vendor === 'unknown') {
|
||||
return models.filter((model) => !model.vendor_name).length;
|
||||
}
|
||||
return models.filter((model) => model.vendor_name === vendor).length;
|
||||
},
|
||||
[models],
|
||||
);
|
||||
|
||||
// 生成供应商选项
|
||||
const items = React.useMemo(() => {
|
||||
const result = [
|
||||
{
|
||||
value: 'all',
|
||||
label: t('全部供应商'),
|
||||
tagCount: getVendorCount('all'),
|
||||
},
|
||||
];
|
||||
|
||||
// 添加所有已知供应商
|
||||
getAllVendors.vendors.forEach((vendor) => {
|
||||
const count = getVendorCount(vendor);
|
||||
const icon = getAllVendors.vendorIcons.get(vendor);
|
||||
result.push({
|
||||
value: vendor,
|
||||
label: vendor,
|
||||
icon: icon ? getLobeHubIcon(icon, 16) : null,
|
||||
tagCount: count,
|
||||
});
|
||||
});
|
||||
|
||||
// 如果系统中存在未知供应商,添加"未知供应商"选项
|
||||
if (getAllVendors.hasUnknownVendor) {
|
||||
const count = getVendorCount('unknown');
|
||||
result.push({
|
||||
value: 'unknown',
|
||||
label: t('未知供应商'),
|
||||
tagCount: count,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [getAllVendors, getVendorCount, t]);
|
||||
|
||||
return (
|
||||
<SelectableButtonGroup
|
||||
title={t('供应商')}
|
||||
items={items}
|
||||
activeValue={filterVendor}
|
||||
onChange={setFilterVendor}
|
||||
loading={loading}
|
||||
variant='violet'
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default PricingVendors;
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Layout, ImagePreview } from '@douyinfe/semi-ui';
|
||||
import PricingSidebar from './PricingSidebar';
|
||||
import PricingContent from './content/PricingContent';
|
||||
import ModelDetailSideSheet from '../modal/ModelDetailSideSheet';
|
||||
import { useModelPricingData } from '../../../../hooks/model-pricing/useModelPricingData';
|
||||
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
||||
|
||||
const PricingPage = () => {
|
||||
const pricingData = useModelPricingData();
|
||||
const { Sider, Content } = Layout;
|
||||
const isMobile = useIsMobile();
|
||||
const [showRatio, setShowRatio] = React.useState(false);
|
||||
const [viewMode, setViewMode] = React.useState('card');
|
||||
const allProps = {
|
||||
...pricingData,
|
||||
showRatio,
|
||||
setShowRatio,
|
||||
viewMode,
|
||||
setViewMode,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='bg-white'>
|
||||
<Layout className='pricing-layout'>
|
||||
{!isMobile && (
|
||||
<Sider className='pricing-scroll-hide pricing-sidebar'>
|
||||
<PricingSidebar {...allProps} />
|
||||
</Sider>
|
||||
)}
|
||||
|
||||
<Content className='pricing-scroll-hide pricing-content'>
|
||||
<PricingContent
|
||||
{...allProps}
|
||||
isMobile={isMobile}
|
||||
sidebarProps={allProps}
|
||||
/>
|
||||
</Content>
|
||||
</Layout>
|
||||
|
||||
<ImagePreview
|
||||
src={pricingData.modalImageUrl}
|
||||
visible={pricingData.isModalOpenurl}
|
||||
onVisibleChange={(visible) => pricingData.setIsModalOpenurl(visible)}
|
||||
/>
|
||||
|
||||
<ModelDetailSideSheet
|
||||
visible={pricingData.showModelDetail}
|
||||
onClose={pricingData.closeModelDetail}
|
||||
modelData={pricingData.selectedModel}
|
||||
groupRatio={pricingData.groupRatio}
|
||||
usableGroup={pricingData.usableGroup}
|
||||
currency={pricingData.currency}
|
||||
siteDisplayType={pricingData.siteDisplayType}
|
||||
tokenUnit={pricingData.tokenUnit}
|
||||
displayPrice={pricingData.displayPrice}
|
||||
showRatio={allProps.showRatio}
|
||||
vendorsMap={pricingData.vendorsMap}
|
||||
endpointMap={pricingData.endpointMap}
|
||||
autoGroups={pricingData.autoGroups}
|
||||
t={pricingData.t}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PricingPage;
|
||||
155
web/src/components/table/model-pricing/layout/PricingSidebar.jsx
Normal file
155
web/src/components/table/model-pricing/layout/PricingSidebar.jsx
Normal file
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Button } from '@douyinfe/semi-ui';
|
||||
import PricingGroups from '../filter/PricingGroups';
|
||||
import PricingQuotaTypes from '../filter/PricingQuotaTypes';
|
||||
import PricingEndpointTypes from '../filter/PricingEndpointTypes';
|
||||
import PricingVendors from '../filter/PricingVendors';
|
||||
import PricingTags from '../filter/PricingTags';
|
||||
|
||||
import { resetPricingFilters } from '../../../../helpers/utils';
|
||||
import { usePricingFilterCounts } from '../../../../hooks/model-pricing/usePricingFilterCounts';
|
||||
|
||||
const PricingSidebar = ({
|
||||
showWithRecharge,
|
||||
setShowWithRecharge,
|
||||
currency,
|
||||
setCurrency,
|
||||
handleChange,
|
||||
setActiveKey,
|
||||
showRatio,
|
||||
setShowRatio,
|
||||
viewMode,
|
||||
setViewMode,
|
||||
filterGroup,
|
||||
setFilterGroup,
|
||||
handleGroupClick,
|
||||
filterQuotaType,
|
||||
setFilterQuotaType,
|
||||
filterEndpointType,
|
||||
setFilterEndpointType,
|
||||
filterVendor,
|
||||
setFilterVendor,
|
||||
filterTag,
|
||||
setFilterTag,
|
||||
currentPage,
|
||||
setCurrentPage,
|
||||
tokenUnit,
|
||||
setTokenUnit,
|
||||
loading,
|
||||
t,
|
||||
...categoryProps
|
||||
}) => {
|
||||
const {
|
||||
quotaTypeModels,
|
||||
endpointTypeModels,
|
||||
vendorModels,
|
||||
tagModels,
|
||||
groupCountModels,
|
||||
} = usePricingFilterCounts({
|
||||
models: categoryProps.models,
|
||||
filterGroup,
|
||||
filterQuotaType,
|
||||
filterEndpointType,
|
||||
filterVendor,
|
||||
filterTag,
|
||||
searchValue: categoryProps.searchValue,
|
||||
});
|
||||
|
||||
const handleResetFilters = () =>
|
||||
resetPricingFilters({
|
||||
handleChange,
|
||||
setShowWithRecharge,
|
||||
setCurrency,
|
||||
setShowRatio,
|
||||
setViewMode,
|
||||
setFilterGroup,
|
||||
setFilterQuotaType,
|
||||
setFilterEndpointType,
|
||||
setFilterVendor,
|
||||
setFilterTag,
|
||||
setCurrentPage,
|
||||
setTokenUnit,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className='p-2'>
|
||||
<div className='flex items-center justify-between mb-6'>
|
||||
<div className='text-lg font-semibold text-gray-800'>{t('筛选')}</div>
|
||||
<Button
|
||||
theme='outline'
|
||||
type='tertiary'
|
||||
onClick={handleResetFilters}
|
||||
className='text-gray-500 hover:text-gray-700'
|
||||
>
|
||||
{t('重置')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<PricingVendors
|
||||
filterVendor={filterVendor}
|
||||
setFilterVendor={setFilterVendor}
|
||||
models={vendorModels}
|
||||
allModels={categoryProps.models}
|
||||
loading={loading}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<PricingGroups
|
||||
filterGroup={filterGroup}
|
||||
setFilterGroup={handleGroupClick}
|
||||
usableGroup={categoryProps.usableGroup}
|
||||
groupRatio={categoryProps.groupRatio}
|
||||
models={groupCountModels}
|
||||
loading={loading}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<PricingQuotaTypes
|
||||
filterQuotaType={filterQuotaType}
|
||||
setFilterQuotaType={setFilterQuotaType}
|
||||
models={quotaTypeModels}
|
||||
loading={loading}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<PricingTags
|
||||
filterTag={filterTag}
|
||||
setFilterTag={setFilterTag}
|
||||
models={tagModels}
|
||||
allModels={categoryProps.models}
|
||||
loading={loading}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<PricingEndpointTypes
|
||||
filterEndpointType={filterEndpointType}
|
||||
setFilterEndpointType={setFilterEndpointType}
|
||||
models={endpointTypeModels}
|
||||
allModels={categoryProps.models}
|
||||
loading={loading}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PricingSidebar;
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PricingTopSection from '../header/PricingTopSection';
|
||||
import PricingView from './PricingView';
|
||||
|
||||
const PricingContent = ({ isMobile, sidebarProps, ...props }) => {
|
||||
return (
|
||||
<div
|
||||
className={isMobile ? 'pricing-content-mobile' : 'pricing-scroll-hide'}
|
||||
>
|
||||
{/* 固定的顶部区域(分类介绍 + 搜索和操作) */}
|
||||
<div className='pricing-search-header'>
|
||||
<PricingTopSection
|
||||
{...props}
|
||||
isMobile={isMobile}
|
||||
sidebarProps={sidebarProps}
|
||||
showWithRecharge={sidebarProps.showWithRecharge}
|
||||
setShowWithRecharge={sidebarProps.setShowWithRecharge}
|
||||
currency={sidebarProps.currency}
|
||||
setCurrency={sidebarProps.setCurrency}
|
||||
showRatio={sidebarProps.showRatio}
|
||||
setShowRatio={sidebarProps.setShowRatio}
|
||||
viewMode={sidebarProps.viewMode}
|
||||
setViewMode={sidebarProps.setViewMode}
|
||||
tokenUnit={sidebarProps.tokenUnit}
|
||||
setTokenUnit={sidebarProps.setTokenUnit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 可滚动的内容区域 */}
|
||||
<div
|
||||
className={
|
||||
isMobile ? 'pricing-view-container-mobile' : 'pricing-view-container'
|
||||
}
|
||||
>
|
||||
<PricingView {...props} viewMode={sidebarProps.viewMode} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PricingContent;
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PricingTable from '../../view/table/PricingTable';
|
||||
import PricingCardView from '../../view/card/PricingCardView';
|
||||
|
||||
const PricingView = ({ viewMode = 'table', ...props }) => {
|
||||
return viewMode === 'card' ? (
|
||||
<PricingCardView {...props} />
|
||||
) : (
|
||||
<PricingTable {...props} />
|
||||
);
|
||||
};
|
||||
|
||||
export default PricingView;
|
||||
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useState, memo } from 'react';
|
||||
import PricingFilterModal from '../../modal/PricingFilterModal';
|
||||
import PricingVendorIntroWithSkeleton from './PricingVendorIntroWithSkeleton';
|
||||
import SearchActions from './SearchActions';
|
||||
|
||||
const PricingTopSection = memo(
|
||||
({
|
||||
selectedRowKeys,
|
||||
copyText,
|
||||
handleChange,
|
||||
handleCompositionStart,
|
||||
handleCompositionEnd,
|
||||
isMobile,
|
||||
sidebarProps,
|
||||
filterVendor,
|
||||
models,
|
||||
filteredModels,
|
||||
loading,
|
||||
searchValue,
|
||||
showWithRecharge,
|
||||
setShowWithRecharge,
|
||||
currency,
|
||||
setCurrency,
|
||||
siteDisplayType,
|
||||
showRatio,
|
||||
setShowRatio,
|
||||
viewMode,
|
||||
setViewMode,
|
||||
tokenUnit,
|
||||
setTokenUnit,
|
||||
t,
|
||||
}) => {
|
||||
const [showFilterModal, setShowFilterModal] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isMobile ? (
|
||||
<>
|
||||
<div className='w-full'>
|
||||
<SearchActions
|
||||
selectedRowKeys={selectedRowKeys}
|
||||
copyText={copyText}
|
||||
handleChange={handleChange}
|
||||
handleCompositionStart={handleCompositionStart}
|
||||
handleCompositionEnd={handleCompositionEnd}
|
||||
isMobile={isMobile}
|
||||
searchValue={searchValue}
|
||||
setShowFilterModal={setShowFilterModal}
|
||||
showWithRecharge={showWithRecharge}
|
||||
setShowWithRecharge={setShowWithRecharge}
|
||||
currency={currency}
|
||||
setCurrency={setCurrency}
|
||||
siteDisplayType={siteDisplayType}
|
||||
showRatio={showRatio}
|
||||
setShowRatio={setShowRatio}
|
||||
viewMode={viewMode}
|
||||
setViewMode={setViewMode}
|
||||
tokenUnit={tokenUnit}
|
||||
setTokenUnit={setTokenUnit}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
<PricingFilterModal
|
||||
visible={showFilterModal}
|
||||
onClose={() => setShowFilterModal(false)}
|
||||
sidebarProps={sidebarProps}
|
||||
t={t}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<PricingVendorIntroWithSkeleton
|
||||
loading={loading}
|
||||
filterVendor={filterVendor}
|
||||
models={filteredModels}
|
||||
allModels={models}
|
||||
t={t}
|
||||
selectedRowKeys={selectedRowKeys}
|
||||
copyText={copyText}
|
||||
handleChange={handleChange}
|
||||
handleCompositionStart={handleCompositionStart}
|
||||
handleCompositionEnd={handleCompositionEnd}
|
||||
isMobile={isMobile}
|
||||
searchValue={searchValue}
|
||||
setShowFilterModal={setShowFilterModal}
|
||||
showWithRecharge={showWithRecharge}
|
||||
setShowWithRecharge={setShowWithRecharge}
|
||||
currency={currency}
|
||||
setCurrency={setCurrency}
|
||||
siteDisplayType={siteDisplayType}
|
||||
showRatio={showRatio}
|
||||
setShowRatio={setShowRatio}
|
||||
viewMode={viewMode}
|
||||
setViewMode={setViewMode}
|
||||
tokenUnit={tokenUnit}
|
||||
setTokenUnit={setTokenUnit}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
PricingTopSection.displayName = 'PricingTopSection';
|
||||
|
||||
export default PricingTopSection;
|
||||
@@ -0,0 +1,419 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useMemo, useCallback, memo } from 'react';
|
||||
import {
|
||||
Card,
|
||||
Tag,
|
||||
Avatar,
|
||||
Typography,
|
||||
Tooltip,
|
||||
Modal,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { getLobeHubIcon } from '../../../../../helpers';
|
||||
import SearchActions from './SearchActions';
|
||||
|
||||
const { Paragraph } = Typography;
|
||||
|
||||
const CONFIG = {
|
||||
CAROUSEL_INTERVAL: 2000,
|
||||
ICON_SIZE: 40,
|
||||
UNKNOWN_VENDOR: 'unknown',
|
||||
};
|
||||
|
||||
const THEME_COLORS = {
|
||||
allVendors: {
|
||||
primary: '37 99 235',
|
||||
background: 'rgba(59, 130, 246, 0.08)',
|
||||
},
|
||||
specific: {
|
||||
primary: '16 185 129',
|
||||
background: 'rgba(16, 185, 129, 0.1)',
|
||||
},
|
||||
};
|
||||
|
||||
const COMPONENT_STYLES = {
|
||||
tag: {
|
||||
backgroundColor: 'rgba(255,255,255,0.95)',
|
||||
color: '#1f2937',
|
||||
border: '1px solid rgba(255,255,255,0.8)',
|
||||
fontWeight: '500',
|
||||
},
|
||||
avatarContainer:
|
||||
'w-16 h-16 rounded-2xl bg-white/90 shadow-md backdrop-blur-sm flex items-center justify-center',
|
||||
titleText: { color: 'white' },
|
||||
descriptionText: { color: 'rgba(255,255,255,0.9)' },
|
||||
};
|
||||
|
||||
const CONTENT_TEXTS = {
|
||||
unknown: {
|
||||
displayName: (t) => t('未知供应商'),
|
||||
description: (t) =>
|
||||
t(
|
||||
'包含来自未知或未标明供应商的AI模型,这些模型可能来自小型供应商或开源项目。',
|
||||
),
|
||||
},
|
||||
all: {
|
||||
description: (t) =>
|
||||
t('查看所有可用的AI模型供应商,包括众多知名供应商的模型。'),
|
||||
},
|
||||
fallback: {
|
||||
description: (t) => t('该供应商提供多种AI模型,适用于不同的应用场景。'),
|
||||
},
|
||||
};
|
||||
|
||||
const getVendorDisplayName = (vendorName, t) => {
|
||||
return vendorName === CONFIG.UNKNOWN_VENDOR
|
||||
? CONTENT_TEXTS.unknown.displayName(t)
|
||||
: vendorName;
|
||||
};
|
||||
|
||||
const createDefaultAvatar = () => (
|
||||
<div className={COMPONENT_STYLES.avatarContainer}>
|
||||
<Avatar size='large' color='transparent'>
|
||||
AI
|
||||
</Avatar>
|
||||
</div>
|
||||
);
|
||||
|
||||
const getAvatarBackgroundColor = (isAllVendors) =>
|
||||
isAllVendors
|
||||
? THEME_COLORS.allVendors.background
|
||||
: THEME_COLORS.specific.background;
|
||||
|
||||
const getAvatarText = (vendorName) =>
|
||||
vendorName === CONFIG.UNKNOWN_VENDOR
|
||||
? '?'
|
||||
: vendorName.charAt(0).toUpperCase();
|
||||
|
||||
const createAvatarContent = (vendor, isAllVendors) => {
|
||||
if (vendor.icon) {
|
||||
return getLobeHubIcon(vendor.icon, CONFIG.ICON_SIZE);
|
||||
}
|
||||
|
||||
return (
|
||||
<Avatar
|
||||
size='large'
|
||||
style={{ backgroundColor: getAvatarBackgroundColor(isAllVendors) }}
|
||||
>
|
||||
{getAvatarText(vendor.name)}
|
||||
</Avatar>
|
||||
);
|
||||
};
|
||||
|
||||
const renderVendorAvatar = (vendor, t, isAllVendors = false) => {
|
||||
if (!vendor) {
|
||||
return createDefaultAvatar();
|
||||
}
|
||||
|
||||
const displayName = getVendorDisplayName(vendor.name, t);
|
||||
const avatarContent = createAvatarContent(vendor, isAllVendors);
|
||||
|
||||
return (
|
||||
<Tooltip content={displayName} position='top'>
|
||||
<div className={COMPONENT_STYLES.avatarContainer}>{avatarContent}</div>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const PricingVendorIntro = memo(
|
||||
({
|
||||
filterVendor,
|
||||
models = [],
|
||||
allModels = [],
|
||||
t,
|
||||
selectedRowKeys = [],
|
||||
copyText,
|
||||
handleChange,
|
||||
handleCompositionStart,
|
||||
handleCompositionEnd,
|
||||
isMobile = false,
|
||||
searchValue = '',
|
||||
setShowFilterModal,
|
||||
showWithRecharge,
|
||||
setShowWithRecharge,
|
||||
currency,
|
||||
setCurrency,
|
||||
showRatio,
|
||||
setShowRatio,
|
||||
viewMode,
|
||||
setViewMode,
|
||||
tokenUnit,
|
||||
setTokenUnit,
|
||||
}) => {
|
||||
const [currentOffset, setCurrentOffset] = useState(0);
|
||||
const [descModalVisible, setDescModalVisible] = useState(false);
|
||||
const [descModalContent, setDescModalContent] = useState('');
|
||||
|
||||
const handleOpenDescModal = useCallback((content) => {
|
||||
setDescModalContent(content || '');
|
||||
setDescModalVisible(true);
|
||||
}, []);
|
||||
|
||||
const handleCloseDescModal = useCallback(() => {
|
||||
setDescModalVisible(false);
|
||||
}, []);
|
||||
|
||||
const renderDescriptionModal = useCallback(
|
||||
() => (
|
||||
<Modal
|
||||
title={t('供应商介绍')}
|
||||
visible={descModalVisible}
|
||||
onCancel={handleCloseDescModal}
|
||||
footer={null}
|
||||
width={isMobile ? '95%' : 600}
|
||||
bodyStyle={{
|
||||
maxHeight: isMobile ? '70vh' : '60vh',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
<div className='text-sm mb-4'>{descModalContent}</div>
|
||||
</Modal>
|
||||
),
|
||||
[descModalVisible, descModalContent, handleCloseDescModal, isMobile, t],
|
||||
);
|
||||
|
||||
const vendorInfo = useMemo(() => {
|
||||
const vendors = new Map();
|
||||
let unknownCount = 0;
|
||||
|
||||
const sourceModels =
|
||||
Array.isArray(allModels) && allModels.length > 0 ? allModels : models;
|
||||
|
||||
sourceModels.forEach((model) => {
|
||||
if (model.vendor_name) {
|
||||
const existing = vendors.get(model.vendor_name);
|
||||
if (existing) {
|
||||
existing.count++;
|
||||
} else {
|
||||
vendors.set(model.vendor_name, {
|
||||
name: model.vendor_name,
|
||||
icon: model.vendor_icon,
|
||||
description: model.vendor_description,
|
||||
count: 1,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
unknownCount++;
|
||||
}
|
||||
});
|
||||
|
||||
const vendorList = Array.from(vendors.values()).sort((a, b) =>
|
||||
a.name.localeCompare(b.name),
|
||||
);
|
||||
|
||||
if (unknownCount > 0) {
|
||||
vendorList.push({
|
||||
name: CONFIG.UNKNOWN_VENDOR,
|
||||
icon: null,
|
||||
description: CONTENT_TEXTS.unknown.description(t),
|
||||
count: unknownCount,
|
||||
});
|
||||
}
|
||||
|
||||
return vendorList;
|
||||
}, [allModels, models, t]);
|
||||
|
||||
const currentModelCount = models.length;
|
||||
|
||||
useEffect(() => {
|
||||
if (filterVendor !== 'all' || vendorInfo.length <= 1) {
|
||||
setCurrentOffset(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setCurrentOffset((prev) => (prev + 1) % vendorInfo.length);
|
||||
}, CONFIG.CAROUSEL_INTERVAL);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [filterVendor, vendorInfo.length]);
|
||||
|
||||
const getVendorDescription = useCallback(
|
||||
(vendorKey) => {
|
||||
if (vendorKey === 'all') {
|
||||
return CONTENT_TEXTS.all.description(t);
|
||||
}
|
||||
if (vendorKey === CONFIG.UNKNOWN_VENDOR) {
|
||||
return CONTENT_TEXTS.unknown.description(t);
|
||||
}
|
||||
const vendor = vendorInfo.find((v) => v.name === vendorKey);
|
||||
return vendor?.description || CONTENT_TEXTS.fallback.description(t);
|
||||
},
|
||||
[vendorInfo, t],
|
||||
);
|
||||
|
||||
const createCoverStyle = useCallback(
|
||||
(primaryColor) => ({
|
||||
'--palette-primary-darkerChannel': primaryColor,
|
||||
backgroundImage: `linear-gradient(0deg, rgba(var(--palette-primary-darkerChannel) / 80%), rgba(var(--palette-primary-darkerChannel) / 80%)), url('/cover-4.webp')`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const renderSearchActions = useCallback(
|
||||
() => (
|
||||
<SearchActions
|
||||
selectedRowKeys={selectedRowKeys}
|
||||
copyText={copyText}
|
||||
handleChange={handleChange}
|
||||
handleCompositionStart={handleCompositionStart}
|
||||
handleCompositionEnd={handleCompositionEnd}
|
||||
isMobile={isMobile}
|
||||
searchValue={searchValue}
|
||||
setShowFilterModal={setShowFilterModal}
|
||||
showWithRecharge={showWithRecharge}
|
||||
setShowWithRecharge={setShowWithRecharge}
|
||||
currency={currency}
|
||||
setCurrency={setCurrency}
|
||||
showRatio={showRatio}
|
||||
setShowRatio={setShowRatio}
|
||||
viewMode={viewMode}
|
||||
setViewMode={setViewMode}
|
||||
tokenUnit={tokenUnit}
|
||||
setTokenUnit={setTokenUnit}
|
||||
t={t}
|
||||
/>
|
||||
),
|
||||
[
|
||||
selectedRowKeys,
|
||||
copyText,
|
||||
handleChange,
|
||||
handleCompositionStart,
|
||||
handleCompositionEnd,
|
||||
isMobile,
|
||||
searchValue,
|
||||
setShowFilterModal,
|
||||
showWithRecharge,
|
||||
setShowWithRecharge,
|
||||
currency,
|
||||
setCurrency,
|
||||
showRatio,
|
||||
setShowRatio,
|
||||
viewMode,
|
||||
setViewMode,
|
||||
tokenUnit,
|
||||
setTokenUnit,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
const renderHeaderCard = useCallback(
|
||||
({ title, count, description, rightContent, primaryDarkerChannel }) => (
|
||||
<Card
|
||||
className='!rounded-2xl shadow-sm border-0'
|
||||
cover={
|
||||
<div
|
||||
className='relative h-full'
|
||||
style={createCoverStyle(primaryDarkerChannel)}
|
||||
>
|
||||
<div className='relative z-10 h-full flex items-center justify-between p-4'>
|
||||
<div className='flex-1 min-w-0 mr-4'>
|
||||
<div className='flex flex-row flex-wrap items-center gap-2 sm:gap-3 mb-2'>
|
||||
<h2
|
||||
className='text-lg sm:text-xl font-bold truncate'
|
||||
style={COMPONENT_STYLES.titleText}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
<Tag
|
||||
style={COMPONENT_STYLES.tag}
|
||||
shape='circle'
|
||||
size='small'
|
||||
className='self-center'
|
||||
>
|
||||
{t('共 {{count}} 个模型', { count })}
|
||||
</Tag>
|
||||
</div>
|
||||
<Paragraph
|
||||
className='text-xs sm:text-sm leading-relaxed !mb-0 cursor-pointer'
|
||||
style={COMPONENT_STYLES.descriptionText}
|
||||
ellipsis={{ rows: 2 }}
|
||||
onClick={() => handleOpenDescModal(description)}
|
||||
>
|
||||
{description}
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
<div className='flex-shrink-0'>{rightContent}</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{renderSearchActions()}
|
||||
</Card>
|
||||
),
|
||||
[renderSearchActions, createCoverStyle, handleOpenDescModal, t],
|
||||
);
|
||||
|
||||
const renderAllVendorsAvatar = useCallback(() => {
|
||||
const currentVendor =
|
||||
vendorInfo.length > 0
|
||||
? vendorInfo[currentOffset % vendorInfo.length]
|
||||
: null;
|
||||
return renderVendorAvatar(currentVendor, t, true);
|
||||
}, [vendorInfo, currentOffset, t]);
|
||||
|
||||
if (filterVendor === 'all') {
|
||||
const headerCard = renderHeaderCard({
|
||||
title: t('全部供应商'),
|
||||
count: currentModelCount,
|
||||
description: getVendorDescription('all'),
|
||||
rightContent: renderAllVendorsAvatar(),
|
||||
primaryDarkerChannel: THEME_COLORS.allVendors.primary,
|
||||
});
|
||||
return (
|
||||
<>
|
||||
{headerCard}
|
||||
{renderDescriptionModal()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const currentVendor = vendorInfo.find((v) => v.name === filterVendor);
|
||||
if (!currentVendor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const vendorDisplayName = getVendorDisplayName(currentVendor.name, t);
|
||||
|
||||
const headerCard = renderHeaderCard({
|
||||
title: vendorDisplayName,
|
||||
count: currentModelCount,
|
||||
description:
|
||||
currentVendor.description || getVendorDescription(currentVendor.name),
|
||||
rightContent: renderVendorAvatar(currentVendor, t, false),
|
||||
primaryDarkerChannel: THEME_COLORS.specific.primary,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{headerCard}
|
||||
{renderDescriptionModal()}
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
PricingVendorIntro.displayName = 'PricingVendorIntro';
|
||||
|
||||
export default PricingVendorIntro;
|
||||
@@ -0,0 +1,212 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import { Card, Skeleton } from '@douyinfe/semi-ui';
|
||||
|
||||
const THEME_COLORS = {
|
||||
allVendors: {
|
||||
primary: '37 99 235',
|
||||
background: 'rgba(59, 130, 246, 0.1)',
|
||||
border: 'rgba(59, 130, 246, 0.2)',
|
||||
},
|
||||
specific: {
|
||||
primary: '16 185 129',
|
||||
background: 'rgba(16, 185, 129, 0.1)',
|
||||
border: 'rgba(16, 185, 129, 0.2)',
|
||||
},
|
||||
neutral: {
|
||||
background: 'rgba(156, 163, 175, 0.1)',
|
||||
border: 'rgba(156, 163, 175, 0.2)',
|
||||
},
|
||||
};
|
||||
|
||||
const SIZES = {
|
||||
title: { width: { all: 120, specific: 100 }, height: 24 },
|
||||
tag: { width: 80, height: 20 },
|
||||
description: { height: 14 },
|
||||
avatar: { width: 40, height: 40 },
|
||||
searchInput: { height: 32 },
|
||||
button: { width: 80, height: 32 },
|
||||
};
|
||||
|
||||
const SKELETON_STYLES = {
|
||||
cover: (primaryColor) => ({
|
||||
'--palette-primary-darkerChannel': primaryColor,
|
||||
backgroundImage: `linear-gradient(0deg, rgba(var(--palette-primary-darkerChannel) / 80%), rgba(var(--palette-primary-darkerChannel) / 80%)), url('/cover-4.webp')`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}),
|
||||
title: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.25)',
|
||||
borderRadius: 8,
|
||||
backdropFilter: 'blur(4px)',
|
||||
},
|
||||
tag: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: 9999,
|
||||
backdropFilter: 'blur(4px)',
|
||||
border: '1px solid rgba(255,255,255,0.3)',
|
||||
},
|
||||
description: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: 4,
|
||||
backdropFilter: 'blur(4px)',
|
||||
},
|
||||
avatar: (isAllVendors) => {
|
||||
const colors = isAllVendors
|
||||
? THEME_COLORS.allVendors
|
||||
: THEME_COLORS.specific;
|
||||
return {
|
||||
backgroundColor: colors.background,
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${colors.border}`,
|
||||
};
|
||||
},
|
||||
searchInput: {
|
||||
backgroundColor: THEME_COLORS.neutral.background,
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${THEME_COLORS.neutral.border}`,
|
||||
},
|
||||
button: {
|
||||
backgroundColor: THEME_COLORS.neutral.background,
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${THEME_COLORS.neutral.border}`,
|
||||
},
|
||||
};
|
||||
|
||||
const createSkeletonRect = (style = {}, key = null) => (
|
||||
<div key={key} className='animate-pulse' style={style} />
|
||||
);
|
||||
|
||||
const PricingVendorIntroSkeleton = memo(
|
||||
({ isAllVendors = false, isMobile = false }) => {
|
||||
const placeholder = (
|
||||
<Card
|
||||
className='!rounded-2xl shadow-sm border-0'
|
||||
cover={
|
||||
<div
|
||||
className='relative h-full'
|
||||
style={SKELETON_STYLES.cover(
|
||||
isAllVendors
|
||||
? THEME_COLORS.allVendors.primary
|
||||
: THEME_COLORS.specific.primary,
|
||||
)}
|
||||
>
|
||||
<div className='relative z-10 h-full flex items-center justify-between p-4'>
|
||||
<div className='flex-1 min-w-0 mr-4'>
|
||||
<div className='flex flex-row flex-wrap items-center gap-2 sm:gap-3 mb-2'>
|
||||
{createSkeletonRect(
|
||||
{
|
||||
...SKELETON_STYLES.title,
|
||||
width: isAllVendors
|
||||
? SIZES.title.width.all
|
||||
: SIZES.title.width.specific,
|
||||
height: SIZES.title.height,
|
||||
},
|
||||
'title',
|
||||
)}
|
||||
{createSkeletonRect(
|
||||
{
|
||||
...SKELETON_STYLES.tag,
|
||||
width: SIZES.tag.width,
|
||||
height: SIZES.tag.height,
|
||||
},
|
||||
'tag',
|
||||
)}
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
{createSkeletonRect(
|
||||
{
|
||||
...SKELETON_STYLES.description,
|
||||
width: '100%',
|
||||
height: SIZES.description.height,
|
||||
},
|
||||
'desc1',
|
||||
)}
|
||||
{createSkeletonRect(
|
||||
{
|
||||
...SKELETON_STYLES.description,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.15)',
|
||||
width: '75%',
|
||||
height: SIZES.description.height,
|
||||
},
|
||||
'desc2',
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex-shrink-0 w-16 h-16 rounded-2xl bg-white/90 shadow-md backdrop-blur-sm flex items-center justify-center'>
|
||||
{createSkeletonRect(
|
||||
{
|
||||
...SKELETON_STYLES.avatar(isAllVendors),
|
||||
width: SIZES.avatar.width,
|
||||
height: SIZES.avatar.height,
|
||||
},
|
||||
'avatar',
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className='flex items-center gap-2 w-full'>
|
||||
<div className='flex-1'>
|
||||
{createSkeletonRect(
|
||||
{
|
||||
...SKELETON_STYLES.searchInput,
|
||||
width: '100%',
|
||||
height: SIZES.searchInput.height,
|
||||
},
|
||||
'search',
|
||||
)}
|
||||
</div>
|
||||
|
||||
{createSkeletonRect(
|
||||
{
|
||||
...SKELETON_STYLES.button,
|
||||
width: SIZES.button.width,
|
||||
height: SIZES.button.height,
|
||||
},
|
||||
'copy-button',
|
||||
)}
|
||||
|
||||
{isMobile &&
|
||||
createSkeletonRect(
|
||||
{
|
||||
...SKELETON_STYLES.button,
|
||||
width: SIZES.button.width,
|
||||
height: SIZES.button.height,
|
||||
},
|
||||
'filter-button',
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<Skeleton loading={true} active placeholder={placeholder}></Skeleton>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
PricingVendorIntroSkeleton.displayName = 'PricingVendorIntroSkeleton';
|
||||
|
||||
export default PricingVendorIntroSkeleton;
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import PricingVendorIntro from './PricingVendorIntro';
|
||||
import PricingVendorIntroSkeleton from './PricingVendorIntroSkeleton';
|
||||
import { useMinimumLoadingTime } from '../../../../../hooks/common/useMinimumLoadingTime';
|
||||
|
||||
const PricingVendorIntroWithSkeleton = memo(
|
||||
({ loading = false, filterVendor, ...restProps }) => {
|
||||
const showSkeleton = useMinimumLoadingTime(loading);
|
||||
|
||||
if (showSkeleton) {
|
||||
return (
|
||||
<PricingVendorIntroSkeleton
|
||||
isAllVendors={filterVendor === 'all'}
|
||||
isMobile={restProps.isMobile}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <PricingVendorIntro filterVendor={filterVendor} {...restProps} />;
|
||||
},
|
||||
);
|
||||
|
||||
PricingVendorIntroWithSkeleton.displayName = 'PricingVendorIntroWithSkeleton';
|
||||
|
||||
export default PricingVendorIntroWithSkeleton;
|
||||
@@ -0,0 +1,163 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { memo, useCallback } from 'react';
|
||||
import { Input, Button, Switch, Select, Divider } from '@douyinfe/semi-ui';
|
||||
import { IconSearch, IconCopy, IconFilter } from '@douyinfe/semi-icons';
|
||||
|
||||
const SearchActions = memo(
|
||||
({
|
||||
selectedRowKeys = [],
|
||||
copyText,
|
||||
handleChange,
|
||||
handleCompositionStart,
|
||||
handleCompositionEnd,
|
||||
isMobile = false,
|
||||
searchValue = '',
|
||||
setShowFilterModal,
|
||||
showWithRecharge,
|
||||
setShowWithRecharge,
|
||||
currency,
|
||||
setCurrency,
|
||||
siteDisplayType,
|
||||
showRatio,
|
||||
setShowRatio,
|
||||
viewMode,
|
||||
setViewMode,
|
||||
tokenUnit,
|
||||
setTokenUnit,
|
||||
t,
|
||||
}) => {
|
||||
const supportsCurrencyDisplay = siteDisplayType !== 'TOKENS';
|
||||
|
||||
const handleCopyClick = useCallback(() => {
|
||||
if (copyText && selectedRowKeys.length > 0) {
|
||||
copyText(selectedRowKeys);
|
||||
}
|
||||
}, [copyText, selectedRowKeys]);
|
||||
|
||||
const handleFilterClick = useCallback(() => {
|
||||
setShowFilterModal?.(true);
|
||||
}, [setShowFilterModal]);
|
||||
|
||||
const handleViewModeToggle = useCallback(() => {
|
||||
setViewMode?.(viewMode === 'table' ? 'card' : 'table');
|
||||
}, [viewMode, setViewMode]);
|
||||
|
||||
const handleTokenUnitToggle = useCallback(() => {
|
||||
setTokenUnit?.(tokenUnit === 'K' ? 'M' : 'K');
|
||||
}, [tokenUnit, setTokenUnit]);
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-2 w-full'>
|
||||
<div className='flex-1'>
|
||||
<Input
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('模糊搜索模型名称')}
|
||||
value={searchValue}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
onChange={handleChange}
|
||||
showClear
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
theme='outline'
|
||||
type='primary'
|
||||
icon={<IconCopy />}
|
||||
onClick={handleCopyClick}
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
className='!bg-blue-500 hover:!bg-blue-600 !text-white disabled:!bg-gray-300 disabled:!text-gray-500'
|
||||
>
|
||||
{t('复制')}
|
||||
</Button>
|
||||
|
||||
{!isMobile && (
|
||||
<>
|
||||
<Divider layout='vertical' margin='8px' />
|
||||
|
||||
{/* 充值价格显示开关 */}
|
||||
{supportsCurrencyDisplay && (
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='text-sm text-gray-600'>{t('充值价格显示')}</span>
|
||||
<Switch
|
||||
checked={showWithRecharge}
|
||||
onChange={setShowWithRecharge}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 货币单位选择 */}
|
||||
{supportsCurrencyDisplay && showWithRecharge && (
|
||||
<Select
|
||||
value={currency}
|
||||
onChange={setCurrency}
|
||||
optionList={[
|
||||
{ value: 'USD', label: 'USD' },
|
||||
{ value: 'CNY', label: 'CNY' },
|
||||
{ value: 'CUSTOM', label: t('自定义货币') },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 显示倍率开关 */}
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='text-sm text-gray-600'>{t('倍率')}</span>
|
||||
<Switch checked={showRatio} onChange={setShowRatio} />
|
||||
</div>
|
||||
|
||||
{/* 视图模式切换按钮 */}
|
||||
<Button
|
||||
theme={viewMode === 'table' ? 'solid' : 'outline'}
|
||||
type={viewMode === 'table' ? 'primary' : 'tertiary'}
|
||||
onClick={handleViewModeToggle}
|
||||
>
|
||||
{t('表格视图')}
|
||||
</Button>
|
||||
|
||||
{/* Token单位切换按钮 */}
|
||||
<Button
|
||||
theme={tokenUnit === 'K' ? 'solid' : 'outline'}
|
||||
type={tokenUnit === 'K' ? 'primary' : 'tertiary'}
|
||||
onClick={handleTokenUnitToggle}
|
||||
>
|
||||
{tokenUnit}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isMobile && (
|
||||
<Button
|
||||
theme='outline'
|
||||
type='tertiary'
|
||||
icon={<IconFilter />}
|
||||
onClick={handleFilterClick}
|
||||
>
|
||||
{t('筛选')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
SearchActions.displayName = 'SearchActions';
|
||||
|
||||
export default SearchActions;
|
||||
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { SideSheet, Typography, Button } from '@douyinfe/semi-ui';
|
||||
import { IconClose } from '@douyinfe/semi-icons';
|
||||
|
||||
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
||||
import ModelHeader from './components/ModelHeader';
|
||||
import ModelBasicInfo from './components/ModelBasicInfo';
|
||||
import ModelEndpoints from './components/ModelEndpoints';
|
||||
import ModelPricingTable from './components/ModelPricingTable';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const ModelDetailSideSheet = ({
|
||||
visible,
|
||||
onClose,
|
||||
modelData,
|
||||
groupRatio,
|
||||
currency,
|
||||
siteDisplayType,
|
||||
tokenUnit,
|
||||
displayPrice,
|
||||
showRatio,
|
||||
usableGroup,
|
||||
vendorsMap,
|
||||
endpointMap,
|
||||
autoGroups,
|
||||
t,
|
||||
}) => {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return (
|
||||
<SideSheet
|
||||
placement='right'
|
||||
title={
|
||||
<ModelHeader modelData={modelData} vendorsMap={vendorsMap} t={t} />
|
||||
}
|
||||
bodyStyle={{
|
||||
padding: '0',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
borderBottom: '1px solid var(--semi-color-border)',
|
||||
}}
|
||||
visible={visible}
|
||||
width={isMobile ? '100%' : 600}
|
||||
closeIcon={
|
||||
<Button
|
||||
className='semi-button-tertiary semi-button-size-small semi-button-borderless'
|
||||
type='button'
|
||||
icon={<IconClose />}
|
||||
onClick={onClose}
|
||||
/>
|
||||
}
|
||||
onCancel={onClose}
|
||||
>
|
||||
<div className='p-2'>
|
||||
{!modelData && (
|
||||
<div className='flex justify-center items-center py-10'>
|
||||
<Text type='secondary'>{t('加载中...')}</Text>
|
||||
</div>
|
||||
)}
|
||||
{modelData && (
|
||||
<>
|
||||
<ModelBasicInfo
|
||||
modelData={modelData}
|
||||
vendorsMap={vendorsMap}
|
||||
t={t}
|
||||
/>
|
||||
<ModelEndpoints
|
||||
modelData={modelData}
|
||||
endpointMap={endpointMap}
|
||||
t={t}
|
||||
/>
|
||||
<ModelPricingTable
|
||||
modelData={modelData}
|
||||
groupRatio={groupRatio}
|
||||
currency={currency}
|
||||
siteDisplayType={siteDisplayType}
|
||||
tokenUnit={tokenUnit}
|
||||
displayPrice={displayPrice}
|
||||
showRatio={showRatio}
|
||||
usableGroup={usableGroup}
|
||||
autoGroups={autoGroups}
|
||||
t={t}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SideSheet>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelDetailSideSheet;
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Modal } from '@douyinfe/semi-ui';
|
||||
import { resetPricingFilters } from '../../../../helpers/utils';
|
||||
import FilterModalContent from './components/FilterModalContent';
|
||||
import FilterModalFooter from './components/FilterModalFooter';
|
||||
|
||||
const PricingFilterModal = ({ visible, onClose, sidebarProps, t }) => {
|
||||
const handleResetFilters = () =>
|
||||
resetPricingFilters({
|
||||
handleChange: sidebarProps.handleChange,
|
||||
setShowWithRecharge: sidebarProps.setShowWithRecharge,
|
||||
setCurrency: sidebarProps.setCurrency,
|
||||
setShowRatio: sidebarProps.setShowRatio,
|
||||
setViewMode: sidebarProps.setViewMode,
|
||||
setFilterGroup: sidebarProps.setFilterGroup,
|
||||
setFilterQuotaType: sidebarProps.setFilterQuotaType,
|
||||
setFilterEndpointType: sidebarProps.setFilterEndpointType,
|
||||
setFilterVendor: sidebarProps.setFilterVendor,
|
||||
setFilterTag: sidebarProps.setFilterTag,
|
||||
setCurrentPage: sidebarProps.setCurrentPage,
|
||||
setTokenUnit: sidebarProps.setTokenUnit,
|
||||
});
|
||||
|
||||
const footer = (
|
||||
<FilterModalFooter onReset={handleResetFilters} onConfirm={onClose} t={t} />
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('筛选')}
|
||||
visible={visible}
|
||||
onCancel={onClose}
|
||||
footer={footer}
|
||||
style={{ width: '100%', height: '100%', margin: 0 }}
|
||||
bodyStyle={{
|
||||
padding: 0,
|
||||
height: 'calc(100vh - 160px)',
|
||||
overflowY: 'auto',
|
||||
scrollbarWidth: 'none',
|
||||
msOverflowStyle: 'none',
|
||||
}}
|
||||
>
|
||||
<FilterModalContent sidebarProps={sidebarProps} t={t} />
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default PricingFilterModal;
|
||||
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PricingDisplaySettings from '../../filter/PricingDisplaySettings';
|
||||
import PricingGroups from '../../filter/PricingGroups';
|
||||
import PricingQuotaTypes from '../../filter/PricingQuotaTypes';
|
||||
import PricingEndpointTypes from '../../filter/PricingEndpointTypes';
|
||||
import PricingVendors from '../../filter/PricingVendors';
|
||||
import PricingTags from '../../filter/PricingTags';
|
||||
import { usePricingFilterCounts } from '../../../../../hooks/model-pricing/usePricingFilterCounts';
|
||||
|
||||
const FilterModalContent = ({ sidebarProps, t }) => {
|
||||
const {
|
||||
showWithRecharge,
|
||||
setShowWithRecharge,
|
||||
currency,
|
||||
setCurrency,
|
||||
siteDisplayType,
|
||||
handleChange,
|
||||
setActiveKey,
|
||||
showRatio,
|
||||
setShowRatio,
|
||||
viewMode,
|
||||
setViewMode,
|
||||
filterGroup,
|
||||
setFilterGroup,
|
||||
filterQuotaType,
|
||||
setFilterQuotaType,
|
||||
filterEndpointType,
|
||||
setFilterEndpointType,
|
||||
filterVendor,
|
||||
setFilterVendor,
|
||||
filterTag,
|
||||
setFilterTag,
|
||||
tokenUnit,
|
||||
setTokenUnit,
|
||||
loading,
|
||||
...categoryProps
|
||||
} = sidebarProps;
|
||||
|
||||
const {
|
||||
quotaTypeModels,
|
||||
endpointTypeModels,
|
||||
vendorModels,
|
||||
tagModels,
|
||||
groupCountModels,
|
||||
} = usePricingFilterCounts({
|
||||
models: categoryProps.models,
|
||||
filterGroup,
|
||||
filterQuotaType,
|
||||
filterEndpointType,
|
||||
filterVendor,
|
||||
filterTag,
|
||||
searchValue: sidebarProps.searchValue,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PricingDisplaySettings
|
||||
showWithRecharge={showWithRecharge}
|
||||
setShowWithRecharge={setShowWithRecharge}
|
||||
currency={currency}
|
||||
setCurrency={setCurrency}
|
||||
siteDisplayType={siteDisplayType}
|
||||
showRatio={showRatio}
|
||||
setShowRatio={setShowRatio}
|
||||
viewMode={viewMode}
|
||||
setViewMode={setViewMode}
|
||||
tokenUnit={tokenUnit}
|
||||
setTokenUnit={setTokenUnit}
|
||||
loading={loading}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<PricingVendors
|
||||
filterVendor={filterVendor}
|
||||
setFilterVendor={setFilterVendor}
|
||||
models={vendorModels}
|
||||
allModels={categoryProps.models}
|
||||
loading={loading}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<PricingGroups
|
||||
filterGroup={filterGroup}
|
||||
setFilterGroup={setFilterGroup}
|
||||
usableGroup={categoryProps.usableGroup}
|
||||
groupRatio={categoryProps.groupRatio}
|
||||
models={groupCountModels}
|
||||
loading={loading}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<PricingQuotaTypes
|
||||
filterQuotaType={filterQuotaType}
|
||||
setFilterQuotaType={setFilterQuotaType}
|
||||
models={quotaTypeModels}
|
||||
loading={loading}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<PricingTags
|
||||
filterTag={filterTag}
|
||||
setFilterTag={setFilterTag}
|
||||
models={tagModels}
|
||||
allModels={categoryProps.models}
|
||||
loading={loading}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<PricingEndpointTypes
|
||||
filterEndpointType={filterEndpointType}
|
||||
setFilterEndpointType={setFilterEndpointType}
|
||||
models={endpointTypeModels}
|
||||
allModels={categoryProps.models}
|
||||
loading={loading}
|
||||
t={t}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilterModalContent;
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Button } from '@douyinfe/semi-ui';
|
||||
|
||||
const FilterModalFooter = ({ onReset, onConfirm, t }) => {
|
||||
return (
|
||||
<div className='flex justify-end'>
|
||||
<Button theme='outline' type='tertiary' onClick={onReset}>
|
||||
{t('重置')}
|
||||
</Button>
|
||||
<Button theme='solid' type='primary' onClick={onConfirm}>
|
||||
{t('确定')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilterModalFooter;
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Card, Avatar, Typography, Tag, Space } from '@douyinfe/semi-ui';
|
||||
import { IconInfoCircle } from '@douyinfe/semi-icons';
|
||||
import { stringToColor } from '../../../../../helpers';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const ModelBasicInfo = ({ modelData, vendorsMap = {}, t }) => {
|
||||
// 获取模型描述(使用后端真实数据)
|
||||
const getModelDescription = () => {
|
||||
if (!modelData) return t('暂无模型描述');
|
||||
|
||||
// 优先使用后端提供的描述
|
||||
if (modelData.description) {
|
||||
return modelData.description;
|
||||
}
|
||||
|
||||
// 如果没有描述但有供应商描述,显示供应商信息
|
||||
if (modelData.vendor_description) {
|
||||
return t('供应商信息:') + modelData.vendor_description;
|
||||
}
|
||||
|
||||
return t('暂无模型描述');
|
||||
};
|
||||
|
||||
// 获取模型标签
|
||||
const getModelTags = () => {
|
||||
const tags = [];
|
||||
|
||||
if (modelData?.tags) {
|
||||
const customTags = modelData.tags.split(',').filter((tag) => tag.trim());
|
||||
customTags.forEach((tag) => {
|
||||
const tagText = tag.trim();
|
||||
tags.push({ text: tagText, color: stringToColor(tagText) });
|
||||
});
|
||||
}
|
||||
|
||||
return tags;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
|
||||
<div className='flex items-center mb-4'>
|
||||
<Avatar size='small' color='blue' className='mr-2 shadow-md'>
|
||||
<IconInfoCircle size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className='text-lg font-medium'>{t('基本信息')}</Text>
|
||||
<div className='text-xs text-gray-600'>
|
||||
{t('模型的详细描述和基本特性')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='text-gray-600'>
|
||||
<p className='mb-4'>{getModelDescription()}</p>
|
||||
{getModelTags().length > 0 && (
|
||||
<Space wrap>
|
||||
{getModelTags().map((tag, index) => (
|
||||
<Tag key={index} color={tag.color} shape='circle' size='small'>
|
||||
{tag.text}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelBasicInfo;
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Card, Avatar, Typography, Badge } from '@douyinfe/semi-ui';
|
||||
import { IconLink } from '@douyinfe/semi-icons';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const ModelEndpoints = ({ modelData, endpointMap = {}, t }) => {
|
||||
const renderAPIEndpoints = () => {
|
||||
if (!modelData) return null;
|
||||
|
||||
const mapping = endpointMap;
|
||||
const types = modelData.supported_endpoint_types || [];
|
||||
|
||||
return types.map((type) => {
|
||||
const info = mapping[type] || {};
|
||||
let path = info.path || '';
|
||||
// 如果路径中包含 {model} 占位符,替换为真实模型名称
|
||||
if (path.includes('{model}')) {
|
||||
const modelName = modelData.model_name || modelData.modelName || '';
|
||||
path = path.replaceAll('{model}', modelName);
|
||||
}
|
||||
const method = info.method || 'POST';
|
||||
return (
|
||||
<div
|
||||
key={type}
|
||||
className='flex justify-between border-b border-dashed last:border-0 py-2 last:pb-0'
|
||||
style={{ borderColor: 'var(--semi-color-border)' }}
|
||||
>
|
||||
<span className='flex items-center pr-5'>
|
||||
<Badge dot type='success' className='mr-2' />
|
||||
{type}
|
||||
{path && ':'}
|
||||
{path && (
|
||||
<span className='text-gray-500 md:ml-1 break-all'>{path}</span>
|
||||
)}
|
||||
</span>
|
||||
{path && (
|
||||
<span className='text-gray-500 text-xs md:ml-1'>{method}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
|
||||
<div className='flex items-center mb-4'>
|
||||
<Avatar size='small' color='purple' className='mr-2 shadow-md'>
|
||||
<IconLink size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className='text-lg font-medium'>{t('API端点')}</Text>
|
||||
<div className='text-xs text-gray-600'>
|
||||
{t('模型支持的接口端点信息')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{renderAPIEndpoints()}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelEndpoints;
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Typography, Toast, Avatar } from '@douyinfe/semi-ui';
|
||||
import { getLobeHubIcon } from '../../../../../helpers';
|
||||
|
||||
const { Paragraph } = Typography;
|
||||
|
||||
const CARD_STYLES = {
|
||||
container:
|
||||
'w-12 h-12 rounded-2xl flex items-center justify-center relative shadow-md',
|
||||
icon: 'w-8 h-8 flex items-center justify-center',
|
||||
};
|
||||
|
||||
const ModelHeader = ({ modelData, vendorsMap = {}, t }) => {
|
||||
// 获取模型图标(优先模型图标,其次供应商图标)
|
||||
const getModelIcon = () => {
|
||||
// 1) 优先使用模型自定义图标
|
||||
if (modelData?.icon) {
|
||||
return (
|
||||
<div className={CARD_STYLES.container}>
|
||||
<div className={CARD_STYLES.icon}>
|
||||
{getLobeHubIcon(modelData.icon, 32)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// 2) 退化为供应商图标
|
||||
if (modelData?.vendor_icon) {
|
||||
return (
|
||||
<div className={CARD_STYLES.container}>
|
||||
<div className={CARD_STYLES.icon}>
|
||||
{getLobeHubIcon(modelData.vendor_icon, 32)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 如果没有供应商图标,使用模型名称的前两个字符
|
||||
const avatarText = modelData?.model_name?.slice(0, 2).toUpperCase() || 'AI';
|
||||
return (
|
||||
<div className={CARD_STYLES.container}>
|
||||
<Avatar
|
||||
size='large'
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 16,
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
{avatarText}
|
||||
</Avatar>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='flex items-center'>
|
||||
{getModelIcon()}
|
||||
<div className='ml-3 font-normal'>
|
||||
<Paragraph
|
||||
className='!mb-0 !text-lg !font-medium'
|
||||
copyable={{
|
||||
content: modelData?.model_name || '',
|
||||
onCopy: () => Toast.success({ content: t('已复制模型名称') }),
|
||||
}}
|
||||
>
|
||||
<span className='truncate max-w-60 font-bold'>
|
||||
{modelData?.model_name || t('未知模型')}
|
||||
</span>
|
||||
</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelHeader;
|
||||
@@ -0,0 +1,188 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Card, Avatar, Typography, Table, Tag } from '@douyinfe/semi-ui';
|
||||
import { IconCoinMoneyStroked } from '@douyinfe/semi-icons';
|
||||
import { calculateModelPrice, getModelPriceItems } from '../../../../../helpers';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const ModelPricingTable = ({
|
||||
modelData,
|
||||
groupRatio,
|
||||
currency,
|
||||
siteDisplayType,
|
||||
tokenUnit,
|
||||
displayPrice,
|
||||
showRatio,
|
||||
usableGroup,
|
||||
autoGroups = [],
|
||||
t,
|
||||
}) => {
|
||||
const modelEnableGroups = Array.isArray(modelData?.enable_groups)
|
||||
? modelData.enable_groups
|
||||
: [];
|
||||
const autoChain = autoGroups.filter((g) => modelEnableGroups.includes(g));
|
||||
const renderGroupPriceTable = () => {
|
||||
// 仅展示模型可用的分组:模型 enable_groups 与用户可用分组的交集
|
||||
|
||||
const availableGroups = Object.keys(usableGroup || {})
|
||||
.filter((g) => g !== '')
|
||||
.filter((g) => g !== 'auto')
|
||||
.filter((g) => modelEnableGroups.includes(g));
|
||||
|
||||
// 准备表格数据
|
||||
const tableData = availableGroups.map((group) => {
|
||||
const priceData = modelData
|
||||
? calculateModelPrice({
|
||||
record: modelData,
|
||||
selectedGroup: group,
|
||||
groupRatio,
|
||||
tokenUnit,
|
||||
displayPrice,
|
||||
currency,
|
||||
quotaDisplayType: siteDisplayType,
|
||||
})
|
||||
: { inputPrice: '-', outputPrice: '-', price: '-' };
|
||||
|
||||
// 获取分组倍率
|
||||
const groupRatioValue =
|
||||
groupRatio && groupRatio[group] ? groupRatio[group] : 1;
|
||||
|
||||
return {
|
||||
key: group,
|
||||
group: group,
|
||||
ratio: groupRatioValue,
|
||||
billingType:
|
||||
modelData?.quota_type === 0
|
||||
? t('按量计费')
|
||||
: modelData?.quota_type === 1
|
||||
? t('按次计费')
|
||||
: '-',
|
||||
priceItems: getModelPriceItems(priceData, t, siteDisplayType),
|
||||
};
|
||||
});
|
||||
|
||||
// 定义表格列
|
||||
const columns = [
|
||||
{
|
||||
title: t('分组'),
|
||||
dataIndex: 'group',
|
||||
render: (text) => (
|
||||
<Tag color='white' size='small' shape='circle'>
|
||||
{text}
|
||||
{t('分组')}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// 如果显示倍率,添加倍率列
|
||||
if (showRatio) {
|
||||
columns.push({
|
||||
title: t('倍率'),
|
||||
dataIndex: 'ratio',
|
||||
render: (text) => (
|
||||
<Tag color='white' size='small' shape='circle'>
|
||||
{text}x
|
||||
</Tag>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// 添加计费类型列
|
||||
columns.push({
|
||||
title: t('计费类型'),
|
||||
dataIndex: 'billingType',
|
||||
render: (text) => {
|
||||
let color = 'white';
|
||||
if (text === t('按量计费')) color = 'violet';
|
||||
else if (text === t('按次计费')) color = 'teal';
|
||||
return (
|
||||
<Tag color={color} size='small' shape='circle'>
|
||||
{text || '-'}
|
||||
</Tag>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
columns.push({
|
||||
title: siteDisplayType === 'TOKENS' ? t('计费摘要') : t('价格摘要'),
|
||||
dataIndex: 'priceItems',
|
||||
render: (items) => (
|
||||
<div className='space-y-1'>
|
||||
{items.map((item) => (
|
||||
<div key={item.key}>
|
||||
<div className='font-semibold text-orange-600'>
|
||||
{item.label} {item.value}
|
||||
</div>
|
||||
<div className='text-xs text-gray-500'>{item.suffix}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
return (
|
||||
<Table
|
||||
dataSource={tableData}
|
||||
columns={columns}
|
||||
pagination={false}
|
||||
size='small'
|
||||
bordered={false}
|
||||
className='!rounded-lg'
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className='!rounded-2xl shadow-sm border-0'>
|
||||
<div className='flex items-center mb-4'>
|
||||
<Avatar size='small' color='orange' className='mr-2 shadow-md'>
|
||||
<IconCoinMoneyStroked size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className='text-lg font-medium'>{t('分组价格')}</Text>
|
||||
<div className='text-xs text-gray-600'>
|
||||
{t('不同用户分组的价格信息')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{autoChain.length > 0 && (
|
||||
<div className='flex flex-wrap items-center gap-1 mb-4'>
|
||||
<span className='text-sm text-gray-600'>{t('auto分组调用链路')}</span>
|
||||
<span className='text-sm'>→</span>
|
||||
{autoChain.map((g, idx) => (
|
||||
<React.Fragment key={g}>
|
||||
<Tag color='white' size='small' shape='circle'>
|
||||
{g}
|
||||
{t('分组')}
|
||||
</Tag>
|
||||
{idx < autoChain.length - 1 && <span className='text-sm'>→</span>}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{renderGroupPriceTable()}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelPricingTable;
|
||||
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Card, Skeleton } from '@douyinfe/semi-ui';
|
||||
|
||||
const PricingCardSkeleton = ({
|
||||
skeletonCount = 100,
|
||||
rowSelection = false,
|
||||
showRatio = false,
|
||||
}) => {
|
||||
const placeholder = (
|
||||
<div className='px-2 pt-2'>
|
||||
<div className='grid grid-cols-1 xl:grid-cols-2 2xl:grid-cols-3 gap-4'>
|
||||
{Array.from({ length: skeletonCount }).map((_, index) => (
|
||||
<Card
|
||||
key={index}
|
||||
className='!rounded-2xl border border-gray-200'
|
||||
bodyStyle={{ padding: '24px' }}
|
||||
>
|
||||
{/* 头部:图标 + 模型名称 + 操作按钮 */}
|
||||
<div className='flex items-start justify-between mb-3'>
|
||||
<div className='flex items-start space-x-3 flex-1 min-w-0'>
|
||||
{/* 模型图标骨架 */}
|
||||
<div className='w-12 h-12 rounded-2xl flex items-center justify-center relative shadow-sm'>
|
||||
<Skeleton.Avatar
|
||||
size='large'
|
||||
style={{ width: 48, height: 48, borderRadius: 16 }}
|
||||
/>
|
||||
</div>
|
||||
{/* 模型名称和价格区域 */}
|
||||
<div className='flex-1 min-w-0'>
|
||||
{/* 模型名称骨架 */}
|
||||
<Skeleton.Title
|
||||
style={{
|
||||
width: `${120 + (index % 3) * 30}px`,
|
||||
height: 20,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
/>
|
||||
{/* 价格信息骨架 */}
|
||||
<Skeleton.Title
|
||||
style={{
|
||||
width: `${160 + (index % 4) * 20}px`,
|
||||
height: 20,
|
||||
marginBottom: 0,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center space-x-2 ml-3'>
|
||||
{/* 复制按钮骨架 */}
|
||||
<Skeleton.Button
|
||||
size='small'
|
||||
style={{ width: 16, height: 16, borderRadius: 4 }}
|
||||
/>
|
||||
{/* 勾选框骨架 */}
|
||||
{rowSelection && (
|
||||
<Skeleton.Button
|
||||
size='small'
|
||||
style={{ width: 16, height: 16, borderRadius: 2 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 模型描述骨架 */}
|
||||
<div className='mb-4'>
|
||||
<Skeleton.Paragraph
|
||||
rows={2}
|
||||
style={{ marginBottom: 0 }}
|
||||
title={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 标签区域骨架 */}
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{Array.from({ length: 2 + (index % 3) }).map((_, tagIndex) => (
|
||||
<Skeleton.Button
|
||||
key={tagIndex}
|
||||
size='small'
|
||||
style={{
|
||||
width: 64,
|
||||
height: 18,
|
||||
borderRadius: 10,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 倍率信息骨架(可选) */}
|
||||
{showRatio && (
|
||||
<div className='mt-4 pt-3 border-t border-gray-100'>
|
||||
<div className='flex items-center space-x-1 mb-2'>
|
||||
<Skeleton.Title
|
||||
style={{ width: 60, height: 12, marginBottom: 0 }}
|
||||
/>
|
||||
<Skeleton.Button
|
||||
size='small'
|
||||
style={{ width: 14, height: 14, borderRadius: 7 }}
|
||||
/>
|
||||
</div>
|
||||
<div className='grid grid-cols-3 gap-2'>
|
||||
{Array.from({ length: 3 }).map((_, ratioIndex) => (
|
||||
<Skeleton.Title
|
||||
key={ratioIndex}
|
||||
style={{ width: '100%', height: 12, marginBottom: 0 }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 分页骨架 */}
|
||||
<div className='flex justify-center mt-6 py-4 border-t pricing-pagination-divider'>
|
||||
<Skeleton.Button style={{ width: 300, height: 32 }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return <Skeleton loading={true} active placeholder={placeholder}></Skeleton>;
|
||||
};
|
||||
|
||||
export default PricingCardSkeleton;
|
||||
@@ -0,0 +1,384 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Card,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Checkbox,
|
||||
Empty,
|
||||
Pagination,
|
||||
Button,
|
||||
Avatar,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { IconHelpCircle } from '@douyinfe/semi-icons';
|
||||
import { Copy } from 'lucide-react';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import {
|
||||
stringToColor,
|
||||
calculateModelPrice,
|
||||
formatPriceInfo,
|
||||
getLobeHubIcon,
|
||||
} from '../../../../../helpers';
|
||||
import PricingCardSkeleton from './PricingCardSkeleton';
|
||||
import { useMinimumLoadingTime } from '../../../../../hooks/common/useMinimumLoadingTime';
|
||||
import { renderLimitedItems } from '../../../../common/ui/RenderUtils';
|
||||
import { useIsMobile } from '../../../../../hooks/common/useIsMobile';
|
||||
|
||||
const CARD_STYLES = {
|
||||
container:
|
||||
'w-12 h-12 rounded-2xl flex items-center justify-center relative shadow-md',
|
||||
icon: 'w-8 h-8 flex items-center justify-center',
|
||||
selected: 'border-blue-500 bg-blue-50',
|
||||
default: 'border-gray-200 hover:border-gray-300',
|
||||
};
|
||||
|
||||
const PricingCardView = ({
|
||||
filteredModels,
|
||||
loading,
|
||||
rowSelection,
|
||||
pageSize,
|
||||
setPageSize,
|
||||
currentPage,
|
||||
setCurrentPage,
|
||||
selectedGroup,
|
||||
groupRatio,
|
||||
copyText,
|
||||
setModalImageUrl,
|
||||
setIsModalOpenurl,
|
||||
currency,
|
||||
siteDisplayType,
|
||||
tokenUnit,
|
||||
displayPrice,
|
||||
showRatio,
|
||||
t,
|
||||
selectedRowKeys = [],
|
||||
setSelectedRowKeys,
|
||||
openModelDetail,
|
||||
}) => {
|
||||
const showSkeleton = useMinimumLoadingTime(loading);
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
const paginatedModels = filteredModels.slice(
|
||||
startIndex,
|
||||
startIndex + pageSize,
|
||||
);
|
||||
const getModelKey = (model) => model.key ?? model.model_name ?? model.id;
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const handleCheckboxChange = (model, checked) => {
|
||||
if (!setSelectedRowKeys) return;
|
||||
const modelKey = getModelKey(model);
|
||||
const newKeys = checked
|
||||
? Array.from(new Set([...selectedRowKeys, modelKey]))
|
||||
: selectedRowKeys.filter((key) => key !== modelKey);
|
||||
setSelectedRowKeys(newKeys);
|
||||
rowSelection?.onChange?.(newKeys, null);
|
||||
};
|
||||
|
||||
// 获取模型图标
|
||||
const getModelIcon = (model) => {
|
||||
if (!model || !model.model_name) {
|
||||
return (
|
||||
<div className={CARD_STYLES.container}>
|
||||
<Avatar size='large'>?</Avatar>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// 1) 优先使用模型自定义图标
|
||||
if (model.icon) {
|
||||
return (
|
||||
<div className={CARD_STYLES.container}>
|
||||
<div className={CARD_STYLES.icon}>
|
||||
{getLobeHubIcon(model.icon, 32)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// 2) 退化为供应商图标
|
||||
if (model.vendor_icon) {
|
||||
return (
|
||||
<div className={CARD_STYLES.container}>
|
||||
<div className={CARD_STYLES.icon}>
|
||||
{getLobeHubIcon(model.vendor_icon, 32)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 如果没有供应商图标,使用模型名称生成头像
|
||||
|
||||
const avatarText = model.model_name.slice(0, 2).toUpperCase();
|
||||
return (
|
||||
<div className={CARD_STYLES.container}>
|
||||
<Avatar
|
||||
size='large'
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 16,
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
{avatarText}
|
||||
</Avatar>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 获取模型描述
|
||||
const getModelDescription = (record) => {
|
||||
return record.description || '';
|
||||
};
|
||||
|
||||
// 渲染标签
|
||||
const renderTags = (record) => {
|
||||
// 计费类型标签(左边)
|
||||
let billingTag = (
|
||||
<Tag key='billing' shape='circle' color='white' size='small'>
|
||||
-
|
||||
</Tag>
|
||||
);
|
||||
if (record.quota_type === 1) {
|
||||
billingTag = (
|
||||
<Tag key='billing' shape='circle' color='teal' size='small'>
|
||||
{t('按次计费')}
|
||||
</Tag>
|
||||
);
|
||||
} else if (record.quota_type === 0) {
|
||||
billingTag = (
|
||||
<Tag key='billing' shape='circle' color='violet' size='small'>
|
||||
{t('按量计费')}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
// 自定义标签(右边)
|
||||
const customTags = [];
|
||||
if (record.tags) {
|
||||
const tagArr = record.tags.split(',').filter(Boolean);
|
||||
tagArr.forEach((tg, idx) => {
|
||||
customTags.push(
|
||||
<Tag
|
||||
key={`custom-${idx}`}
|
||||
shape='circle'
|
||||
color={stringToColor(tg)}
|
||||
size='small'
|
||||
>
|
||||
{tg}
|
||||
</Tag>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>{billingTag}</div>
|
||||
<div className='flex items-center gap-1'>
|
||||
{customTags.length > 0 &&
|
||||
renderLimitedItems({
|
||||
items: customTags.map((tag, idx) => ({
|
||||
key: `custom-${idx}`,
|
||||
element: tag,
|
||||
})),
|
||||
renderItem: (item, idx) => item.element,
|
||||
maxDisplay: 3,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 显示骨架屏
|
||||
if (showSkeleton) {
|
||||
return (
|
||||
<PricingCardSkeleton
|
||||
rowSelection={!!rowSelection}
|
||||
showRatio={showRatio}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!filteredModels || filteredModels.length === 0) {
|
||||
return (
|
||||
<div className='flex justify-center items-center py-20'>
|
||||
<Empty
|
||||
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
|
||||
darkModeImage={
|
||||
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
|
||||
}
|
||||
description={t('搜索无结果')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='px-2 pt-2'>
|
||||
<div className='grid grid-cols-1 xl:grid-cols-2 2xl:grid-cols-3 gap-4'>
|
||||
{paginatedModels.map((model, index) => {
|
||||
const modelKey = getModelKey(model);
|
||||
const isSelected = selectedRowKeys.includes(modelKey);
|
||||
|
||||
const priceData = calculateModelPrice({
|
||||
record: model,
|
||||
selectedGroup,
|
||||
groupRatio,
|
||||
tokenUnit,
|
||||
displayPrice,
|
||||
currency,
|
||||
quotaDisplayType: siteDisplayType,
|
||||
});
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={modelKey || index}
|
||||
className={`!rounded-2xl transition-all duration-200 hover:shadow-lg border cursor-pointer ${isSelected ? CARD_STYLES.selected : CARD_STYLES.default}`}
|
||||
bodyStyle={{ height: '100%' }}
|
||||
onClick={() => openModelDetail && openModelDetail(model)}
|
||||
>
|
||||
<div className='flex flex-col h-full'>
|
||||
{/* 头部:图标 + 模型名称 + 操作按钮 */}
|
||||
<div className='flex items-start justify-between mb-3'>
|
||||
<div className='flex items-start space-x-3 flex-1 min-w-0'>
|
||||
{getModelIcon(model)}
|
||||
<div className='flex-1 min-w-0'>
|
||||
<h3 className='text-lg font-bold text-gray-900 truncate'>
|
||||
{model.model_name}
|
||||
</h3>
|
||||
<div className='flex flex-col gap-1 text-xs mt-1'>
|
||||
{formatPriceInfo(priceData, t, siteDisplayType)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center space-x-2 ml-3'>
|
||||
{/* 复制按钮 */}
|
||||
<Button
|
||||
size='small'
|
||||
theme='outline'
|
||||
type='tertiary'
|
||||
icon={<Copy size={12} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copyText(model.model_name);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 选择框 */}
|
||||
{rowSelection && (
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCheckboxChange(model, e.target.checked);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 模型描述 - 占据剩余空间 */}
|
||||
<div className='flex-1 mb-4'>
|
||||
<p
|
||||
className='text-xs line-clamp-2 leading-relaxed'
|
||||
style={{ color: 'var(--semi-color-text-2)' }}
|
||||
>
|
||||
{getModelDescription(model)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 底部区域 */}
|
||||
<div className='mt-auto'>
|
||||
{/* 标签区域 */}
|
||||
{renderTags(model)}
|
||||
|
||||
{/* 倍率信息(可选) */}
|
||||
{showRatio && (
|
||||
<div className='pt-3'>
|
||||
<div className='flex items-center space-x-1 mb-2'>
|
||||
<span className='text-xs font-medium text-gray-700'>
|
||||
{t('倍率信息')}
|
||||
</span>
|
||||
<Tooltip
|
||||
content={t('倍率是为了方便换算不同价格的模型')}
|
||||
>
|
||||
<IconHelpCircle
|
||||
className='text-blue-500 cursor-pointer'
|
||||
size='small'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setModalImageUrl('/ratio.png');
|
||||
setIsModalOpenurl(true);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className='grid grid-cols-3 gap-2 text-xs text-gray-600'>
|
||||
<div>
|
||||
{t('模型')}:{' '}
|
||||
{model.quota_type === 0 ? model.model_ratio : t('无')}
|
||||
</div>
|
||||
<div>
|
||||
{t('补全')}:{' '}
|
||||
{model.quota_type === 0
|
||||
? parseFloat(model.completion_ratio.toFixed(3))
|
||||
: t('无')}
|
||||
</div>
|
||||
<div>
|
||||
{t('分组')}: {priceData?.usedGroupRatio ?? '-'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 分页 */}
|
||||
{filteredModels.length > 0 && (
|
||||
<div className='flex justify-center mt-6 py-4 border-t pricing-pagination-divider'>
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
pageSize={pageSize}
|
||||
total={filteredModels.length}
|
||||
showSizeChanger={true}
|
||||
pageSizeOptions={[10, 20, 50, 100]}
|
||||
size={isMobile ? 'small' : 'default'}
|
||||
showQuickJumper={isMobile}
|
||||
onPageChange={(page) => setCurrentPage(page)}
|
||||
onPageSizeChange={(size) => {
|
||||
setPageSize(size);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PricingCardView;
|
||||
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { Card, Table, Empty } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import { getPricingTableColumns } from './PricingTableColumns';
|
||||
|
||||
const PricingTable = ({
|
||||
filteredModels,
|
||||
loading,
|
||||
rowSelection,
|
||||
pageSize,
|
||||
setPageSize,
|
||||
selectedGroup,
|
||||
groupRatio,
|
||||
copyText,
|
||||
setModalImageUrl,
|
||||
setIsModalOpenurl,
|
||||
currency,
|
||||
siteDisplayType,
|
||||
tokenUnit,
|
||||
displayPrice,
|
||||
searchValue,
|
||||
showRatio,
|
||||
compactMode = false,
|
||||
openModelDetail,
|
||||
t,
|
||||
}) => {
|
||||
const columns = useMemo(() => {
|
||||
return getPricingTableColumns({
|
||||
t,
|
||||
selectedGroup,
|
||||
groupRatio,
|
||||
copyText,
|
||||
setModalImageUrl,
|
||||
setIsModalOpenurl,
|
||||
currency,
|
||||
siteDisplayType,
|
||||
tokenUnit,
|
||||
displayPrice,
|
||||
showRatio,
|
||||
});
|
||||
}, [
|
||||
t,
|
||||
selectedGroup,
|
||||
groupRatio,
|
||||
copyText,
|
||||
setModalImageUrl,
|
||||
setIsModalOpenurl,
|
||||
currency,
|
||||
siteDisplayType,
|
||||
tokenUnit,
|
||||
displayPrice,
|
||||
showRatio,
|
||||
]);
|
||||
|
||||
// 更新列定义中的 searchValue
|
||||
const processedColumns = useMemo(() => {
|
||||
const cols = columns.map((column) => {
|
||||
if (column.dataIndex === 'model_name') {
|
||||
return {
|
||||
...column,
|
||||
filteredValue: searchValue ? [searchValue] : [],
|
||||
};
|
||||
}
|
||||
return column;
|
||||
});
|
||||
|
||||
// Remove fixed property when in compact mode (mobile view)
|
||||
if (compactMode) {
|
||||
return cols.map(({ fixed, ...rest }) => rest);
|
||||
}
|
||||
return cols;
|
||||
}, [columns, searchValue, compactMode]);
|
||||
|
||||
const ModelTable = useMemo(
|
||||
() => (
|
||||
<Card className='!rounded-xl overflow-hidden' bordered={false}>
|
||||
<Table
|
||||
columns={processedColumns}
|
||||
dataSource={filteredModels}
|
||||
loading={loading}
|
||||
rowSelection={rowSelection}
|
||||
scroll={compactMode ? undefined : { x: 'max-content' }}
|
||||
onRow={(record) => ({
|
||||
onClick: () => openModelDetail && openModelDetail(record),
|
||||
style: { cursor: 'pointer' },
|
||||
})}
|
||||
empty={
|
||||
<Empty
|
||||
image={
|
||||
<IllustrationNoResult style={{ width: 150, height: 150 }} />
|
||||
}
|
||||
darkModeImage={
|
||||
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
|
||||
}
|
||||
description={t('搜索无结果')}
|
||||
style={{ padding: 30 }}
|
||||
/>
|
||||
}
|
||||
pagination={{
|
||||
defaultPageSize: 20,
|
||||
pageSize: pageSize,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [10, 20, 50, 100],
|
||||
onPageSizeChange: (size) => setPageSize(size),
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
),
|
||||
[
|
||||
filteredModels,
|
||||
loading,
|
||||
processedColumns,
|
||||
rowSelection,
|
||||
pageSize,
|
||||
setPageSize,
|
||||
openModelDetail,
|
||||
t,
|
||||
compactMode,
|
||||
],
|
||||
);
|
||||
|
||||
return ModelTable;
|
||||
};
|
||||
|
||||
export default PricingTable;
|
||||
@@ -0,0 +1,259 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Tag, Space, Tooltip } from '@douyinfe/semi-ui';
|
||||
import { IconHelpCircle } from '@douyinfe/semi-icons';
|
||||
import {
|
||||
renderModelTag,
|
||||
stringToColor,
|
||||
calculateModelPrice,
|
||||
getModelPriceItems,
|
||||
getLobeHubIcon,
|
||||
} from '../../../../../helpers';
|
||||
import {
|
||||
renderLimitedItems,
|
||||
renderDescription,
|
||||
} from '../../../../common/ui/RenderUtils';
|
||||
import { useIsMobile } from '../../../../../hooks/common/useIsMobile';
|
||||
|
||||
function renderQuotaType(type, t) {
|
||||
switch (type) {
|
||||
case 1:
|
||||
return (
|
||||
<Tag color='teal' shape='circle'>
|
||||
{t('按次计费')}
|
||||
</Tag>
|
||||
);
|
||||
case 0:
|
||||
return (
|
||||
<Tag color='violet' shape='circle'>
|
||||
{t('按量计费')}
|
||||
</Tag>
|
||||
);
|
||||
default:
|
||||
return t('未知');
|
||||
}
|
||||
}
|
||||
|
||||
// Render vendor name
|
||||
const renderVendor = (vendorName, vendorIcon, t) => {
|
||||
if (!vendorName) return '-';
|
||||
return (
|
||||
<Tag
|
||||
color='white'
|
||||
shape='circle'
|
||||
prefixIcon={getLobeHubIcon(vendorIcon || 'Layers', 14)}
|
||||
>
|
||||
{vendorName}
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
|
||||
// Render tags list using RenderUtils
|
||||
const renderTags = (text) => {
|
||||
if (!text) return '-';
|
||||
const tagsArr = text.split(',').filter((tag) => tag.trim());
|
||||
return renderLimitedItems({
|
||||
items: tagsArr,
|
||||
renderItem: (tag, idx) => (
|
||||
<Tag
|
||||
key={idx}
|
||||
color={stringToColor(tag.trim())}
|
||||
shape='circle'
|
||||
size='small'
|
||||
>
|
||||
{tag.trim()}
|
||||
</Tag>
|
||||
),
|
||||
maxDisplay: 3,
|
||||
});
|
||||
};
|
||||
|
||||
function renderSupportedEndpoints(endpoints) {
|
||||
if (!endpoints || endpoints.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Space wrap>
|
||||
{endpoints.map((endpoint, idx) => (
|
||||
<Tag key={endpoint} color={stringToColor(endpoint)} shape='circle'>
|
||||
{endpoint}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
export const getPricingTableColumns = ({
|
||||
t,
|
||||
selectedGroup,
|
||||
groupRatio,
|
||||
copyText,
|
||||
setModalImageUrl,
|
||||
setIsModalOpenurl,
|
||||
currency,
|
||||
siteDisplayType,
|
||||
tokenUnit,
|
||||
displayPrice,
|
||||
showRatio,
|
||||
}) => {
|
||||
const isMobile = useIsMobile();
|
||||
const priceDataCache = new WeakMap();
|
||||
|
||||
const getPriceData = (record) => {
|
||||
let cache = priceDataCache.get(record);
|
||||
if (!cache) {
|
||||
cache = calculateModelPrice({
|
||||
record,
|
||||
selectedGroup,
|
||||
groupRatio,
|
||||
tokenUnit,
|
||||
displayPrice,
|
||||
currency,
|
||||
quotaDisplayType: siteDisplayType,
|
||||
});
|
||||
priceDataCache.set(record, cache);
|
||||
}
|
||||
return cache;
|
||||
};
|
||||
|
||||
const endpointColumn = {
|
||||
title: t('可用端点类型'),
|
||||
dataIndex: 'supported_endpoint_types',
|
||||
render: (text, record, index) => {
|
||||
return renderSupportedEndpoints(text);
|
||||
},
|
||||
};
|
||||
|
||||
const modelNameColumn = {
|
||||
title: t('模型名称'),
|
||||
dataIndex: 'model_name',
|
||||
render: (text, record, index) => {
|
||||
return renderModelTag(text, {
|
||||
onClick: () => {
|
||||
copyText(text);
|
||||
},
|
||||
});
|
||||
},
|
||||
onFilter: (value, record) =>
|
||||
record.model_name.toLowerCase().includes(value.toLowerCase()),
|
||||
};
|
||||
|
||||
const quotaColumn = {
|
||||
title: t('计费类型'),
|
||||
dataIndex: 'quota_type',
|
||||
render: (text, record, index) => {
|
||||
return renderQuotaType(parseInt(text), t);
|
||||
},
|
||||
sorter: (a, b) => a.quota_type - b.quota_type,
|
||||
};
|
||||
|
||||
const descriptionColumn = {
|
||||
title: t('描述'),
|
||||
dataIndex: 'description',
|
||||
render: (text) => renderDescription(text, 200),
|
||||
};
|
||||
|
||||
const tagsColumn = {
|
||||
title: t('标签'),
|
||||
dataIndex: 'tags',
|
||||
render: renderTags,
|
||||
};
|
||||
|
||||
const vendorColumn = {
|
||||
title: t('供应商'),
|
||||
dataIndex: 'vendor_name',
|
||||
render: (text, record) => renderVendor(text, record.vendor_icon, t),
|
||||
};
|
||||
|
||||
const baseColumns = [
|
||||
modelNameColumn,
|
||||
vendorColumn,
|
||||
descriptionColumn,
|
||||
tagsColumn,
|
||||
quotaColumn,
|
||||
];
|
||||
|
||||
const ratioColumn = {
|
||||
title: () => (
|
||||
<div className='flex items-center space-x-1'>
|
||||
<span>{t('倍率')}</span>
|
||||
<Tooltip content={t('倍率是为了方便换算不同价格的模型')}>
|
||||
<IconHelpCircle
|
||||
className='text-blue-500 cursor-pointer'
|
||||
onClick={() => {
|
||||
setModalImageUrl('/ratio.png');
|
||||
setIsModalOpenurl(true);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
),
|
||||
dataIndex: 'model_ratio',
|
||||
render: (text, record, index) => {
|
||||
const completionRatio = parseFloat(record.completion_ratio.toFixed(3));
|
||||
const priceData = getPriceData(record);
|
||||
|
||||
return (
|
||||
<div className='space-y-1'>
|
||||
<div className='text-gray-700'>
|
||||
{t('模型倍率')}:{record.quota_type === 0 ? text : t('无')}
|
||||
</div>
|
||||
<div className='text-gray-700'>
|
||||
{t('补全倍率')}:
|
||||
{record.quota_type === 0 ? completionRatio : t('无')}
|
||||
</div>
|
||||
<div className='text-gray-700'>
|
||||
{t('分组倍率')}:{priceData?.usedGroupRatio ?? '-'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const priceColumn = {
|
||||
title: siteDisplayType === 'TOKENS' ? t('计费摘要') : t('模型价格'),
|
||||
dataIndex: 'model_price',
|
||||
...(isMobile ? {} : { fixed: 'right' }),
|
||||
render: (text, record, index) => {
|
||||
const priceData = getPriceData(record);
|
||||
const priceItems = getModelPriceItems(priceData, t, siteDisplayType);
|
||||
|
||||
return (
|
||||
<div className='space-y-1'>
|
||||
{priceItems.map((item) => (
|
||||
<div key={item.key} className='text-gray-700'>
|
||||
{item.label} {item.value}
|
||||
{item.suffix}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const columns = [...baseColumns];
|
||||
columns.push(endpointColumn);
|
||||
if (showRatio) {
|
||||
columns.push(ratioColumn);
|
||||
}
|
||||
columns.push(priceColumn);
|
||||
return columns;
|
||||
};
|
||||
259
web/src/components/table/models/ModelsActions.jsx
Normal file
259
web/src/components/table/models/ModelsActions.jsx
Normal file
@@ -0,0 +1,259 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import MissingModelsModal from './modals/MissingModelsModal';
|
||||
import PrefillGroupManagement from './modals/PrefillGroupManagement';
|
||||
import EditPrefillGroupModal from './modals/EditPrefillGroupModal';
|
||||
import { Button, Modal, Popover, RadioGroup, Radio } from '@douyinfe/semi-ui';
|
||||
import { showSuccess, showError, copy } from '../../../helpers';
|
||||
import CompactModeToggle from '../../common/ui/CompactModeToggle';
|
||||
import SelectionNotification from './components/SelectionNotification';
|
||||
import UpstreamConflictModal from './modals/UpstreamConflictModal';
|
||||
import SyncWizardModal from './modals/SyncWizardModal';
|
||||
|
||||
const ModelsActions = ({
|
||||
selectedKeys,
|
||||
setSelectedKeys,
|
||||
setEditingModel,
|
||||
setShowEdit,
|
||||
batchDeleteModels,
|
||||
syncing,
|
||||
previewing,
|
||||
syncUpstream,
|
||||
previewUpstreamDiff,
|
||||
applyUpstreamOverwrite,
|
||||
compactMode,
|
||||
setCompactMode,
|
||||
t,
|
||||
}) => {
|
||||
// Modal states
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [showMissingModal, setShowMissingModal] = useState(false);
|
||||
const [showGroupManagement, setShowGroupManagement] = useState(false);
|
||||
const [showAddPrefill, setShowAddPrefill] = useState(false);
|
||||
const [prefillInit, setPrefillInit] = useState({ id: undefined });
|
||||
const [showConflict, setShowConflict] = useState(false);
|
||||
const [conflicts, setConflicts] = useState([]);
|
||||
const [showSyncModal, setShowSyncModal] = useState(false);
|
||||
const [syncLocale, setSyncLocale] = useState('zh');
|
||||
|
||||
const handleSyncUpstream = async (locale) => {
|
||||
// 先预览
|
||||
const data = await previewUpstreamDiff?.({ locale });
|
||||
const conflictItems = data?.conflicts || [];
|
||||
if (conflictItems.length > 0) {
|
||||
setConflicts(conflictItems);
|
||||
setShowConflict(true);
|
||||
return;
|
||||
}
|
||||
// 无冲突,直接同步缺失
|
||||
await syncUpstream?.({ locale });
|
||||
};
|
||||
|
||||
// Handle delete selected models with confirmation
|
||||
const handleDeleteSelectedModels = () => {
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
// Handle delete confirmation
|
||||
const handleConfirmDelete = () => {
|
||||
batchDeleteModels();
|
||||
setShowDeleteModal(false);
|
||||
};
|
||||
|
||||
// Handle clear selection
|
||||
const handleClearSelected = () => {
|
||||
setSelectedKeys([]);
|
||||
};
|
||||
|
||||
// Handle add selected models to prefill group
|
||||
const handleCopyNames = async () => {
|
||||
const text = selectedKeys.map((m) => m.model_name).join(',');
|
||||
if (!text) return;
|
||||
const ok = await copy(text);
|
||||
if (ok) {
|
||||
showSuccess(t('已复制模型名称'));
|
||||
} else {
|
||||
showError(t('复制失败'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddToPrefill = () => {
|
||||
// Prepare initial data
|
||||
const items = selectedKeys.map((m) => m.model_name);
|
||||
setPrefillInit({ id: undefined, type: 'model', items });
|
||||
setShowAddPrefill(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='flex flex-wrap gap-2 w-full md:w-auto order-2 md:order-1'>
|
||||
<Button
|
||||
type='primary'
|
||||
className='flex-1 md:flex-initial'
|
||||
onClick={() => {
|
||||
setEditingModel({
|
||||
id: undefined,
|
||||
});
|
||||
setShowEdit(true);
|
||||
}}
|
||||
size='small'
|
||||
>
|
||||
{t('添加模型')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type='secondary'
|
||||
className='flex-1 md:flex-initial'
|
||||
size='small'
|
||||
onClick={() => setShowMissingModal(true)}
|
||||
>
|
||||
{t('未配置模型')}
|
||||
</Button>
|
||||
|
||||
<Popover
|
||||
position='bottom'
|
||||
trigger='hover'
|
||||
content={
|
||||
<div className='p-2 max-w-[360px]'>
|
||||
<div className='text-[var(--semi-color-text-2)] text-sm'>
|
||||
{t(
|
||||
'模型社区需要大家的共同维护,如发现数据有误或想贡献新的模型数据,请访问:',
|
||||
)}
|
||||
</div>
|
||||
<a
|
||||
href='https://github.com/basellm/llm-metadata'
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
className='text-blue-600 underline'
|
||||
>
|
||||
https://github.com/basellm/llm-metadata
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type='secondary'
|
||||
className='flex-1 md:flex-initial'
|
||||
size='small'
|
||||
loading={syncing || previewing}
|
||||
onClick={() => {
|
||||
setSyncLocale('zh');
|
||||
setShowSyncModal(true);
|
||||
}}
|
||||
>
|
||||
{t('同步')}
|
||||
</Button>
|
||||
</Popover>
|
||||
|
||||
<Button
|
||||
type='secondary'
|
||||
className='flex-1 md:flex-initial'
|
||||
size='small'
|
||||
onClick={() => setShowGroupManagement(true)}
|
||||
>
|
||||
{t('预填组管理')}
|
||||
</Button>
|
||||
|
||||
<CompactModeToggle
|
||||
compactMode={compactMode}
|
||||
setCompactMode={setCompactMode}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SelectionNotification
|
||||
selectedKeys={selectedKeys}
|
||||
t={t}
|
||||
onDelete={handleDeleteSelectedModels}
|
||||
onAddPrefill={handleAddToPrefill}
|
||||
onClear={handleClearSelected}
|
||||
onCopy={handleCopyNames}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={t('批量删除模型')}
|
||||
visible={showDeleteModal}
|
||||
onCancel={() => setShowDeleteModal(false)}
|
||||
onOk={handleConfirmDelete}
|
||||
type='warning'
|
||||
>
|
||||
<div>
|
||||
{t('确定要删除所选的 {{count}} 个模型吗?', {
|
||||
count: selectedKeys.length,
|
||||
})}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<SyncWizardModal
|
||||
visible={showSyncModal}
|
||||
onClose={() => setShowSyncModal(false)}
|
||||
loading={syncing || previewing}
|
||||
t={t}
|
||||
onConfirm={async ({ option, locale }) => {
|
||||
setSyncLocale(locale);
|
||||
if (option === 'official') {
|
||||
await handleSyncUpstream(locale);
|
||||
}
|
||||
setShowSyncModal(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<MissingModelsModal
|
||||
visible={showMissingModal}
|
||||
onClose={() => setShowMissingModal(false)}
|
||||
onConfigureModel={(name) => {
|
||||
setEditingModel({ id: undefined, model_name: name });
|
||||
setShowEdit(true);
|
||||
setShowMissingModal(false);
|
||||
}}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<PrefillGroupManagement
|
||||
visible={showGroupManagement}
|
||||
onClose={() => setShowGroupManagement(false)}
|
||||
/>
|
||||
|
||||
<EditPrefillGroupModal
|
||||
visible={showAddPrefill}
|
||||
onClose={() => setShowAddPrefill(false)}
|
||||
editingGroup={prefillInit}
|
||||
onSuccess={() => setShowAddPrefill(false)}
|
||||
/>
|
||||
|
||||
<UpstreamConflictModal
|
||||
visible={showConflict}
|
||||
onClose={() => setShowConflict(false)}
|
||||
conflicts={conflicts}
|
||||
onSubmit={async (payload) => {
|
||||
return await applyUpstreamOverwrite?.({
|
||||
overwrite: payload,
|
||||
locale: syncLocale,
|
||||
});
|
||||
}}
|
||||
t={t}
|
||||
loading={syncing}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelsActions;
|
||||
380
web/src/components/table/models/ModelsColumnDefs.jsx
Normal file
380
web/src/components/table/models/ModelsColumnDefs.jsx
Normal file
@@ -0,0 +1,380 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Button,
|
||||
Space,
|
||||
Tag,
|
||||
Typography,
|
||||
Modal,
|
||||
Tooltip,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
timestamp2string,
|
||||
getLobeHubIcon,
|
||||
stringToColor,
|
||||
} from '../../../helpers';
|
||||
import {
|
||||
renderLimitedItems,
|
||||
renderDescription,
|
||||
} from '../../common/ui/RenderUtils';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
// Render timestamp
|
||||
function renderTimestamp(timestamp) {
|
||||
return <>{timestamp2string(timestamp)}</>;
|
||||
}
|
||||
|
||||
// Render model icon column: prefer model.icon, then fallback to vendor icon
|
||||
const renderModelIconCol = (record, vendorMap) => {
|
||||
const iconKey = record?.icon || vendorMap[record?.vendor_id]?.icon;
|
||||
if (!iconKey) return '-';
|
||||
return (
|
||||
<div className='flex items-center justify-center'>
|
||||
{getLobeHubIcon(iconKey, 20)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Render vendor column with icon
|
||||
const renderVendorTag = (vendorId, vendorMap, t) => {
|
||||
if (!vendorId || !vendorMap[vendorId]) return '-';
|
||||
const v = vendorMap[vendorId];
|
||||
return (
|
||||
<Tag
|
||||
color='white'
|
||||
shape='circle'
|
||||
prefixIcon={getLobeHubIcon(v.icon || 'Layers', 14)}
|
||||
>
|
||||
{v.name}
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
|
||||
// Render groups (enable_groups)
|
||||
const renderGroups = (groups) => {
|
||||
if (!groups || groups.length === 0) return '-';
|
||||
return renderLimitedItems({
|
||||
items: groups,
|
||||
renderItem: (g, idx) => (
|
||||
<Tag key={idx} size='small' shape='circle' color={stringToColor(g)}>
|
||||
{g}
|
||||
</Tag>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
// Render tags
|
||||
const renderTags = (text) => {
|
||||
if (!text) return '-';
|
||||
const tagsArr = text.split(',').filter(Boolean);
|
||||
return renderLimitedItems({
|
||||
items: tagsArr,
|
||||
renderItem: (tag, idx) => (
|
||||
<Tag key={idx} size='small' shape='circle' color={stringToColor(tag)}>
|
||||
{tag}
|
||||
</Tag>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
// Render endpoints (supports object map or legacy array)
|
||||
const renderEndpoints = (value) => {
|
||||
try {
|
||||
const parsed = typeof value === 'string' ? JSON.parse(value) : value;
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
const keys = Object.keys(parsed || {});
|
||||
if (keys.length === 0) return '-';
|
||||
return renderLimitedItems({
|
||||
items: keys,
|
||||
renderItem: (key, idx) => (
|
||||
<Tag key={idx} size='small' shape='circle' color={stringToColor(key)}>
|
||||
{key}
|
||||
</Tag>
|
||||
),
|
||||
maxDisplay: 3,
|
||||
});
|
||||
}
|
||||
if (Array.isArray(parsed)) {
|
||||
if (parsed.length === 0) return '-';
|
||||
return renderLimitedItems({
|
||||
items: parsed,
|
||||
renderItem: (ep, idx) => (
|
||||
<Tag key={idx} color='white' size='small' shape='circle'>
|
||||
{ep}
|
||||
</Tag>
|
||||
),
|
||||
maxDisplay: 3,
|
||||
});
|
||||
}
|
||||
return value || '-';
|
||||
} catch (_) {
|
||||
return value || '-';
|
||||
}
|
||||
};
|
||||
|
||||
// Render quota types (array) using common limited items renderer
|
||||
const renderQuotaTypes = (arr, t) => {
|
||||
if (!Array.isArray(arr) || arr.length === 0) return '-';
|
||||
return renderLimitedItems({
|
||||
items: arr,
|
||||
renderItem: (qt, idx) => {
|
||||
if (qt === 1) {
|
||||
return (
|
||||
<Tag key={`${qt}-${idx}`} color='teal' size='small' shape='circle'>
|
||||
{t('按次计费')}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
if (qt === 0) {
|
||||
return (
|
||||
<Tag key={`${qt}-${idx}`} color='violet' size='small' shape='circle'>
|
||||
{t('按量计费')}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Tag key={`${qt}-${idx}`} color='white' size='small' shape='circle'>
|
||||
{qt}
|
||||
</Tag>
|
||||
);
|
||||
},
|
||||
maxDisplay: 3,
|
||||
});
|
||||
};
|
||||
|
||||
// Render bound channels
|
||||
const renderBoundChannels = (channels) => {
|
||||
if (!channels || channels.length === 0) return '-';
|
||||
return renderLimitedItems({
|
||||
items: channels,
|
||||
renderItem: (c, idx) => (
|
||||
<Tag key={idx} color='white' size='small' shape='circle'>
|
||||
{c.name}({c.type})
|
||||
</Tag>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
// Render operations column
|
||||
const renderOperations = (
|
||||
text,
|
||||
record,
|
||||
setEditingModel,
|
||||
setShowEdit,
|
||||
manageModel,
|
||||
refresh,
|
||||
t,
|
||||
) => {
|
||||
return (
|
||||
<Space wrap>
|
||||
{record.status === 1 ? (
|
||||
<Button
|
||||
type='danger'
|
||||
size='small'
|
||||
onClick={() => manageModel(record.id, 'disable', record)}
|
||||
>
|
||||
{t('禁用')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size='small'
|
||||
onClick={() => manageModel(record.id, 'enable', record)}
|
||||
>
|
||||
{t('启用')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type='tertiary'
|
||||
size='small'
|
||||
onClick={() => {
|
||||
setEditingModel(record);
|
||||
setShowEdit(true);
|
||||
}}
|
||||
>
|
||||
{t('编辑')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type='danger'
|
||||
size='small'
|
||||
onClick={() => {
|
||||
Modal.confirm({
|
||||
title: t('确定是否要删除此模型?'),
|
||||
content: t('此修改将不可逆'),
|
||||
onOk: () => {
|
||||
(async () => {
|
||||
await manageModel(record.id, 'delete', record);
|
||||
await refresh();
|
||||
})();
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t('删除')}
|
||||
</Button>
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
|
||||
// 名称匹配类型渲染(带匹配数量 Tooltip)
|
||||
const renderNameRule = (rule, record, t) => {
|
||||
const map = {
|
||||
0: { color: 'green', label: t('精确') },
|
||||
1: { color: 'blue', label: t('前缀') },
|
||||
2: { color: 'orange', label: t('包含') },
|
||||
3: { color: 'purple', label: t('后缀') },
|
||||
};
|
||||
const cfg = map[rule];
|
||||
if (!cfg) return '-';
|
||||
|
||||
let label = cfg.label;
|
||||
if (rule !== 0 && record.matched_count) {
|
||||
label = `${cfg.label} ${record.matched_count}${t('个模型')}`;
|
||||
}
|
||||
|
||||
const tagElement = (
|
||||
<Tag color={cfg.color} size='small' shape='circle'>
|
||||
{label}
|
||||
</Tag>
|
||||
);
|
||||
|
||||
if (
|
||||
rule === 0 ||
|
||||
!record.matched_models ||
|
||||
record.matched_models.length === 0
|
||||
) {
|
||||
return tagElement;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip content={record.matched_models.join(', ')} showArrow>
|
||||
{tagElement}
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export const getModelsColumns = ({
|
||||
t,
|
||||
manageModel,
|
||||
setEditingModel,
|
||||
setShowEdit,
|
||||
refresh,
|
||||
vendorMap,
|
||||
}) => {
|
||||
return [
|
||||
{
|
||||
title: t('图标'),
|
||||
dataIndex: 'icon',
|
||||
width: 70,
|
||||
align: 'center',
|
||||
render: (text, record) => renderModelIconCol(record, vendorMap),
|
||||
},
|
||||
{
|
||||
title: t('模型名称'),
|
||||
dataIndex: 'model_name',
|
||||
render: (text) => (
|
||||
<Text copyable onClick={(e) => e.stopPropagation()}>
|
||||
{text}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('匹配类型'),
|
||||
dataIndex: 'name_rule',
|
||||
render: (val, record) => renderNameRule(val, record, t),
|
||||
},
|
||||
{
|
||||
title: t('参与官方同步'),
|
||||
dataIndex: 'sync_official',
|
||||
render: (val) => (
|
||||
<Tag size='small' shape='circle' color={val === 1 ? 'green' : 'orange'}>
|
||||
{val === 1 ? t('是') : t('否')}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('描述'),
|
||||
dataIndex: 'description',
|
||||
render: (text) => renderDescription(text, 200),
|
||||
},
|
||||
{
|
||||
title: t('供应商'),
|
||||
dataIndex: 'vendor_id',
|
||||
render: (vendorId, record) => renderVendorTag(vendorId, vendorMap, t),
|
||||
},
|
||||
{
|
||||
title: t('标签'),
|
||||
dataIndex: 'tags',
|
||||
render: renderTags,
|
||||
},
|
||||
{
|
||||
title: t('端点'),
|
||||
dataIndex: 'endpoints',
|
||||
render: renderEndpoints,
|
||||
},
|
||||
{
|
||||
title: t('已绑定渠道'),
|
||||
dataIndex: 'bound_channels',
|
||||
render: renderBoundChannels,
|
||||
},
|
||||
{
|
||||
title: t('可用分组'),
|
||||
dataIndex: 'enable_groups',
|
||||
render: renderGroups,
|
||||
},
|
||||
{
|
||||
title: t('计费类型'),
|
||||
dataIndex: 'quota_types',
|
||||
render: (qts) => renderQuotaTypes(qts, t),
|
||||
},
|
||||
{
|
||||
title: t('创建时间'),
|
||||
dataIndex: 'created_time',
|
||||
render: (text, record, index) => {
|
||||
return <div>{renderTimestamp(text)}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('更新时间'),
|
||||
dataIndex: 'updated_time',
|
||||
render: (text, record, index) => {
|
||||
return <div>{renderTimestamp(text)}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
dataIndex: 'operate',
|
||||
fixed: 'right',
|
||||
render: (text, record, index) =>
|
||||
renderOperations(
|
||||
text,
|
||||
record,
|
||||
setEditingModel,
|
||||
setShowEdit,
|
||||
manageModel,
|
||||
refresh,
|
||||
t,
|
||||
),
|
||||
},
|
||||
];
|
||||
};
|
||||
106
web/src/components/table/models/ModelsFilters.jsx
Normal file
106
web/src/components/table/models/ModelsFilters.jsx
Normal file
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useRef } from 'react';
|
||||
import { Form, Button } from '@douyinfe/semi-ui';
|
||||
import { IconSearch } from '@douyinfe/semi-icons';
|
||||
|
||||
const ModelsFilters = ({
|
||||
formInitValues,
|
||||
setFormApi,
|
||||
searchModels,
|
||||
loading,
|
||||
searching,
|
||||
t,
|
||||
}) => {
|
||||
// Handle form reset and immediate search
|
||||
const formApiRef = useRef(null);
|
||||
|
||||
const handleReset = () => {
|
||||
if (!formApiRef.current) return;
|
||||
formApiRef.current.reset();
|
||||
setTimeout(() => {
|
||||
searchModels();
|
||||
}, 100);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form
|
||||
initValues={formInitValues}
|
||||
getFormApi={(api) => {
|
||||
setFormApi(api);
|
||||
formApiRef.current = api;
|
||||
}}
|
||||
onSubmit={searchModels}
|
||||
allowEmpty={true}
|
||||
autoComplete='off'
|
||||
layout='horizontal'
|
||||
trigger='change'
|
||||
stopValidateWithError={false}
|
||||
className='w-full md:w-auto order-1 md:order-2'
|
||||
>
|
||||
<div className='flex flex-col md:flex-row items-center gap-2 w-full md:w-auto'>
|
||||
<div className='relative w-full md:w-56'>
|
||||
<Form.Input
|
||||
field='searchKeyword'
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('搜索模型名称')}
|
||||
showClear
|
||||
pure
|
||||
size='small'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='relative w-full md:w-56'>
|
||||
<Form.Input
|
||||
field='searchVendor'
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('搜索供应商')}
|
||||
showClear
|
||||
pure
|
||||
size='small'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex gap-2 w-full md:w-auto'>
|
||||
<Button
|
||||
type='tertiary'
|
||||
htmlType='submit'
|
||||
loading={loading || searching}
|
||||
className='flex-1 md:flex-initial md:w-auto'
|
||||
size='small'
|
||||
>
|
||||
{t('查询')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type='tertiary'
|
||||
onClick={handleReset}
|
||||
className='flex-1 md:flex-initial md:w-auto'
|
||||
size='small'
|
||||
>
|
||||
{t('重置')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelsFilters;
|
||||
108
web/src/components/table/models/ModelsTable.jsx
Normal file
108
web/src/components/table/models/ModelsTable.jsx
Normal file
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { Empty } from '@douyinfe/semi-ui';
|
||||
import CardTable from '../../common/ui/CardTable';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import { getModelsColumns } from './ModelsColumnDefs';
|
||||
|
||||
const ModelsTable = (modelsData) => {
|
||||
const {
|
||||
models,
|
||||
loading,
|
||||
activePage,
|
||||
pageSize,
|
||||
modelCount,
|
||||
compactMode,
|
||||
handlePageChange,
|
||||
handlePageSizeChange,
|
||||
rowSelection,
|
||||
handleRow,
|
||||
manageModel,
|
||||
setEditingModel,
|
||||
setShowEdit,
|
||||
refresh,
|
||||
vendorMap,
|
||||
t,
|
||||
} = modelsData;
|
||||
|
||||
// Get all columns
|
||||
const columns = useMemo(() => {
|
||||
return getModelsColumns({
|
||||
t,
|
||||
manageModel,
|
||||
setEditingModel,
|
||||
setShowEdit,
|
||||
refresh,
|
||||
vendorMap,
|
||||
});
|
||||
}, [t, manageModel, setEditingModel, setShowEdit, refresh, vendorMap]);
|
||||
|
||||
// Handle compact mode by removing fixed positioning
|
||||
const tableColumns = useMemo(() => {
|
||||
return compactMode
|
||||
? columns.map((col) => {
|
||||
if (col.dataIndex === 'operate') {
|
||||
const { fixed, ...rest } = col;
|
||||
return rest;
|
||||
}
|
||||
return col;
|
||||
})
|
||||
: columns;
|
||||
}, [compactMode, columns]);
|
||||
|
||||
return (
|
||||
<CardTable
|
||||
columns={tableColumns}
|
||||
dataSource={models}
|
||||
scroll={compactMode ? undefined : { x: 'max-content' }}
|
||||
pagination={{
|
||||
currentPage: activePage,
|
||||
pageSize: pageSize,
|
||||
total: modelCount,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [10, 20, 50, 100],
|
||||
onPageSizeChange: handlePageSizeChange,
|
||||
onPageChange: handlePageChange,
|
||||
}}
|
||||
hidePagination={true}
|
||||
loading={loading}
|
||||
rowSelection={rowSelection}
|
||||
onRow={handleRow}
|
||||
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'
|
||||
size='middle'
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelsTable;
|
||||
178
web/src/components/table/models/ModelsTabs.jsx
Normal file
178
web/src/components/table/models/ModelsTabs.jsx
Normal file
@@ -0,0 +1,178 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Tabs, TabPane, Tag, Button, Dropdown, Modal } from '@douyinfe/semi-ui';
|
||||
import { IconEdit, IconDelete } from '@douyinfe/semi-icons';
|
||||
import { getLobeHubIcon, showError, showSuccess } from '../../../helpers';
|
||||
import { API } from '../../../helpers';
|
||||
|
||||
const ModelsTabs = ({
|
||||
activeVendorKey,
|
||||
setActiveVendorKey,
|
||||
vendorCounts,
|
||||
vendors,
|
||||
loadModels,
|
||||
activePage,
|
||||
pageSize,
|
||||
setActivePage,
|
||||
setShowAddVendor,
|
||||
setShowEditVendor,
|
||||
setEditingVendor,
|
||||
loadVendors,
|
||||
t,
|
||||
}) => {
|
||||
const handleTabChange = (key) => {
|
||||
setActiveVendorKey(key);
|
||||
setActivePage(1);
|
||||
loadModels(1, pageSize, key);
|
||||
};
|
||||
|
||||
const handleEditVendor = (vendor, e) => {
|
||||
e.stopPropagation(); // 阻止事件冒泡,避免触发tab切换
|
||||
setEditingVendor(vendor);
|
||||
setShowEditVendor(true);
|
||||
};
|
||||
|
||||
const handleDeleteVendor = async (vendor, e) => {
|
||||
e.stopPropagation(); // 阻止事件冒泡,避免触发tab切换
|
||||
try {
|
||||
const res = await API.delete(`/api/vendors/${vendor.id}`);
|
||||
if (res.data.success) {
|
||||
showSuccess(t('供应商删除成功'));
|
||||
// 如果删除的是当前选中的供应商,切换到"全部"
|
||||
if (activeVendorKey === String(vendor.id)) {
|
||||
setActiveVendorKey('all');
|
||||
loadModels(1, pageSize, 'all');
|
||||
} else {
|
||||
loadModels(activePage, pageSize, activeVendorKey);
|
||||
}
|
||||
loadVendors(); // 重新加载供应商列表
|
||||
} else {
|
||||
showError(res.data.message || t('删除失败'));
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error.response?.data?.message || t('删除失败'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
activeKey={activeVendorKey}
|
||||
type='card'
|
||||
collapsible
|
||||
onChange={handleTabChange}
|
||||
className='mb-2'
|
||||
tabBarExtraContent={
|
||||
<Button
|
||||
type='primary'
|
||||
size='small'
|
||||
onClick={() => setShowAddVendor(true)}
|
||||
>
|
||||
{t('新增供应商')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<TabPane
|
||||
itemKey='all'
|
||||
tab={
|
||||
<span className='flex items-center gap-2'>
|
||||
{t('全部')}
|
||||
<Tag
|
||||
color={activeVendorKey === 'all' ? 'red' : 'grey'}
|
||||
shape='circle'
|
||||
>
|
||||
{vendorCounts['all'] || 0}
|
||||
</Tag>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
||||
{vendors.map((vendor) => {
|
||||
const key = String(vendor.id);
|
||||
const count = vendorCounts[vendor.id] || 0;
|
||||
return (
|
||||
<TabPane
|
||||
key={key}
|
||||
itemKey={key}
|
||||
tab={
|
||||
<span className='flex items-center gap-2'>
|
||||
{getLobeHubIcon(vendor.icon || 'Layers', 14)}
|
||||
{vendor.name}
|
||||
<Tag
|
||||
color={activeVendorKey === key ? 'red' : 'grey'}
|
||||
shape='circle'
|
||||
>
|
||||
{count}
|
||||
</Tag>
|
||||
<Dropdown
|
||||
trigger='click'
|
||||
position='bottomRight'
|
||||
render={
|
||||
<Dropdown.Menu>
|
||||
<Dropdown.Item
|
||||
icon={<IconEdit />}
|
||||
onClick={(e) => handleEditVendor(vendor, e)}
|
||||
>
|
||||
{t('编辑')}
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
type='danger'
|
||||
icon={<IconDelete />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
Modal.confirm({
|
||||
title: t('确认删除'),
|
||||
content: t(
|
||||
'确定要删除供应商 "{{name}}" 吗?此操作不可撤销。',
|
||||
{ name: vendor.name },
|
||||
),
|
||||
onOk: () => handleDeleteVendor(vendor, e),
|
||||
okText: t('删除'),
|
||||
cancelText: t('取消'),
|
||||
type: 'warning',
|
||||
okType: 'danger',
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t('删除')}
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
}
|
||||
onClickOutSide={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Button
|
||||
size='small'
|
||||
type='tertiary'
|
||||
theme='outline'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{t('操作')}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelsTabs;
|
||||
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { Notification, Button, Space, Typography } from '@douyinfe/semi-ui';
|
||||
|
||||
// 固定通知 ID,保持同一个实例即可避免闪烁
|
||||
const NOTICE_ID = 'models-batch-actions';
|
||||
|
||||
/**
|
||||
* SelectionNotification 选择通知组件
|
||||
* 1. 当 selectedKeys.length > 0 时,使用固定 id 创建/更新通知
|
||||
* 2. 当 selectedKeys 清空时关闭通知
|
||||
*/
|
||||
const SelectionNotification = ({
|
||||
selectedKeys = [],
|
||||
t,
|
||||
onDelete,
|
||||
onAddPrefill,
|
||||
onClear,
|
||||
onCopy,
|
||||
}) => {
|
||||
// 根据选中数量决定显示/隐藏或更新通知
|
||||
useEffect(() => {
|
||||
const selectedCount = selectedKeys.length;
|
||||
|
||||
if (selectedCount > 0) {
|
||||
const titleNode = (
|
||||
<Space wrap>
|
||||
<span>{t('批量操作')}</span>
|
||||
<Typography.Text type='tertiary' size='small'>
|
||||
{t('已选择 {{count}} 个模型', { count: selectedCount })}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
);
|
||||
|
||||
const content = (
|
||||
<Space wrap>
|
||||
<Button size='small' type='tertiary' theme='solid' onClick={onClear}>
|
||||
{t('取消全选')}
|
||||
</Button>
|
||||
<Button
|
||||
size='small'
|
||||
type='primary'
|
||||
theme='solid'
|
||||
onClick={onAddPrefill}
|
||||
>
|
||||
{t('加入预填组')}
|
||||
</Button>
|
||||
<Button size='small' type='secondary' theme='solid' onClick={onCopy}>
|
||||
{t('复制名称')}
|
||||
</Button>
|
||||
<Button size='small' type='danger' theme='solid' onClick={onDelete}>
|
||||
{t('删除所选')}
|
||||
</Button>
|
||||
</Space>
|
||||
);
|
||||
|
||||
// 使用相同 id 更新通知(若已存在则就地更新,不存在则创建)
|
||||
Notification.info({
|
||||
id: NOTICE_ID,
|
||||
title: titleNode,
|
||||
content,
|
||||
duration: 0, // 不自动关闭
|
||||
position: 'bottom',
|
||||
showClose: false,
|
||||
});
|
||||
} else {
|
||||
// 取消全部勾选时关闭通知
|
||||
Notification.close(NOTICE_ID);
|
||||
}
|
||||
}, [selectedKeys, t, onDelete, onAddPrefill, onClear, onCopy]);
|
||||
|
||||
// 卸载时确保关闭通知
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
Notification.close(NOTICE_ID);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null; // 该组件不渲染可见内容
|
||||
};
|
||||
|
||||
export default SelectionNotification;
|
||||
210
web/src/components/table/models/index.jsx
Normal file
210
web/src/components/table/models/index.jsx
Normal file
@@ -0,0 +1,210 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Banner, Button, Modal } from '@douyinfe/semi-ui';
|
||||
import { IconAlertTriangle, IconClose } from '@douyinfe/semi-icons';
|
||||
import CardPro from '../../common/ui/CardPro';
|
||||
import ModelsTable from './ModelsTable';
|
||||
import ModelsActions from './ModelsActions';
|
||||
import ModelsFilters from './ModelsFilters';
|
||||
import ModelsTabs from './ModelsTabs';
|
||||
import EditModelModal from './modals/EditModelModal';
|
||||
import EditVendorModal from './modals/EditVendorModal';
|
||||
import { useModelsData } from '../../../hooks/models/useModelsData';
|
||||
import { useIsMobile } from '../../../hooks/common/useIsMobile';
|
||||
import { createCardProPagination } from '../../../helpers/utils';
|
||||
|
||||
const MARKETPLACE_DISPLAY_NOTICE_STORAGE_KEY =
|
||||
'models_marketplace_display_notice_dismissed';
|
||||
|
||||
const ModelsPage = () => {
|
||||
const modelsData = useModelsData();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const {
|
||||
// Edit state
|
||||
showEdit,
|
||||
editingModel,
|
||||
closeEdit,
|
||||
refresh,
|
||||
|
||||
// Actions state
|
||||
selectedKeys,
|
||||
setSelectedKeys,
|
||||
setEditingModel,
|
||||
setShowEdit,
|
||||
batchDeleteModels,
|
||||
|
||||
// Filters state
|
||||
formInitValues,
|
||||
setFormApi,
|
||||
searchModels,
|
||||
loading,
|
||||
searching,
|
||||
|
||||
// Description state
|
||||
compactMode,
|
||||
setCompactMode,
|
||||
|
||||
// Vendor state
|
||||
showAddVendor,
|
||||
setShowAddVendor,
|
||||
showEditVendor,
|
||||
setShowEditVendor,
|
||||
editingVendor,
|
||||
setEditingVendor,
|
||||
loadVendors,
|
||||
|
||||
// Translation
|
||||
t,
|
||||
} = modelsData;
|
||||
|
||||
const [showMarketplaceDisplayNotice, setShowMarketplaceDisplayNotice] =
|
||||
useState(() => {
|
||||
try {
|
||||
return (
|
||||
localStorage.getItem(MARKETPLACE_DISPLAY_NOTICE_STORAGE_KEY) !== '1'
|
||||
);
|
||||
} catch (_) {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
const confirmCloseMarketplaceDisplayNotice = () => {
|
||||
Modal.confirm({
|
||||
title: t('确认关闭提示'),
|
||||
content: t(
|
||||
'关闭后将不再显示此提示(仅对当前浏览器生效)。确定要关闭吗?',
|
||||
),
|
||||
okText: t('关闭提示'),
|
||||
cancelText: t('取消'),
|
||||
okButtonProps: {
|
||||
type: 'danger',
|
||||
},
|
||||
onOk: () => {
|
||||
try {
|
||||
localStorage.setItem(MARKETPLACE_DISPLAY_NOTICE_STORAGE_KEY, '1');
|
||||
} catch (_) {}
|
||||
setShowMarketplaceDisplayNotice(false);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<EditModelModal
|
||||
refresh={refresh}
|
||||
editingModel={editingModel}
|
||||
visiable={showEdit}
|
||||
handleClose={closeEdit}
|
||||
/>
|
||||
|
||||
<EditVendorModal
|
||||
visible={showAddVendor || showEditVendor}
|
||||
handleClose={() => {
|
||||
setShowAddVendor(false);
|
||||
setShowEditVendor(false);
|
||||
setEditingVendor({ id: undefined });
|
||||
}}
|
||||
editingVendor={showEditVendor ? editingVendor : { id: undefined }}
|
||||
refresh={() => {
|
||||
loadVendors();
|
||||
refresh();
|
||||
}}
|
||||
/>
|
||||
|
||||
{showMarketplaceDisplayNotice ? (
|
||||
<div style={{ position: 'relative', marginBottom: 12 }}>
|
||||
<Banner
|
||||
type='warning'
|
||||
closeIcon={null}
|
||||
icon={
|
||||
<IconAlertTriangle
|
||||
size='large'
|
||||
style={{ color: 'var(--semi-color-warning)' }}
|
||||
/>
|
||||
}
|
||||
description={t(
|
||||
'提示:此处配置仅用于控制「模型广场」对用户的展示效果,不会影响模型的实际调用与路由。若需配置真实调用行为,请前往「渠道管理」进行设置。',
|
||||
)}
|
||||
style={{ marginBottom: 0 }}
|
||||
/>
|
||||
<Button
|
||||
theme='borderless'
|
||||
size='small'
|
||||
type='tertiary'
|
||||
icon={<IconClose aria-hidden={true} />}
|
||||
onClick={confirmCloseMarketplaceDisplayNotice}
|
||||
style={{ position: 'absolute', top: 8, right: 8 }}
|
||||
aria-label={t('关闭')}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<CardPro
|
||||
type='type3'
|
||||
tabsArea={<ModelsTabs {...modelsData} />}
|
||||
actionsArea={
|
||||
<div className='flex flex-col md:flex-row justify-between items-center gap-2 w-full'>
|
||||
<ModelsActions
|
||||
selectedKeys={selectedKeys}
|
||||
setSelectedKeys={setSelectedKeys}
|
||||
setEditingModel={setEditingModel}
|
||||
setShowEdit={setShowEdit}
|
||||
batchDeleteModels={batchDeleteModels}
|
||||
syncing={modelsData.syncing}
|
||||
syncUpstream={modelsData.syncUpstream}
|
||||
previewing={modelsData.previewing}
|
||||
previewUpstreamDiff={modelsData.previewUpstreamDiff}
|
||||
applyUpstreamOverwrite={modelsData.applyUpstreamOverwrite}
|
||||
compactMode={compactMode}
|
||||
setCompactMode={setCompactMode}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<div className='w-full md:w-full lg:w-auto order-1 md:order-2'>
|
||||
<ModelsFilters
|
||||
formInitValues={formInitValues}
|
||||
setFormApi={setFormApi}
|
||||
searchModels={searchModels}
|
||||
loading={loading}
|
||||
searching={searching}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
paginationArea={createCardProPagination({
|
||||
currentPage: modelsData.activePage,
|
||||
pageSize: modelsData.pageSize,
|
||||
total: modelsData.modelCount,
|
||||
onPageChange: modelsData.handlePageChange,
|
||||
onPageSizeChange: modelsData.handlePageSizeChange,
|
||||
isMobile: isMobile,
|
||||
t: modelsData.t,
|
||||
})}
|
||||
t={modelsData.t}
|
||||
>
|
||||
<ModelsTable {...modelsData} />
|
||||
</CardPro>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelsPage;
|
||||
554
web/src/components/table/models/modals/EditModelModal.jsx
Normal file
554
web/src/components/table/models/modals/EditModelModal.jsx
Normal file
@@ -0,0 +1,554 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import JSONEditor from '../../../common/ui/JSONEditor';
|
||||
import {
|
||||
Banner,
|
||||
SideSheet,
|
||||
Form,
|
||||
Button,
|
||||
Space,
|
||||
Spin,
|
||||
Typography,
|
||||
Card,
|
||||
Tag,
|
||||
Avatar,
|
||||
Col,
|
||||
Row,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { Save, X, FileText } from 'lucide-react';
|
||||
import { IconAlertTriangle, IconLink } from '@douyinfe/semi-icons';
|
||||
import { API, showError, showSuccess } from '../../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
// Example endpoint template for quick fill
|
||||
const ENDPOINT_TEMPLATE = {
|
||||
openai: { path: '/v1/chat/completions', method: 'POST' },
|
||||
'openai-response': { path: '/v1/responses', method: 'POST' },
|
||||
'openai-response-compact': { path: '/v1/responses/compact', method: 'POST' },
|
||||
anthropic: { path: '/v1/messages', method: 'POST' },
|
||||
gemini: { path: '/v1beta/models/{model}:generateContent', method: 'POST' },
|
||||
'jina-rerank': { path: '/v1/rerank', method: 'POST' },
|
||||
'image-generation': { path: '/v1/images/generations', method: 'POST' },
|
||||
};
|
||||
|
||||
const nameRuleOptions = [
|
||||
{ label: '精确名称匹配', value: 0 },
|
||||
{ label: '前缀名称匹配', value: 1 },
|
||||
{ label: '包含名称匹配', value: 2 },
|
||||
{ label: '后缀名称匹配', value: 3 },
|
||||
];
|
||||
|
||||
const EditModelModal = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
const formApiRef = useRef(null);
|
||||
const isEdit = props.editingModel && props.editingModel.id !== undefined;
|
||||
const placement = useMemo(() => (isEdit ? 'right' : 'left'), [isEdit]);
|
||||
|
||||
// 供应商列表
|
||||
const [vendors, setVendors] = useState([]);
|
||||
|
||||
// 预填组(标签、端点)
|
||||
const [tagGroups, setTagGroups] = useState([]);
|
||||
const [endpointGroups, setEndpointGroups] = useState([]);
|
||||
|
||||
// 获取供应商列表
|
||||
const fetchVendors = async () => {
|
||||
try {
|
||||
const res = await API.get('/api/vendors/?page_size=1000'); // 获取全部供应商
|
||||
if (res.data.success) {
|
||||
const items = res.data.data.items || res.data.data || [];
|
||||
setVendors(Array.isArray(items) ? items : []);
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
// 获取预填组(标签、端点)
|
||||
const fetchPrefillGroups = async () => {
|
||||
try {
|
||||
const [tagRes, endpointRes] = await Promise.all([
|
||||
API.get('/api/prefill_group?type=tag'),
|
||||
API.get('/api/prefill_group?type=endpoint'),
|
||||
]);
|
||||
if (tagRes?.data?.success) {
|
||||
setTagGroups(tagRes.data.data || []);
|
||||
}
|
||||
if (endpointRes?.data?.success) {
|
||||
setEndpointGroups(endpointRes.data.data || []);
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (props.visiable) {
|
||||
fetchVendors();
|
||||
fetchPrefillGroups();
|
||||
}
|
||||
}, [props.visiable]);
|
||||
|
||||
const getInitValues = () => ({
|
||||
model_name: props.editingModel?.model_name || '',
|
||||
description: '',
|
||||
icon: '',
|
||||
tags: [],
|
||||
vendor_id: undefined,
|
||||
vendor: '',
|
||||
vendor_icon: '',
|
||||
endpoints: '',
|
||||
name_rule: props.editingModel?.model_name ? 0 : undefined, // 通过未配置模型过来的固定为精确匹配
|
||||
status: true,
|
||||
sync_official: true,
|
||||
});
|
||||
|
||||
const handleCancel = () => {
|
||||
props.handleClose();
|
||||
};
|
||||
|
||||
const loadModel = async () => {
|
||||
if (!isEdit || !props.editingModel.id) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.get(`/api/models/${props.editingModel.id}`);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
// 处理tags
|
||||
if (data.tags) {
|
||||
data.tags = data.tags.split(',').filter(Boolean);
|
||||
} else {
|
||||
data.tags = [];
|
||||
}
|
||||
// endpoints 保持原始 JSON 字符串,若为空设为空串
|
||||
if (!data.endpoints) {
|
||||
data.endpoints = '';
|
||||
}
|
||||
// 处理status/sync_official,将数字转为布尔值
|
||||
data.status = data.status === 1;
|
||||
data.sync_official = (data.sync_official ?? 1) === 1;
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValues({ ...getInitValues(), ...data });
|
||||
}
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('加载模型信息失败'));
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (formApiRef.current) {
|
||||
if (!isEdit) {
|
||||
formApiRef.current.setValues({
|
||||
...getInitValues(),
|
||||
model_name: props.editingModel?.model_name || '',
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [props.editingModel?.id, props.editingModel?.model_name]);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.visiable) {
|
||||
if (isEdit) {
|
||||
loadModel();
|
||||
} else {
|
||||
formApiRef.current?.setValues({
|
||||
...getInitValues(),
|
||||
model_name: props.editingModel?.model_name || '',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
formApiRef.current?.reset();
|
||||
}
|
||||
}, [props.visiable, props.editingModel?.id, props.editingModel?.model_name]);
|
||||
|
||||
const submit = async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const submitData = {
|
||||
...values,
|
||||
tags: Array.isArray(values.tags) ? values.tags.join(',') : values.tags,
|
||||
endpoints: values.endpoints || '',
|
||||
status: values.status ? 1 : 0,
|
||||
sync_official: values.sync_official ? 1 : 0,
|
||||
};
|
||||
|
||||
if (isEdit) {
|
||||
submitData.id = props.editingModel.id;
|
||||
const res = await API.put('/api/models/', submitData);
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess(t('模型更新成功!'));
|
||||
props.refresh();
|
||||
props.handleClose();
|
||||
} else {
|
||||
showError(t(message));
|
||||
}
|
||||
} else {
|
||||
const res = await API.post('/api/models/', submitData);
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess(t('模型创建成功!'));
|
||||
props.refresh();
|
||||
props.handleClose();
|
||||
} else {
|
||||
showError(t(message));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error.response?.data?.message || t('操作失败'));
|
||||
}
|
||||
setLoading(false);
|
||||
formApiRef.current?.setValues(getInitValues());
|
||||
};
|
||||
|
||||
return (
|
||||
<SideSheet
|
||||
placement={placement}
|
||||
title={
|
||||
<Space>
|
||||
{isEdit ? (
|
||||
<Tag color='blue' shape='circle'>
|
||||
{t('更新')}
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag color='green' shape='circle'>
|
||||
{t('新建')}
|
||||
</Tag>
|
||||
)}
|
||||
<Title heading={4} className='m-0'>
|
||||
{isEdit ? t('更新模型信息') : t('创建新的模型')}
|
||||
</Title>
|
||||
</Space>
|
||||
}
|
||||
bodyStyle={{ padding: '0' }}
|
||||
visible={props.visiable}
|
||||
width={isMobile ? '100%' : 600}
|
||||
footer={
|
||||
<div className='flex justify-end bg-white'>
|
||||
<Space>
|
||||
<Button
|
||||
theme='solid'
|
||||
className='!rounded-lg'
|
||||
onClick={() => formApiRef.current?.submitForm()}
|
||||
icon={<Save size={16} />}
|
||||
loading={loading}
|
||||
>
|
||||
{t('提交')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='light'
|
||||
className='!rounded-lg'
|
||||
type='primary'
|
||||
onClick={handleCancel}
|
||||
icon={<X size={16} />}
|
||||
>
|
||||
{t('取消')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
closeIcon={null}
|
||||
onCancel={() => handleCancel()}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
<Form
|
||||
key={isEdit ? 'edit' : 'new'}
|
||||
initValues={getInitValues()}
|
||||
getFormApi={(api) => (formApiRef.current = api)}
|
||||
onSubmit={submit}
|
||||
>
|
||||
{({ values }) => (
|
||||
<div className='p-2'>
|
||||
{/* 基本信息 */}
|
||||
<Card className='!rounded-2xl shadow-sm border-0'>
|
||||
<div className='flex items-center mb-2'>
|
||||
<Avatar size='small' color='green' className='mr-2 shadow-md'>
|
||||
<FileText size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className='text-lg font-medium'>{t('基本信息')}</Text>
|
||||
<div className='text-xs text-gray-600'>
|
||||
{t('设置模型的基本信息')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Row gutter={12}>
|
||||
<Col span={24}>
|
||||
<Form.Input
|
||||
field='model_name'
|
||||
label={t('模型名称')}
|
||||
placeholder={t('请输入模型名称,如:gpt-4')}
|
||||
rules={[{ required: true, message: t('请输入模型名称') }]}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col span={24}>
|
||||
<Form.Select
|
||||
field='name_rule'
|
||||
label={t('名称匹配类型')}
|
||||
placeholder={t('请选择名称匹配类型')}
|
||||
optionList={nameRuleOptions.map((o) => ({
|
||||
label: t(o.label),
|
||||
value: o.value,
|
||||
}))}
|
||||
rules={[
|
||||
{ required: true, message: t('请选择名称匹配类型') },
|
||||
]}
|
||||
extraText={t(
|
||||
'根据模型名称和匹配规则查找模型元数据,优先级:精确 > 前缀 > 后缀 > 包含',
|
||||
)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col span={24}>
|
||||
<Form.Input
|
||||
field='icon'
|
||||
label={t('模型图标')}
|
||||
placeholder={t('请输入图标名称')}
|
||||
extraText={
|
||||
<span>
|
||||
{t(
|
||||
"图标使用@lobehub/icons库,如:OpenAI、Claude.Color,支持链式参数:OpenAI.Avatar.type={'platform'}、OpenRouter.Avatar.shape={'square'},查询所有可用图标请 ",
|
||||
)}
|
||||
<Typography.Text
|
||||
link={{
|
||||
href: 'https://icons.lobehub.com/components/lobe-hub',
|
||||
target: '_blank',
|
||||
}}
|
||||
icon={<IconLink />}
|
||||
underline
|
||||
>
|
||||
{t('请点击我')}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col span={24}>
|
||||
<Form.TextArea
|
||||
field='description'
|
||||
label={t('描述')}
|
||||
placeholder={t('请输入模型描述')}
|
||||
rows={3}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.TagInput
|
||||
field='tags'
|
||||
label={t('标签')}
|
||||
placeholder={t('输入标签或使用","分隔多个标签')}
|
||||
addOnBlur
|
||||
showClear
|
||||
onChange={(newTags) => {
|
||||
if (!formApiRef.current) return;
|
||||
const normalize = (tags) => {
|
||||
if (!Array.isArray(tags)) return [];
|
||||
return [
|
||||
...new Set(
|
||||
tags.flatMap((tag) =>
|
||||
tag
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
),
|
||||
];
|
||||
};
|
||||
const normalized = normalize(newTags);
|
||||
formApiRef.current.setValue('tags', normalized);
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
{...(tagGroups.length > 0 && {
|
||||
extraText: (
|
||||
<Space wrap>
|
||||
{tagGroups.map((group) => (
|
||||
<Button
|
||||
key={group.id}
|
||||
size='small'
|
||||
type='primary'
|
||||
onClick={() => {
|
||||
if (formApiRef.current) {
|
||||
const currentTags =
|
||||
formApiRef.current.getValue('tags') || [];
|
||||
const newTags = [
|
||||
...currentTags,
|
||||
...(group.items || []),
|
||||
];
|
||||
const uniqueTags = [...new Set(newTags)];
|
||||
formApiRef.current.setValue(
|
||||
'tags',
|
||||
uniqueTags,
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{group.name}
|
||||
</Button>
|
||||
))}
|
||||
</Space>
|
||||
),
|
||||
})}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Select
|
||||
field='vendor_id'
|
||||
label={t('供应商')}
|
||||
placeholder={t('选择模型供应商')}
|
||||
optionList={vendors.map((v) => ({
|
||||
label: v.name,
|
||||
value: v.id,
|
||||
}))}
|
||||
filter
|
||||
showClear
|
||||
onChange={(value) => {
|
||||
const vendorInfo = vendors.find((v) => v.id === value);
|
||||
if (vendorInfo && formApiRef.current) {
|
||||
formApiRef.current.setValue(
|
||||
'vendor',
|
||||
vendorInfo.name,
|
||||
);
|
||||
}
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Banner
|
||||
type='warning'
|
||||
closeIcon={null}
|
||||
icon={
|
||||
<IconAlertTriangle
|
||||
size='large'
|
||||
style={{ color: 'var(--semi-color-warning)' }}
|
||||
/>
|
||||
}
|
||||
description={t(
|
||||
'提示:此处配置仅用于控制「模型广场」对用户的展示效果,不会影响模型的实际调用与路由。若需配置真实调用行为,请前往「渠道管理」进行设置。',
|
||||
)}
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
<JSONEditor
|
||||
field='endpoints'
|
||||
label={t('在模型广场向用户展示的端点')}
|
||||
placeholder={
|
||||
'{\n "openai": {"path": "/v1/chat/completions", "method": "POST"}\n}'
|
||||
}
|
||||
value={values.endpoints}
|
||||
onChange={(val) =>
|
||||
formApiRef.current?.setValue('endpoints', val)
|
||||
}
|
||||
formApi={formApiRef.current}
|
||||
editorType='object'
|
||||
template={ENDPOINT_TEMPLATE}
|
||||
templateLabel={t('填入模板')}
|
||||
extraText={t('留空则使用默认端点;支持 {path, method}')}
|
||||
extraFooter={
|
||||
endpointGroups.length > 0 && (
|
||||
<Space wrap>
|
||||
{endpointGroups.map((group) => (
|
||||
<Button
|
||||
key={group.id}
|
||||
size='small'
|
||||
type='primary'
|
||||
onClick={() => {
|
||||
try {
|
||||
const current =
|
||||
formApiRef.current?.getValue(
|
||||
'endpoints',
|
||||
) || '';
|
||||
let base = {};
|
||||
if (current && current.trim())
|
||||
base = JSON.parse(current);
|
||||
const groupObj =
|
||||
typeof group.items === 'string'
|
||||
? JSON.parse(group.items || '{}')
|
||||
: group.items || {};
|
||||
const merged = { ...base, ...groupObj };
|
||||
formApiRef.current?.setValue(
|
||||
'endpoints',
|
||||
JSON.stringify(merged, null, 2),
|
||||
);
|
||||
} catch (e) {
|
||||
try {
|
||||
const groupObj =
|
||||
typeof group.items === 'string'
|
||||
? JSON.parse(group.items || '{}')
|
||||
: group.items || {};
|
||||
formApiRef.current?.setValue(
|
||||
'endpoints',
|
||||
JSON.stringify(groupObj, null, 2),
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{group.name}
|
||||
</Button>
|
||||
))}
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Switch
|
||||
field='sync_official'
|
||||
label={t('参与官方同步')}
|
||||
extraText={t(
|
||||
'关闭后,此模型将不会被“同步官方”自动覆盖或创建',
|
||||
)}
|
||||
size='large'
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Switch
|
||||
field='status'
|
||||
label={t('状态')}
|
||||
size='large'
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
</Spin>
|
||||
</SideSheet>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditModelModal;
|
||||
276
web/src/components/table/models/modals/EditPrefillGroupModal.jsx
Normal file
276
web/src/components/table/models/modals/EditPrefillGroupModal.jsx
Normal file
@@ -0,0 +1,276 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import JSONEditor from '../../../common/ui/JSONEditor';
|
||||
import {
|
||||
SideSheet,
|
||||
Button,
|
||||
Form,
|
||||
Typography,
|
||||
Space,
|
||||
Tag,
|
||||
Row,
|
||||
Col,
|
||||
Card,
|
||||
Avatar,
|
||||
Spin,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { IconLayers, IconSave, IconClose } from '@douyinfe/semi-icons';
|
||||
import { API, showError, showSuccess } from '../../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
// Example endpoint template for quick fill
|
||||
const ENDPOINT_TEMPLATE = {
|
||||
openai: { path: '/v1/chat/completions', method: 'POST' },
|
||||
'openai-response': { path: '/v1/responses', method: 'POST' },
|
||||
'openai-response-compact': { path: '/v1/responses/compact', method: 'POST' },
|
||||
anthropic: { path: '/v1/messages', method: 'POST' },
|
||||
gemini: { path: '/v1beta/models/{model}:generateContent', method: 'POST' },
|
||||
'jina-rerank': { path: '/v1/rerank', method: 'POST' },
|
||||
'image-generation': { path: '/v1/images/generations', method: 'POST' },
|
||||
};
|
||||
|
||||
const EditPrefillGroupModal = ({
|
||||
visible,
|
||||
onClose,
|
||||
editingGroup,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsMobile();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const formRef = useRef(null);
|
||||
const isEdit = editingGroup && editingGroup.id !== undefined;
|
||||
|
||||
const [selectedType, setSelectedType] = useState(editingGroup?.type || 'tag');
|
||||
|
||||
// 当外部传入的编辑组类型变化时同步 selectedType
|
||||
useEffect(() => {
|
||||
setSelectedType(editingGroup?.type || 'tag');
|
||||
}, [editingGroup?.type]);
|
||||
|
||||
const typeOptions = [
|
||||
{ label: t('模型组'), value: 'model' },
|
||||
{ label: t('标签组'), value: 'tag' },
|
||||
{ label: t('端点组'), value: 'endpoint' },
|
||||
{ label: t('订阅分组'), value: 'subscription_group' },
|
||||
];
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const submitData = {
|
||||
...values,
|
||||
};
|
||||
if (values.type === 'endpoint') {
|
||||
submitData.items = values.items || '';
|
||||
} else {
|
||||
submitData.items = Array.isArray(values.items) ? values.items : [];
|
||||
}
|
||||
|
||||
if (editingGroup.id) {
|
||||
submitData.id = editingGroup.id;
|
||||
const res = await API.put('/api/prefill_group', submitData);
|
||||
if (res.data.success) {
|
||||
showSuccess(t('更新成功'));
|
||||
onSuccess();
|
||||
} else {
|
||||
showError(res.data.message || t('更新失败'));
|
||||
}
|
||||
} else {
|
||||
const res = await API.post('/api/prefill_group', submitData);
|
||||
if (res.data.success) {
|
||||
showSuccess(t('创建成功'));
|
||||
onSuccess();
|
||||
} else {
|
||||
showError(res.data.message || t('创建失败'));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('操作失败'));
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<SideSheet
|
||||
placement='left'
|
||||
title={
|
||||
<Space>
|
||||
{isEdit ? (
|
||||
<Tag color='blue' shape='circle'>
|
||||
{t('更新')}
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag color='green' shape='circle'>
|
||||
{t('新建')}
|
||||
</Tag>
|
||||
)}
|
||||
<Title heading={4} className='m-0'>
|
||||
{isEdit ? t('更新预填组') : t('创建新的预填组')}
|
||||
</Title>
|
||||
</Space>
|
||||
}
|
||||
visible={visible}
|
||||
onCancel={onClose}
|
||||
width={isMobile ? '100%' : 600}
|
||||
bodyStyle={{ padding: '0' }}
|
||||
footer={
|
||||
<div className='flex justify-end bg-white'>
|
||||
<Space>
|
||||
<Button
|
||||
theme='solid'
|
||||
className='!rounded-lg'
|
||||
onClick={() => formRef.current?.submitForm()}
|
||||
icon={<IconSave />}
|
||||
loading={loading}
|
||||
>
|
||||
{t('提交')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='light'
|
||||
className='!rounded-lg'
|
||||
type='primary'
|
||||
onClick={onClose}
|
||||
icon={<IconClose />}
|
||||
>
|
||||
{t('取消')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
closeIcon={null}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
<Form
|
||||
getFormApi={(api) => (formRef.current = api)}
|
||||
initValues={{
|
||||
name: editingGroup?.name || '',
|
||||
type: editingGroup?.type || 'tag',
|
||||
description: editingGroup?.description || '',
|
||||
items: (() => {
|
||||
try {
|
||||
if (editingGroup?.type === 'endpoint') {
|
||||
// 保持原始字符串
|
||||
return typeof editingGroup?.items === 'string'
|
||||
? editingGroup.items
|
||||
: JSON.stringify(editingGroup.items || {}, null, 2);
|
||||
}
|
||||
return Array.isArray(editingGroup?.items)
|
||||
? editingGroup.items
|
||||
: [];
|
||||
} catch {
|
||||
return editingGroup?.type === 'endpoint' ? '' : [];
|
||||
}
|
||||
})(),
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<div className='p-2'>
|
||||
{/* 基本信息 */}
|
||||
<Card className='!rounded-2xl shadow-sm border-0'>
|
||||
<div className='flex items-center mb-2'>
|
||||
<Avatar size='small' color='green' className='mr-2 shadow-md'>
|
||||
<IconLayers size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className='text-lg font-medium'>{t('基本信息')}</Text>
|
||||
<div className='text-xs text-gray-600'>
|
||||
{t('设置预填组的基本信息')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Row gutter={12}>
|
||||
<Col span={24}>
|
||||
<Form.Input
|
||||
field='name'
|
||||
label={t('组名')}
|
||||
placeholder={t('请输入组名')}
|
||||
rules={[{ required: true, message: t('请输入组名') }]}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Select
|
||||
field='type'
|
||||
label={t('类型')}
|
||||
placeholder={t('选择组类型')}
|
||||
optionList={typeOptions}
|
||||
rules={[{ required: true, message: t('请选择组类型') }]}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(val) => setSelectedType(val)}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.TextArea
|
||||
field='description'
|
||||
label={t('描述')}
|
||||
placeholder={t('请输入组描述')}
|
||||
rows={3}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
{selectedType === 'endpoint' ? (
|
||||
<JSONEditor
|
||||
field='items'
|
||||
label={t('端点映射')}
|
||||
value={
|
||||
formRef.current?.getValue('items') ??
|
||||
(typeof editingGroup?.items === 'string'
|
||||
? editingGroup.items
|
||||
: JSON.stringify(editingGroup.items || {}, null, 2))
|
||||
}
|
||||
onChange={(val) =>
|
||||
formRef.current?.setValue('items', val)
|
||||
}
|
||||
editorType='object'
|
||||
placeholder={
|
||||
'{\n "openai": {"path": "/v1/chat/completions", "method": "POST"}\n}'
|
||||
}
|
||||
template={ENDPOINT_TEMPLATE}
|
||||
templateLabel={t('填入模板')}
|
||||
extraText={t('键为端点类型,值为路径和方法对象')}
|
||||
/>
|
||||
) : (
|
||||
<Form.TagInput
|
||||
field='items'
|
||||
label={t('项目')}
|
||||
placeholder={t('输入项目名称,按回车添加')}
|
||||
addOnBlur
|
||||
showClear
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
</div>
|
||||
</Form>
|
||||
</Spin>
|
||||
</SideSheet>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditPrefillGroupModal;
|
||||
186
web/src/components/table/models/modals/EditVendorModal.jsx
Normal file
186
web/src/components/table/models/modals/EditVendorModal.jsx
Normal file
@@ -0,0 +1,186 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Modal, Form, Col, Row } from '@douyinfe/semi-ui';
|
||||
import { API, showError, showSuccess } from '../../../../helpers';
|
||||
import { Typography } from '@douyinfe/semi-ui';
|
||||
import { IconLink } from '@douyinfe/semi-icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
||||
|
||||
const EditVendorModal = ({ visible, handleClose, refresh, editingVendor }) => {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const formApiRef = useRef(null);
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
const isEdit = editingVendor && editingVendor.id !== undefined;
|
||||
|
||||
const getInitValues = () => ({
|
||||
name: '',
|
||||
description: '',
|
||||
icon: '',
|
||||
status: true,
|
||||
});
|
||||
|
||||
const handleCancel = () => {
|
||||
handleClose();
|
||||
formApiRef.current?.reset();
|
||||
};
|
||||
|
||||
const loadVendor = async () => {
|
||||
if (!isEdit || !editingVendor.id) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.get(`/api/vendors/${editingVendor.id}`);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
// 将数字状态转为布尔值
|
||||
data.status = data.status === 1;
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValues({ ...getInitValues(), ...data });
|
||||
}
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('加载供应商信息失败'));
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
if (isEdit) {
|
||||
loadVendor();
|
||||
} else {
|
||||
formApiRef.current?.setValues(getInitValues());
|
||||
}
|
||||
} else {
|
||||
formApiRef.current?.reset();
|
||||
}
|
||||
}, [visible, editingVendor?.id]);
|
||||
|
||||
const submit = async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// 转换 status 为数字
|
||||
const submitData = {
|
||||
...values,
|
||||
status: values.status ? 1 : 0,
|
||||
};
|
||||
|
||||
if (isEdit) {
|
||||
submitData.id = editingVendor.id;
|
||||
const res = await API.put('/api/vendors/', submitData);
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess(t('供应商更新成功!'));
|
||||
refresh();
|
||||
handleClose();
|
||||
} else {
|
||||
showError(t(message));
|
||||
}
|
||||
} else {
|
||||
const res = await API.post('/api/vendors/', submitData);
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess(t('供应商创建成功!'));
|
||||
refresh();
|
||||
handleClose();
|
||||
} else {
|
||||
showError(t(message));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error.response?.data?.message || t('操作失败'));
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={isEdit ? t('编辑供应商') : t('新增供应商')}
|
||||
visible={visible}
|
||||
onOk={() => formApiRef.current?.submitForm()}
|
||||
onCancel={handleCancel}
|
||||
confirmLoading={loading}
|
||||
size={isMobile ? 'full-width' : 'small'}
|
||||
>
|
||||
<Form
|
||||
initValues={getInitValues()}
|
||||
getFormApi={(api) => (formApiRef.current = api)}
|
||||
onSubmit={submit}
|
||||
>
|
||||
<Row gutter={12}>
|
||||
<Col span={24}>
|
||||
<Form.Input
|
||||
field='name'
|
||||
label={t('供应商名称')}
|
||||
placeholder={t('请输入供应商名称,如:OpenAI')}
|
||||
rules={[{ required: true, message: t('请输入供应商名称') }]}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.TextArea
|
||||
field='description'
|
||||
label={t('描述')}
|
||||
placeholder={t('请输入供应商描述')}
|
||||
rows={3}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Input
|
||||
field='icon'
|
||||
label={t('供应商图标')}
|
||||
placeholder={t('请输入图标名称')}
|
||||
extraText={
|
||||
<span>
|
||||
{t(
|
||||
"图标使用@lobehub/icons库,如:OpenAI、Claude.Color,支持链式参数:OpenAI.Avatar.type={'platform'}、OpenRouter.Avatar.shape={'square'},查询所有可用图标请 ",
|
||||
)}
|
||||
<Typography.Text
|
||||
link={{
|
||||
href: 'https://icons.lobehub.com/components/lobe-hub',
|
||||
target: '_blank',
|
||||
}}
|
||||
icon={<IconLink />}
|
||||
underline
|
||||
>
|
||||
{t('请点击我')}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Switch field='status' label={t('状态')} initValue={true} />
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditVendorModal;
|
||||
198
web/src/components/table/models/modals/MissingModelsModal.jsx
Normal file
198
web/src/components/table/models/modals/MissingModelsModal.jsx
Normal file
@@ -0,0 +1,198 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Table,
|
||||
Spin,
|
||||
Button,
|
||||
Typography,
|
||||
Empty,
|
||||
Input,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import { IconSearch } from '@douyinfe/semi-icons';
|
||||
import { API, showError } from '../../../../helpers';
|
||||
import { MODEL_TABLE_PAGE_SIZE } from '../../../../constants';
|
||||
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
||||
|
||||
const MissingModelsModal = ({ visible, onClose, onConfigureModel, t }) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [missingModels, setMissingModels] = useState([]);
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const fetchMissing = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.get('/api/models/missing');
|
||||
if (res.data.success) {
|
||||
setMissingModels(res.data.data || []);
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
}
|
||||
} catch (_) {
|
||||
showError(t('获取未配置模型失败'));
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
fetchMissing();
|
||||
setSearchKeyword('');
|
||||
setCurrentPage(1);
|
||||
} else {
|
||||
setMissingModels([]);
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
// 过滤和分页逻辑
|
||||
const filteredModels = missingModels.filter((model) =>
|
||||
model.toLowerCase().includes(searchKeyword.toLowerCase()),
|
||||
);
|
||||
|
||||
const dataSource = (() => {
|
||||
const start = (currentPage - 1) * MODEL_TABLE_PAGE_SIZE;
|
||||
const end = start + MODEL_TABLE_PAGE_SIZE;
|
||||
return filteredModels.slice(start, end).map((model) => ({
|
||||
model,
|
||||
key: model,
|
||||
}));
|
||||
})();
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('模型名称'),
|
||||
dataIndex: 'model',
|
||||
render: (text) => (
|
||||
<div className='flex items-center'>
|
||||
<Typography.Text strong>{text}</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
dataIndex: 'operate',
|
||||
fixed: 'right',
|
||||
width: 120,
|
||||
render: (text, record) => (
|
||||
<Button
|
||||
type='primary'
|
||||
size='small'
|
||||
onClick={() => onConfigureModel(record.model)}
|
||||
>
|
||||
{t('配置')}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<div className='flex flex-col gap-2 w-full'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Typography.Text
|
||||
strong
|
||||
className='!text-[var(--semi-color-text-0)] !text-base'
|
||||
>
|
||||
{t('未配置的模型列表')}
|
||||
</Typography.Text>
|
||||
<Typography.Text type='tertiary' size='small'>
|
||||
{t('共')} {missingModels.length} {t('个未配置模型')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
visible={visible}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
size={isMobile ? 'full-width' : 'medium'}
|
||||
className='!rounded-lg'
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
{missingModels.length === 0 && !loading ? (
|
||||
<Empty
|
||||
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
|
||||
darkModeImage={
|
||||
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
|
||||
}
|
||||
description={t('暂无缺失模型')}
|
||||
style={{ padding: 30 }}
|
||||
/>
|
||||
) : (
|
||||
<div className='missing-models-content'>
|
||||
{/* 搜索框 */}
|
||||
<div className='flex items-center justify-end gap-2 w-full mb-4'>
|
||||
<Input
|
||||
placeholder={t('搜索模型...')}
|
||||
value={searchKeyword}
|
||||
onChange={(v) => {
|
||||
setSearchKeyword(v);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className='!w-full'
|
||||
prefix={<IconSearch />}
|
||||
showClear
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 表格 */}
|
||||
{filteredModels.length > 0 ? (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={dataSource}
|
||||
pagination={{
|
||||
currentPage: currentPage,
|
||||
pageSize: MODEL_TABLE_PAGE_SIZE,
|
||||
total: filteredModels.length,
|
||||
showSizeChanger: false,
|
||||
onPageChange: (page) => setCurrentPage(page),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Empty
|
||||
image={
|
||||
<IllustrationNoResult style={{ width: 100, height: 100 }} />
|
||||
}
|
||||
darkModeImage={
|
||||
<IllustrationNoResultDark
|
||||
style={{ width: 100, height: 100 }}
|
||||
/>
|
||||
}
|
||||
description={
|
||||
searchKeyword ? t('未找到匹配的模型') : t('暂无缺失模型')
|
||||
}
|
||||
style={{ padding: 20 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Spin>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default MissingModelsModal;
|
||||
@@ -0,0 +1,309 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
SideSheet,
|
||||
Button,
|
||||
Typography,
|
||||
Space,
|
||||
Tag,
|
||||
Popconfirm,
|
||||
Card,
|
||||
Avatar,
|
||||
Spin,
|
||||
Empty,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { IconPlus, IconLayers } from '@douyinfe/semi-icons';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import {
|
||||
API,
|
||||
showError,
|
||||
showSuccess,
|
||||
stringToColor,
|
||||
} from '../../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
||||
import CardTable from '../../../common/ui/CardTable';
|
||||
import EditPrefillGroupModal from './EditPrefillGroupModal';
|
||||
import {
|
||||
renderLimitedItems,
|
||||
renderDescription,
|
||||
} from '../../../common/ui/RenderUtils';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
const PrefillGroupManagement = ({ visible, onClose }) => {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsMobile();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [groups, setGroups] = useState([]);
|
||||
const [showEdit, setShowEdit] = useState(false);
|
||||
const [editingGroup, setEditingGroup] = useState({ id: undefined });
|
||||
|
||||
const typeOptions = [
|
||||
{ label: t('模型组'), value: 'model' },
|
||||
{ label: t('标签组'), value: 'tag' },
|
||||
{ label: t('端点组'), value: 'endpoint' },
|
||||
{ label: t('订阅分组'), value: 'subscription_group' },
|
||||
];
|
||||
|
||||
// 加载组列表
|
||||
const loadGroups = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.get('/api/prefill_group');
|
||||
if (res.data.success) {
|
||||
setGroups(res.data.data || []);
|
||||
} else {
|
||||
showError(res.data.message || t('获取组列表失败'));
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('获取组列表失败'));
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
// 删除组
|
||||
const deleteGroup = async (id) => {
|
||||
try {
|
||||
const res = await API.delete(`/api/prefill_group/${id}`);
|
||||
if (res.data.success) {
|
||||
showSuccess(t('删除成功'));
|
||||
loadGroups();
|
||||
} else {
|
||||
showError(res.data.message || t('删除失败'));
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('删除失败'));
|
||||
}
|
||||
};
|
||||
|
||||
// 编辑组
|
||||
const handleEdit = (group = {}) => {
|
||||
setEditingGroup(group);
|
||||
setShowEdit(true);
|
||||
};
|
||||
|
||||
// 关闭编辑
|
||||
const closeEdit = () => {
|
||||
setShowEdit(false);
|
||||
setTimeout(() => {
|
||||
setEditingGroup({ id: undefined });
|
||||
}, 300);
|
||||
};
|
||||
|
||||
// 编辑成功回调
|
||||
const handleEditSuccess = () => {
|
||||
closeEdit();
|
||||
loadGroups();
|
||||
};
|
||||
|
||||
// 表格列定义
|
||||
const columns = [
|
||||
{
|
||||
title: t('组名'),
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
render: (text, record) => (
|
||||
<Space>
|
||||
<Text strong>{text}</Text>
|
||||
<Tag color='white' shape='circle' size='small'>
|
||||
{typeOptions.find((opt) => opt.value === record.type)?.label ||
|
||||
record.type}
|
||||
</Tag>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('描述'),
|
||||
dataIndex: 'description',
|
||||
key: 'description',
|
||||
render: (text) => renderDescription(text, 150),
|
||||
},
|
||||
{
|
||||
title: t('项目内容'),
|
||||
dataIndex: 'items',
|
||||
key: 'items',
|
||||
render: (items, record) => {
|
||||
try {
|
||||
if (record.type === 'endpoint') {
|
||||
const obj =
|
||||
typeof items === 'string'
|
||||
? JSON.parse(items || '{}')
|
||||
: items || {};
|
||||
const keys = Object.keys(obj);
|
||||
if (keys.length === 0)
|
||||
return <Text type='tertiary'>{t('暂无项目')}</Text>;
|
||||
return renderLimitedItems({
|
||||
items: keys,
|
||||
renderItem: (key, idx) => (
|
||||
<Tag
|
||||
key={idx}
|
||||
size='small'
|
||||
shape='circle'
|
||||
color={stringToColor(key)}
|
||||
>
|
||||
{key}
|
||||
</Tag>
|
||||
),
|
||||
maxDisplay: 3,
|
||||
});
|
||||
}
|
||||
const itemsArray =
|
||||
typeof items === 'string' ? JSON.parse(items) : items;
|
||||
if (!Array.isArray(itemsArray) || itemsArray.length === 0) {
|
||||
return <Text type='tertiary'>{t('暂无项目')}</Text>;
|
||||
}
|
||||
return renderLimitedItems({
|
||||
items: itemsArray,
|
||||
renderItem: (item, idx) => (
|
||||
<Tag
|
||||
key={idx}
|
||||
size='small'
|
||||
shape='circle'
|
||||
color={stringToColor(item)}
|
||||
>
|
||||
{item}
|
||||
</Tag>
|
||||
),
|
||||
maxDisplay: 3,
|
||||
});
|
||||
} catch {
|
||||
return <Text type='tertiary'>{t('数据格式错误')}</Text>;
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
key: 'action',
|
||||
fixed: 'right',
|
||||
width: 140,
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Button size='small' onClick={() => handleEdit(record)}>
|
||||
{t('编辑')}
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title={t('确定删除此组?')}
|
||||
onConfirm={() => deleteGroup(record.id)}
|
||||
>
|
||||
<Button size='small' type='danger'>
|
||||
{t('删除')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
loadGroups();
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SideSheet
|
||||
placement='left'
|
||||
title={
|
||||
<Space>
|
||||
<Tag color='blue' shape='circle'>
|
||||
{t('管理')}
|
||||
</Tag>
|
||||
<Title heading={4} className='m-0'>
|
||||
{t('预填组管理')}
|
||||
</Title>
|
||||
</Space>
|
||||
}
|
||||
visible={visible}
|
||||
onCancel={onClose}
|
||||
width={isMobile ? '100%' : 800}
|
||||
bodyStyle={{ padding: '0' }}
|
||||
closeIcon={null}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
<div className='p-2'>
|
||||
<Card className='!rounded-2xl shadow-sm border-0'>
|
||||
<div className='flex items-center mb-2'>
|
||||
<Avatar size='small' color='blue' className='mr-2 shadow-md'>
|
||||
<IconLayers size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className='text-lg font-medium'>{t('组列表')}</Text>
|
||||
<div className='text-xs text-gray-600'>
|
||||
{t('管理模型、标签、端点等预填组')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex justify-end mb-4'>
|
||||
<Button
|
||||
type='primary'
|
||||
theme='solid'
|
||||
size='small'
|
||||
icon={<IconPlus />}
|
||||
onClick={() => handleEdit()}
|
||||
>
|
||||
{t('新建组')}
|
||||
</Button>
|
||||
</div>
|
||||
{groups.length > 0 ? (
|
||||
<CardTable
|
||||
columns={columns}
|
||||
dataSource={groups}
|
||||
rowKey='id'
|
||||
hidePagination={true}
|
||||
size='small'
|
||||
scroll={{ x: 'max-content' }}
|
||||
/>
|
||||
) : (
|
||||
<Empty
|
||||
image={
|
||||
<IllustrationNoResult style={{ width: 150, height: 150 }} />
|
||||
}
|
||||
darkModeImage={
|
||||
<IllustrationNoResultDark
|
||||
style={{ width: 150, height: 150 }}
|
||||
/>
|
||||
}
|
||||
description={t('暂无预填组')}
|
||||
style={{ padding: 30 }}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</Spin>
|
||||
</SideSheet>
|
||||
|
||||
{/* 编辑组件 */}
|
||||
<EditPrefillGroupModal
|
||||
visible={showEdit}
|
||||
onClose={closeEdit}
|
||||
editingGroup={editingGroup}
|
||||
onSuccess={handleEditSuccess}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PrefillGroupManagement;
|
||||
135
web/src/components/table/models/modals/SyncWizardModal.jsx
Normal file
135
web/src/components/table/models/modals/SyncWizardModal.jsx
Normal file
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Modal, RadioGroup, Radio, Steps, Button } from '@douyinfe/semi-ui';
|
||||
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
||||
|
||||
const SyncWizardModal = ({ visible, onClose, onConfirm, loading, t }) => {
|
||||
const [step, setStep] = useState(0);
|
||||
const [option, setOption] = useState('official');
|
||||
const [locale, setLocale] = useState('zh-CN');
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setStep(0);
|
||||
setOption('official');
|
||||
setLocale('zh-CN');
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('同步向导')}
|
||||
visible={visible}
|
||||
onCancel={onClose}
|
||||
footer={
|
||||
<div className='flex justify-end'>
|
||||
{step === 1 && (
|
||||
<Button onClick={() => setStep(0)}>{t('上一步')}</Button>
|
||||
)}
|
||||
<Button onClick={onClose}>{t('取消')}</Button>
|
||||
{step === 0 && (
|
||||
<Button
|
||||
type='primary'
|
||||
onClick={() => setStep(1)}
|
||||
disabled={option !== 'official'}
|
||||
>
|
||||
{t('下一步')}
|
||||
</Button>
|
||||
)}
|
||||
{step === 1 && (
|
||||
<Button
|
||||
type='primary'
|
||||
theme='solid'
|
||||
loading={loading}
|
||||
onClick={async () => {
|
||||
await onConfirm?.({ option, locale });
|
||||
}}
|
||||
>
|
||||
{t('开始同步')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
width={isMobile ? '100%' : 'small'}
|
||||
>
|
||||
<div className='mb-3'>
|
||||
<Steps type='basic' current={step} size='small'>
|
||||
<Steps.Step title={t('选择方式')} description={t('选择同步来源')} />
|
||||
<Steps.Step title={t('选择语言')} description={t('选择同步语言')} />
|
||||
</Steps>
|
||||
</div>
|
||||
|
||||
{step === 0 && (
|
||||
<div className='mt-2 flex justify-center'>
|
||||
<RadioGroup
|
||||
value={option}
|
||||
onChange={(e) => setOption(e?.target?.value ?? e)}
|
||||
type='card'
|
||||
direction='horizontal'
|
||||
aria-label='同步方式选择'
|
||||
name='sync-mode-selection'
|
||||
>
|
||||
<Radio value='official' extra={t('从官方模型库同步')}>
|
||||
{t('官方模型同步')}
|
||||
</Radio>
|
||||
<Radio value='config' extra={t('从配置文件同步')} disabled>
|
||||
{t('配置文件同步')}
|
||||
</Radio>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<div className='mt-2'>
|
||||
<div className='mb-2 text-[var(--semi-color-text-2)]'>
|
||||
{t('请选择同步语言')}
|
||||
</div>
|
||||
<div className='flex justify-center'>
|
||||
<RadioGroup
|
||||
value={locale}
|
||||
onChange={(e) => setLocale(e?.target?.value ?? e)}
|
||||
type='card'
|
||||
direction='horizontal'
|
||||
aria-label='语言选择'
|
||||
name='sync-locale-selection'
|
||||
>
|
||||
<Radio value='en' extra='English'>
|
||||
en
|
||||
</Radio>
|
||||
<Radio value='zh-CN' extra='简体中文'>
|
||||
zh-CN
|
||||
</Radio>
|
||||
<Radio value='zh-TW' extra='繁體中文'>
|
||||
zh-TW
|
||||
</Radio>
|
||||
<Radio value='ja' extra='日本語'>
|
||||
ja
|
||||
</Radio>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default SyncWizardModal;
|
||||
324
web/src/components/table/models/modals/UpstreamConflictModal.jsx
Normal file
324
web/src/components/table/models/modals/UpstreamConflictModal.jsx
Normal file
@@ -0,0 +1,324 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useMemo, useState, useCallback } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Table,
|
||||
Checkbox,
|
||||
Typography,
|
||||
Empty,
|
||||
Tag,
|
||||
Popover,
|
||||
Input,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { MousePointerClick } from 'lucide-react';
|
||||
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
||||
import { MODEL_TABLE_PAGE_SIZE } from '../../../../constants';
|
||||
import { IconSearch } from '@douyinfe/semi-icons';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const FIELD_LABELS = {
|
||||
description: '描述',
|
||||
icon: '图标',
|
||||
tags: '标签',
|
||||
vendor: '供应商',
|
||||
name_rule: '命名规则',
|
||||
status: '状态',
|
||||
};
|
||||
const FIELD_KEYS = Object.keys(FIELD_LABELS);
|
||||
|
||||
const UpstreamConflictModal = ({
|
||||
visible,
|
||||
onClose,
|
||||
conflicts = [],
|
||||
onSubmit,
|
||||
t,
|
||||
loading = false,
|
||||
}) => {
|
||||
const [selections, setSelections] = useState({});
|
||||
const isMobile = useIsMobile();
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
|
||||
const formatValue = (v) => {
|
||||
if (v === null || v === undefined) return '-';
|
||||
if (typeof v === 'string') return v || '-';
|
||||
try {
|
||||
return JSON.stringify(v, null, 2);
|
||||
} catch (_) {
|
||||
return String(v);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
const init = {};
|
||||
conflicts.forEach((item) => {
|
||||
init[item.model_name] = new Set();
|
||||
});
|
||||
setSelections(init);
|
||||
setCurrentPage(1);
|
||||
setSearchKeyword('');
|
||||
} else {
|
||||
setSelections({});
|
||||
}
|
||||
}, [visible, conflicts]);
|
||||
|
||||
const toggleField = useCallback((modelName, field, checked) => {
|
||||
setSelections((prev) => {
|
||||
const next = { ...prev };
|
||||
const set = new Set(next[modelName] || []);
|
||||
if (checked) set.add(field);
|
||||
else set.delete(field);
|
||||
next[modelName] = set;
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 构造数据源与过滤后的数据源
|
||||
const dataSource = useMemo(
|
||||
() =>
|
||||
(conflicts || []).map((c) => ({
|
||||
key: c.model_name,
|
||||
model_name: c.model_name,
|
||||
fields: c.fields || [],
|
||||
})),
|
||||
[conflicts],
|
||||
);
|
||||
|
||||
const filteredDataSource = useMemo(() => {
|
||||
const kw = (searchKeyword || '').toLowerCase();
|
||||
if (!kw) return dataSource;
|
||||
return dataSource.filter((item) =>
|
||||
(item.model_name || '').toLowerCase().includes(kw),
|
||||
);
|
||||
}, [dataSource, searchKeyword]);
|
||||
|
||||
// 列头工具:当前过滤范围内可操作的行集合/勾选状态/批量设置
|
||||
const getPresentRowsForField = useCallback(
|
||||
(fieldKey) =>
|
||||
(filteredDataSource || []).filter((row) =>
|
||||
(row.fields || []).some((f) => f.field === fieldKey),
|
||||
),
|
||||
[filteredDataSource],
|
||||
);
|
||||
|
||||
const getHeaderState = useCallback(
|
||||
(fieldKey) => {
|
||||
const presentRows = getPresentRowsForField(fieldKey);
|
||||
const selectedCount = presentRows.filter((row) =>
|
||||
selections[row.model_name]?.has(fieldKey),
|
||||
).length;
|
||||
const allCount = presentRows.length;
|
||||
return {
|
||||
headerChecked: allCount > 0 && selectedCount === allCount,
|
||||
headerIndeterminate: selectedCount > 0 && selectedCount < allCount,
|
||||
hasAny: allCount > 0,
|
||||
};
|
||||
},
|
||||
[getPresentRowsForField, selections],
|
||||
);
|
||||
|
||||
const applyHeaderChange = useCallback(
|
||||
(fieldKey, checked) => {
|
||||
setSelections((prev) => {
|
||||
const next = { ...prev };
|
||||
getPresentRowsForField(fieldKey).forEach((row) => {
|
||||
const set = new Set(next[row.model_name] || []);
|
||||
if (checked) set.add(fieldKey);
|
||||
else set.delete(fieldKey);
|
||||
next[row.model_name] = set;
|
||||
});
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[getPresentRowsForField],
|
||||
);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
const base = [
|
||||
{
|
||||
title: t('模型'),
|
||||
dataIndex: 'model_name',
|
||||
fixed: 'left',
|
||||
render: (text) => <Text strong>{text}</Text>,
|
||||
},
|
||||
];
|
||||
|
||||
const cols = FIELD_KEYS.map((fieldKey) => {
|
||||
const rawLabel = FIELD_LABELS[fieldKey] || fieldKey;
|
||||
const label = t(rawLabel);
|
||||
|
||||
const { headerChecked, headerIndeterminate, hasAny } =
|
||||
getHeaderState(fieldKey);
|
||||
if (!hasAny) return null;
|
||||
const onHeaderChange = (e) =>
|
||||
applyHeaderChange(fieldKey, e?.target?.checked);
|
||||
|
||||
return {
|
||||
title: (
|
||||
<div className='flex items-center gap-2'>
|
||||
<Checkbox
|
||||
checked={headerChecked}
|
||||
indeterminate={headerIndeterminate}
|
||||
onChange={onHeaderChange}
|
||||
/>
|
||||
<Text>{label}</Text>
|
||||
</div>
|
||||
),
|
||||
dataIndex: fieldKey,
|
||||
render: (_, record) => {
|
||||
const f = (record.fields || []).find((x) => x.field === fieldKey);
|
||||
if (!f) return <Text type='tertiary'>-</Text>;
|
||||
const checked = selections[record.model_name]?.has(fieldKey) || false;
|
||||
return (
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onChange={(e) =>
|
||||
toggleField(record.model_name, fieldKey, e?.target?.checked)
|
||||
}
|
||||
>
|
||||
<Popover
|
||||
trigger='hover'
|
||||
position='top'
|
||||
content={
|
||||
<div className='p-2 max-w-[520px]'>
|
||||
<div className='mb-2'>
|
||||
<Text type='tertiary' size='small'>
|
||||
{t('本地')}
|
||||
</Text>
|
||||
<pre className='whitespace-pre-wrap m-0'>
|
||||
{formatValue(f.local)}
|
||||
</pre>
|
||||
</div>
|
||||
<div>
|
||||
<Text type='tertiary' size='small'>
|
||||
{t('官方')}
|
||||
</Text>
|
||||
<pre className='whitespace-pre-wrap m-0'>
|
||||
{formatValue(f.upstream)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Tag
|
||||
color='white'
|
||||
size='small'
|
||||
prefixIcon={<MousePointerClick size={14} />}
|
||||
>
|
||||
{t('点击查看差异')}
|
||||
</Tag>
|
||||
</Popover>
|
||||
</Checkbox>
|
||||
);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return [...base, ...cols.filter(Boolean)];
|
||||
}, [
|
||||
t,
|
||||
selections,
|
||||
filteredDataSource,
|
||||
getHeaderState,
|
||||
applyHeaderChange,
|
||||
toggleField,
|
||||
]);
|
||||
|
||||
const pagedDataSource = useMemo(() => {
|
||||
const start = (currentPage - 1) * MODEL_TABLE_PAGE_SIZE;
|
||||
const end = start + MODEL_TABLE_PAGE_SIZE;
|
||||
return filteredDataSource.slice(start, end);
|
||||
}, [filteredDataSource, currentPage]);
|
||||
|
||||
const handleOk = async () => {
|
||||
const payload = Object.entries(selections)
|
||||
.map(([modelName, set]) => ({
|
||||
model_name: modelName,
|
||||
fields: Array.from(set || []),
|
||||
}))
|
||||
.filter((x) => x.fields.length > 0);
|
||||
|
||||
const ok = await onSubmit?.(payload);
|
||||
if (ok) onClose?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('选择要覆盖的冲突项')}
|
||||
visible={visible}
|
||||
onCancel={onClose}
|
||||
onOk={handleOk}
|
||||
confirmLoading={loading}
|
||||
okText={t('应用覆盖')}
|
||||
cancelText={t('取消')}
|
||||
width={isMobile ? '100%' : 1000}
|
||||
>
|
||||
{dataSource.length === 0 ? (
|
||||
<Empty description={t('无冲突项')} className='p-6' />
|
||||
) : (
|
||||
<>
|
||||
<div className='mb-3 text-[var(--semi-color-text-2)]'>
|
||||
{t('仅会覆盖你勾选的字段,未勾选的字段保持本地不变。')}
|
||||
</div>
|
||||
{/* 搜索框 */}
|
||||
<div className='flex items-center justify-end gap-2 w-full mb-4'>
|
||||
<Input
|
||||
placeholder={t('搜索模型...')}
|
||||
value={searchKeyword}
|
||||
onChange={(v) => {
|
||||
setSearchKeyword(v);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className='!w-full'
|
||||
prefix={<IconSearch />}
|
||||
showClear
|
||||
/>
|
||||
</div>
|
||||
{filteredDataSource.length > 0 ? (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={pagedDataSource}
|
||||
pagination={{
|
||||
currentPage: currentPage,
|
||||
pageSize: MODEL_TABLE_PAGE_SIZE,
|
||||
total: filteredDataSource.length,
|
||||
showSizeChanger: false,
|
||||
onPageChange: (page) => setCurrentPage(page),
|
||||
}}
|
||||
scroll={{ x: 'max-content' }}
|
||||
/>
|
||||
) : (
|
||||
<Empty
|
||||
description={
|
||||
searchKeyword ? t('未找到匹配的模型') : t('无冲突项')
|
||||
}
|
||||
className='p-6'
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpstreamConflictModal;
|
||||
71
web/src/components/table/redemptions/RedemptionsActions.jsx
Normal file
71
web/src/components/table/redemptions/RedemptionsActions.jsx
Normal file
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Button } from '@douyinfe/semi-ui';
|
||||
|
||||
const RedemptionsActions = ({
|
||||
selectedKeys,
|
||||
setEditingRedemption,
|
||||
setShowEdit,
|
||||
batchCopyRedemptions,
|
||||
batchDeleteRedemptions,
|
||||
t,
|
||||
}) => {
|
||||
// Add new redemption code
|
||||
const handleAddRedemption = () => {
|
||||
setEditingRedemption({
|
||||
id: undefined,
|
||||
});
|
||||
setShowEdit(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='flex flex-wrap gap-2 w-full md:w-auto order-2 md:order-1'>
|
||||
<Button
|
||||
type='primary'
|
||||
className='flex-1 md:flex-initial'
|
||||
onClick={handleAddRedemption}
|
||||
size='small'
|
||||
>
|
||||
{t('添加兑换码')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type='tertiary'
|
||||
className='flex-1 md:flex-initial'
|
||||
onClick={batchCopyRedemptions}
|
||||
size='small'
|
||||
>
|
||||
{t('复制所选兑换码到剪贴板')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type='danger'
|
||||
className='w-full md:w-auto'
|
||||
onClick={batchDeleteRedemptions}
|
||||
size='small'
|
||||
>
|
||||
{t('清除失效兑换码')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RedemptionsActions;
|
||||
222
web/src/components/table/redemptions/RedemptionsColumnDefs.jsx
Normal file
222
web/src/components/table/redemptions/RedemptionsColumnDefs.jsx
Normal file
@@ -0,0 +1,222 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Tag, Button, Space, Popover, Dropdown } from '@douyinfe/semi-ui';
|
||||
import { IconMore } from '@douyinfe/semi-icons';
|
||||
import { renderQuota, timestamp2string } from '../../../helpers';
|
||||
import {
|
||||
REDEMPTION_STATUS,
|
||||
REDEMPTION_STATUS_MAP,
|
||||
REDEMPTION_ACTIONS,
|
||||
} from '../../../constants/redemption.constants';
|
||||
|
||||
/**
|
||||
* Check if redemption code is expired
|
||||
*/
|
||||
export const isExpired = (record) => {
|
||||
return (
|
||||
record.status === REDEMPTION_STATUS.UNUSED &&
|
||||
record.expired_time !== 0 &&
|
||||
record.expired_time < Math.floor(Date.now() / 1000)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Render timestamp
|
||||
*/
|
||||
const renderTimestamp = (timestamp) => {
|
||||
return <>{timestamp2string(timestamp)}</>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Render redemption code status
|
||||
*/
|
||||
const renderStatus = (status, record, t) => {
|
||||
if (isExpired(record)) {
|
||||
return (
|
||||
<Tag color='orange' shape='circle'>
|
||||
{t('已过期')}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
const statusConfig = REDEMPTION_STATUS_MAP[status];
|
||||
if (statusConfig) {
|
||||
return (
|
||||
<Tag color={statusConfig.color} shape='circle'>
|
||||
{t(statusConfig.text)}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tag color='black' shape='circle'>
|
||||
{t('未知状态')}
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get redemption code table column definitions
|
||||
*/
|
||||
export const getRedemptionsColumns = ({
|
||||
t,
|
||||
manageRedemption,
|
||||
copyText,
|
||||
setEditingRedemption,
|
||||
setShowEdit,
|
||||
refresh,
|
||||
redemptions,
|
||||
activePage,
|
||||
showDeleteRedemptionModal,
|
||||
}) => {
|
||||
return [
|
||||
{
|
||||
title: t('ID'),
|
||||
dataIndex: 'id',
|
||||
},
|
||||
{
|
||||
title: t('名称'),
|
||||
dataIndex: 'name',
|
||||
},
|
||||
{
|
||||
title: t('状态'),
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (text, record) => {
|
||||
return <div>{renderStatus(text, record, t)}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('额度'),
|
||||
dataIndex: 'quota',
|
||||
render: (text) => {
|
||||
return (
|
||||
<div>
|
||||
<Tag color='grey' shape='circle'>
|
||||
{renderQuota(parseInt(text))}
|
||||
</Tag>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('创建时间'),
|
||||
dataIndex: 'created_time',
|
||||
render: (text) => {
|
||||
return <div>{renderTimestamp(text)}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('过期时间'),
|
||||
dataIndex: 'expired_time',
|
||||
render: (text) => {
|
||||
return <div>{text === 0 ? t('永不过期') : renderTimestamp(text)}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('兑换人ID'),
|
||||
dataIndex: 'used_user_id',
|
||||
render: (text) => {
|
||||
return <div>{text === 0 ? t('无') : text}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
dataIndex: 'operate',
|
||||
fixed: 'right',
|
||||
width: 205,
|
||||
render: (text, record) => {
|
||||
// Create dropdown menu items for more operations
|
||||
const moreMenuItems = [
|
||||
{
|
||||
node: 'item',
|
||||
name: t('删除'),
|
||||
type: 'danger',
|
||||
onClick: () => {
|
||||
showDeleteRedemptionModal(record);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (record.status === REDEMPTION_STATUS.UNUSED && !isExpired(record)) {
|
||||
moreMenuItems.push({
|
||||
node: 'item',
|
||||
name: t('禁用'),
|
||||
type: 'warning',
|
||||
onClick: () => {
|
||||
manageRedemption(record.id, REDEMPTION_ACTIONS.DISABLE, record);
|
||||
},
|
||||
});
|
||||
} else if (!isExpired(record)) {
|
||||
moreMenuItems.push({
|
||||
node: 'item',
|
||||
name: t('启用'),
|
||||
type: 'secondary',
|
||||
onClick: () => {
|
||||
manageRedemption(record.id, REDEMPTION_ACTIONS.ENABLE, record);
|
||||
},
|
||||
disabled: record.status === REDEMPTION_STATUS.USED,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Space>
|
||||
<Popover
|
||||
content={record.key}
|
||||
style={{ padding: 20 }}
|
||||
position='top'
|
||||
>
|
||||
<Button type='tertiary' size='small'>
|
||||
{t('查看')}
|
||||
</Button>
|
||||
</Popover>
|
||||
<Button
|
||||
size='small'
|
||||
onClick={async () => {
|
||||
await copyText(record.key);
|
||||
}}
|
||||
>
|
||||
{t('复制')}
|
||||
</Button>
|
||||
<Button
|
||||
type='tertiary'
|
||||
size='small'
|
||||
onClick={() => {
|
||||
setEditingRedemption(record);
|
||||
setShowEdit(true);
|
||||
}}
|
||||
disabled={record.status !== REDEMPTION_STATUS.UNUSED}
|
||||
>
|
||||
{t('编辑')}
|
||||
</Button>
|
||||
<Dropdown
|
||||
trigger='click'
|
||||
position='bottomRight'
|
||||
menu={moreMenuItems}
|
||||
>
|
||||
<Button type='tertiary' size='small' icon={<IconMore />} />
|
||||
</Dropdown>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Typography } from '@douyinfe/semi-ui';
|
||||
import { Ticket } from 'lucide-react';
|
||||
import CompactModeToggle from '../../common/ui/CompactModeToggle';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const RedemptionsDescription = ({ compactMode, setCompactMode, t }) => {
|
||||
return (
|
||||
<div className='flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full'>
|
||||
<div className='flex items-center text-orange-500'>
|
||||
<Ticket size={16} className='mr-2' />
|
||||
<Text>{t('兑换码管理')}</Text>
|
||||
</div>
|
||||
|
||||
<CompactModeToggle
|
||||
compactMode={compactMode}
|
||||
setCompactMode={setCompactMode}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RedemptionsDescription;
|
||||
93
web/src/components/table/redemptions/RedemptionsFilters.jsx
Normal file
93
web/src/components/table/redemptions/RedemptionsFilters.jsx
Normal file
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useRef } from 'react';
|
||||
import { Form, Button } from '@douyinfe/semi-ui';
|
||||
import { IconSearch } from '@douyinfe/semi-icons';
|
||||
|
||||
const RedemptionsFilters = ({
|
||||
formInitValues,
|
||||
setFormApi,
|
||||
searchRedemptions,
|
||||
loading,
|
||||
searching,
|
||||
t,
|
||||
}) => {
|
||||
// Handle form reset and immediate search
|
||||
const formApiRef = useRef(null);
|
||||
|
||||
const handleReset = () => {
|
||||
if (!formApiRef.current) return;
|
||||
formApiRef.current.reset();
|
||||
setTimeout(() => {
|
||||
searchRedemptions();
|
||||
}, 100);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form
|
||||
initValues={formInitValues}
|
||||
getFormApi={(api) => {
|
||||
setFormApi(api);
|
||||
formApiRef.current = api;
|
||||
}}
|
||||
onSubmit={searchRedemptions}
|
||||
allowEmpty={true}
|
||||
autoComplete='off'
|
||||
layout='horizontal'
|
||||
trigger='change'
|
||||
stopValidateWithError={false}
|
||||
className='w-full md:w-auto order-1 md:order-2'
|
||||
>
|
||||
<div className='flex flex-col md:flex-row items-center gap-2 w-full md:w-auto'>
|
||||
<div className='relative w-full md:w-64'>
|
||||
<Form.Input
|
||||
field='searchKeyword'
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('关键字(id或者名称)')}
|
||||
showClear
|
||||
pure
|
||||
size='small'
|
||||
/>
|
||||
</div>
|
||||
<div className='flex gap-2 w-full md:w-auto'>
|
||||
<Button
|
||||
type='tertiary'
|
||||
htmlType='submit'
|
||||
loading={loading || searching}
|
||||
className='flex-1 md:flex-initial md:w-auto'
|
||||
size='small'
|
||||
>
|
||||
{t('查询')}
|
||||
</Button>
|
||||
<Button
|
||||
type='tertiary'
|
||||
onClick={handleReset}
|
||||
className='flex-1 md:flex-initial md:w-auto'
|
||||
size='small'
|
||||
>
|
||||
{t('重置')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default RedemptionsFilters;
|
||||
144
web/src/components/table/redemptions/RedemptionsTable.jsx
Normal file
144
web/src/components/table/redemptions/RedemptionsTable.jsx
Normal file
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Empty } from '@douyinfe/semi-ui';
|
||||
import CardTable from '../../common/ui/CardTable';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import { getRedemptionsColumns, isExpired } from './RedemptionsColumnDefs';
|
||||
import DeleteRedemptionModal from './modals/DeleteRedemptionModal';
|
||||
|
||||
const RedemptionsTable = (redemptionsData) => {
|
||||
const {
|
||||
redemptions,
|
||||
loading,
|
||||
activePage,
|
||||
pageSize,
|
||||
tokenCount,
|
||||
compactMode,
|
||||
handlePageChange,
|
||||
rowSelection,
|
||||
handleRow,
|
||||
manageRedemption,
|
||||
copyText,
|
||||
setEditingRedemption,
|
||||
setShowEdit,
|
||||
refresh,
|
||||
t,
|
||||
} = redemptionsData;
|
||||
|
||||
// Modal states
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [deletingRecord, setDeletingRecord] = useState(null);
|
||||
|
||||
// Handle show delete modal
|
||||
const showDeleteRedemptionModal = (record) => {
|
||||
setDeletingRecord(record);
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
// Get all columns
|
||||
const columns = useMemo(() => {
|
||||
return getRedemptionsColumns({
|
||||
t,
|
||||
manageRedemption,
|
||||
copyText,
|
||||
setEditingRedemption,
|
||||
setShowEdit,
|
||||
refresh,
|
||||
redemptions,
|
||||
activePage,
|
||||
showDeleteRedemptionModal,
|
||||
});
|
||||
}, [
|
||||
t,
|
||||
manageRedemption,
|
||||
copyText,
|
||||
setEditingRedemption,
|
||||
setShowEdit,
|
||||
refresh,
|
||||
redemptions,
|
||||
activePage,
|
||||
showDeleteRedemptionModal,
|
||||
]);
|
||||
|
||||
// Handle compact mode by removing fixed positioning
|
||||
const tableColumns = useMemo(() => {
|
||||
return compactMode
|
||||
? columns.map((col) => {
|
||||
if (col.dataIndex === 'operate') {
|
||||
const { fixed, ...rest } = col;
|
||||
return rest;
|
||||
}
|
||||
return col;
|
||||
})
|
||||
: columns;
|
||||
}, [compactMode, columns]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardTable
|
||||
columns={tableColumns}
|
||||
dataSource={redemptions}
|
||||
scroll={compactMode ? undefined : { x: 'max-content' }}
|
||||
pagination={{
|
||||
currentPage: activePage,
|
||||
pageSize: pageSize,
|
||||
total: tokenCount,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [10, 20, 50, 100],
|
||||
onPageSizeChange: redemptionsData.handlePageSizeChange,
|
||||
onPageChange: handlePageChange,
|
||||
}}
|
||||
hidePagination={true}
|
||||
loading={loading}
|
||||
rowSelection={rowSelection}
|
||||
onRow={handleRow}
|
||||
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'
|
||||
size='middle'
|
||||
/>
|
||||
|
||||
<DeleteRedemptionModal
|
||||
visible={showDeleteModal}
|
||||
onCancel={() => setShowDeleteModal(false)}
|
||||
record={deletingRecord}
|
||||
manageRedemption={manageRedemption}
|
||||
refresh={refresh}
|
||||
redemptions={redemptions}
|
||||
activePage={activePage}
|
||||
t={t}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default RedemptionsTable;
|
||||
122
web/src/components/table/redemptions/index.jsx
Normal file
122
web/src/components/table/redemptions/index.jsx
Normal file
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import CardPro from '../../common/ui/CardPro';
|
||||
import RedemptionsTable from './RedemptionsTable';
|
||||
import RedemptionsActions from './RedemptionsActions';
|
||||
import RedemptionsFilters from './RedemptionsFilters';
|
||||
import RedemptionsDescription from './RedemptionsDescription';
|
||||
import EditRedemptionModal from './modals/EditRedemptionModal';
|
||||
import { useRedemptionsData } from '../../../hooks/redemptions/useRedemptionsData';
|
||||
import { useIsMobile } from '../../../hooks/common/useIsMobile';
|
||||
import { createCardProPagination } from '../../../helpers/utils';
|
||||
|
||||
const RedemptionsPage = () => {
|
||||
const redemptionsData = useRedemptionsData();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const {
|
||||
// Edit state
|
||||
showEdit,
|
||||
editingRedemption,
|
||||
closeEdit,
|
||||
refresh,
|
||||
|
||||
// Actions state
|
||||
selectedKeys,
|
||||
setEditingRedemption,
|
||||
setShowEdit,
|
||||
batchCopyRedemptions,
|
||||
batchDeleteRedemptions,
|
||||
|
||||
// Filters state
|
||||
formInitValues,
|
||||
setFormApi,
|
||||
searchRedemptions,
|
||||
loading,
|
||||
searching,
|
||||
|
||||
// UI state
|
||||
compactMode,
|
||||
setCompactMode,
|
||||
|
||||
// Translation
|
||||
t,
|
||||
} = redemptionsData;
|
||||
|
||||
return (
|
||||
<>
|
||||
<EditRedemptionModal
|
||||
refresh={refresh}
|
||||
editingRedemption={editingRedemption}
|
||||
visiable={showEdit}
|
||||
handleClose={closeEdit}
|
||||
/>
|
||||
|
||||
<CardPro
|
||||
type='type1'
|
||||
descriptionArea={
|
||||
<RedemptionsDescription
|
||||
compactMode={compactMode}
|
||||
setCompactMode={setCompactMode}
|
||||
t={t}
|
||||
/>
|
||||
}
|
||||
actionsArea={
|
||||
<div className='flex flex-col md:flex-row justify-between items-center gap-2 w-full'>
|
||||
<RedemptionsActions
|
||||
selectedKeys={selectedKeys}
|
||||
setEditingRedemption={setEditingRedemption}
|
||||
setShowEdit={setShowEdit}
|
||||
batchCopyRedemptions={batchCopyRedemptions}
|
||||
batchDeleteRedemptions={batchDeleteRedemptions}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<div className='w-full md:w-full lg:w-auto order-1 md:order-2'>
|
||||
<RedemptionsFilters
|
||||
formInitValues={formInitValues}
|
||||
setFormApi={setFormApi}
|
||||
searchRedemptions={searchRedemptions}
|
||||
loading={loading}
|
||||
searching={searching}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
paginationArea={createCardProPagination({
|
||||
currentPage: redemptionsData.activePage,
|
||||
pageSize: redemptionsData.pageSize,
|
||||
total: redemptionsData.tokenCount,
|
||||
onPageChange: redemptionsData.handlePageChange,
|
||||
onPageSizeChange: redemptionsData.handlePageSizeChange,
|
||||
isMobile: isMobile,
|
||||
t: redemptionsData.t,
|
||||
})}
|
||||
t={redemptionsData.t}
|
||||
>
|
||||
<RedemptionsTable {...redemptionsData} />
|
||||
</CardPro>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default RedemptionsPage;
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Modal } from '@douyinfe/semi-ui';
|
||||
import { REDEMPTION_ACTIONS } from '../../../../constants/redemption.constants';
|
||||
|
||||
const DeleteRedemptionModal = ({
|
||||
visible,
|
||||
onCancel,
|
||||
record,
|
||||
manageRedemption,
|
||||
refresh,
|
||||
redemptions,
|
||||
activePage,
|
||||
t,
|
||||
}) => {
|
||||
const handleConfirm = async () => {
|
||||
await manageRedemption(record.id, REDEMPTION_ACTIONS.DELETE, record);
|
||||
await refresh();
|
||||
setTimeout(() => {
|
||||
if (redemptions.length === 0 && activePage > 1) {
|
||||
refresh(activePage - 1);
|
||||
}
|
||||
}, 100);
|
||||
onCancel(); // Close modal after success
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('确定是否要删除此兑换码?')}
|
||||
visible={visible}
|
||||
onCancel={onCancel}
|
||||
onOk={handleConfirm}
|
||||
type='warning'
|
||||
>
|
||||
{t('此修改将不可逆')}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteRedemptionModal;
|
||||
@@ -0,0 +1,353 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
API,
|
||||
downloadTextAsFile,
|
||||
showError,
|
||||
showSuccess,
|
||||
renderQuota,
|
||||
renderQuotaWithPrompt,
|
||||
} from '../../../../helpers';
|
||||
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
||||
import {
|
||||
Button,
|
||||
Modal,
|
||||
SideSheet,
|
||||
Space,
|
||||
Spin,
|
||||
Typography,
|
||||
Card,
|
||||
Tag,
|
||||
Form,
|
||||
Avatar,
|
||||
Row,
|
||||
Col,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconCreditCard,
|
||||
IconSave,
|
||||
IconClose,
|
||||
IconGift,
|
||||
} from '@douyinfe/semi-icons';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
const EditRedemptionModal = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const isEdit = props.editingRedemption.id !== undefined;
|
||||
const [loading, setLoading] = useState(isEdit);
|
||||
const isMobile = useIsMobile();
|
||||
const formApiRef = useRef(null);
|
||||
|
||||
const getInitValues = () => ({
|
||||
name: '',
|
||||
quota: 100000,
|
||||
count: 1,
|
||||
expired_time: null,
|
||||
});
|
||||
|
||||
const handleCancel = () => {
|
||||
props.handleClose();
|
||||
};
|
||||
|
||||
const loadRedemption = async () => {
|
||||
setLoading(true);
|
||||
let res = await API.get(`/api/redemption/${props.editingRedemption.id}`);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
if (data.expired_time === 0) {
|
||||
data.expired_time = null;
|
||||
} else {
|
||||
data.expired_time = new Date(data.expired_time * 1000);
|
||||
}
|
||||
formApiRef.current?.setValues({ ...getInitValues(), ...data });
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (formApiRef.current) {
|
||||
if (isEdit) {
|
||||
loadRedemption();
|
||||
} else {
|
||||
formApiRef.current.setValues(getInitValues());
|
||||
}
|
||||
}
|
||||
}, [props.editingRedemption.id]);
|
||||
|
||||
const submit = async (values) => {
|
||||
let name = values.name;
|
||||
if (!isEdit && (!name || name === '')) {
|
||||
name = renderQuota(values.quota);
|
||||
}
|
||||
setLoading(true);
|
||||
let localInputs = { ...values };
|
||||
localInputs.count = parseInt(localInputs.count) || 0;
|
||||
localInputs.quota = parseInt(localInputs.quota) || 0;
|
||||
localInputs.name = name;
|
||||
if (!localInputs.expired_time) {
|
||||
localInputs.expired_time = 0;
|
||||
} else {
|
||||
localInputs.expired_time = Math.floor(
|
||||
localInputs.expired_time.getTime() / 1000,
|
||||
);
|
||||
}
|
||||
let res;
|
||||
if (isEdit) {
|
||||
res = await API.put(`/api/redemption/`, {
|
||||
...localInputs,
|
||||
id: parseInt(props.editingRedemption.id),
|
||||
});
|
||||
} else {
|
||||
res = await API.post(`/api/redemption/`, {
|
||||
...localInputs,
|
||||
});
|
||||
}
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
if (isEdit) {
|
||||
showSuccess(t('兑换码更新成功!'));
|
||||
props.refresh();
|
||||
props.handleClose();
|
||||
} else {
|
||||
showSuccess(t('兑换码创建成功!'));
|
||||
props.refresh();
|
||||
formApiRef.current?.setValues(getInitValues());
|
||||
props.handleClose();
|
||||
}
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
if (!isEdit && data) {
|
||||
let text = '';
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
text += data[i] + '\n';
|
||||
}
|
||||
Modal.confirm({
|
||||
title: t('兑换码创建成功'),
|
||||
content: (
|
||||
<div>
|
||||
<p>{t('兑换码创建成功,是否下载兑换码?')}</p>
|
||||
<p>{t('兑换码将以文本文件的形式下载,文件名为兑换码的名称。')}</p>
|
||||
</div>
|
||||
),
|
||||
onOk: () => {
|
||||
downloadTextAsFile(text, `${localInputs.name}.txt`);
|
||||
},
|
||||
});
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SideSheet
|
||||
placement={isEdit ? 'right' : 'left'}
|
||||
title={
|
||||
<Space>
|
||||
{isEdit ? (
|
||||
<Tag color='blue' shape='circle'>
|
||||
{t('更新')}
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag color='green' shape='circle'>
|
||||
{t('新建')}
|
||||
</Tag>
|
||||
)}
|
||||
<Title heading={4} className='m-0'>
|
||||
{isEdit ? t('更新兑换码信息') : t('创建新的兑换码')}
|
||||
</Title>
|
||||
</Space>
|
||||
}
|
||||
bodyStyle={{ padding: '0' }}
|
||||
visible={props.visiable}
|
||||
width={isMobile ? '100%' : 600}
|
||||
footer={
|
||||
<div className='flex justify-end bg-white'>
|
||||
<Space>
|
||||
<Button
|
||||
theme='solid'
|
||||
onClick={() => formApiRef.current?.submitForm()}
|
||||
icon={<IconSave />}
|
||||
loading={loading}
|
||||
>
|
||||
{t('提交')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='light'
|
||||
type='primary'
|
||||
onClick={handleCancel}
|
||||
icon={<IconClose />}
|
||||
>
|
||||
{t('取消')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
closeIcon={null}
|
||||
onCancel={() => handleCancel()}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
<Form
|
||||
initValues={getInitValues()}
|
||||
getFormApi={(api) => (formApiRef.current = api)}
|
||||
onSubmit={submit}
|
||||
>
|
||||
{({ values }) => (
|
||||
<div className='p-2'>
|
||||
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
|
||||
{/* Header: Basic Info */}
|
||||
<div className='flex items-center mb-2'>
|
||||
<Avatar
|
||||
size='small'
|
||||
color='blue'
|
||||
className='mr-2 shadow-md'
|
||||
>
|
||||
<IconGift size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className='text-lg font-medium'>
|
||||
{t('基本信息')}
|
||||
</Text>
|
||||
<div className='text-xs text-gray-600'>
|
||||
{t('设置兑换码的基本信息')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Row gutter={12}>
|
||||
<Col span={24}>
|
||||
<Form.Input
|
||||
field='name'
|
||||
label={t('名称')}
|
||||
placeholder={t('请输入名称')}
|
||||
style={{ width: '100%' }}
|
||||
rules={
|
||||
!isEdit
|
||||
? []
|
||||
: [{ required: true, message: t('请输入名称') }]
|
||||
}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.DatePicker
|
||||
field='expired_time'
|
||||
label={t('过期时间')}
|
||||
type='dateTime'
|
||||
placeholder={t('选择过期时间(可选,留空为永久)')}
|
||||
style={{ width: '100%' }}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<Card className='!rounded-2xl shadow-sm border-0'>
|
||||
{/* Header: Quota Settings */}
|
||||
<div className='flex items-center mb-2'>
|
||||
<Avatar
|
||||
size='small'
|
||||
color='green'
|
||||
className='mr-2 shadow-md'
|
||||
>
|
||||
<IconCreditCard size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className='text-lg font-medium'>
|
||||
{t('额度设置')}
|
||||
</Text>
|
||||
<div className='text-xs text-gray-600'>
|
||||
{t('设置兑换码的额度和数量')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Row gutter={12}>
|
||||
<Col span={12}>
|
||||
<Form.AutoComplete
|
||||
field='quota'
|
||||
label={t('额度')}
|
||||
placeholder={t('请输入额度')}
|
||||
style={{ width: '100%' }}
|
||||
type='number'
|
||||
rules={[
|
||||
{ required: true, message: t('请输入额度') },
|
||||
{
|
||||
validator: (rule, v) => {
|
||||
const num = parseInt(v, 10);
|
||||
return num > 0
|
||||
? Promise.resolve()
|
||||
: Promise.reject(t('额度必须大于0'));
|
||||
},
|
||||
},
|
||||
]}
|
||||
extraText={renderQuotaWithPrompt(
|
||||
Number(values.quota) || 0,
|
||||
)}
|
||||
data={[
|
||||
{ value: 500000, label: '1$' },
|
||||
{ value: 5000000, label: '10$' },
|
||||
{ value: 25000000, label: '50$' },
|
||||
{ value: 50000000, label: '100$' },
|
||||
{ value: 250000000, label: '500$' },
|
||||
{ value: 500000000, label: '1000$' },
|
||||
]}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
{!isEdit && (
|
||||
<Col span={12}>
|
||||
<Form.InputNumber
|
||||
field='count'
|
||||
label={t('生成数量')}
|
||||
min={1}
|
||||
rules={[
|
||||
{ required: true, message: t('请输入生成数量') },
|
||||
{
|
||||
validator: (rule, v) => {
|
||||
const num = parseInt(v, 10);
|
||||
return num > 0
|
||||
? Promise.resolve()
|
||||
: Promise.reject(t('生成数量必须大于0'));
|
||||
},
|
||||
},
|
||||
]}
|
||||
style={{ width: '100%' }}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
</Spin>
|
||||
</SideSheet>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditRedemptionModal;
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Button } from '@douyinfe/semi-ui';
|
||||
|
||||
const SubscriptionsActions = ({ openCreate, t }) => {
|
||||
return (
|
||||
<div className='flex gap-2 w-full md:w-auto'>
|
||||
<Button
|
||||
type='primary'
|
||||
className='w-full md:w-auto'
|
||||
onClick={openCreate}
|
||||
size='small'
|
||||
>
|
||||
{t('新建套餐')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubscriptionsActions;
|
||||
@@ -0,0 +1,357 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Button,
|
||||
Modal,
|
||||
Space,
|
||||
Tag,
|
||||
Typography,
|
||||
Popover,
|
||||
Divider,
|
||||
Badge,
|
||||
Tooltip,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { renderQuota } from '../../../helpers';
|
||||
import { convertUSDToCurrency } from '../../../helpers/render';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
function formatDuration(plan, t) {
|
||||
if (!plan) return '';
|
||||
const u = plan.duration_unit || 'month';
|
||||
if (u === 'custom') {
|
||||
return `${t('自定义')} ${plan.custom_seconds || 0}s`;
|
||||
}
|
||||
const unitMap = {
|
||||
year: t('年'),
|
||||
month: t('月'),
|
||||
day: t('日'),
|
||||
hour: t('小时'),
|
||||
};
|
||||
return `${plan.duration_value || 0}${unitMap[u] || u}`;
|
||||
}
|
||||
|
||||
function formatResetPeriod(plan, t) {
|
||||
const period = plan?.quota_reset_period || 'never';
|
||||
if (period === 'daily') return t('每天');
|
||||
if (period === 'weekly') return t('每周');
|
||||
if (period === 'monthly') return t('每月');
|
||||
if (period === 'custom') {
|
||||
const seconds = Number(plan?.quota_reset_custom_seconds || 0);
|
||||
if (seconds >= 86400) return `${Math.floor(seconds / 86400)} ${t('天')}`;
|
||||
if (seconds >= 3600) return `${Math.floor(seconds / 3600)} ${t('小时')}`;
|
||||
if (seconds >= 60) return `${Math.floor(seconds / 60)} ${t('分钟')}`;
|
||||
return `${seconds} ${t('秒')}`;
|
||||
}
|
||||
return t('不重置');
|
||||
}
|
||||
|
||||
const renderPlanTitle = (text, record, t) => {
|
||||
const subtitle = record?.plan?.subtitle;
|
||||
const plan = record?.plan;
|
||||
const popoverContent = (
|
||||
<div style={{ width: 260 }}>
|
||||
<Text strong>{text}</Text>
|
||||
{subtitle && (
|
||||
<Text type='tertiary' style={{ display: 'block', marginTop: 4 }}>
|
||||
{subtitle}
|
||||
</Text>
|
||||
)}
|
||||
<Divider margin={12} />
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
||||
<Text type='tertiary'>{t('价格')}</Text>
|
||||
<Text strong style={{ color: 'var(--semi-color-success)' }}>
|
||||
{convertUSDToCurrency(Number(plan?.price_amount || 0), 2)}
|
||||
</Text>
|
||||
<Text type='tertiary'>{t('总额度')}</Text>
|
||||
{plan?.total_amount > 0 ? (
|
||||
<Tooltip content={`${t('原生额度')}:${plan.total_amount}`}>
|
||||
<Text>{renderQuota(plan.total_amount)}</Text>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Text>{t('不限')}</Text>
|
||||
)}
|
||||
<Text type='tertiary'>{t('升级分组')}</Text>
|
||||
<Text>{plan?.upgrade_group ? plan.upgrade_group : t('不升级')}</Text>
|
||||
<Text type='tertiary'>{t('购买上限')}</Text>
|
||||
<Text>
|
||||
{plan?.max_purchase_per_user > 0
|
||||
? plan.max_purchase_per_user
|
||||
: t('不限')}
|
||||
</Text>
|
||||
<Text type='tertiary'>{t('有效期')}</Text>
|
||||
<Text>{formatDuration(plan, t)}</Text>
|
||||
<Text type='tertiary'>{t('重置')}</Text>
|
||||
<Text>{formatResetPeriod(plan, t)}</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover content={popoverContent} position='rightTop' showArrow>
|
||||
<div style={{ cursor: 'pointer', maxWidth: 180 }}>
|
||||
<Text strong ellipsis={{ showTooltip: false }}>
|
||||
{text}
|
||||
</Text>
|
||||
{subtitle && (
|
||||
<Text
|
||||
type='tertiary'
|
||||
ellipsis={{ showTooltip: false }}
|
||||
style={{ display: 'block' }}
|
||||
>
|
||||
{subtitle}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
const renderPrice = (text) => {
|
||||
return (
|
||||
<Text strong style={{ color: 'var(--semi-color-success)' }}>
|
||||
{convertUSDToCurrency(Number(text || 0), 2)}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
const renderPurchaseLimit = (text, record, t) => {
|
||||
const limit = Number(record?.plan?.max_purchase_per_user || 0);
|
||||
return (
|
||||
<Text type={limit > 0 ? 'secondary' : 'tertiary'}>
|
||||
{limit > 0 ? limit : t('不限')}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
const renderDuration = (text, record, t) => {
|
||||
return <Text type='secondary'>{formatDuration(record?.plan, t)}</Text>;
|
||||
};
|
||||
|
||||
const renderEnabled = (text, record, t) => {
|
||||
return text ? (
|
||||
<Tag
|
||||
color='white'
|
||||
shape='circle'
|
||||
type='light'
|
||||
prefixIcon={<Badge dot type='success' />}
|
||||
>
|
||||
{t('启用')}
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag
|
||||
color='white'
|
||||
shape='circle'
|
||||
type='light'
|
||||
prefixIcon={<Badge dot type='danger' />}
|
||||
>
|
||||
{t('禁用')}
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
|
||||
const renderTotalAmount = (text, record, t) => {
|
||||
const total = Number(record?.plan?.total_amount || 0);
|
||||
return (
|
||||
<Text type={total > 0 ? 'secondary' : 'tertiary'}>
|
||||
{total > 0 ? (
|
||||
<Tooltip content={`${t('原生额度')}:${total}`}>
|
||||
<span>{renderQuota(total)}</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
t('不限')
|
||||
)}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
const renderUpgradeGroup = (text, record, t) => {
|
||||
const group = record?.plan?.upgrade_group || '';
|
||||
return (
|
||||
<Text type={group ? 'secondary' : 'tertiary'}>
|
||||
{group ? group : t('不升级')}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
const renderResetPeriod = (text, record, t) => {
|
||||
const period = record?.plan?.quota_reset_period || 'never';
|
||||
const isNever = period === 'never';
|
||||
return (
|
||||
<Text type={isNever ? 'tertiary' : 'secondary'}>
|
||||
{formatResetPeriod(record?.plan, t)}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
const renderPaymentConfig = (text, record, t, enableEpay) => {
|
||||
const hasStripe = !!record?.plan?.stripe_price_id;
|
||||
const hasCreem = !!record?.plan?.creem_product_id;
|
||||
const hasEpay = !!enableEpay;
|
||||
|
||||
return (
|
||||
<Space spacing={4}>
|
||||
{hasStripe && (
|
||||
<Tag color='violet' shape='circle'>
|
||||
Stripe
|
||||
</Tag>
|
||||
)}
|
||||
{hasCreem && (
|
||||
<Tag color='cyan' shape='circle'>
|
||||
Creem
|
||||
</Tag>
|
||||
)}
|
||||
{hasEpay && (
|
||||
<Tag color='light-green' shape='circle'>
|
||||
{t('易支付')}
|
||||
</Tag>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
|
||||
const renderOperations = (text, record, { openEdit, setPlanEnabled, t }) => {
|
||||
const isEnabled = record?.plan?.enabled;
|
||||
|
||||
const handleToggle = () => {
|
||||
if (isEnabled) {
|
||||
Modal.confirm({
|
||||
title: t('确认禁用'),
|
||||
content: t('禁用后用户端不再展示,但历史订单不受影响。是否继续?'),
|
||||
centered: true,
|
||||
onOk: () => setPlanEnabled(record, false),
|
||||
});
|
||||
} else {
|
||||
Modal.confirm({
|
||||
title: t('确认启用'),
|
||||
content: t('启用后套餐将在用户端展示。是否继续?'),
|
||||
centered: true,
|
||||
onOk: () => setPlanEnabled(record, true),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Space spacing={8}>
|
||||
<Button
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
size='small'
|
||||
onClick={() => openEdit(record)}
|
||||
>
|
||||
{t('编辑')}
|
||||
</Button>
|
||||
{isEnabled ? (
|
||||
<Button theme='light' type='danger' size='small' onClick={handleToggle}>
|
||||
{t('禁用')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
theme='light'
|
||||
type='primary'
|
||||
size='small'
|
||||
onClick={handleToggle}
|
||||
>
|
||||
{t('启用')}
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
|
||||
export const getSubscriptionsColumns = ({
|
||||
t,
|
||||
openEdit,
|
||||
setPlanEnabled,
|
||||
enableEpay,
|
||||
}) => {
|
||||
return [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: ['plan', 'id'],
|
||||
width: 60,
|
||||
render: (text) => <Text type='tertiary'>#{text}</Text>,
|
||||
},
|
||||
{
|
||||
title: t('套餐'),
|
||||
dataIndex: ['plan', 'title'],
|
||||
width: 200,
|
||||
render: (text, record) => renderPlanTitle(text, record, t),
|
||||
},
|
||||
{
|
||||
title: t('价格'),
|
||||
dataIndex: ['plan', 'price_amount'],
|
||||
width: 100,
|
||||
render: (text) => renderPrice(text),
|
||||
},
|
||||
{
|
||||
title: t('购买上限'),
|
||||
width: 90,
|
||||
render: (text, record) => renderPurchaseLimit(text, record, t),
|
||||
},
|
||||
{
|
||||
title: t('优先级'),
|
||||
dataIndex: ['plan', 'sort_order'],
|
||||
width: 80,
|
||||
render: (text) => <Text type='tertiary'>{Number(text || 0)}</Text>,
|
||||
},
|
||||
{
|
||||
title: t('有效期'),
|
||||
width: 100,
|
||||
render: (text, record) => renderDuration(text, record, t),
|
||||
},
|
||||
{
|
||||
title: t('重置'),
|
||||
width: 80,
|
||||
render: (text, record) => renderResetPeriod(text, record, t),
|
||||
},
|
||||
{
|
||||
title: t('状态'),
|
||||
dataIndex: ['plan', 'enabled'],
|
||||
width: 80,
|
||||
render: (text, record) => renderEnabled(text, record, t),
|
||||
},
|
||||
{
|
||||
title: t('支付渠道'),
|
||||
width: 180,
|
||||
render: (text, record) =>
|
||||
renderPaymentConfig(text, record, t, enableEpay),
|
||||
},
|
||||
{
|
||||
title: t('总额度'),
|
||||
width: 100,
|
||||
render: (text, record) => renderTotalAmount(text, record, t),
|
||||
},
|
||||
{
|
||||
title: t('升级分组'),
|
||||
width: 100,
|
||||
render: (text, record) => renderUpgradeGroup(text, record, t),
|
||||
},
|
||||
{
|
||||
title: t('操作'),
|
||||
dataIndex: 'operate',
|
||||
fixed: 'right',
|
||||
width: 160,
|
||||
render: (text, record) =>
|
||||
renderOperations(text, record, { openEdit, setPlanEnabled, t }),
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Typography } from '@douyinfe/semi-ui';
|
||||
import { CalendarClock } from 'lucide-react';
|
||||
import CompactModeToggle from '../../common/ui/CompactModeToggle';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const SubscriptionsDescription = ({ compactMode, setCompactMode, t }) => {
|
||||
return (
|
||||
<div className='flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full'>
|
||||
<div className='flex items-center text-blue-500'>
|
||||
<CalendarClock size={16} className='mr-2' />
|
||||
<Text>{t('订阅管理')}</Text>
|
||||
</div>
|
||||
|
||||
<CompactModeToggle
|
||||
compactMode={compactMode}
|
||||
setCompactMode={setCompactMode}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubscriptionsDescription;
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { Empty } from '@douyinfe/semi-ui';
|
||||
import CardTable from '../../common/ui/CardTable';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import { getSubscriptionsColumns } from './SubscriptionsColumnDefs';
|
||||
|
||||
const SubscriptionsTable = (subscriptionsData) => {
|
||||
const {
|
||||
plans,
|
||||
loading,
|
||||
compactMode,
|
||||
openEdit,
|
||||
setPlanEnabled,
|
||||
t,
|
||||
enableEpay,
|
||||
} = subscriptionsData;
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return getSubscriptionsColumns({
|
||||
t,
|
||||
openEdit,
|
||||
setPlanEnabled,
|
||||
enableEpay,
|
||||
});
|
||||
}, [t, openEdit, setPlanEnabled, enableEpay]);
|
||||
|
||||
const tableColumns = useMemo(() => {
|
||||
return compactMode
|
||||
? columns.map((col) => {
|
||||
if (col.dataIndex === 'operate') {
|
||||
const { fixed, ...rest } = col;
|
||||
return rest;
|
||||
}
|
||||
return col;
|
||||
})
|
||||
: columns;
|
||||
}, [compactMode, columns]);
|
||||
|
||||
return (
|
||||
<CardTable
|
||||
columns={tableColumns}
|
||||
dataSource={plans}
|
||||
scroll={compactMode ? undefined : { x: 'max-content' }}
|
||||
pagination={false}
|
||||
hidePagination={true}
|
||||
loading={loading}
|
||||
rowKey={(row) => row?.plan?.id}
|
||||
empty={
|
||||
<Empty
|
||||
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
|
||||
darkModeImage={
|
||||
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
|
||||
}
|
||||
description={t('暂无订阅套餐')}
|
||||
style={{ padding: 30 }}
|
||||
/>
|
||||
}
|
||||
className='overflow-hidden'
|
||||
size='middle'
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubscriptionsTable;
|
||||
103
web/src/components/table/subscriptions/index.jsx
Normal file
103
web/src/components/table/subscriptions/index.jsx
Normal file
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useContext } from 'react';
|
||||
import { Banner } from '@douyinfe/semi-ui';
|
||||
import CardPro from '../../common/ui/CardPro';
|
||||
import SubscriptionsTable from './SubscriptionsTable';
|
||||
import SubscriptionsActions from './SubscriptionsActions';
|
||||
import SubscriptionsDescription from './SubscriptionsDescription';
|
||||
import AddEditSubscriptionModal from './modals/AddEditSubscriptionModal';
|
||||
import { useSubscriptionsData } from '../../../hooks/subscriptions/useSubscriptionsData';
|
||||
import { useIsMobile } from '../../../hooks/common/useIsMobile';
|
||||
import { createCardProPagination } from '../../../helpers/utils';
|
||||
import { StatusContext } from '../../../context/Status';
|
||||
|
||||
const SubscriptionsPage = () => {
|
||||
const subscriptionsData = useSubscriptionsData();
|
||||
const isMobile = useIsMobile();
|
||||
const [statusState] = useContext(StatusContext);
|
||||
const enableEpay = !!statusState?.status?.enable_online_topup;
|
||||
|
||||
const {
|
||||
showEdit,
|
||||
editingPlan,
|
||||
sheetPlacement,
|
||||
closeEdit,
|
||||
refresh,
|
||||
openCreate,
|
||||
compactMode,
|
||||
setCompactMode,
|
||||
t,
|
||||
} = subscriptionsData;
|
||||
|
||||
return (
|
||||
<>
|
||||
<AddEditSubscriptionModal
|
||||
visible={showEdit}
|
||||
handleClose={closeEdit}
|
||||
editingPlan={editingPlan}
|
||||
placement={sheetPlacement}
|
||||
refresh={refresh}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<CardPro
|
||||
type='type1'
|
||||
descriptionArea={
|
||||
<SubscriptionsDescription
|
||||
compactMode={compactMode}
|
||||
setCompactMode={setCompactMode}
|
||||
t={t}
|
||||
/>
|
||||
}
|
||||
actionsArea={
|
||||
<div className='flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full'>
|
||||
{/* Mobile: actions first; Desktop: actions left */}
|
||||
<div className='order-1 md:order-0 w-full md:w-auto'>
|
||||
<SubscriptionsActions openCreate={openCreate} t={t} />
|
||||
</div>
|
||||
<Banner
|
||||
type='info'
|
||||
description={t('Stripe/Creem 需在第三方平台创建商品并填入 ID')}
|
||||
closeIcon={null}
|
||||
// Mobile: banner below; Desktop: banner right
|
||||
className='!rounded-lg order-2 md:order-1'
|
||||
style={{ maxWidth: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
paginationArea={createCardProPagination({
|
||||
currentPage: subscriptionsData.activePage,
|
||||
pageSize: subscriptionsData.pageSize,
|
||||
total: subscriptionsData.planCount,
|
||||
onPageChange: subscriptionsData.handlePageChange,
|
||||
onPageSizeChange: subscriptionsData.handlePageSizeChange,
|
||||
isMobile,
|
||||
t: subscriptionsData.t,
|
||||
})}
|
||||
t={t}
|
||||
>
|
||||
<SubscriptionsTable {...subscriptionsData} enableEpay={enableEpay} />
|
||||
</CardPro>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubscriptionsPage;
|
||||
@@ -0,0 +1,584 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Form,
|
||||
Row,
|
||||
Select,
|
||||
SideSheet,
|
||||
Space,
|
||||
Spin,
|
||||
Tag,
|
||||
Typography,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconCalendarClock,
|
||||
IconClose,
|
||||
IconCreditCard,
|
||||
IconSave,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { Clock, RefreshCw } from 'lucide-react';
|
||||
import { API, showError, showSuccess } from '../../../../helpers';
|
||||
import {
|
||||
quotaToDisplayAmount,
|
||||
displayAmountToQuota,
|
||||
} from '../../../../helpers/quota';
|
||||
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
const durationUnitOptions = [
|
||||
{ value: 'year', label: '年' },
|
||||
{ value: 'month', label: '月' },
|
||||
{ value: 'day', label: '日' },
|
||||
{ value: 'hour', label: '小时' },
|
||||
{ value: 'custom', label: '自定义(秒)' },
|
||||
];
|
||||
|
||||
const resetPeriodOptions = [
|
||||
{ value: 'never', label: '不重置' },
|
||||
{ value: 'daily', label: '每天' },
|
||||
{ value: 'weekly', label: '每周' },
|
||||
{ value: 'monthly', label: '每月' },
|
||||
{ value: 'custom', label: '自定义(秒)' },
|
||||
];
|
||||
|
||||
const AddEditSubscriptionModal = ({
|
||||
visible,
|
||||
handleClose,
|
||||
editingPlan,
|
||||
placement = 'left',
|
||||
refresh,
|
||||
t,
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [groupOptions, setGroupOptions] = useState([]);
|
||||
const [groupLoading, setGroupLoading] = useState(false);
|
||||
const [subGroupOptions, setSubGroupOptions] = useState([]);
|
||||
const isMobile = useIsMobile();
|
||||
const formApiRef = useRef(null);
|
||||
const isEdit = editingPlan?.plan?.id !== undefined;
|
||||
const formKey = isEdit ? `edit-${editingPlan?.plan?.id}` : 'create';
|
||||
|
||||
const getInitValues = () => ({
|
||||
title: '',
|
||||
subtitle: '',
|
||||
price_amount: 0,
|
||||
currency: 'USD',
|
||||
duration_unit: 'month',
|
||||
duration_value: 1,
|
||||
custom_seconds: 0,
|
||||
quota_reset_period: 'never',
|
||||
quota_reset_custom_seconds: 0,
|
||||
enabled: true,
|
||||
sort_order: 0,
|
||||
max_purchase_per_user: 0,
|
||||
total_amount: 0,
|
||||
upgrade_group: '',
|
||||
allowed_consume_groups: [],
|
||||
stripe_price_id: '',
|
||||
creem_product_id: '',
|
||||
});
|
||||
|
||||
const buildFormValues = () => {
|
||||
const base = getInitValues();
|
||||
if (editingPlan?.plan?.id === undefined) return base;
|
||||
const p = editingPlan.plan || {};
|
||||
return {
|
||||
...base,
|
||||
title: p.title || '',
|
||||
subtitle: p.subtitle || '',
|
||||
price_amount: Number(p.price_amount || 0),
|
||||
currency: 'USD',
|
||||
duration_unit: p.duration_unit || 'month',
|
||||
duration_value: Number(p.duration_value || 1),
|
||||
custom_seconds: Number(p.custom_seconds || 0),
|
||||
quota_reset_period: p.quota_reset_period || 'never',
|
||||
quota_reset_custom_seconds: Number(p.quota_reset_custom_seconds || 0),
|
||||
enabled: p.enabled !== false,
|
||||
sort_order: Number(p.sort_order || 0),
|
||||
max_purchase_per_user: Number(p.max_purchase_per_user || 0),
|
||||
total_amount: Number(
|
||||
quotaToDisplayAmount(p.total_amount || 0).toFixed(2),
|
||||
),
|
||||
upgrade_group: p.upgrade_group || '',
|
||||
allowed_consume_groups: (p.allowed_consume_groups || '').split(',').filter(Boolean),
|
||||
stripe_price_id: p.stripe_price_id || '',
|
||||
creem_product_id: p.creem_product_id || '',
|
||||
};
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
setGroupLoading(true);
|
||||
API.get('/api/group')
|
||||
.then((res) => {
|
||||
if (res.data?.success) {
|
||||
setGroupOptions(res.data?.data || []);
|
||||
} else {
|
||||
setGroupOptions([]);
|
||||
}
|
||||
})
|
||||
.catch(() => setGroupOptions([]))
|
||||
.finally(() => setGroupLoading(false));
|
||||
API.get("/api/subscription/admin/groups")
|
||||
.then((res) => {
|
||||
if (res.data?.success) {
|
||||
setSubGroupOptions(res.data?.data || []);
|
||||
} else {
|
||||
setSubGroupOptions([]);
|
||||
}
|
||||
})
|
||||
.catch(() => setSubGroupOptions([]));
|
||||
}, [visible]);
|
||||
|
||||
const submit = async (values) => {
|
||||
if (!values.title || values.title.trim() === '') {
|
||||
showError(t('套餐标题不能为空'));
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const payload = {
|
||||
plan: {
|
||||
...values,
|
||||
price_amount: Number(values.price_amount || 0),
|
||||
currency: 'USD',
|
||||
duration_value: Number(values.duration_value || 0),
|
||||
custom_seconds: Number(values.custom_seconds || 0),
|
||||
quota_reset_period: values.quota_reset_period || 'never',
|
||||
quota_reset_custom_seconds:
|
||||
values.quota_reset_period === 'custom'
|
||||
? Number(values.quota_reset_custom_seconds || 0)
|
||||
: 0,
|
||||
sort_order: Number(values.sort_order || 0),
|
||||
max_purchase_per_user: Number(values.max_purchase_per_user || 0),
|
||||
total_amount: displayAmountToQuota(values.total_amount),
|
||||
upgrade_group: values.upgrade_group || '',
|
||||
allowed_consume_groups: (values.allowed_consume_groups || []).join(','),
|
||||
},
|
||||
};
|
||||
if (editingPlan?.plan?.id) {
|
||||
const res = await API.put(
|
||||
`/api/subscription/admin/plans/${editingPlan.plan.id}`,
|
||||
payload,
|
||||
);
|
||||
if (res.data?.success) {
|
||||
showSuccess(t('更新成功'));
|
||||
handleClose();
|
||||
refresh?.();
|
||||
} else {
|
||||
showError(res.data?.message || t('更新失败'));
|
||||
}
|
||||
} else {
|
||||
const res = await API.post('/api/subscription/admin/plans', payload);
|
||||
if (res.data?.success) {
|
||||
showSuccess(t('创建成功'));
|
||||
handleClose();
|
||||
refresh?.();
|
||||
} else {
|
||||
showError(res.data?.message || t('创建失败'));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
showError(t('请求失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SideSheet
|
||||
placement={placement}
|
||||
title={
|
||||
<Space>
|
||||
{isEdit ? (
|
||||
<Tag color='blue' shape='circle'>
|
||||
{t('更新')}
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag color='green' shape='circle'>
|
||||
{t('新建')}
|
||||
</Tag>
|
||||
)}
|
||||
<Title heading={4} className='m-0'>
|
||||
{isEdit ? t('更新套餐信息') : t('创建新的订阅套餐')}
|
||||
</Title>
|
||||
</Space>
|
||||
}
|
||||
bodyStyle={{ padding: '0' }}
|
||||
visible={visible}
|
||||
width={isMobile ? '100%' : 600}
|
||||
footer={
|
||||
<div className='flex justify-end bg-white'>
|
||||
<Space>
|
||||
<Button
|
||||
theme='solid'
|
||||
onClick={() => formApiRef.current?.submitForm()}
|
||||
icon={<IconSave />}
|
||||
loading={loading}
|
||||
>
|
||||
{t('提交')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='light'
|
||||
type='primary'
|
||||
onClick={handleClose}
|
||||
icon={<IconClose />}
|
||||
>
|
||||
{t('取消')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
closeIcon={null}
|
||||
onCancel={handleClose}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
<Form
|
||||
key={formKey}
|
||||
initValues={buildFormValues()}
|
||||
getFormApi={(api) => (formApiRef.current = api)}
|
||||
onSubmit={submit}
|
||||
>
|
||||
{({ values }) => (
|
||||
<div className='p-2'>
|
||||
{/* 基本信息 */}
|
||||
<Card className='!rounded-2xl shadow-sm border-0 mb-4'>
|
||||
<div className='flex items-center mb-2'>
|
||||
<Avatar
|
||||
size='small'
|
||||
color='blue'
|
||||
className='mr-2 shadow-md'
|
||||
>
|
||||
<IconCalendarClock size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className='text-lg font-medium'>
|
||||
{t('基本信息')}
|
||||
</Text>
|
||||
<div className='text-xs text-gray-600'>
|
||||
{t('套餐的基本信息和定价')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Row gutter={12}>
|
||||
<Col span={24}>
|
||||
<Form.Input
|
||||
field='title'
|
||||
label={t('套餐标题')}
|
||||
placeholder={t('例如:基础套餐')}
|
||||
required
|
||||
rules={[
|
||||
{ required: true, message: t('请输入套餐标题') },
|
||||
]}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col span={24}>
|
||||
<Form.Input
|
||||
field='subtitle'
|
||||
label={t('套餐副标题')}
|
||||
placeholder={t('例如:适合轻度使用')}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
<Form.InputNumber
|
||||
field='price_amount'
|
||||
label={t('实付金额')}
|
||||
required
|
||||
min={0}
|
||||
precision={2}
|
||||
rules={[{ required: true, message: t('请输入金额') }]}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
<Form.InputNumber
|
||||
field='total_amount'
|
||||
label={t('总额度')}
|
||||
required
|
||||
min={0}
|
||||
precision={2}
|
||||
rules={[{ required: true, message: t('请输入总额度') }]}
|
||||
extraText={`${t('0 表示不限')} · ${t('原生额度')}:${displayAmountToQuota(
|
||||
values.total_amount,
|
||||
)}`}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
<Form.Select
|
||||
field='upgrade_group'
|
||||
label={t('升级分组')}
|
||||
showClear
|
||||
loading={groupLoading}
|
||||
placeholder={t('不升级')}
|
||||
extraText={t(
|
||||
'购买或手动新增订阅会升级到该分组;当套餐失效/过期或手动作废/删除后,将回退到升级前分组。回退不会立即生效,通常会有几分钟延迟。',
|
||||
)}
|
||||
>
|
||||
<Select.Option value=''>{t('不升级')}</Select.Option>
|
||||
{(groupOptions || []).map((g) => (
|
||||
<Select.Option key={g} value={g}>
|
||||
{g}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Form.Select>
|
||||
</Col>
|
||||
|
||||
|
||||
<Col span={12}>
|
||||
<Form.Select
|
||||
field='allowed_consume_groups'
|
||||
label={t('可消费分组')}
|
||||
multiple
|
||||
loading={groupLoading}
|
||||
placeholder={t('留空表示不限制')}
|
||||
extraText={t('仅用于订阅消费模式,令牌分组必须在此范围内')}
|
||||
>
|
||||
{(subGroupOptions || []).map((g) => (
|
||||
<Select.Option key={g} value={g}>
|
||||
{g}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Form.Select>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
<Form.Input
|
||||
field='currency'
|
||||
label={t('币种')}
|
||||
disabled
|
||||
extraText={t('由全站货币展示设置统一控制')}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
<Form.InputNumber
|
||||
field='sort_order'
|
||||
label={t('排序')}
|
||||
precision={0}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
<Form.InputNumber
|
||||
field='max_purchase_per_user'
|
||||
label={t('购买上限')}
|
||||
min={0}
|
||||
precision={0}
|
||||
extraText={t('0 表示不限')}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
<Form.Switch
|
||||
field='enabled'
|
||||
label={t('启用状态')}
|
||||
size='large'
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* 有效期设置 */}
|
||||
<Card className='!rounded-2xl shadow-sm border-0 mb-4'>
|
||||
<div className='flex items-center mb-2'>
|
||||
<Avatar
|
||||
size='small'
|
||||
color='green'
|
||||
className='mr-2 shadow-md'
|
||||
>
|
||||
<Clock size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className='text-lg font-medium'>
|
||||
{t('有效期设置')}
|
||||
</Text>
|
||||
<div className='text-xs text-gray-600'>
|
||||
{t('配置套餐的有效时长')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Row gutter={12}>
|
||||
<Col span={12}>
|
||||
<Form.Select
|
||||
field='duration_unit'
|
||||
label={t('有效期单位')}
|
||||
required
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
{durationUnitOptions.map((o) => (
|
||||
<Select.Option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Form.Select>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
{values.duration_unit === 'custom' ? (
|
||||
<Form.InputNumber
|
||||
field='custom_seconds'
|
||||
label={t('自定义秒数')}
|
||||
required
|
||||
min={1}
|
||||
precision={0}
|
||||
rules={[{ required: true, message: t('请输入秒数') }]}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
) : (
|
||||
<Form.InputNumber
|
||||
field='duration_value'
|
||||
label={t('有效期数值')}
|
||||
required
|
||||
min={1}
|
||||
precision={0}
|
||||
rules={[{ required: true, message: t('请输入数值') }]}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* 额度重置 */}
|
||||
<Card className='!rounded-2xl shadow-sm border-0 mb-4'>
|
||||
<div className='flex items-center mb-2'>
|
||||
<Avatar
|
||||
size='small'
|
||||
color='orange'
|
||||
className='mr-2 shadow-md'
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className='text-lg font-medium'>
|
||||
{t('额度重置')}
|
||||
</Text>
|
||||
<div className='text-xs text-gray-600'>
|
||||
{t('支持周期性重置套餐权益额度')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Row gutter={12}>
|
||||
<Col span={12}>
|
||||
<Form.Select
|
||||
field='quota_reset_period'
|
||||
label={t('重置周期')}
|
||||
>
|
||||
{resetPeriodOptions.map((o) => (
|
||||
<Select.Option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Form.Select>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
{values.quota_reset_period === 'custom' ? (
|
||||
<Form.InputNumber
|
||||
field='quota_reset_custom_seconds'
|
||||
label={t('自定义秒数')}
|
||||
required
|
||||
min={60}
|
||||
precision={0}
|
||||
rules={[{ required: true, message: t('请输入秒数') }]}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
) : (
|
||||
<Form.InputNumber
|
||||
field='quota_reset_custom_seconds'
|
||||
label={t('自定义秒数')}
|
||||
min={0}
|
||||
precision={0}
|
||||
style={{ width: '100%' }}
|
||||
disabled
|
||||
/>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* 第三方支付配置 */}
|
||||
<Card className='!rounded-2xl shadow-sm border-0 mb-4'>
|
||||
<div className='flex items-center mb-2'>
|
||||
<Avatar
|
||||
size='small'
|
||||
color='purple'
|
||||
className='mr-2 shadow-md'
|
||||
>
|
||||
<IconCreditCard size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className='text-lg font-medium'>
|
||||
{t('第三方支付配置')}
|
||||
</Text>
|
||||
<div className='text-xs text-gray-600'>
|
||||
{t('Stripe/Creem 商品ID(可选)')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Row gutter={12}>
|
||||
<Col span={24}>
|
||||
<Form.Input
|
||||
field='stripe_price_id'
|
||||
label='Stripe PriceId'
|
||||
placeholder='price_...'
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col span={24}>
|
||||
<Form.Input
|
||||
field='creem_product_id'
|
||||
label='Creem ProductId'
|
||||
placeholder='prod_...'
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
</Spin>
|
||||
</SideSheet>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddEditSubscriptionModal;
|
||||
43
web/src/components/table/task-logs/TaskLogsActions.jsx
Normal file
43
web/src/components/table/task-logs/TaskLogsActions.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Typography } from '@douyinfe/semi-ui';
|
||||
import { IconEyeOpened } from '@douyinfe/semi-icons';
|
||||
import CompactModeToggle from '../../common/ui/CompactModeToggle';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const TaskLogsActions = ({ compactMode, setCompactMode, t }) => {
|
||||
return (
|
||||
<div className='flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full'>
|
||||
<div className='flex items-center text-orange-500 mb-2 md:mb-0'>
|
||||
<IconEyeOpened className='mr-2' />
|
||||
<Text>{t('任务记录')}</Text>
|
||||
</div>
|
||||
<CompactModeToggle
|
||||
compactMode={compactMode}
|
||||
setCompactMode={setCompactMode}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskLogsActions;
|
||||
450
web/src/components/table/task-logs/TaskLogsColumnDefs.jsx
Normal file
450
web/src/components/table/task-logs/TaskLogsColumnDefs.jsx
Normal file
@@ -0,0 +1,450 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Progress, Tag, Tooltip, Typography } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
Music,
|
||||
FileText,
|
||||
HelpCircle,
|
||||
CheckCircle,
|
||||
Pause,
|
||||
Clock,
|
||||
Play,
|
||||
XCircle,
|
||||
Loader,
|
||||
List,
|
||||
Hash,
|
||||
Video,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
TASK_ACTION_FIRST_TAIL_GENERATE,
|
||||
TASK_ACTION_GENERATE,
|
||||
TASK_ACTION_REFERENCE_GENERATE,
|
||||
TASK_ACTION_TEXT_GENERATE,
|
||||
TASK_ACTION_REMIX_GENERATE,
|
||||
} from '../../../constants/common.constant';
|
||||
import { CHANNEL_OPTIONS } from '../../../constants/channel.constants';
|
||||
import { stringToColor } from '../../../helpers/render';
|
||||
import { Avatar, Space } from '@douyinfe/semi-ui';
|
||||
|
||||
const colors = [
|
||||
'amber',
|
||||
'blue',
|
||||
'cyan',
|
||||
'green',
|
||||
'grey',
|
||||
'indigo',
|
||||
'light-blue',
|
||||
'lime',
|
||||
'orange',
|
||||
'pink',
|
||||
'purple',
|
||||
'red',
|
||||
'teal',
|
||||
'violet',
|
||||
'yellow',
|
||||
];
|
||||
|
||||
// Render functions
|
||||
const renderTimestamp = (timestampInSeconds) => {
|
||||
const date = new Date(timestampInSeconds * 1000); // 从秒转换为毫秒
|
||||
|
||||
const year = date.getFullYear(); // 获取年份
|
||||
const month = ('0' + (date.getMonth() + 1)).slice(-2); // 获取月份,从0开始需要+1,并保证两位数
|
||||
const day = ('0' + date.getDate()).slice(-2); // 获取日期,并保证两位数
|
||||
const hours = ('0' + date.getHours()).slice(-2); // 获取小时,并保证两位数
|
||||
const minutes = ('0' + date.getMinutes()).slice(-2); // 获取分钟,并保证两位数
|
||||
const seconds = ('0' + date.getSeconds()).slice(-2); // 获取秒钟,并保证两位数
|
||||
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; // 格式化输出
|
||||
};
|
||||
|
||||
function renderDuration(submit_time, finishTime) {
|
||||
if (!submit_time || !finishTime) return 'N/A';
|
||||
const durationSec = finishTime - submit_time;
|
||||
const color = durationSec > 60 ? 'red' : 'green';
|
||||
|
||||
// 返回带有样式的颜色标签
|
||||
return (
|
||||
<Tag color={color} shape='circle'>
|
||||
{durationSec} s
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
const renderType = (type, t) => {
|
||||
switch (type) {
|
||||
case 'MUSIC':
|
||||
return (
|
||||
<Tag color='grey' shape='circle' prefixIcon={<Music size={14} />}>
|
||||
{t('生成音乐')}
|
||||
</Tag>
|
||||
);
|
||||
case 'LYRICS':
|
||||
return (
|
||||
<Tag color='pink' shape='circle' prefixIcon={<FileText size={14} />}>
|
||||
{t('生成歌词')}
|
||||
</Tag>
|
||||
);
|
||||
case TASK_ACTION_GENERATE:
|
||||
return (
|
||||
<Tag color='blue' shape='circle' prefixIcon={<Sparkles size={14} />}>
|
||||
{t('图生视频')}
|
||||
</Tag>
|
||||
);
|
||||
case TASK_ACTION_TEXT_GENERATE:
|
||||
return (
|
||||
<Tag color='blue' shape='circle' prefixIcon={<Sparkles size={14} />}>
|
||||
{t('文生视频')}
|
||||
</Tag>
|
||||
);
|
||||
case TASK_ACTION_FIRST_TAIL_GENERATE:
|
||||
return (
|
||||
<Tag color='blue' shape='circle' prefixIcon={<Sparkles size={14} />}>
|
||||
{t('首尾生视频')}
|
||||
</Tag>
|
||||
);
|
||||
case TASK_ACTION_REFERENCE_GENERATE:
|
||||
return (
|
||||
<Tag color='blue' shape='circle' prefixIcon={<Sparkles size={14} />}>
|
||||
{t('参照生视频')}
|
||||
</Tag>
|
||||
);
|
||||
case TASK_ACTION_REMIX_GENERATE:
|
||||
return (
|
||||
<Tag color='blue' shape='circle' prefixIcon={<Sparkles size={14} />}>
|
||||
{t('视频Remix')}
|
||||
</Tag>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Tag color='white' shape='circle' prefixIcon={<HelpCircle size={14} />}>
|
||||
{t('未知')}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const renderPlatform = (platform, t) => {
|
||||
let option = CHANNEL_OPTIONS.find(
|
||||
(opt) => String(opt.value) === String(platform),
|
||||
);
|
||||
if (option) {
|
||||
return (
|
||||
<Tag color={option.color} shape='circle'>
|
||||
{option.label}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
switch (platform) {
|
||||
case 'suno':
|
||||
return (
|
||||
<Tag color='green' shape='circle'>
|
||||
Suno
|
||||
</Tag>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Tag color='white' shape='circle'>
|
||||
{t('未知')}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const renderStatus = (type, t) => {
|
||||
switch (type) {
|
||||
case 'SUCCESS':
|
||||
return (
|
||||
<Tag
|
||||
color='green'
|
||||
shape='circle'
|
||||
prefixIcon={<CheckCircle size={14} />}
|
||||
>
|
||||
{t('成功')}
|
||||
</Tag>
|
||||
);
|
||||
case 'NOT_START':
|
||||
return (
|
||||
<Tag color='grey' shape='circle' prefixIcon={<Pause size={14} />}>
|
||||
{t('未启动')}
|
||||
</Tag>
|
||||
);
|
||||
case 'SUBMITTED':
|
||||
return (
|
||||
<Tag color='yellow' shape='circle' prefixIcon={<Clock size={14} />}>
|
||||
{t('队列中')}
|
||||
</Tag>
|
||||
);
|
||||
case 'IN_PROGRESS':
|
||||
return (
|
||||
<Tag color='blue' shape='circle' prefixIcon={<Play size={14} />}>
|
||||
{t('执行中')}
|
||||
</Tag>
|
||||
);
|
||||
case 'FAILURE':
|
||||
return (
|
||||
<Tag color='red' shape='circle' prefixIcon={<XCircle size={14} />}>
|
||||
{t('失败')}
|
||||
</Tag>
|
||||
);
|
||||
case 'QUEUED':
|
||||
return (
|
||||
<Tag color='orange' shape='circle' prefixIcon={<List size={14} />}>
|
||||
{t('排队中')}
|
||||
</Tag>
|
||||
);
|
||||
case 'UNKNOWN':
|
||||
return (
|
||||
<Tag color='white' shape='circle' prefixIcon={<HelpCircle size={14} />}>
|
||||
{t('未知')}
|
||||
</Tag>
|
||||
);
|
||||
case '':
|
||||
return (
|
||||
<Tag color='grey' shape='circle' prefixIcon={<Loader size={14} />}>
|
||||
{t('正在提交')}
|
||||
</Tag>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Tag color='white' shape='circle' prefixIcon={<HelpCircle size={14} />}>
|
||||
{t('未知')}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const getTaskLogsColumns = ({
|
||||
t,
|
||||
COLUMN_KEYS,
|
||||
copyText,
|
||||
openContentModal,
|
||||
isAdminUser,
|
||||
openVideoModal,
|
||||
openAudioModal,
|
||||
}) => {
|
||||
return [
|
||||
{
|
||||
key: COLUMN_KEYS.SUBMIT_TIME,
|
||||
title: t('提交时间'),
|
||||
dataIndex: 'submit_time',
|
||||
render: (text, record, index) => {
|
||||
return <div>{text ? renderTimestamp(text) : '-'}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.FINISH_TIME,
|
||||
title: t('结束时间'),
|
||||
dataIndex: 'finish_time',
|
||||
render: (text, record, index) => {
|
||||
return <div>{text ? renderTimestamp(text) : '-'}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.DURATION,
|
||||
title: t('花费时间'),
|
||||
dataIndex: 'finish_time',
|
||||
render: (finish, record) => {
|
||||
return <>{finish ? renderDuration(record.submit_time, finish) : '-'}</>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.CHANNEL,
|
||||
title: t('渠道'),
|
||||
dataIndex: 'channel_id',
|
||||
render: (text, record, index) => {
|
||||
return isAdminUser ? (
|
||||
<div>
|
||||
<Tag
|
||||
color={colors[parseInt(text) % colors.length]}
|
||||
size='large'
|
||||
shape='circle'
|
||||
onClick={() => {
|
||||
copyText(text);
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</Tag>
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.USERNAME,
|
||||
title: t('用户'),
|
||||
dataIndex: 'username',
|
||||
render: (userId, record, index) => {
|
||||
if (!isAdminUser) {
|
||||
return <></>;
|
||||
}
|
||||
const displayText = String(record.username || userId || '?');
|
||||
return (
|
||||
<Space>
|
||||
<Avatar
|
||||
size='extra-small'
|
||||
color={stringToColor(displayText)}
|
||||
>
|
||||
{displayText.slice(0, 1)}
|
||||
</Avatar>
|
||||
<Typography.Text>
|
||||
{displayText}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.PLATFORM,
|
||||
title: t('平台'),
|
||||
dataIndex: 'platform',
|
||||
render: (text, record, index) => {
|
||||
return <div>{renderPlatform(text, t)}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.TYPE,
|
||||
title: t('类型'),
|
||||
dataIndex: 'action',
|
||||
render: (text, record, index) => {
|
||||
return <div>{renderType(text, t)}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.TASK_ID,
|
||||
title: t('任务ID'),
|
||||
dataIndex: 'task_id',
|
||||
render: (text, record, index) => {
|
||||
return (
|
||||
<Typography.Text
|
||||
ellipsis={{ showTooltip: true }}
|
||||
onClick={() => {
|
||||
openContentModal(JSON.stringify(record, null, 2));
|
||||
}}
|
||||
>
|
||||
<div>{text}</div>
|
||||
</Typography.Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.TASK_STATUS,
|
||||
title: t('任务状态'),
|
||||
dataIndex: 'status',
|
||||
render: (text, record, index) => {
|
||||
return <div>{renderStatus(text, t)}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.PROGRESS,
|
||||
title: t('进度'),
|
||||
dataIndex: 'progress',
|
||||
render: (text, record, index) => {
|
||||
return (
|
||||
<div>
|
||||
{isNaN(text?.replace('%', '')) ? (
|
||||
text || '-'
|
||||
) : (
|
||||
<Progress
|
||||
stroke={
|
||||
record.status === 'FAILURE'
|
||||
? 'var(--semi-color-warning)'
|
||||
: null
|
||||
}
|
||||
percent={text ? parseInt(text.replace('%', '')) : 0}
|
||||
showInfo={true}
|
||||
aria-label='task progress'
|
||||
style={{ minWidth: '160px' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.FAIL_REASON,
|
||||
title: t('详情'),
|
||||
dataIndex: 'fail_reason',
|
||||
fixed: 'right',
|
||||
render: (text, record, index) => {
|
||||
// Suno audio preview
|
||||
const isSunoSuccess =
|
||||
record.platform === 'suno' &&
|
||||
record.status === 'SUCCESS' &&
|
||||
Array.isArray(record.data) &&
|
||||
record.data.some((c) => c.audio_url);
|
||||
if (isSunoSuccess) {
|
||||
return (
|
||||
<a
|
||||
href='#'
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
openAudioModal(record.data);
|
||||
}}
|
||||
>
|
||||
{t('点击预览音乐')}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
// 视频预览:优先使用 result_url,兼容旧数据 fail_reason 中的 URL
|
||||
const isVideoTask =
|
||||
record.action === TASK_ACTION_GENERATE ||
|
||||
record.action === TASK_ACTION_TEXT_GENERATE ||
|
||||
record.action === TASK_ACTION_FIRST_TAIL_GENERATE ||
|
||||
record.action === TASK_ACTION_REFERENCE_GENERATE ||
|
||||
record.action === TASK_ACTION_REMIX_GENERATE;
|
||||
const isSuccess = record.status === 'SUCCESS';
|
||||
const resultUrl = record.result_url;
|
||||
const hasResultUrl = typeof resultUrl === 'string' && /^https?:\/\//.test(resultUrl);
|
||||
if (isSuccess && isVideoTask && hasResultUrl) {
|
||||
return (
|
||||
<a
|
||||
href='#'
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
openVideoModal(resultUrl);
|
||||
}}
|
||||
>
|
||||
{t('点击预览视频')}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
if (!text) {
|
||||
return t('无');
|
||||
}
|
||||
return (
|
||||
<Typography.Text
|
||||
ellipsis={{ showTooltip: true }}
|
||||
style={{ width: 100 }}
|
||||
onClick={() => {
|
||||
openContentModal(text);
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</Typography.Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
131
web/src/components/table/task-logs/TaskLogsFilters.jsx
Normal file
131
web/src/components/table/task-logs/TaskLogsFilters.jsx
Normal file
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Button, Form } from '@douyinfe/semi-ui';
|
||||
import { IconSearch } from '@douyinfe/semi-icons';
|
||||
|
||||
import { DATE_RANGE_PRESETS } from '../../../constants/console.constants';
|
||||
|
||||
const TaskLogsFilters = ({
|
||||
formInitValues,
|
||||
setFormApi,
|
||||
refresh,
|
||||
setShowColumnSelector,
|
||||
formApi,
|
||||
loading,
|
||||
isAdminUser,
|
||||
t,
|
||||
}) => {
|
||||
return (
|
||||
<Form
|
||||
initValues={formInitValues}
|
||||
getFormApi={(api) => setFormApi(api)}
|
||||
onSubmit={refresh}
|
||||
allowEmpty={true}
|
||||
autoComplete='off'
|
||||
layout='vertical'
|
||||
trigger='change'
|
||||
stopValidateWithError={false}
|
||||
>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-2'>
|
||||
{/* 时间选择器 */}
|
||||
<div className='col-span-1 lg:col-span-2'>
|
||||
<Form.DatePicker
|
||||
field='dateRange'
|
||||
className='w-full'
|
||||
type='dateTimeRange'
|
||||
placeholder={[t('开始时间'), t('结束时间')]}
|
||||
showClear
|
||||
pure
|
||||
size='small'
|
||||
presets={DATE_RANGE_PRESETS.map((preset) => ({
|
||||
text: t(preset.text),
|
||||
start: preset.start(),
|
||||
end: preset.end(),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 任务 ID */}
|
||||
<Form.Input
|
||||
field='task_id'
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('任务 ID')}
|
||||
showClear
|
||||
pure
|
||||
size='small'
|
||||
/>
|
||||
|
||||
{/* 渠道 ID - 仅管理员可见 */}
|
||||
{isAdminUser && (
|
||||
<Form.Input
|
||||
field='channel_id'
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('渠道 ID')}
|
||||
showClear
|
||||
pure
|
||||
size='small'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮区域 */}
|
||||
<div className='flex justify-between items-center'>
|
||||
<div></div>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
type='tertiary'
|
||||
htmlType='submit'
|
||||
loading={loading}
|
||||
size='small'
|
||||
>
|
||||
{t('查询')}
|
||||
</Button>
|
||||
<Button
|
||||
type='tertiary'
|
||||
onClick={() => {
|
||||
if (formApi) {
|
||||
formApi.reset();
|
||||
// 重置后立即查询,使用setTimeout确保表单重置完成
|
||||
setTimeout(() => {
|
||||
refresh();
|
||||
}, 100);
|
||||
}
|
||||
}}
|
||||
size='small'
|
||||
>
|
||||
{t('重置')}
|
||||
</Button>
|
||||
<Button
|
||||
type='tertiary'
|
||||
onClick={() => setShowColumnSelector(true)}
|
||||
size='small'
|
||||
>
|
||||
{t('列设置')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskLogsFilters;
|
||||
112
web/src/components/table/task-logs/TaskLogsTable.jsx
Normal file
112
web/src/components/table/task-logs/TaskLogsTable.jsx
Normal file
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { Empty } from '@douyinfe/semi-ui';
|
||||
import CardTable from '../../common/ui/CardTable';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import { getTaskLogsColumns } from './TaskLogsColumnDefs';
|
||||
|
||||
const TaskLogsTable = (taskLogsData) => {
|
||||
const {
|
||||
logs,
|
||||
loading,
|
||||
activePage,
|
||||
pageSize,
|
||||
logCount,
|
||||
compactMode,
|
||||
visibleColumns,
|
||||
handlePageChange,
|
||||
handlePageSizeChange,
|
||||
copyText,
|
||||
openContentModal,
|
||||
openVideoModal,
|
||||
openAudioModal,
|
||||
showUserInfoFunc,
|
||||
isAdminUser,
|
||||
t,
|
||||
COLUMN_KEYS,
|
||||
} = taskLogsData;
|
||||
|
||||
// Get all columns
|
||||
const allColumns = useMemo(() => {
|
||||
return getTaskLogsColumns({
|
||||
t,
|
||||
COLUMN_KEYS,
|
||||
copyText,
|
||||
openContentModal,
|
||||
openVideoModal,
|
||||
openAudioModal,
|
||||
showUserInfoFunc,
|
||||
isAdminUser,
|
||||
});
|
||||
}, [t, COLUMN_KEYS, copyText, openContentModal, openVideoModal, openAudioModal, showUserInfoFunc, isAdminUser]);
|
||||
|
||||
// Filter columns based on visibility settings
|
||||
const getVisibleColumns = () => {
|
||||
return allColumns.filter((column) => visibleColumns[column.key]);
|
||||
};
|
||||
|
||||
const visibleColumnsList = useMemo(() => {
|
||||
return getVisibleColumns();
|
||||
}, [visibleColumns, allColumns]);
|
||||
|
||||
const tableColumns = useMemo(() => {
|
||||
return compactMode
|
||||
? visibleColumnsList.map(({ fixed, ...rest }) => rest)
|
||||
: visibleColumnsList;
|
||||
}, [compactMode, visibleColumnsList]);
|
||||
|
||||
return (
|
||||
<CardTable
|
||||
columns={tableColumns}
|
||||
dataSource={logs}
|
||||
rowKey='key'
|
||||
loading={loading}
|
||||
scroll={compactMode ? undefined : { x: 'max-content' }}
|
||||
className='rounded-xl overflow-hidden'
|
||||
size='middle'
|
||||
empty={
|
||||
<Empty
|
||||
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
|
||||
darkModeImage={
|
||||
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
|
||||
}
|
||||
description={t('搜索无结果')}
|
||||
style={{ padding: 30 }}
|
||||
/>
|
||||
}
|
||||
pagination={{
|
||||
currentPage: activePage,
|
||||
pageSize: pageSize,
|
||||
total: logCount,
|
||||
pageSizeOptions: [10, 20, 50, 100],
|
||||
showSizeChanger: true,
|
||||
onPageSizeChange: handlePageSizeChange,
|
||||
onPageChange: handlePageChange,
|
||||
}}
|
||||
hidePagination={true}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskLogsTable;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user