Commit d28dd059 by Apple\Apple

🤯feat(channels): enhance channel management UI and model testing

This commit improves the ChannelsTable component with enhanced UI and functionality:

UI Improvements:
- Refactor operation column layout with primary actions exposed
- Move secondary actions (delete, copy) to dropdown menu
- Unify button styles with theme='light' and size="small"
- Add !rounded-full design to all buttons
- Add appropriate icons (IconStop, IconPlay etc.)

Column Settings Modal:
- Replace inline styles with Tailwind CSS
- Add rounded corners design
- Optimize button layout and styling
- Improve responsive design

Batch Operations:
- Unify dropdown button styles with !rounded-full
- Replace inline styles with Tailwind w-full
- Maintain semantic button types (warning/secondary/danger)
- Improve visual hierarchy

Model Testing Enhancement:
- Add comprehensive model testing modal
- Implement batch testing functionality
- Add model search and filtering
- Add real-time test status indicators
- Show response time for successful tests
- Add test queue management system
- Implement graceful test cancellation

Other Improvements:
- Optimize responsive layout for mobile devices
- Add i18n support for all new features
- Improve error handling and user feedback
- Enhance performance with optimized state management
parent 485ea965
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { import {
API, API,
isMobile,
shouldShowPrompt,
showError, showError,
showInfo, showInfo,
showSuccess, showSuccess,
showWarning,
timestamp2string, timestamp2string,
} from '../helpers'; } from '../helpers';
import { CHANNEL_OPTIONS, ITEMS_PER_PAGE } from '../constants'; import { CHANNEL_OPTIONS, ITEMS_PER_PAGE } from '../constants';
import { import {
getQuotaPerUnit,
renderGroup, renderGroup,
renderNumberWithPoint, renderNumberWithPoint,
renderQuota, renderQuota,
renderQuotaWithPrompt,
stringToColor,
} from '../helpers/render'; } from '../helpers/render';
import { import {
Button, Button,
Divider, Divider,
Dropdown, Dropdown,
Form,
Input, Input,
InputNumber, InputNumber,
Modal, Modal,
Popconfirm,
Space, Space,
SplitButtonGroup, SplitButtonGroup,
Switch, Switch,
...@@ -36,27 +28,30 @@ import { ...@@ -36,27 +28,30 @@ import {
Tooltip, Tooltip,
Typography, Typography,
Checkbox, Checkbox,
Layout, Card,
Select,
} from '@douyinfe/semi-ui'; } from '@douyinfe/semi-ui';
import EditChannel from '../pages/Channel/EditChannel'; import EditChannel from '../pages/Channel/EditChannel';
import { import {
IconList, IconList,
IconTreeTriangleDown, IconTreeTriangleDown,
IconClose,
IconFilter, IconFilter,
IconPlus, IconPlus,
IconRefresh, IconRefresh,
IconSetting, IconSetting,
IconSearch,
IconEdit,
IconDelete,
IconStop,
IconPlay,
IconMore,
IconCopy,
IconSmallTriangleRight
} from '@douyinfe/semi-icons'; } from '@douyinfe/semi-icons';
import { loadChannelModels } from './utils.js'; import { loadChannelModels } from './utils.js';
import EditTagModal from '../pages/Channel/EditTagModal.js'; import EditTagModal from '../pages/Channel/EditTagModal.js';
import TextNumberInput from './custom/TextNumberInput.js';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
function renderTimestamp(timestamp) {
return <>{timestamp2string(timestamp)}</>;
}
const ChannelsTable = () => { const ChannelsTable = () => {
const { t } = useTranslation(); const { t } = useTranslation();
...@@ -71,7 +66,7 @@ const ChannelsTable = () => { ...@@ -71,7 +66,7 @@ const ChannelsTable = () => {
type2label[0] = { value: 0, label: t('未知类型'), color: 'grey' }; type2label[0] = { value: 0, label: t('未知类型'), color: 'grey' };
} }
return ( return (
<Tag size='large' color={type2label[type]?.color}> <Tag size='large' color={type2label[type]?.color} shape='circle'>
{type2label[type]?.label} {type2label[type]?.label}
</Tag> </Tag>
); );
...@@ -95,25 +90,25 @@ const ChannelsTable = () => { ...@@ -95,25 +90,25 @@ const ChannelsTable = () => {
switch (status) { switch (status) {
case 1: case 1:
return ( return (
<Tag size='large' color='green'> <Tag size='large' color='green' shape='circle'>
{t('已启用')} {t('已启用')}
</Tag> </Tag>
); );
case 2: case 2:
return ( return (
<Tag size='large' color='yellow'> <Tag size='large' color='yellow' shape='circle'>
{t('已禁用')} {t('已禁用')}
</Tag> </Tag>
); );
case 3: case 3:
return ( return (
<Tag size='large' color='yellow'> <Tag size='large' color='yellow' shape='circle'>
{t('自动禁用')} {t('自动禁用')}
</Tag> </Tag>
); );
default: default:
return ( return (
<Tag size='large' color='grey'> <Tag size='large' color='grey' shape='circle'>
{t('未知状态')} {t('未知状态')}
</Tag> </Tag>
); );
...@@ -125,31 +120,31 @@ const ChannelsTable = () => { ...@@ -125,31 +120,31 @@ const ChannelsTable = () => {
time = time.toFixed(2) + t(' 秒'); time = time.toFixed(2) + t(' 秒');
if (responseTime === 0) { if (responseTime === 0) {
return ( return (
<Tag size='large' color='grey'> <Tag size='large' color='grey' shape='circle'>
{t('未测试')} {t('未测试')}
</Tag> </Tag>
); );
} else if (responseTime <= 1000) { } else if (responseTime <= 1000) {
return ( return (
<Tag size='large' color='green'> <Tag size='large' color='green' shape='circle'>
{time} {time}
</Tag> </Tag>
); );
} else if (responseTime <= 3000) { } else if (responseTime <= 3000) {
return ( return (
<Tag size='large' color='lime'> <Tag size='large' color='lime' shape='circle'>
{time} {time}
</Tag> </Tag>
); );
} else if (responseTime <= 5000) { } else if (responseTime <= 5000) {
return ( return (
<Tag size='large' color='yellow'> <Tag size='large' color='yellow' shape='circle'>
{time} {time}
</Tag> </Tag>
); );
} else { } else {
return ( return (
<Tag size='large' color='red'> <Tag size='large' color='red' shape='circle'>
{time} {time}
</Tag> </Tag>
); );
...@@ -260,8 +255,7 @@ const ChannelsTable = () => { ...@@ -260,8 +255,7 @@ const ChannelsTable = () => {
key: COLUMN_KEYS.GROUP, key: COLUMN_KEYS.GROUP,
title: t('分组'), title: t('分组'),
dataIndex: 'group', dataIndex: 'group',
render: (text, record, index) => { render: (text, record, index) => (
return (
<div> <div>
<Space spacing={2}> <Space spacing={2}>
{text {text
...@@ -271,13 +265,10 @@ const ChannelsTable = () => { ...@@ -271,13 +265,10 @@ const ChannelsTable = () => {
if (b === 'default') return 1; if (b === 'default') return 1;
return a.localeCompare(b); return a.localeCompare(b);
}) })
.map((item, index) => { .map((item, index) => renderGroup(item))}
return renderGroup(item);
})}
</Space> </Space>
</div> </div>
); ),
},
}, },
{ {
key: COLUMN_KEYS.TYPE, key: COLUMN_KEYS.TYPE,
...@@ -306,9 +297,7 @@ const ChannelsTable = () => { ...@@ -306,9 +297,7 @@ const ChannelsTable = () => {
return ( return (
<div> <div>
<Tooltip <Tooltip
content={ content={t('原因:') + reason + t(',时间:') + timestamp2string(time)}
t('原因:') + reason + t(',时间:') + timestamp2string(time)
}
> >
{renderStatus(text)} {renderStatus(text)}
</Tooltip> </Tooltip>
...@@ -323,9 +312,9 @@ const ChannelsTable = () => { ...@@ -323,9 +312,9 @@ const ChannelsTable = () => {
key: COLUMN_KEYS.RESPONSE_TIME, key: COLUMN_KEYS.RESPONSE_TIME,
title: t('响应时间'), title: t('响应时间'),
dataIndex: 'response_time', dataIndex: 'response_time',
render: (text, record, index) => { render: (text, record, index) => (
return <div>{renderResponseTime(text)}</div>; <div>{renderResponseTime(text)}</div>
}, ),
}, },
{ {
key: COLUMN_KEYS.BALANCE, key: COLUMN_KEYS.BALANCE,
...@@ -337,20 +326,17 @@ const ChannelsTable = () => { ...@@ -337,20 +326,17 @@ const ChannelsTable = () => {
<div> <div>
<Space spacing={1}> <Space spacing={1}>
<Tooltip content={t('已用额度')}> <Tooltip content={t('已用额度')}>
<Tag color='white' type='ghost' size='large'> <Tag color='white' type='ghost' size='large' shape='circle'>
{renderQuota(record.used_quota)} {renderQuota(record.used_quota)}
</Tag> </Tag>
</Tooltip> </Tooltip>
<Tooltip <Tooltip content={t('剩余额度') + record.balance + t(',点击更新')}>
content={t('剩余额度') + record.balance + t(',点击更新')}
>
<Tag <Tag
color='white' color='white'
type='ghost' type='ghost'
size='large' size='large'
onClick={() => { shape='circle'
updateChannelBalance(record); onClick={() => updateChannelBalance(record)}
}}
> >
${renderNumberWithPoint(record.balance)} ${renderNumberWithPoint(record.balance)}
</Tag> </Tag>
...@@ -361,7 +347,7 @@ const ChannelsTable = () => { ...@@ -361,7 +347,7 @@ const ChannelsTable = () => {
} else { } else {
return ( return (
<Tooltip content={t('已用额度')}> <Tooltip content={t('已用额度')}>
<Tag color='white' type='ghost' size='large'> <Tag color='white' type='ghost' size='large' shape='circle'>
{renderQuota(record.used_quota)} {renderQuota(record.used_quota)}
</Tag> </Tag>
</Tooltip> </Tooltip>
...@@ -387,12 +373,12 @@ const ChannelsTable = () => { ...@@ -387,12 +373,12 @@ const ChannelsTable = () => {
innerButtons innerButtons
defaultValue={record.priority} defaultValue={record.priority}
min={-999} min={-999}
size="small"
/> />
</div> </div>
); );
} else { } else {
return ( return (
<>
<InputNumber <InputNumber
style={{ width: 70 }} style={{ width: 70 }}
name='priority' name='priority'
...@@ -400,10 +386,7 @@ const ChannelsTable = () => { ...@@ -400,10 +386,7 @@ const ChannelsTable = () => {
onBlur={(e) => { onBlur={(e) => {
Modal.warning({ Modal.warning({
title: t('修改子渠道优先级'), title: t('修改子渠道优先级'),
content: content: t('确定要修改所有子渠道优先级为 ') + e.target.value + t(' 吗?'),
t('确定要修改所有子渠道优先级为 ') +
e.target.value +
t(' 吗?'),
onOk: () => { onOk: () => {
if (e.target.value === '') { if (e.target.value === '') {
return; return;
...@@ -418,8 +401,8 @@ const ChannelsTable = () => { ...@@ -418,8 +401,8 @@ const ChannelsTable = () => {
innerButtons innerButtons
defaultValue={record.priority} defaultValue={record.priority}
min={-999} min={-999}
size="small"
/> />
</>
); );
} }
}, },
...@@ -442,6 +425,7 @@ const ChannelsTable = () => { ...@@ -442,6 +425,7 @@ const ChannelsTable = () => {
innerButtons innerButtons
defaultValue={record.weight} defaultValue={record.weight}
min={0} min={0}
size="small"
/> />
</div> </div>
); );
...@@ -454,10 +438,7 @@ const ChannelsTable = () => { ...@@ -454,10 +438,7 @@ const ChannelsTable = () => {
onBlur={(e) => { onBlur={(e) => {
Modal.warning({ Modal.warning({
title: t('修改子渠道权重'), title: t('修改子渠道权重'),
content: content: t('确定要修改所有子渠道权重为 ') + e.target.value + t(' 吗?'),
t('确定要修改所有子渠道权重为 ') +
e.target.value +
t(' 吗?'),
onOk: () => { onOk: () => {
if (e.target.value === '') { if (e.target.value === '') {
return; return;
...@@ -472,6 +453,7 @@ const ChannelsTable = () => { ...@@ -472,6 +453,7 @@ const ChannelsTable = () => {
innerButtons innerButtons
defaultValue={record.weight} defaultValue={record.weight}
min={-999} min={-999}
size="small"
/> />
); );
} }
...@@ -483,53 +465,72 @@ const ChannelsTable = () => { ...@@ -483,53 +465,72 @@ const ChannelsTable = () => {
dataIndex: 'operate', dataIndex: 'operate',
render: (text, record, index) => { render: (text, record, index) => {
if (record.children === undefined) { 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 ( return (
<div> <Space wrap>
<SplitButtonGroup <SplitButtonGroup
style={{ marginRight: 1 }} className="!rounded-full overflow-hidden"
aria-label={t('测试单个渠道操作项目组')} aria-label={t('测试单个渠道操作项目组')}
> >
<Button <Button
theme='light' theme='light'
onClick={() => { size="small"
testChannel(record, ''); onClick={() => testChannel(record, '')}
}}
> >
{t('测试')} {t('测试')}
</Button> </Button>
<Button <Button
style={{ padding: '8px 4px' }} theme='light'
type='primary' size="small"
icon={<IconTreeTriangleDown />} icon={<IconTreeTriangleDown />}
onClick={() => { onClick={() => {
setCurrentTestChannel(record); setCurrentTestChannel(record);
setShowModelTestModal(true); setShowModelTestModal(true);
}} }}
></Button> />
</SplitButtonGroup> </SplitButtonGroup>
<Popconfirm
title={t('确定是否要删除此渠道?')}
content={t('此修改将不可逆')}
okType={'danger'}
position={'left'}
onConfirm={() => {
manageChannel(record.id, 'delete', record).then(() => {
removeRecord(record);
});
}}
>
<Button theme='light' type='danger' style={{ marginRight: 1 }}>
{t('删除')}
</Button>
</Popconfirm>
{record.status === 1 ? ( {record.status === 1 ? (
<Button <Button
theme='light' theme='light'
type='warning' type='warning'
style={{ marginRight: 1 }} size="small"
onClick={async () => { className="!rounded-full"
manageChannel(record.id, 'disable', record); icon={<IconStop />}
}} onClick={() => manageChannel(record.id, 'disable', record)}
> >
{t('禁用')} {t('禁用')}
</Button> </Button>
...@@ -537,18 +538,21 @@ const ChannelsTable = () => { ...@@ -537,18 +538,21 @@ const ChannelsTable = () => {
<Button <Button
theme='light' theme='light'
type='secondary' type='secondary'
style={{ marginRight: 1 }} size="small"
onClick={async () => { className="!rounded-full"
manageChannel(record.id, 'enable', record); icon={<IconPlay />}
}} onClick={() => manageChannel(record.id, 'enable', record)}
> >
{t('启用')} {t('启用')}
</Button> </Button>
)} )}
<Button <Button
theme='light' theme='light'
type='tertiary' type='tertiary'
style={{ marginRight: 1 }} size="small"
className="!rounded-full"
icon={<IconEdit />}
onClick={() => { onClick={() => {
setEditingChannel(record); setEditingChannel(record);
setShowEdit(true); setShowEdit(true);
...@@ -556,56 +560,72 @@ const ChannelsTable = () => { ...@@ -556,56 +560,72 @@ const ChannelsTable = () => {
> >
{t('编辑')} {t('编辑')}
</Button> </Button>
<Popconfirm
title={t('确定是否要复制此渠道?')} <Dropdown
content={t('复制渠道的所有信息')} trigger='click'
okType={'danger'} position='bottomRight'
position={'left'} menu={moreMenuItems}
onConfirm={async () => {
copySelectedChannel(record);
}}
> >
<Button theme='light' type='primary' style={{ marginRight: 1 }}> <Button
{t('复制')} icon={<IconMore />}
</Button> theme='light'
</Popconfirm> type='tertiary'
</div> size="small"
className="!rounded-full"
/>
</Dropdown>
</Space>
); );
} else { } else {
// 标签操作的下拉菜单项
const tagMenuItems = [
{
node: 'item',
name: t('编辑'),
icon: <IconEdit />,
onClick: () => {
setShowEditTag(true);
setEditingTag(record.key);
},
},
];
return ( return (
<> <Space wrap>
<Button <Button
theme='light' theme='light'
type='secondary' type='secondary'
style={{ marginRight: 1 }} size="small"
onClick={async () => { className="!rounded-full"
manageTag(record.key, 'enable'); icon={<IconPlay />}
}} onClick={() => manageTag(record.key, 'enable')}
> >
{t('启用全部')} {t('启用全部')}
</Button> </Button>
<Button <Button
theme='light' theme='light'
type='warning' type='warning'
style={{ marginRight: 1 }} size="small"
onClick={async () => { className="!rounded-full"
manageTag(record.key, 'disable'); icon={<IconStop />}
}} onClick={() => manageTag(record.key, 'disable')}
> >
{t('禁用全部')} {t('禁用全部')}
</Button> </Button>
<Dropdown
trigger='click'
position='bottomRight'
menu={tagMenuItems}
>
<Button <Button
icon={<IconMore />}
theme='light' theme='light'
type='tertiary' type='tertiary'
style={{ marginRight: 1 }} size="small"
onClick={() => { className="!rounded-full"
setShowEditTag(true); />
setEditingTag(record.key); </Dropdown>
}} </Space>
>
{t('编辑')}
</Button>
</>
); );
} }
}, },
...@@ -625,18 +645,32 @@ const ChannelsTable = () => { ...@@ -625,18 +645,32 @@ const ChannelsTable = () => {
visible={showColumnSelector} visible={showColumnSelector}
onCancel={() => setShowColumnSelector(false)} onCancel={() => setShowColumnSelector(false)}
footer={ footer={
<> <div className="flex justify-end">
<Button onClick={() => initDefaultColumns()}>{t('重置')}</Button> <Button
<Button onClick={() => setShowColumnSelector(false)}> theme="light"
onClick={() => initDefaultColumns()}
className="!rounded-full"
>
{t('重置')}
</Button>
<Button
theme="light"
onClick={() => setShowColumnSelector(false)}
className="!rounded-full"
>
{t('取消')} {t('取消')}
</Button> </Button>
<Button type='primary' onClick={() => setShowColumnSelector(false)}> <Button
type='primary'
onClick={() => setShowColumnSelector(false)}
className="!rounded-full"
>
{t('确定')} {t('确定')}
</Button> </Button>
</> </div>
} }
style={{ width: isMobile() ? '90%' : 500 }} size="middle"
bodyStyle={{ padding: '24px' }} centered={true}
> >
<div style={{ marginBottom: 20 }}> <div style={{ marginBottom: 20 }}>
<Checkbox <Checkbox
...@@ -651,15 +685,8 @@ const ChannelsTable = () => { ...@@ -651,15 +685,8 @@ const ChannelsTable = () => {
</Checkbox> </Checkbox>
</div> </div>
<div <div
style={{ className="flex flex-wrap max-h-96 overflow-y-auto rounded-lg p-4"
display: 'flex', style={{ border: '1px solid var(--semi-color-border)' }}
flexWrap: 'wrap',
maxHeight: '400px',
overflowY: 'auto',
border: '1px solid var(--semi-color-border)',
borderRadius: '6px',
padding: '16px',
}}
> >
{allColumns.map((column) => { {allColumns.map((column) => {
// Skip columns without title // Skip columns without title
...@@ -670,11 +697,7 @@ const ChannelsTable = () => { ...@@ -670,11 +697,7 @@ const ChannelsTable = () => {
return ( return (
<div <div
key={column.key} key={column.key}
style={{ className="w-1/2 mb-4 pr-2"
width: isMobile() ? '100%' : '50%',
marginBottom: 16,
paddingRight: 8,
}}
> >
<Checkbox <Checkbox
checked={!!visibleColumns[column.key]} checked={!!visibleColumns[column.key]}
...@@ -700,11 +723,7 @@ const ChannelsTable = () => { ...@@ -700,11 +723,7 @@ const ChannelsTable = () => {
const [searchGroup, setSearchGroup] = useState(''); const [searchGroup, setSearchGroup] = useState('');
const [searchModel, setSearchModel] = useState(''); const [searchModel, setSearchModel] = useState('');
const [searching, setSearching] = useState(false); const [searching, setSearching] = useState(false);
const [updatingBalance, setUpdatingBalance] = useState(false);
const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE); const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
const [showPrompt, setShowPrompt] = useState(
shouldShowPrompt('channel-test'),
);
const [channelCount, setChannelCount] = useState(pageSize); const [channelCount, setChannelCount] = useState(pageSize);
const [groupOptions, setGroupOptions] = useState([]); const [groupOptions, setGroupOptions] = useState([]);
const [showEdit, setShowEdit] = useState(false); const [showEdit, setShowEdit] = useState(false);
...@@ -715,13 +734,17 @@ const ChannelsTable = () => { ...@@ -715,13 +734,17 @@ const ChannelsTable = () => {
const [showEditTag, setShowEditTag] = useState(false); const [showEditTag, setShowEditTag] = useState(false);
const [editingTag, setEditingTag] = useState(''); const [editingTag, setEditingTag] = useState('');
const [selectedChannels, setSelectedChannels] = useState([]); const [selectedChannels, setSelectedChannels] = useState([]);
const [showEditPriority, setShowEditPriority] = useState(false);
const [enableTagMode, setEnableTagMode] = useState(false); const [enableTagMode, setEnableTagMode] = useState(false);
const [showBatchSetTag, setShowBatchSetTag] = useState(false); const [showBatchSetTag, setShowBatchSetTag] = useState(false);
const [batchSetTagValue, setBatchSetTagValue] = useState(''); const [batchSetTagValue, setBatchSetTagValue] = useState('');
const [showModelTestModal, setShowModelTestModal] = useState(false); const [showModelTestModal, setShowModelTestModal] = useState(false);
const [currentTestChannel, setCurrentTestChannel] = useState(null); const [currentTestChannel, setCurrentTestChannel] = useState(null);
const [modelSearchKeyword, setModelSearchKeyword] = useState(''); 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) => { const removeRecord = (record) => {
let newDataSource = [...channels]; let newDataSource = [...channels];
...@@ -1034,106 +1057,88 @@ const ChannelsTable = () => { ...@@ -1034,106 +1057,88 @@ const ChannelsTable = () => {
} }
}; };
const testChannel = async (record, model) => { const processTestQueue = async () => {
const res = await API.get(`/api/channel/test/${record.id}?model=${model}`); 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; const { success, message, time } = res.data;
if (success) {
// Also update the channels state to persist the change
updateChannelProperty(record.id, (channel) => {
channel.response_time = time * 1000;
channel.test_time = Date.now() / 1000;
});
showInfo( setModelTestResults(prev => ({
t('通道 ${name} 测试成功,耗时 ${time.toFixed(2)} 秒。') ...prev,
.replace('${name}', record.name) [`${channel.id}-${model}`]: { success, time }
.replace('${time.toFixed(2)}', time.toFixed(2)), }));
);
} 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) { if (success) {
updateChannelProperty(record.id, (channel) => { updateChannelProperty(channel.id, (ch) => {
channel.balance = balance; ch.response_time = time * 1000;
channel.balance_updated_time = Date.now() / 1000; ch.test_time = Date.now() / 1000;
}); });
showInfo(
t('通道 ${name} 余额更新成功!').replace('${name}', record.name),
);
} else { } else {
showError(message); showError(message);
} }
}; } catch (error) {
showError(error.message);
const testAllChannels = async () => { } finally {
const res = await API.get(`/api/channel/test`); setTestingModels(prev => {
const { success, message } = res.data; const newSet = new Set(prev);
if (success) { newSet.delete(model);
showInfo(t('已成功开始测试所有已启用通道,请刷新页面查看结果。')); return newSet;
} else { });
showError(message);
} }
// 移除已处理的测试
setTestQueue(prev => prev.slice(1));
}; };
const deleteAllDisabledChannels = async () => { // 监听队列变化
const res = await API.delete(`/api/channel/disabled`); useEffect(() => {
const { success, message, data } = res.data; if (testQueue.length > 0 && isProcessingQueue) {
if (success) { processTestQueue();
showSuccess( } else if (testQueue.length === 0 && isProcessingQueue) {
t('已删除所有禁用渠道,共计 ${data} 个').replace('${data}', data), setIsProcessingQueue(false);
); setIsBatchTesting(false);
await refresh();
} else {
showError(message);
} }
}; }, [testQueue, isProcessingQueue]);
const updateAllChannelsBalance = async () => { const testChannel = async (record, model) => {
setUpdatingBalance(true); setTestQueue(prev => [...prev, { channel: record, model }]);
const res = await API.get(`/api/channel/update_balance`); if (!isProcessingQueue) {
const { success, message } = res.data; setIsProcessingQueue(true);
if (success) {
showInfo(t('已更新完毕所有已启用通道余额!'));
} else {
showError(message);
} }
setUpdatingBalance(false);
}; };
const batchDeleteChannels = async () => { const batchTestModels = async () => {
if (selectedChannels.length === 0) { if (!currentTestChannel) return;
showError(t('请先选择要删除的通道!'));
return; setIsBatchTesting(true);
}
setLoading(true); const models = currentTestChannel.models
let ids = []; .split(',')
selectedChannels.forEach((channel) => { .filter((model) =>
ids.push(channel.id); model.toLowerCase().includes(modelSearchKeyword.toLowerCase())
}); );
const res = await API.post(`/api/channel/batch`, { ids: ids });
const { success, message, data } = res.data; setTestQueue(models.map(model => ({
if (success) { channel: currentTestChannel,
showSuccess(t('已删除 ${data} 个通道!').replace('${data}', data)); model
await refresh(); })));
} else { setIsProcessingQueue(true);
showError(message);
}
setLoading(false);
}; };
const fixChannelsAbilities = async () => { const handleCloseModal = () => {
const res = await API.post(`/api/channel/fix`); if (isBatchTesting) {
const { success, message, data } = res.data; // 清空测试队列来停止测试
if (success) { setTestQueue([]);
showSuccess(t('已修复 ${data} 个通道!').replace('${data}', data)); setIsProcessingQueue(false);
await refresh(); setIsBatchTesting(false);
showSuccess(t('已停止测试'));
} else { } else {
showError(message); setShowModelTestModal(false);
setModelSearchKeyword('');
} }
}; };
...@@ -1146,7 +1151,7 @@ const ChannelsTable = () => { ...@@ -1146,7 +1151,7 @@ const ChannelsTable = () => {
setActivePage(page); setActivePage(page);
if (page === Math.ceil(channels.length / pageSize) + 1) { if (page === Math.ceil(channels.length / pageSize) + 1) {
// In this case we have to load more data and then append them. // In this case we have to load more data and then append them.
loadChannels(page - 1, pageSize, idSort, enableTagMode).then((r) => {}); loadChannels(page - 1, pageSize, idSort, enableTagMode).then((r) => { });
} }
}; };
...@@ -1254,155 +1259,115 @@ const ChannelsTable = () => { ...@@ -1254,155 +1259,115 @@ const ChannelsTable = () => {
} }
}; };
return ( const testAllChannels = async () => {
<> const res = await API.get(`/api/channel/test`);
{renderColumnSelector()} const { success, message } = res.data;
<EditTagModal if (success) {
visible={showEditTag} showInfo(t('已成功开始测试所有已启用通道,请刷新页面查看结果。'));
tag={editingTag} } else {
handleClose={() => setShowEditTag(false)} showError(message);
refresh={refresh} }
/> };
<EditChannel
refresh={refresh} const deleteAllDisabledChannels = async () => {
visible={showEdit} const res = await API.delete(`/api/channel/disabled`);
handleClose={closeEdit} const { success, message, data } = res.data;
editingChannel={editingChannel} if (success) {
/> showSuccess(
<Form t('已删除所有禁用渠道,共计 ${data} 个').replace('${data}', data),
onSubmit={() => {
searchChannels(
searchKeyword,
searchGroup,
searchModel,
enableTagMode,
); );
}} await refresh();
labelPosition='left' } else {
> showError(message);
<div style={{ display: 'flex' }}> }
<Space> };
<Form.Input
field='search_keyword' const updateAllChannelsBalance = async () => {
label={t('搜索渠道关键词')} const res = await API.get(`/api/channel/update_balance`);
placeholder={t('搜索渠道的 ID,名称,密钥和API地址 ...')} const { success, message } = res.data;
value={searchKeyword} if (success) {
loading={searching} showInfo(t('已更新完毕所有已启用通道余额!'));
onChange={(v) => { } else {
setSearchKeyword(v.trim()); showError(message);
}} }
/> };
<Form.Input
field='search_model' const updateChannelBalance = async (record) => {
label={t('模型')} const res = await API.get(`/api/channel/update_balance/${record.id}/`);
placeholder={t('模型关键字')} const { success, message, balance } = res.data;
value={searchModel} if (success) {
loading={searching} updateChannelProperty(record.id, (channel) => {
onChange={(v) => { channel.balance = balance;
setSearchModel(v.trim()); channel.balance_updated_time = Date.now() / 1000;
}}
/>
<Form.Select
field='group'
label={t('分组')}
optionList={[
{ label: t('选择分组'), value: null },
...groupOptions,
]}
initValue={null}
onChange={(v) => {
setSearchGroup(v);
searchChannels(searchKeyword, v, searchModel, enableTagMode);
}}
/>
<Button
label={t('查询')}
type='primary'
htmlType='submit'
className='btn-margin-right'
style={{ marginRight: 8 }}
>
{t('查询')}
</Button>
</Space>
</div>
</Form>
<Divider style={{ marginBottom: 15 }} />
<div
style={{
display: 'flex',
flexDirection: isMobile() ? 'column' : 'row',
marginTop: isMobile() ? 0 : -45,
zIndex: 999,
pointerEvents: 'none',
}}
>
<Space
style={{
pointerEvents: 'auto',
marginTop: isMobile() ? 0 : 45,
marginBottom: isMobile() ? 16 : 0,
display: 'flex',
flexWrap: isMobile() ? 'wrap' : 'nowrap',
gap: '8px',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
marginRight: 16,
flexWrap: 'nowrap',
}}
>
<Typography.Text strong style={{ marginRight: 8 }}>
{t('使用ID排序')}
</Typography.Text>
<Switch
checked={idSort}
label={t('使用ID排序')}
uncheckedText={t('关')}
aria-label={t('是否用ID排序')}
onChange={(v) => {
localStorage.setItem('id-sort', v + '');
setIdSort(v);
loadChannels(0, pageSize, v, enableTagMode)
.then()
.catch((reason) => {
showError(reason);
}); });
}} showInfo(
></Switch> t('通道 ${name} 余额更新成功!').replace('${name}', record.name),
</div> );
} else {
showError(message);
}
};
<div const batchDeleteChannels = async () => {
style={{ if (selectedChannels.length === 0) {
display: 'flex', showError(t('请先选择要删除的通道!'));
flexWrap: 'wrap', return;
gap: '8px', }
}} 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 <Button
disabled={!enableBatchDelete}
theme='light' theme='light'
type='primary' type='danger'
icon={<IconPlus />} className="!rounded-full w-full md:w-auto"
onClick={() => { onClick={() => {
setEditingChannel({ Modal.confirm({
id: undefined, title: t('确定是否要删除所选通道?'),
content: t('此修改将不可逆'),
onOk: () => batchDeleteChannels(),
}); });
setShowEdit(true);
}} }}
> >
{t('添加渠道')} {t('删除所选通道')}
</Button> </Button>
<Button <Button
disabled={!enableBatchDelete}
theme='light' theme='light'
type='primary' type='primary'
icon={<IconRefresh />} onClick={() => setShowBatchSetTag(true)}
onClick={refresh} className="!rounded-full w-full md:w-auto"
> >
{t('刷新')} {t('批量设置标签')}
</Button> </Button>
<Dropdown <Dropdown
...@@ -1410,169 +1375,154 @@ const ChannelsTable = () => { ...@@ -1410,169 +1375,154 @@ const ChannelsTable = () => {
render={ render={
<Dropdown.Menu> <Dropdown.Menu>
<Dropdown.Item> <Dropdown.Item>
<Popconfirm
title={t('确定?')}
okType={'warning'}
onConfirm={testAllChannels}
position={isMobile() ? 'top' : 'top'}
>
<Button <Button
theme='light' theme='light'
type='warning' type='warning'
style={{ width: '100%' }} className="!rounded-full w-full"
onClick={() => {
Modal.confirm({
title: t('确定?'),
content: t('确定要测试所有通道吗?'),
onOk: () => testAllChannels(),
size: 'small',
centered: true,
});
}}
> >
{t('测试所有通道')} {t('测试所有通道')}
</Button> </Button>
</Popconfirm>
</Dropdown.Item> </Dropdown.Item>
<Dropdown.Item> <Dropdown.Item>
<Popconfirm
title={t('确定?')}
okType={'secondary'}
onConfirm={updateAllChannelsBalance}
>
<Button <Button
theme='light' theme='light'
type='secondary' type='secondary'
style={{ width: '100%' }} className="!rounded-full w-full"
onClick={() => {
Modal.confirm({
title: t('确定?'),
content: t('确定要更新所有已启用通道余额吗?'),
onOk: () => updateAllChannelsBalance(),
size: 'sm',
centered: true,
});
}}
> >
{t('更新所有已启用通道余额')} {t('更新所有已启用通道余额')}
</Button> </Button>
</Popconfirm>
</Dropdown.Item> </Dropdown.Item>
<Dropdown.Item> <Dropdown.Item>
<Popconfirm
title={t('确定是否要删除禁用通道?')}
content={t('此修改将不可逆')}
okType={'danger'}
onConfirm={deleteAllDisabledChannels}
>
<Button <Button
theme='light' theme='light'
type='danger' type='danger'
style={{ width: '100%' }} className="!rounded-full w-full"
onClick={() => {
Modal.confirm({
title: t('确定是否要删除禁用通道?'),
content: t('此修改将不可逆'),
onOk: () => deleteAllDisabledChannels(),
size: 'sm',
centered: true,
});
}}
> >
{t('删除禁用通道')} {t('删除禁用通道')}
</Button> </Button>
</Popconfirm> </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.Item>
</Dropdown.Menu> </Dropdown.Menu>
} }
> >
<Button theme='light' type='tertiary' icon={<IconSetting />}> <Button theme='light' type='tertiary' icon={<IconSetting />} className="!rounded-full w-full md:w-auto">
{t('批量操作')} {t('批量操作')}
</Button> </Button>
</Dropdown> </Dropdown>
</div> </div>
</Space>
</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 <div className="flex items-center justify-between w-full md:w-auto">
style={{ <Typography.Text strong className="mr-2">
marginTop: 20, {t('使用ID排序')}
display: 'flex',
flexDirection: isMobile() ? 'column' : 'row',
alignItems: isMobile() ? 'flex-start' : 'center',
gap: isMobile() ? '8px' : '16px',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
marginBottom: isMobile() ? 8 : 0,
}}
>
<Typography.Text strong style={{ marginRight: 8 }}>
{t('开启批量操作')}
</Typography.Text> </Typography.Text>
<Switch <Switch
label={t('开启批量操作')} checked={idSort}
uncheckedText={t('关')}
aria-label={t('是否开启批量操作')}
onChange={(v) => { onChange={(v) => {
setEnableBatchDelete(v); localStorage.setItem('id-sort', v + '');
setIdSort(v);
loadChannels(0, pageSize, v, enableTagMode);
}} }}
/> />
</div> </div>
<div <div className="flex items-center justify-between w-full md:w-auto">
style={{ <Typography.Text strong className="mr-2">
display: 'flex', {t('开启批量操作')}
flexWrap: 'wrap', </Typography.Text>
gap: '8px', <Switch
onChange={(v) => {
setEnableBatchDelete(v);
}} }}
> />
<Popconfirm
title={t('确定是否要删除所选通道?')}
content={t('此修改将不可逆')}
okType={'danger'}
onConfirm={batchDeleteChannels}
disabled={!enableBatchDelete}
>
<Button disabled={!enableBatchDelete} theme='light' type='danger'>
{t('删除所选通道')}
</Button>
</Popconfirm>
<Popconfirm
title={t('确定是否要修复数据库一致性?')}
content={t(
'进行该操作时,可能导致渠道访问错误,请仅在数据库出现问题时使用',
)}
okType={'warning'}
onConfirm={fixChannelsAbilities}
>
<Button theme='light' type='secondary'>
{t('修复数据库一致性')}
</Button>
</Popconfirm>
</div>
</div> </div>
<div <div className="flex items-center justify-between w-full md:w-auto">
style={{ <Typography.Text strong className="mr-2">
marginTop: 20,
display: 'flex',
flexDirection: isMobile() ? 'column' : 'row',
alignItems: isMobile() ? 'flex-start' : 'center',
gap: isMobile() ? '8px' : '16px',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
marginBottom: isMobile() ? 8 : 0,
}}
>
<Typography.Text strong style={{ marginRight: 8 }}>
{t('标签聚合模式')} {t('标签聚合模式')}
</Typography.Text> </Typography.Text>
<Switch <Switch
checked={enableTagMode} checked={enableTagMode}
label={t('标签聚合模式')}
uncheckedText={t('关')}
aria-label={t('是否启用标签聚合')}
onChange={(v) => { onChange={(v) => {
setEnableTagMode(v); setEnableTagMode(v);
loadChannels(0, pageSize, idSort, v); loadChannels(0, pageSize, idSort, v);
}} }}
/> />
</div> </div>
</div>
</div>
<div <Divider margin="12px" />
style={{
display: 'flex', <div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full">
flexWrap: 'wrap', <div className="flex gap-2 w-full md:w-auto order-2 md:order-1">
gap: '8px', <Button
theme='light'
type='primary'
icon={<IconPlus />}
className="!rounded-full w-full md:w-auto"
onClick={() => {
setEditingChannel({
id: undefined,
});
setShowEdit(true);
}} }}
> >
{t('添加渠道')}
</Button>
<Button <Button
disabled={!enableBatchDelete}
theme='light' theme='light'
type='primary' type='primary'
onClick={() => setShowBatchSetTag(true)} icon={<IconRefresh />}
className="!rounded-full w-full md:w-auto"
onClick={refresh}
> >
{t('批量设置标签')} {t('刷新')}
</Button> </Button>
<Button <Button
...@@ -1580,14 +1530,92 @@ const ChannelsTable = () => { ...@@ -1580,14 +1530,92 @@ const ChannelsTable = () => {
type='tertiary' type='tertiary'
icon={<IconSetting />} icon={<IconSetting />}
onClick={() => setShowColumnSelector(true)} onClick={() => setShowColumnSelector(true)}
className="!rounded-full w-full md:w-auto"
> >
{t('列设置')} {t('列设置')}
</Button> </Button>
</div> </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>
</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 <Table
loading={loading}
columns={getVisibleColumns()} columns={getVisibleColumns()}
dataSource={pageData} dataSource={pageData}
pagination={{ pagination={{
...@@ -1596,9 +1624,13 @@ const ChannelsTable = () => { ...@@ -1596,9 +1624,13 @@ const ChannelsTable = () => {
total: channelCount, total: channelCount,
pageSizeOpts: [10, 20, 50, 100], pageSizeOpts: [10, 20, 50, 100],
showSizeChanger: true, showSizeChanger: true,
formatPageText: (page) => '', formatPageText: (page) => t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
start: page.currentStart,
end: page.currentEnd,
total: channels.length,
}),
onPageSizeChange: (size) => { onPageSizeChange: (size) => {
handlePageSizeChange(size).then(); handlePageSizeChange(size);
}, },
onPageChange: handlePageChange, onPageChange: handlePageChange,
}} }}
...@@ -1608,13 +1640,18 @@ const ChannelsTable = () => { ...@@ -1608,13 +1640,18 @@ const ChannelsTable = () => {
enableBatchDelete enableBatchDelete
? { ? {
onChange: (selectedRowKeys, selectedRows) => { onChange: (selectedRowKeys, selectedRows) => {
// console.log(`selectedRowKeys: ${selectedRowKeys}`, 'selectedRows: ', selectedRows);
setSelectedChannels(selectedRows); setSelectedChannels(selectedRows);
}, },
} }
: null : null
} }
className="rounded-xl overflow-hidden"
size="middle"
loading={loading}
/> />
</Card>
{/* 批量设置标签模态框 */}
<Modal <Modal
title={t('批量设置标签')} title={t('批量设置标签')}
visible={showBatchSetTag} visible={showBatchSetTag}
...@@ -1622,9 +1659,10 @@ const ChannelsTable = () => { ...@@ -1622,9 +1659,10 @@ const ChannelsTable = () => {
onCancel={() => setShowBatchSetTag(false)} onCancel={() => setShowBatchSetTag(false)}
maskClosable={false} maskClosable={false}
centered={true} centered={true}
style={{ width: isMobile() ? '90%' : 500 }} size="small"
className="!rounded-lg"
> >
<div style={{ marginBottom: 20 }}> <div className="mb-5">
<Typography.Text>{t('请输入要设置的标签名称')}</Typography.Text> <Typography.Text>{t('请输入要设置的标签名称')}</Typography.Text>
</div> </div>
<Input <Input
...@@ -1632,102 +1670,177 @@ const ChannelsTable = () => { ...@@ -1632,102 +1670,177 @@ const ChannelsTable = () => {
value={batchSetTagValue} value={batchSetTagValue}
onChange={(v) => setBatchSetTagValue(v)} onChange={(v) => setBatchSetTagValue(v)}
size='large' size='large'
className="!rounded-full"
/> />
<div style={{ marginTop: 16 }}> <div className="mt-4">
<Typography.Text type='secondary'> <Typography.Text type='secondary'>
{t('已选择 ${count} 个渠道').replace( {t('已选择 ${count} 个渠道').replace('${count}', selectedChannels.length)}
'${count}',
selectedChannels.length,
)}
</Typography.Text> </Typography.Text>
</div> </div>
</Modal> </Modal>
{/* 模型测试弹窗 */} {/* 模型测试弹窗 */}
<Modal <Modal
title={t('选择模型进行测试')} 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} visible={showModelTestModal && currentTestChannel !== null}
onCancel={() => { onCancel={handleCloseModal}
setShowModelTestModal(false); footer={
setModelSearchKeyword(''); <div className="flex justify-end">
}} {isBatchTesting ? (
footer={null} <Button
maskClosable={true} 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} centered={true}
className="!rounded-lg"
size="large"
> >
<div style={{ maxHeight: '500px', overflowY: 'auto', padding: '10px' }}> <div className="max-h-[600px] overflow-y-auto">
{currentTestChannel && ( {currentTestChannel && (
<div> <div>
<Typography.Title heading={6} style={{ marginBottom: '16px' }}> <div className="flex items-center justify-end mb-2">
{t('渠道')}: {currentTestChannel.name}
</Typography.Title>
{/* 搜索框 */}
<Input <Input
placeholder={t('搜索模型...')} placeholder={t('搜索模型...')}
value={modelSearchKeyword} value={modelSearchKeyword}
onChange={(v) => setModelSearchKeyword(v)} onChange={(v) => setModelSearchKeyword(v)}
style={{ marginBottom: '16px' }} className="w-64 !rounded-full"
prefix={<IconFilter />} prefix={<IconSearch />}
showClear showClear
/> />
</div>
<div <Table
style={{ columns={[
display: 'grid', {
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', title: t('模型名称'),
gap: '12px', dataIndex: 'model',
marginBottom: '16px', render: (text) => (
}} <div className="flex items-center">
> <Typography.Text strong>{text}</Typography.Text>
{currentTestChannel.models </div>
.split(',')
.filter((model) =>
model
.toLowerCase()
.includes(modelSearchKeyword.toLowerCase()),
) )
.map((model, index) => { },
{
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 ( return (
<Button <Button
theme='light' theme='light'
type='tertiary' type='primary'
style={{ className="!rounded-full"
height: 'auto', onClick={() => testChannel(currentTestChannel, record.model)}
padding: '10px 12px', loading={isTesting}
textAlign: 'center', size='small'
whiteSpace: 'nowrap', icon={<IconSmallTriangleRight />}
overflow: 'hidden',
textOverflow: 'ellipsis',
width: '100%',
borderRadius: '6px',
}}
onClick={() => {
testChannel(currentTestChannel, model);
}}
> >
{model} {t('测试')}
</Button> </Button>
); );
})} }
</div> }
]}
{/* 显示搜索结果数量 */} dataSource={currentTestChannel.models
{modelSearchKeyword && (
<Typography.Text type='secondary' style={{ display: 'block' }}>
{t('找到')}{' '}
{
currentTestChannel.models
.split(',') .split(',')
.filter((model) => .filter((model) =>
model model.toLowerCase().includes(modelSearchKeyword.toLowerCase())
.toLowerCase() )
.includes(modelSearchKeyword.toLowerCase()), .map((model) => ({
).length model,
}{' '} key: model
{t('个模型')} }))}
</Typography.Text> pagination={false}
)} size="middle"
/>
</div> </div>
)} )}
</div> </div>
......
...@@ -715,6 +715,7 @@ const LogsTable = () => { ...@@ -715,6 +715,7 @@ const LogsTable = () => {
</div> </div>
<div <div
className="flex flex-wrap max-h-96 overflow-y-auto rounded-lg p-4" 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) => { {allColumns.map((column) => {
// Skip admin-only columns for non-admin users // Skip admin-only columns for non-admin users
......
...@@ -730,7 +730,7 @@ const LogsTable = () => { ...@@ -730,7 +730,7 @@ const LogsTable = () => {
{t('全选')} {t('全选')}
</Checkbox> </Checkbox>
</div> </div>
<div className="flex flex-wrap max-h-96 overflow-y-auto rounded-lg p-4"> <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) => { {allColumns.map((column) => {
// 为非管理员用户跳过管理员专用列 // 为非管理员用户跳过管理员专用列
if ( if (
......
...@@ -573,7 +573,7 @@ const LogsTable = () => { ...@@ -573,7 +573,7 @@ const LogsTable = () => {
{t('全选')} {t('全选')}
</Checkbox> </Checkbox>
</div> </div>
<div className="flex flex-wrap max-h-96 overflow-y-auto rounded-lg p-4"> <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) => { {allColumns.map((column) => {
// 为非管理员用户跳过管理员专用列 // 为非管理员用户跳过管理员专用列
if (!isAdminUser && column.key === COLUMN_KEYS.CHANNEL) { if (!isAdminUser && column.key === COLUMN_KEYS.CHANNEL) {
......
...@@ -1480,5 +1480,17 @@ ...@@ -1480,5 +1480,17 @@
"删除账户确认": "Delete Account Confirmation", "删除账户确认": "Delete Account Confirmation",
"请输入您的用户名以确认删除": "Please enter your username to confirm deletion", "请输入您的用户名以确认删除": "Please enter your username to confirm deletion",
"接受未设置价格模型": "Accept models without price settings", "接受未设置价格模型": "Accept models without price settings",
"当模型没有设置价格时仍接受调用,仅当您信任该网站时使用,可能会产生高额费用": "Accept calls even if the model has no price settings, use only when you trust the website, which may incur high costs" "当模型没有设置价格时仍接受调用,仅当您信任该网站时使用,可能会产生高额费用": "Accept calls even if the model has no price settings, use only when you trust the website, which may incur high costs",
"批量操作": "Batch Operations",
"未开始": "Not Started",
"测试中": "Testing",
"请求时长: ${time}s": "Request time: ${time}s",
"搜索模型...": "Search models...",
"批量测试${count}个模型": "Batch test ${count} models",
"测试中...": "Testing...",
"渠道的模型测试": "Channel Model Test",
"共": "Total",
"确定要测试所有通道吗?": "Are you sure you want to test all channels?",
"确定要更新所有已启用通道余额吗?": "Are you sure you want to update the balance of all enabled channels?",
"已选择 ${count} 个渠道": "Selected ${count} channels"
} }
\ No newline at end of file
@layer tailwind-base,semi,tailwind-components,tailwind-utils; @layer tailwind-base, semi, tailwind-components, tailwind-utils;
@layer tailwind-base{
@layer tailwind-base {
@tailwind base; @tailwind base;
} }
@layer tailwind-components{
@layer tailwind-components {
@tailwind components; @tailwind components;
} }
@layer tailwind-utils { @layer tailwind-utils {
@tailwind utilities; @tailwind utilities;
} }
...@@ -29,20 +32,7 @@ body { ...@@ -29,20 +32,7 @@ body {
overflow: hidden; overflow: hidden;
} }
#root #root>section>header>section>div>div>div>div.semi-navigation-header-list-outer>div.semi-navigation-list-wrapper>ul>div>a>li>span {
> section
> header
> section
> div
> div
> div
> div.semi-navigation-header-list-outer
> div.semi-navigation-list-wrapper
> ul
> div
> a
> li
> span {
font-weight: 600 !important; font-weight: 600 !important;
} }
...@@ -51,6 +41,7 @@ body { ...@@ -51,6 +41,7 @@ body {
} }
@media only screen and (max-width: 767px) { @media only screen and (max-width: 767px) {
/*.semi-navigation-sub-wrap .semi-navigation-sub-title, .semi-navigation-item {*/ /*.semi-navigation-sub-wrap .semi-navigation-sub-title, .semi-navigation-item {*/
/* padding: 0 0;*/ /* padding: 0 0;*/
/*}*/ /*}*/
...@@ -68,64 +59,24 @@ body { ...@@ -68,64 +59,24 @@ body {
overflow-x: auto; overflow-x: auto;
scrollbar-width: none; scrollbar-width: none;
} }
#root
> section #root>section>header>section>div>div>div>div.semi-navigation-footer>div>a>li {
> header
> section
> div
> div
> div
> div.semi-navigation-footer
> div
> a
> li {
padding: 0 0; padding: 0 0;
} }
#root
> section #root>section>header>section>div>div>div>div.semi-navigation-header-list-outer>div.semi-navigation-list-wrapper>ul>div>a>li {
> header
> section
> div
> div
> div
> div.semi-navigation-header-list-outer
> div.semi-navigation-list-wrapper
> ul
> div
> a
> li {
padding: 0 5px; padding: 0 5px;
} }
#root
> section #root>section>header>section>div>div>div>div.semi-navigation-footer>div:nth-child(1)>a>li {
> header
> section
> div
> div
> div
> div.semi-navigation-footer
> div:nth-child(1)
> a
> li {
padding: 0 5px; padding: 0 5px;
} }
.semi-navigation-footer { .semi-navigation-footer {
padding-left: 0; padding-left: 0;
padding-right: 0; padding-right: 0;
} }
.semi-table-tbody,
.semi-table-row,
.semi-table-row-cell {
display: block !important;
width: auto !important;
padding: 2px !important;
}
.semi-table-row-cell {
border-bottom: 0 !important;
}
.semi-table-tbody > .semi-table-row {
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
}
.semi-space { .semi-space {
/*display: block!important;*/ /*display: block!important;*/
display: flex; display: flex;
...@@ -165,16 +116,6 @@ body { ...@@ -165,16 +116,6 @@ body {
} }
} }
.semi-table-tbody > .semi-table-row > .semi-table-row-cell {
padding: 16px 14px;
}
.channel-table {
.semi-table-tbody > .semi-table-row > .semi-table-row-cell {
padding: 16px 8px;
}
}
/*.semi-layout {*/ /*.semi-layout {*/
/* height: 100%;*/ /* height: 100%;*/
/*}*/ /*}*/
...@@ -238,6 +179,7 @@ code { ...@@ -238,6 +179,7 @@ code {
from { from {
transform: translateY(-100%); transform: translateY(-100%);
} }
to { to {
transform: translateY(0); transform: translateY(0);
} }
......
import React from 'react'; import React from 'react';
import ChannelsTable from '../../components/ChannelsTable'; import ChannelsTable from '../../components/ChannelsTable';
import { Layout } from '@douyinfe/semi-ui';
import { useTranslation } from 'react-i18next';
const File = () => { const File = () => {
const { t } = useTranslation();
return ( return (
<> <>
<Layout>
<Layout.Header>
<h3>{t('管理渠道')}</h3>
</Layout.Header>
<Layout.Content>
<ChannelsTable /> <ChannelsTable />
</Layout.Content>
</Layout>
</> </>
); );
}; };
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment