mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-04-28 00:28:37 +00:00
- Add success notification when testing a single channel - Only show notification for individual channel test, not for batch model testing - Include channel name and response time in the notification message - Keep error notifications for both single and batch testing scenarios This improves user feedback when manually testing channel connectivity.
1860 lines
55 KiB
JavaScript
1860 lines
55 KiB
JavaScript
import React, { useEffect, useState } from 'react';
|
||
import {
|
||
API,
|
||
showError,
|
||
showInfo,
|
||
showSuccess,
|
||
timestamp2string,
|
||
} from '../helpers';
|
||
|
||
import { CHANNEL_OPTIONS, ITEMS_PER_PAGE } from '../constants';
|
||
import {
|
||
renderGroup,
|
||
renderNumberWithPoint,
|
||
renderQuota,
|
||
} from '../helpers/render';
|
||
import {
|
||
Button,
|
||
Divider,
|
||
Dropdown,
|
||
Input,
|
||
InputNumber,
|
||
Modal,
|
||
Space,
|
||
SplitButtonGroup,
|
||
Switch,
|
||
Table,
|
||
Tag,
|
||
Tooltip,
|
||
Typography,
|
||
Checkbox,
|
||
Card,
|
||
Select,
|
||
} from '@douyinfe/semi-ui';
|
||
import EditChannel from '../pages/Channel/EditChannel';
|
||
import {
|
||
IconList,
|
||
IconTreeTriangleDown,
|
||
IconFilter,
|
||
IconPlus,
|
||
IconRefresh,
|
||
IconSetting,
|
||
IconSearch,
|
||
IconEdit,
|
||
IconDelete,
|
||
IconStop,
|
||
IconPlay,
|
||
IconMore,
|
||
IconCopy,
|
||
IconSmallTriangleRight
|
||
} from '@douyinfe/semi-icons';
|
||
import { loadChannelModels } from './utils.js';
|
||
import EditTagModal from '../pages/Channel/EditTagModal.js';
|
||
import { useTranslation } from 'react-i18next';
|
||
|
||
const ChannelsTable = () => {
|
||
const { t } = useTranslation();
|
||
|
||
let type2label = undefined;
|
||
|
||
const renderType = (type) => {
|
||
if (!type2label) {
|
||
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' };
|
||
}
|
||
return (
|
||
<Tag size='large' color={type2label[type]?.color} shape='circle'>
|
||
{type2label[type]?.label}
|
||
</Tag>
|
||
);
|
||
};
|
||
|
||
const renderTagType = () => {
|
||
return (
|
||
<Tag
|
||
color='light-blue'
|
||
prefixIcon={<IconList />}
|
||
size='large'
|
||
shape='circle'
|
||
type='light'
|
||
>
|
||
{t('标签聚合')}
|
||
</Tag>
|
||
);
|
||
};
|
||
|
||
const renderStatus = (status) => {
|
||
switch (status) {
|
||
case 1:
|
||
return (
|
||
<Tag size='large' color='green' shape='circle'>
|
||
{t('已启用')}
|
||
</Tag>
|
||
);
|
||
case 2:
|
||
return (
|
||
<Tag size='large' color='yellow' shape='circle'>
|
||
{t('已禁用')}
|
||
</Tag>
|
||
);
|
||
case 3:
|
||
return (
|
||
<Tag size='large' color='yellow' shape='circle'>
|
||
{t('自动禁用')}
|
||
</Tag>
|
||
);
|
||
default:
|
||
return (
|
||
<Tag size='large' color='grey' shape='circle'>
|
||
{t('未知状态')}
|
||
</Tag>
|
||
);
|
||
}
|
||
};
|
||
|
||
const renderResponseTime = (responseTime) => {
|
||
let time = responseTime / 1000;
|
||
time = time.toFixed(2) + t(' 秒');
|
||
if (responseTime === 0) {
|
||
return (
|
||
<Tag size='large' color='grey' shape='circle'>
|
||
{t('未测试')}
|
||
</Tag>
|
||
);
|
||
} else if (responseTime <= 1000) {
|
||
return (
|
||
<Tag size='large' color='green' shape='circle'>
|
||
{time}
|
||
</Tag>
|
||
);
|
||
} else if (responseTime <= 3000) {
|
||
return (
|
||
<Tag size='large' color='lime' shape='circle'>
|
||
{time}
|
||
</Tag>
|
||
);
|
||
} else if (responseTime <= 5000) {
|
||
return (
|
||
<Tag size='large' color='yellow' shape='circle'>
|
||
{time}
|
||
</Tag>
|
||
);
|
||
} else {
|
||
return (
|
||
<Tag size='large' color='red' shape='circle'>
|
||
{time}
|
||
</Tag>
|
||
);
|
||
}
|
||
};
|
||
|
||
// Define column keys for selection
|
||
const COLUMN_KEYS = {
|
||
ID: 'id',
|
||
NAME: 'name',
|
||
GROUP: 'group',
|
||
TYPE: 'type',
|
||
STATUS: 'status',
|
||
RESPONSE_TIME: 'response_time',
|
||
BALANCE: 'balance',
|
||
PRIORITY: 'priority',
|
||
WEIGHT: 'weight',
|
||
OPERATE: 'operate',
|
||
};
|
||
|
||
// State for column visibility
|
||
const [visibleColumns, setVisibleColumns] = useState({});
|
||
const [showColumnSelector, setShowColumnSelector] = useState(false);
|
||
|
||
// Load saved column preferences from localStorage
|
||
useEffect(() => {
|
||
const savedColumns = localStorage.getItem('channels-table-columns');
|
||
if (savedColumns) {
|
||
try {
|
||
const parsed = JSON.parse(savedColumns);
|
||
// Make sure all columns are accounted for
|
||
const defaults = getDefaultColumnVisibility();
|
||
const merged = { ...defaults, ...parsed };
|
||
setVisibleColumns(merged);
|
||
} catch (e) {
|
||
console.error('Failed to parse saved column preferences', e);
|
||
initDefaultColumns();
|
||
}
|
||
} else {
|
||
initDefaultColumns();
|
||
}
|
||
}, []);
|
||
|
||
// Update table when column visibility changes
|
||
useEffect(() => {
|
||
if (Object.keys(visibleColumns).length > 0) {
|
||
// Save to localStorage
|
||
localStorage.setItem(
|
||
'channels-table-columns',
|
||
JSON.stringify(visibleColumns),
|
||
);
|
||
}
|
||
}, [visibleColumns]);
|
||
|
||
// Get default column visibility
|
||
const getDefaultColumnVisibility = () => {
|
||
return {
|
||
[COLUMN_KEYS.ID]: true,
|
||
[COLUMN_KEYS.NAME]: true,
|
||
[COLUMN_KEYS.GROUP]: true,
|
||
[COLUMN_KEYS.TYPE]: true,
|
||
[COLUMN_KEYS.STATUS]: true,
|
||
[COLUMN_KEYS.RESPONSE_TIME]: true,
|
||
[COLUMN_KEYS.BALANCE]: true,
|
||
[COLUMN_KEYS.PRIORITY]: true,
|
||
[COLUMN_KEYS.WEIGHT]: true,
|
||
[COLUMN_KEYS.OPERATE]: true,
|
||
};
|
||
};
|
||
|
||
// Initialize default column visibility
|
||
const initDefaultColumns = () => {
|
||
const defaults = getDefaultColumnVisibility();
|
||
setVisibleColumns(defaults);
|
||
};
|
||
|
||
// Handle column visibility change
|
||
const handleColumnVisibilityChange = (columnKey, checked) => {
|
||
const updatedColumns = { ...visibleColumns, [columnKey]: checked };
|
||
setVisibleColumns(updatedColumns);
|
||
};
|
||
|
||
// Handle "Select All" checkbox
|
||
const handleSelectAll = (checked) => {
|
||
const allKeys = Object.keys(COLUMN_KEYS).map((key) => COLUMN_KEYS[key]);
|
||
const updatedColumns = {};
|
||
|
||
allKeys.forEach((key) => {
|
||
updatedColumns[key] = checked;
|
||
});
|
||
|
||
setVisibleColumns(updatedColumns);
|
||
};
|
||
|
||
// Define all columns with keys
|
||
const allColumns = [
|
||
{
|
||
key: COLUMN_KEYS.ID,
|
||
title: t('ID'),
|
||
dataIndex: 'id',
|
||
},
|
||
{
|
||
key: COLUMN_KEYS.NAME,
|
||
title: t('名称'),
|
||
dataIndex: 'name',
|
||
},
|
||
{
|
||
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)}</>;
|
||
} else {
|
||
return <>{renderTagType()}</>;
|
||
}
|
||
},
|
||
},
|
||
{
|
||
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)}
|
||
</Tooltip>
|
||
</div>
|
||
);
|
||
} else {
|
||
return renderStatus(text);
|
||
}
|
||
},
|
||
},
|
||
{
|
||
key: COLUMN_KEYS.RESPONSE_TIME,
|
||
title: t('响应时间'),
|
||
dataIndex: 'response_time',
|
||
render: (text, record, index) => (
|
||
<div>{renderResponseTime(text)}</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' size='large' shape='circle'>
|
||
{renderQuota(record.used_quota)}
|
||
</Tag>
|
||
</Tooltip>
|
||
<Tooltip content={t('剩余额度') + record.balance + t(',点击更新')}>
|
||
<Tag
|
||
color='white'
|
||
type='ghost'
|
||
size='large'
|
||
shape='circle'
|
||
onClick={() => updateChannelBalance(record)}
|
||
>
|
||
${renderNumberWithPoint(record.balance)}
|
||
</Tag>
|
||
</Tooltip>
|
||
</Space>
|
||
</div>
|
||
);
|
||
} else {
|
||
return (
|
||
<Tooltip content={t('已用额度')}>
|
||
<Tag color='white' type='ghost' size='large' 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',
|
||
render: (text, record, index) => {
|
||
if (record.children === undefined) {
|
||
// 创建更多操作的下拉菜单项
|
||
const moreMenuItems = [
|
||
{
|
||
node: 'item',
|
||
name: t('删除'),
|
||
icon: <IconDelete />,
|
||
type: 'danger',
|
||
onClick: () => {
|
||
Modal.confirm({
|
||
title: t('确定是否要删除此渠道?'),
|
||
content: t('此修改将不可逆'),
|
||
onOk: () => {
|
||
manageChannel(record.id, 'delete', record).then(() => {
|
||
removeRecord(record);
|
||
});
|
||
},
|
||
});
|
||
},
|
||
},
|
||
{
|
||
node: 'item',
|
||
name: t('复制'),
|
||
icon: <IconCopy />,
|
||
type: 'primary',
|
||
onClick: () => {
|
||
Modal.confirm({
|
||
title: t('确定是否要复制此渠道?'),
|
||
content: t('复制渠道的所有信息'),
|
||
onOk: () => copySelectedChannel(record),
|
||
});
|
||
},
|
||
},
|
||
];
|
||
|
||
return (
|
||
<Space wrap>
|
||
<SplitButtonGroup
|
||
className="!rounded-full overflow-hidden"
|
||
aria-label={t('测试单个渠道操作项目组')}
|
||
>
|
||
<Button
|
||
theme='light'
|
||
size="small"
|
||
onClick={() => testChannel(record, '')}
|
||
>
|
||
{t('测试')}
|
||
</Button>
|
||
<Button
|
||
theme='light'
|
||
size="small"
|
||
icon={<IconTreeTriangleDown />}
|
||
onClick={() => {
|
||
setCurrentTestChannel(record);
|
||
setShowModelTestModal(true);
|
||
}}
|
||
/>
|
||
</SplitButtonGroup>
|
||
|
||
{record.status === 1 ? (
|
||
<Button
|
||
theme='light'
|
||
type='warning'
|
||
size="small"
|
||
className="!rounded-full"
|
||
icon={<IconStop />}
|
||
onClick={() => manageChannel(record.id, 'disable', record)}
|
||
>
|
||
{t('禁用')}
|
||
</Button>
|
||
) : (
|
||
<Button
|
||
theme='light'
|
||
type='secondary'
|
||
size="small"
|
||
className="!rounded-full"
|
||
icon={<IconPlay />}
|
||
onClick={() => manageChannel(record.id, 'enable', record)}
|
||
>
|
||
{t('启用')}
|
||
</Button>
|
||
)}
|
||
|
||
<Button
|
||
theme='light'
|
||
type='tertiary'
|
||
size="small"
|
||
className="!rounded-full"
|
||
icon={<IconEdit />}
|
||
onClick={() => {
|
||
setEditingChannel(record);
|
||
setShowEdit(true);
|
||
}}
|
||
>
|
||
{t('编辑')}
|
||
</Button>
|
||
|
||
<Dropdown
|
||
trigger='click'
|
||
position='bottomRight'
|
||
menu={moreMenuItems}
|
||
>
|
||
<Button
|
||
icon={<IconMore />}
|
||
theme='light'
|
||
type='tertiary'
|
||
size="small"
|
||
className="!rounded-full"
|
||
/>
|
||
</Dropdown>
|
||
</Space>
|
||
);
|
||
} else {
|
||
// 标签操作的下拉菜单项
|
||
const tagMenuItems = [
|
||
{
|
||
node: 'item',
|
||
name: t('编辑'),
|
||
icon: <IconEdit />,
|
||
onClick: () => {
|
||
setShowEditTag(true);
|
||
setEditingTag(record.key);
|
||
},
|
||
},
|
||
];
|
||
|
||
return (
|
||
<Space wrap>
|
||
<Button
|
||
theme='light'
|
||
type='secondary'
|
||
size="small"
|
||
className="!rounded-full"
|
||
icon={<IconPlay />}
|
||
onClick={() => manageTag(record.key, 'enable')}
|
||
>
|
||
{t('启用全部')}
|
||
</Button>
|
||
<Button
|
||
theme='light'
|
||
type='warning'
|
||
size="small"
|
||
className="!rounded-full"
|
||
icon={<IconStop />}
|
||
onClick={() => manageTag(record.key, 'disable')}
|
||
>
|
||
{t('禁用全部')}
|
||
</Button>
|
||
<Dropdown
|
||
trigger='click'
|
||
position='bottomRight'
|
||
menu={tagMenuItems}
|
||
>
|
||
<Button
|
||
icon={<IconMore />}
|
||
theme='light'
|
||
type='tertiary'
|
||
size="small"
|
||
className="!rounded-full"
|
||
/>
|
||
</Dropdown>
|
||
</Space>
|
||
);
|
||
}
|
||
},
|
||
},
|
||
];
|
||
|
||
// Filter columns based on visibility settings
|
||
const getVisibleColumns = () => {
|
||
return allColumns.filter((column) => visibleColumns[column.key]);
|
||
};
|
||
|
||
// Column selector modal
|
||
const renderColumnSelector = () => {
|
||
return (
|
||
<Modal
|
||
title={t('列设置')}
|
||
visible={showColumnSelector}
|
||
onCancel={() => setShowColumnSelector(false)}
|
||
footer={
|
||
<div className="flex justify-end">
|
||
<Button
|
||
theme="light"
|
||
onClick={() => initDefaultColumns()}
|
||
className="!rounded-full"
|
||
>
|
||
{t('重置')}
|
||
</Button>
|
||
<Button
|
||
theme="light"
|
||
onClick={() => setShowColumnSelector(false)}
|
||
className="!rounded-full"
|
||
>
|
||
{t('取消')}
|
||
</Button>
|
||
<Button
|
||
type='primary'
|
||
onClick={() => setShowColumnSelector(false)}
|
||
className="!rounded-full"
|
||
>
|
||
{t('确定')}
|
||
</Button>
|
||
</div>
|
||
}
|
||
size="middle"
|
||
centered={true}
|
||
>
|
||
<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>
|
||
);
|
||
};
|
||
|
||
const [channels, setChannels] = useState([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [activePage, setActivePage] = useState(1);
|
||
const [idSort, setIdSort] = useState(false);
|
||
const [searchKeyword, setSearchKeyword] = useState('');
|
||
const [searchGroup, setSearchGroup] = useState('');
|
||
const [searchModel, setSearchModel] = useState('');
|
||
const [searching, setSearching] = useState(false);
|
||
const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
|
||
const [channelCount, setChannelCount] = useState(pageSize);
|
||
const [groupOptions, setGroupOptions] = useState([]);
|
||
const [showEdit, setShowEdit] = useState(false);
|
||
const [enableBatchDelete, setEnableBatchDelete] = useState(false);
|
||
const [editingChannel, setEditingChannel] = useState({
|
||
id: undefined,
|
||
});
|
||
const [showEditTag, setShowEditTag] = useState(false);
|
||
const [editingTag, setEditingTag] = useState('');
|
||
const [selectedChannels, setSelectedChannels] = useState([]);
|
||
const [enableTagMode, setEnableTagMode] = useState(false);
|
||
const [showBatchSetTag, setShowBatchSetTag] = useState(false);
|
||
const [batchSetTagValue, setBatchSetTagValue] = useState('');
|
||
const [showModelTestModal, setShowModelTestModal] = useState(false);
|
||
const [currentTestChannel, setCurrentTestChannel] = useState(null);
|
||
const [modelSearchKeyword, setModelSearchKeyword] = useState('');
|
||
const [modelTestResults, setModelTestResults] = useState({});
|
||
const [testingModels, setTestingModels] = useState(new Set());
|
||
const [isBatchTesting, setIsBatchTesting] = useState(false);
|
||
const [testQueue, setTestQueue] = useState([]);
|
||
const [isProcessingQueue, setIsProcessingQueue] = useState(false);
|
||
|
||
const removeRecord = (record) => {
|
||
let newDataSource = [...channels];
|
||
if (record.id != null) {
|
||
let idx = newDataSource.findIndex((data) => {
|
||
if (data.children !== undefined) {
|
||
for (let i = 0; i < data.children.length; i++) {
|
||
if (data.children[i].id === record.id) {
|
||
data.children.splice(i, 1);
|
||
return false;
|
||
}
|
||
}
|
||
} else {
|
||
return data.id === record.id;
|
||
}
|
||
});
|
||
|
||
if (idx > -1) {
|
||
newDataSource.splice(idx, 1);
|
||
setChannels(newDataSource);
|
||
}
|
||
}
|
||
};
|
||
|
||
const setChannelFormat = (channels, enableTagMode) => {
|
||
let channelDates = [];
|
||
let channelTags = {};
|
||
for (let i = 0; i < channels.length; i++) {
|
||
channels[i].key = '' + channels[i].id;
|
||
if (!enableTagMode) {
|
||
channelDates.push(channels[i]);
|
||
} else {
|
||
let tag = channels[i].tag ? channels[i].tag : '';
|
||
// find from channelTags
|
||
let tagIndex = channelTags[tag];
|
||
let tagChannelDates = undefined;
|
||
if (tagIndex === undefined) {
|
||
// not found, create a new tag
|
||
channelTags[tag] = 1;
|
||
tagChannelDates = {
|
||
key: tag,
|
||
id: tag,
|
||
tag: tag,
|
||
name: '标签:' + tag,
|
||
group: '',
|
||
used_quota: 0,
|
||
response_time: 0,
|
||
priority: -1,
|
||
weight: -1,
|
||
};
|
||
tagChannelDates.children = [];
|
||
channelDates.push(tagChannelDates);
|
||
} else {
|
||
// found, add to the tag
|
||
tagChannelDates = channelDates.find((item) => item.key === tag);
|
||
}
|
||
if (tagChannelDates.priority === -1) {
|
||
tagChannelDates.priority = channels[i].priority;
|
||
} else {
|
||
if (tagChannelDates.priority !== channels[i].priority) {
|
||
tagChannelDates.priority = '';
|
||
}
|
||
}
|
||
if (tagChannelDates.weight === -1) {
|
||
tagChannelDates.weight = channels[i].weight;
|
||
} else {
|
||
if (tagChannelDates.weight !== channels[i].weight) {
|
||
tagChannelDates.weight = '';
|
||
}
|
||
}
|
||
|
||
if (tagChannelDates.group === '') {
|
||
tagChannelDates.group = channels[i].group;
|
||
} else {
|
||
let channelGroupsStr = channels[i].group;
|
||
channelGroupsStr.split(',').forEach((item, index) => {
|
||
if (tagChannelDates.group.indexOf(item) === -1) {
|
||
// join
|
||
tagChannelDates.group += ',' + item;
|
||
}
|
||
});
|
||
}
|
||
|
||
tagChannelDates.children.push(channels[i]);
|
||
if (channels[i].status === 1) {
|
||
tagChannelDates.status = 1;
|
||
}
|
||
tagChannelDates.used_quota += channels[i].used_quota;
|
||
tagChannelDates.response_time += channels[i].response_time;
|
||
tagChannelDates.response_time = tagChannelDates.response_time / 2;
|
||
}
|
||
}
|
||
// data.key = '' + data.id
|
||
setChannels(channelDates);
|
||
if (channelDates.length >= pageSize) {
|
||
setChannelCount(channelDates.length + pageSize);
|
||
} else {
|
||
setChannelCount(channelDates.length);
|
||
}
|
||
};
|
||
|
||
const loadChannels = async (startIdx, pageSize, idSort, enableTagMode) => {
|
||
setLoading(true);
|
||
const res = await API.get(
|
||
`/api/channel/?p=${startIdx}&page_size=${pageSize}&id_sort=${idSort}&tag_mode=${enableTagMode}`,
|
||
);
|
||
if (res === undefined) {
|
||
return;
|
||
}
|
||
const { success, message, data } = res.data;
|
||
if (success) {
|
||
if (startIdx === 0) {
|
||
setChannelFormat(data, enableTagMode);
|
||
} else {
|
||
let newChannels = [...channels];
|
||
newChannels.splice(startIdx * pageSize, data.length, ...data);
|
||
setChannelFormat(newChannels, enableTagMode);
|
||
}
|
||
} else {
|
||
showError(message);
|
||
}
|
||
setLoading(false);
|
||
};
|
||
|
||
const copySelectedChannel = async (record) => {
|
||
const channelToCopy = record;
|
||
channelToCopy.name += t('_复制');
|
||
channelToCopy.created_time = null;
|
||
channelToCopy.balance = 0;
|
||
channelToCopy.used_quota = 0;
|
||
if (!channelToCopy) {
|
||
showError(t('渠道未找到,请刷新页面后重试。'));
|
||
return;
|
||
}
|
||
try {
|
||
const newChannel = { ...channelToCopy, id: undefined };
|
||
const response = await API.post('/api/channel/', newChannel);
|
||
if (response.data.success) {
|
||
showSuccess(t('渠道复制成功'));
|
||
await refresh();
|
||
} else {
|
||
showError(response.data.message);
|
||
}
|
||
} catch (error) {
|
||
showError(t('渠道复制失败: ') + error.message);
|
||
}
|
||
};
|
||
|
||
const refresh = async () => {
|
||
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
|
||
await loadChannels(activePage - 1, pageSize, idSort, enableTagMode);
|
||
} else {
|
||
await searchChannels(
|
||
searchKeyword,
|
||
searchGroup,
|
||
searchModel,
|
||
enableTagMode,
|
||
);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
// console.log('default effect')
|
||
const localIdSort = localStorage.getItem('id-sort') === 'true';
|
||
const localPageSize =
|
||
parseInt(localStorage.getItem('page-size')) || ITEMS_PER_PAGE;
|
||
setIdSort(localIdSort);
|
||
setPageSize(localPageSize);
|
||
loadChannels(0, localPageSize, localIdSort, enableTagMode)
|
||
.then()
|
||
.catch((reason) => {
|
||
showError(reason);
|
||
});
|
||
fetchGroups().then();
|
||
loadChannelModels().then();
|
||
}, []);
|
||
|
||
const manageChannel = async (id, action, record, value) => {
|
||
let data = { id };
|
||
let res;
|
||
switch (action) {
|
||
case 'delete':
|
||
res = await API.delete(`/api/channel/${id}/`);
|
||
break;
|
||
case 'enable':
|
||
data.status = 1;
|
||
res = await API.put('/api/channel/', data);
|
||
break;
|
||
case 'disable':
|
||
data.status = 2;
|
||
res = await API.put('/api/channel/', data);
|
||
break;
|
||
case 'priority':
|
||
if (value === '') {
|
||
return;
|
||
}
|
||
data.priority = parseInt(value);
|
||
res = await API.put('/api/channel/', data);
|
||
break;
|
||
case 'weight':
|
||
if (value === '') {
|
||
return;
|
||
}
|
||
data.weight = parseInt(value);
|
||
if (data.weight < 0) {
|
||
data.weight = 0;
|
||
}
|
||
res = await API.put('/api/channel/', data);
|
||
break;
|
||
}
|
||
const { success, message } = res.data;
|
||
if (success) {
|
||
showSuccess(t('操作成功完成!'));
|
||
let channel = res.data.data;
|
||
let newChannels = [...channels];
|
||
if (action === 'delete') {
|
||
} else {
|
||
record.status = channel.status;
|
||
}
|
||
setChannels(newChannels);
|
||
} else {
|
||
showError(message);
|
||
}
|
||
};
|
||
|
||
const manageTag = async (tag, action) => {
|
||
console.log(tag, action);
|
||
let res;
|
||
switch (action) {
|
||
case 'enable':
|
||
res = await API.post('/api/channel/tag/enabled', {
|
||
tag: tag,
|
||
});
|
||
break;
|
||
case 'disable':
|
||
res = await API.post('/api/channel/tag/disabled', {
|
||
tag: tag,
|
||
});
|
||
break;
|
||
}
|
||
const { success, message } = res.data;
|
||
if (success) {
|
||
showSuccess('操作成功完成!');
|
||
let newChannels = [...channels];
|
||
for (let i = 0; i < newChannels.length; i++) {
|
||
if (newChannels[i].tag === tag) {
|
||
let status = action === 'enable' ? 1 : 2;
|
||
newChannels[i]?.children?.forEach((channel) => {
|
||
channel.status = status;
|
||
});
|
||
newChannels[i].status = status;
|
||
}
|
||
}
|
||
setChannels(newChannels);
|
||
} else {
|
||
showError(message);
|
||
}
|
||
};
|
||
|
||
const searchChannels = async (
|
||
searchKeyword,
|
||
searchGroup,
|
||
searchModel,
|
||
enableTagMode,
|
||
) => {
|
||
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
|
||
await loadChannels(activePage - 1, pageSize, idSort, enableTagMode);
|
||
// setActivePage(1);
|
||
return;
|
||
}
|
||
setSearching(true);
|
||
const res = await API.get(
|
||
`/api/channel/search?keyword=${searchKeyword}&group=${searchGroup}&model=${searchModel}&id_sort=${idSort}&tag_mode=${enableTagMode}`,
|
||
);
|
||
const { success, message, data } = res.data;
|
||
if (success) {
|
||
setChannelFormat(data, enableTagMode);
|
||
setActivePage(1);
|
||
} else {
|
||
showError(message);
|
||
}
|
||
setSearching(false);
|
||
};
|
||
|
||
const updateChannelProperty = (channelId, updateFn) => {
|
||
// Create a new copy of channels array
|
||
const newChannels = [...channels];
|
||
let updated = false;
|
||
|
||
// Find and update the correct channel
|
||
newChannels.forEach((channel) => {
|
||
if (channel.children !== undefined) {
|
||
// If this is a tag group, search in its children
|
||
channel.children.forEach((child) => {
|
||
if (child.id === channelId) {
|
||
updateFn(child);
|
||
updated = true;
|
||
}
|
||
});
|
||
} else if (channel.id === channelId) {
|
||
// Direct channel match
|
||
updateFn(channel);
|
||
updated = true;
|
||
}
|
||
});
|
||
|
||
// Only update state if we actually modified a channel
|
||
if (updated) {
|
||
setChannels(newChannels);
|
||
}
|
||
};
|
||
|
||
const processTestQueue = async () => {
|
||
if (!isProcessingQueue || testQueue.length === 0) return;
|
||
|
||
const { channel, model } = testQueue[0];
|
||
|
||
try {
|
||
setTestingModels(prev => new Set([...prev, model]));
|
||
const res = await API.get(`/api/channel/test/${channel.id}?model=${model}`);
|
||
const { success, message, time } = res.data;
|
||
|
||
setModelTestResults(prev => ({
|
||
...prev,
|
||
[`${channel.id}-${model}`]: { success, time }
|
||
}));
|
||
|
||
if (success) {
|
||
updateChannelProperty(channel.id, (ch) => {
|
||
ch.response_time = time * 1000;
|
||
ch.test_time = Date.now() / 1000;
|
||
});
|
||
if (!model) {
|
||
showInfo(
|
||
t('通道 ${name} 测试成功,耗时 ${time.toFixed(2)} 秒。')
|
||
.replace('${name}', channel.name)
|
||
.replace('${time.toFixed(2)}', time.toFixed(2)),
|
||
);
|
||
}
|
||
} else {
|
||
showError(message);
|
||
}
|
||
} catch (error) {
|
||
showError(error.message);
|
||
} finally {
|
||
setTestingModels(prev => {
|
||
const newSet = new Set(prev);
|
||
newSet.delete(model);
|
||
return newSet;
|
||
});
|
||
}
|
||
|
||
// 移除已处理的测试
|
||
setTestQueue(prev => prev.slice(1));
|
||
};
|
||
|
||
// 监听队列变化
|
||
useEffect(() => {
|
||
if (testQueue.length > 0 && isProcessingQueue) {
|
||
processTestQueue();
|
||
} else if (testQueue.length === 0 && isProcessingQueue) {
|
||
setIsProcessingQueue(false);
|
||
setIsBatchTesting(false);
|
||
}
|
||
}, [testQueue, isProcessingQueue]);
|
||
|
||
const testChannel = async (record, model) => {
|
||
setTestQueue(prev => [...prev, { channel: record, model }]);
|
||
if (!isProcessingQueue) {
|
||
setIsProcessingQueue(true);
|
||
}
|
||
};
|
||
|
||
const batchTestModels = async () => {
|
||
if (!currentTestChannel) return;
|
||
|
||
setIsBatchTesting(true);
|
||
|
||
const models = currentTestChannel.models
|
||
.split(',')
|
||
.filter((model) =>
|
||
model.toLowerCase().includes(modelSearchKeyword.toLowerCase())
|
||
);
|
||
|
||
setTestQueue(models.map(model => ({
|
||
channel: currentTestChannel,
|
||
model
|
||
})));
|
||
setIsProcessingQueue(true);
|
||
};
|
||
|
||
const handleCloseModal = () => {
|
||
if (isBatchTesting) {
|
||
// 清空测试队列来停止测试
|
||
setTestQueue([]);
|
||
setIsProcessingQueue(false);
|
||
setIsBatchTesting(false);
|
||
showSuccess(t('已停止测试'));
|
||
} else {
|
||
setShowModelTestModal(false);
|
||
setModelSearchKeyword('');
|
||
}
|
||
};
|
||
|
||
let pageData = channels.slice(
|
||
(activePage - 1) * pageSize,
|
||
activePage * pageSize,
|
||
);
|
||
|
||
const handlePageChange = (page) => {
|
||
setActivePage(page);
|
||
if (page === Math.ceil(channels.length / pageSize) + 1) {
|
||
// In this case we have to load more data and then append them.
|
||
loadChannels(page - 1, pageSize, idSort, enableTagMode).then((r) => { });
|
||
}
|
||
};
|
||
|
||
const handlePageSizeChange = async (size) => {
|
||
localStorage.setItem('page-size', size + '');
|
||
setPageSize(size);
|
||
setActivePage(1);
|
||
loadChannels(0, size, idSort, enableTagMode)
|
||
.then()
|
||
.catch((reason) => {
|
||
showError(reason);
|
||
});
|
||
};
|
||
|
||
const fetchGroups = async () => {
|
||
try {
|
||
let res = await API.get(`/api/group/`);
|
||
// add 'all' option
|
||
// res.data.data.unshift('all');
|
||
if (res === undefined) {
|
||
return;
|
||
}
|
||
setGroupOptions(
|
||
res.data.data.map((group) => ({
|
||
label: group,
|
||
value: group,
|
||
})),
|
||
);
|
||
} catch (error) {
|
||
showError(error.message);
|
||
}
|
||
};
|
||
|
||
const submitTagEdit = async (type, data) => {
|
||
switch (type) {
|
||
case 'priority':
|
||
if (data.priority === undefined || data.priority === '') {
|
||
showInfo('优先级必须是整数!');
|
||
return;
|
||
}
|
||
data.priority = parseInt(data.priority);
|
||
break;
|
||
case 'weight':
|
||
if (
|
||
data.weight === undefined ||
|
||
data.weight < 0 ||
|
||
data.weight === ''
|
||
) {
|
||
showInfo('权重必须是非负整数!');
|
||
return;
|
||
}
|
||
data.weight = parseInt(data.weight);
|
||
break;
|
||
}
|
||
|
||
try {
|
||
const res = await API.put('/api/channel/tag', data);
|
||
if (res?.data?.success) {
|
||
showSuccess('更新成功!');
|
||
await refresh();
|
||
}
|
||
} catch (error) {
|
||
showError(error);
|
||
}
|
||
};
|
||
|
||
const closeEdit = () => {
|
||
setShowEdit(false);
|
||
};
|
||
|
||
const handleRow = (record, index) => {
|
||
if (record.status !== 1) {
|
||
return {
|
||
style: {
|
||
background: 'var(--semi-color-disabled-border)',
|
||
},
|
||
};
|
||
} else {
|
||
return {};
|
||
}
|
||
};
|
||
|
||
const batchSetChannelTag = async () => {
|
||
if (selectedChannels.length === 0) {
|
||
showError(t('请先选择要设置标签的渠道!'));
|
||
return;
|
||
}
|
||
if (batchSetTagValue === '') {
|
||
showError(t('标签不能为空!'));
|
||
return;
|
||
}
|
||
let ids = selectedChannels.map((channel) => channel.id);
|
||
const res = await API.post('/api/channel/batch/tag', {
|
||
ids: ids,
|
||
tag: batchSetTagValue === '' ? null : batchSetTagValue,
|
||
});
|
||
if (res.data.success) {
|
||
showSuccess(
|
||
t('已为 ${count} 个渠道设置标签!').replace('${count}', res.data.data),
|
||
);
|
||
await refresh();
|
||
setShowBatchSetTag(false);
|
||
} else {
|
||
showError(res.data.message);
|
||
}
|
||
};
|
||
|
||
const testAllChannels = async () => {
|
||
const res = await API.get(`/api/channel/test`);
|
||
const { success, message } = res.data;
|
||
if (success) {
|
||
showInfo(t('已成功开始测试所有已启用通道,请刷新页面查看结果。'));
|
||
} else {
|
||
showError(message);
|
||
}
|
||
};
|
||
|
||
const deleteAllDisabledChannels = async () => {
|
||
const res = await API.delete(`/api/channel/disabled`);
|
||
const { success, message, data } = res.data;
|
||
if (success) {
|
||
showSuccess(
|
||
t('已删除所有禁用渠道,共计 ${data} 个').replace('${data}', data),
|
||
);
|
||
await refresh();
|
||
} else {
|
||
showError(message);
|
||
}
|
||
};
|
||
|
||
const updateAllChannelsBalance = async () => {
|
||
const res = await API.get(`/api/channel/update_balance`);
|
||
const { success, message } = res.data;
|
||
if (success) {
|
||
showInfo(t('已更新完毕所有已启用通道余额!'));
|
||
} else {
|
||
showError(message);
|
||
}
|
||
};
|
||
|
||
const updateChannelBalance = async (record) => {
|
||
const res = await API.get(`/api/channel/update_balance/${record.id}/`);
|
||
const { success, message, balance } = res.data;
|
||
if (success) {
|
||
updateChannelProperty(record.id, (channel) => {
|
||
channel.balance = balance;
|
||
channel.balance_updated_time = Date.now() / 1000;
|
||
});
|
||
showInfo(
|
||
t('通道 ${name} 余额更新成功!').replace('${name}', record.name),
|
||
);
|
||
} else {
|
||
showError(message);
|
||
}
|
||
};
|
||
|
||
const batchDeleteChannels = async () => {
|
||
if (selectedChannels.length === 0) {
|
||
showError(t('请先选择要删除的通道!'));
|
||
return;
|
||
}
|
||
setLoading(true);
|
||
let ids = [];
|
||
selectedChannels.forEach((channel) => {
|
||
ids.push(channel.id);
|
||
});
|
||
const res = await API.post(`/api/channel/batch`, { ids: ids });
|
||
const { success, message, data } = res.data;
|
||
if (success) {
|
||
showSuccess(t('已删除 ${data} 个通道!').replace('${data}', data));
|
||
await refresh();
|
||
} else {
|
||
showError(message);
|
||
}
|
||
setLoading(false);
|
||
};
|
||
|
||
const fixChannelsAbilities = async () => {
|
||
const res = await API.post(`/api/channel/fix`);
|
||
const { success, message, data } = res.data;
|
||
if (success) {
|
||
showSuccess(t('已修复 ${data} 个通道!').replace('${data}', data));
|
||
await refresh();
|
||
} else {
|
||
showError(message);
|
||
}
|
||
};
|
||
|
||
const renderHeader = () => (
|
||
<div className="flex flex-col w-full">
|
||
<div className="flex flex-col md:flex-row justify-between gap-4">
|
||
<div className="flex flex-wrap md:flex-nowrap items-center gap-2 w-full md:w-auto order-2 md:order-1">
|
||
<Button
|
||
disabled={!enableBatchDelete}
|
||
theme='light'
|
||
type='danger'
|
||
className="!rounded-full w-full md:w-auto"
|
||
onClick={() => {
|
||
Modal.confirm({
|
||
title: t('确定是否要删除所选通道?'),
|
||
content: t('此修改将不可逆'),
|
||
onOk: () => batchDeleteChannels(),
|
||
});
|
||
}}
|
||
>
|
||
{t('删除所选通道')}
|
||
</Button>
|
||
|
||
<Button
|
||
disabled={!enableBatchDelete}
|
||
theme='light'
|
||
type='primary'
|
||
onClick={() => setShowBatchSetTag(true)}
|
||
className="!rounded-full w-full md:w-auto"
|
||
>
|
||
{t('批量设置标签')}
|
||
</Button>
|
||
|
||
<Dropdown
|
||
trigger='click'
|
||
render={
|
||
<Dropdown.Menu>
|
||
<Dropdown.Item>
|
||
<Button
|
||
theme='light'
|
||
type='warning'
|
||
className="!rounded-full w-full"
|
||
onClick={() => {
|
||
Modal.confirm({
|
||
title: t('确定?'),
|
||
content: t('确定要测试所有通道吗?'),
|
||
onOk: () => testAllChannels(),
|
||
size: 'small',
|
||
centered: true,
|
||
});
|
||
}}
|
||
>
|
||
{t('测试所有通道')}
|
||
</Button>
|
||
</Dropdown.Item>
|
||
<Dropdown.Item>
|
||
<Button
|
||
theme='light'
|
||
type='secondary'
|
||
className="!rounded-full w-full"
|
||
onClick={() => {
|
||
Modal.confirm({
|
||
title: t('确定?'),
|
||
content: t('确定要更新所有已启用通道余额吗?'),
|
||
onOk: () => updateAllChannelsBalance(),
|
||
size: 'sm',
|
||
centered: true,
|
||
});
|
||
}}
|
||
>
|
||
{t('更新所有已启用通道余额')}
|
||
</Button>
|
||
</Dropdown.Item>
|
||
<Dropdown.Item>
|
||
<Button
|
||
theme='light'
|
||
type='danger'
|
||
className="!rounded-full w-full"
|
||
onClick={() => {
|
||
Modal.confirm({
|
||
title: t('确定是否要删除禁用通道?'),
|
||
content: t('此修改将不可逆'),
|
||
onOk: () => deleteAllDisabledChannels(),
|
||
size: 'sm',
|
||
centered: true,
|
||
});
|
||
}}
|
||
>
|
||
{t('删除禁用通道')}
|
||
</Button>
|
||
</Dropdown.Item>
|
||
<Dropdown.Item>
|
||
<Button
|
||
theme='light'
|
||
type='tertiary'
|
||
className="!rounded-full w-full"
|
||
onClick={() => {
|
||
Modal.confirm({
|
||
title: t('确定是否要修复数据库一致性?'),
|
||
content: t('进行该操作时,可能导致渠道访问错误,请仅在数据库出现问题时使用'),
|
||
onOk: () => fixChannelsAbilities(),
|
||
size: 'sm',
|
||
centered: true,
|
||
});
|
||
}}
|
||
>
|
||
{t('修复数据库一致性')}
|
||
</Button>
|
||
</Dropdown.Item>
|
||
</Dropdown.Menu>
|
||
}
|
||
>
|
||
<Button theme='light' type='tertiary' icon={<IconSetting />} className="!rounded-full w-full md:w-auto">
|
||
{t('批量操作')}
|
||
</Button>
|
||
</Dropdown>
|
||
</div>
|
||
|
||
<div className="flex flex-col md:flex-row items-start md:items-center gap-4 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
|
||
checked={idSort}
|
||
onChange={(v) => {
|
||
localStorage.setItem('id-sort', v + '');
|
||
setIdSort(v);
|
||
loadChannels(0, pageSize, v, enableTagMode);
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex items-center justify-between w-full md:w-auto">
|
||
<Typography.Text strong className="mr-2">
|
||
{t('开启批量操作')}
|
||
</Typography.Text>
|
||
<Switch
|
||
onChange={(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
|
||
checked={enableTagMode}
|
||
onChange={(v) => {
|
||
setEnableTagMode(v);
|
||
loadChannels(0, pageSize, idSort, v);
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<Divider margin="12px" />
|
||
|
||
<div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full">
|
||
<div className="flex gap-2 w-full md:w-auto order-2 md:order-1">
|
||
<Button
|
||
theme='light'
|
||
type='primary'
|
||
icon={<IconPlus />}
|
||
className="!rounded-full w-full md:w-auto"
|
||
onClick={() => {
|
||
setEditingChannel({
|
||
id: undefined,
|
||
});
|
||
setShowEdit(true);
|
||
}}
|
||
>
|
||
{t('添加渠道')}
|
||
</Button>
|
||
|
||
<Button
|
||
theme='light'
|
||
type='primary'
|
||
icon={<IconRefresh />}
|
||
className="!rounded-full w-full md:w-auto"
|
||
onClick={refresh}
|
||
>
|
||
{t('刷新')}
|
||
</Button>
|
||
|
||
<Button
|
||
theme='light'
|
||
type='tertiary'
|
||
icon={<IconSetting />}
|
||
onClick={() => setShowColumnSelector(true)}
|
||
className="!rounded-full w-full md:w-auto"
|
||
>
|
||
{t('列设置')}
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto order-1 md:order-2">
|
||
<div className="relative w-full md:w-64">
|
||
<Input
|
||
prefix={<IconSearch />}
|
||
placeholder={t('搜索渠道的 ID,名称,密钥和API地址 ...')}
|
||
value={searchKeyword}
|
||
loading={searching}
|
||
onChange={(v) => {
|
||
setSearchKeyword(v.trim());
|
||
}}
|
||
className="!rounded-full"
|
||
showClear
|
||
/>
|
||
</div>
|
||
<div className="w-full md:w-48">
|
||
<Input
|
||
prefix={<IconFilter />}
|
||
placeholder={t('模型关键字')}
|
||
value={searchModel}
|
||
loading={searching}
|
||
onChange={(v) => {
|
||
setSearchModel(v.trim());
|
||
}}
|
||
className="!rounded-full"
|
||
showClear
|
||
/>
|
||
</div>
|
||
<div className="w-full md:w-48">
|
||
<Select
|
||
placeholder={t('选择分组')}
|
||
optionList={[
|
||
{ label: t('选择分组'), value: null },
|
||
...groupOptions,
|
||
]}
|
||
value={searchGroup}
|
||
onChange={(v) => {
|
||
setSearchGroup(v);
|
||
searchChannels(searchKeyword, v, searchModel, enableTagMode);
|
||
}}
|
||
className="!rounded-full w-full"
|
||
showClear
|
||
/>
|
||
</div>
|
||
<Button
|
||
type="primary"
|
||
onClick={() => {
|
||
searchChannels(searchKeyword, searchGroup, searchModel, enableTagMode);
|
||
}}
|
||
loading={searching}
|
||
className="!rounded-full w-full md:w-auto"
|
||
>
|
||
{t('查询')}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
<>
|
||
{renderColumnSelector()}
|
||
<EditTagModal
|
||
visible={showEditTag}
|
||
tag={editingTag}
|
||
handleClose={() => setShowEditTag(false)}
|
||
refresh={refresh}
|
||
/>
|
||
<EditChannel
|
||
refresh={refresh}
|
||
visible={showEdit}
|
||
handleClose={closeEdit}
|
||
editingChannel={editingChannel}
|
||
/>
|
||
|
||
<Card
|
||
className="!rounded-2xl overflow-hidden"
|
||
title={renderHeader()}
|
||
shadows='hover'
|
||
>
|
||
<Table
|
||
columns={getVisibleColumns()}
|
||
dataSource={pageData}
|
||
pagination={{
|
||
currentPage: activePage,
|
||
pageSize: pageSize,
|
||
total: channelCount,
|
||
pageSizeOpts: [10, 20, 50, 100],
|
||
showSizeChanger: true,
|
||
formatPageText: (page) => t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
|
||
start: page.currentStart,
|
||
end: page.currentEnd,
|
||
total: channels.length,
|
||
}),
|
||
onPageSizeChange: (size) => {
|
||
handlePageSizeChange(size);
|
||
},
|
||
onPageChange: handlePageChange,
|
||
}}
|
||
expandAllRows={false}
|
||
onRow={handleRow}
|
||
rowSelection={
|
||
enableBatchDelete
|
||
? {
|
||
onChange: (selectedRowKeys, selectedRows) => {
|
||
setSelectedChannels(selectedRows);
|
||
},
|
||
}
|
||
: null
|
||
}
|
||
className="rounded-xl overflow-hidden"
|
||
size="middle"
|
||
loading={loading}
|
||
/>
|
||
</Card>
|
||
|
||
{/* 批量设置标签模态框 */}
|
||
<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)}
|
||
size='large'
|
||
className="!rounded-full"
|
||
/>
|
||
<div className="mt-4">
|
||
<Typography.Text type='secondary'>
|
||
{t('已选择 ${count} 个渠道').replace('${count}', selectedChannels.length)}
|
||
</Typography.Text>
|
||
</div>
|
||
</Modal>
|
||
|
||
{/* 模型测试弹窗 */}
|
||
<Modal
|
||
title={
|
||
currentTestChannel && (
|
||
<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" className="!text-xs flex items-center">
|
||
{t('共')} {currentTestChannel.models.split(',').length} {t('个模型')}
|
||
</Typography.Text>
|
||
</div>
|
||
)
|
||
}
|
||
visible={showModelTestModal && currentTestChannel !== null}
|
||
onCancel={handleCloseModal}
|
||
footer={
|
||
<div className="flex justify-end">
|
||
{isBatchTesting ? (
|
||
<Button
|
||
theme='light'
|
||
type='warning'
|
||
className="!rounded-full"
|
||
onClick={handleCloseModal}
|
||
>
|
||
{t('停止测试')}
|
||
</Button>
|
||
) : (
|
||
<Button
|
||
theme='light'
|
||
type='tertiary'
|
||
className="!rounded-full"
|
||
onClick={handleCloseModal}
|
||
>
|
||
{t('取消')}
|
||
</Button>
|
||
)}
|
||
<Button
|
||
theme='light'
|
||
type='primary'
|
||
className="!rounded-full"
|
||
onClick={batchTestModels}
|
||
loading={isBatchTesting}
|
||
disabled={isBatchTesting}
|
||
>
|
||
{isBatchTesting ? t('测试中...') : t('批量测试${count}个模型').replace(
|
||
'${count}',
|
||
currentTestChannel
|
||
? currentTestChannel.models
|
||
.split(',')
|
||
.filter((model) =>
|
||
model.toLowerCase().includes(modelSearchKeyword.toLowerCase())
|
||
).length
|
||
: 0
|
||
)}
|
||
</Button>
|
||
</div>
|
||
}
|
||
maskClosable={!isBatchTesting}
|
||
centered={true}
|
||
className="!rounded-lg"
|
||
size="large"
|
||
>
|
||
<div className="max-h-[600px] overflow-y-auto">
|
||
{currentTestChannel && (
|
||
<div>
|
||
<div className="flex items-center justify-end mb-2">
|
||
<Input
|
||
placeholder={t('搜索模型...')}
|
||
value={modelSearchKeyword}
|
||
onChange={(v) => setModelSearchKeyword(v)}
|
||
className="w-64 !rounded-full"
|
||
prefix={<IconSearch />}
|
||
showClear
|
||
/>
|
||
</div>
|
||
|
||
<Table
|
||
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 size='large' color='blue' className="!rounded-full">
|
||
{t('测试中')}
|
||
</Tag>
|
||
);
|
||
}
|
||
|
||
if (!testResult) {
|
||
return (
|
||
<Tag size='large' color='grey' className="!rounded-full">
|
||
{t('未开始')}
|
||
</Tag>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="flex items-center gap-2">
|
||
<Tag
|
||
size='large'
|
||
color={testResult.success ? 'green' : 'red'}
|
||
className="!rounded-full"
|
||
>
|
||
{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
|
||
theme='light'
|
||
type='primary'
|
||
className="!rounded-full"
|
||
onClick={() => testChannel(currentTestChannel, record.model)}
|
||
loading={isTesting}
|
||
size='small'
|
||
icon={<IconSmallTriangleRight />}
|
||
>
|
||
{t('测试')}
|
||
</Button>
|
||
);
|
||
}
|
||
}
|
||
]}
|
||
dataSource={currentTestChannel.models
|
||
.split(',')
|
||
.filter((model) =>
|
||
model.toLowerCase().includes(modelSearchKeyword.toLowerCase())
|
||
)
|
||
.map((model) => ({
|
||
model,
|
||
key: model
|
||
}))}
|
||
pagination={false}
|
||
size="middle"
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Modal>
|
||
</>
|
||
);
|
||
};
|
||
|
||
export default ChannelsTable;
|