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,24 +255,20 @@ const ChannelsTable = () => { ...@@ -260,24 +255,20 @@ 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 ?.split(',')
?.split(',') .sort((a, b) => {
.sort((a, b) => { if (a === 'default') return -1;
if (a === 'default') return -1; if (b === 'default') return 1;
if (b === 'default') return 1; return a.localeCompare(b);
return a.localeCompare(b); })
}) .map((item, index) => renderGroup(item))}
.map((item, index) => { </Space>
return renderGroup(item); </div>
})} ),
</Space>
</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,39 +373,36 @@ const ChannelsTable = () => { ...@@ -387,39 +373,36 @@ 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' keepFocus={true}
keepFocus={true} onBlur={(e) => {
onBlur={(e) => { Modal.warning({
Modal.warning({ title: t('修改子渠道优先级'),
title: t('修改子渠道优先级'), content: t('确定要修改所有子渠道优先级为 ') + e.target.value + t(' 吗?'),
content: onOk: () => {
t('确定要修改所有子渠道优先级为 ') + if (e.target.value === '') {
e.target.value + return;
t(' 吗?'), }
onOk: () => { submitTagEdit('priority', {
if (e.target.value === '') { tag: record.key,
return; priority: e.target.value,
} });
submitTagEdit('priority', { },
tag: record.key, });
priority: e.target.value, }}
}); innerButtons
}, defaultValue={record.priority}
}); min={-999}
}} size="small"
innerButtons />
defaultValue={record.priority}
min={-999}
/>
</>
); );
} }
}, },
...@@ -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>
<Button <Dropdown
theme='light' trigger='click'
type='tertiary' position='bottomRight'
style={{ marginRight: 1 }} menu={tagMenuItems}
onClick={() => {
setShowEditTag(true);
setEditingTag(record.key);
}}
> >
{t('编辑')} <Button
</Button> icon={<IconMore />}
</> theme='light'
type='tertiary'
size="small"
className="!rounded-full"
/>
</Dropdown>
</Space>
); );
} }
}, },
...@@ -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 { 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( const { channel, model } = testQueue[0];
t('通道 ${name} 测试成功,耗时 ${time.toFixed(2)} 秒。')
.replace('${name}', record.name)
.replace('${time.toFixed(2)}', time.toFixed(2)),
);
} else {
showError(message);
}
};
const updateChannelBalance = async (record) => { try {
const res = await API.get(`/api/channel/update_balance/${record.id}/`); setTestingModels(prev => new Set([...prev, model]));
const { success, message, balance } = res.data; const res = await API.get(`/api/channel/test/${channel.id}?model=${model}`);
if (success) { const { success, message, time } = res.data;
updateChannelProperty(record.id, (channel) => {
channel.balance = balance; setModelTestResults(prev => ({
channel.balance_updated_time = Date.now() / 1000; ...prev,
[`${channel.id}-${model}`]: { success, time }
}));
if (success) {
updateChannelProperty(channel.id, (ch) => {
ch.response_time = time * 1000;
ch.test_time = Date.now() / 1000;
});
} else {
showError(message);
}
} catch (error) {
showError(error.message);
} finally {
setTestingModels(prev => {
const newSet = new Set(prev);
newSet.delete(model);
return newSet;
}); });
showInfo(
t('通道 ${name} 余额更新成功!').replace('${name}', record.name),
);
} else {
showError(message);
} }
};
const testAllChannels = async () => { // 移除已处理的测试
const res = await API.get(`/api/channel/test`); setTestQueue(prev => prev.slice(1));
const { success, message } = res.data;
if (success) {
showInfo(t('已成功开始测试所有已启用通道,请刷新页面查看结果。'));
} else {
showError(message);
}
}; };
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,325 +1259,270 @@ const ChannelsTable = () => { ...@@ -1254,325 +1259,270 @@ 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( await refresh();
searchKeyword, } else {
searchGroup, showError(message);
searchModel, }
enableTagMode, };
);
}} const updateAllChannelsBalance = async () => {
labelPosition='left' const res = await API.get(`/api/channel/update_balance`);
> const { success, message } = res.data;
<div style={{ display: 'flex' }}> if (success) {
<Space> showInfo(t('已更新完毕所有已启用通道余额!'));
<Form.Input } else {
field='search_keyword' showError(message);
label={t('搜索渠道关键词')} }
placeholder={t('搜索渠道的 ID,名称,密钥和API地址 ...')} };
value={searchKeyword}
loading={searching} const updateChannelBalance = async (record) => {
onChange={(v) => { const res = await API.get(`/api/channel/update_balance/${record.id}/`);
setSearchKeyword(v.trim()); const { success, message, balance } = res.data;
}} if (success) {
/> updateChannelProperty(record.id, (channel) => {
<Form.Input channel.balance = balance;
field='search_model' channel.balance_updated_time = Date.now() / 1000;
label={t('模型')} });
placeholder={t('模型关键字')} showInfo(
value={searchModel} t('通道 ${name} 余额更新成功!').replace('${name}', record.name),
loading={searching} );
onChange={(v) => { } else {
setSearchModel(v.trim()); showError(message);
}} }
/> };
<Form.Select
field='group' const batchDeleteChannels = async () => {
label={t('分组')} if (selectedChannels.length === 0) {
optionList={[ showError(t('请先选择要删除的通道!'));
{ label: t('选择分组'), value: null }, return;
...groupOptions, }
]} setLoading(true);
initValue={null} let ids = [];
onChange={(v) => { selectedChannels.forEach((channel) => {
setSearchGroup(v); ids.push(channel.id);
searchChannels(searchKeyword, v, searchModel, enableTagMode); });
}} const res = await API.post(`/api/channel/batch`, { ids: ids });
/> const { success, message, data } = res.data;
<Button if (success) {
label={t('查询')} showSuccess(t('已删除 ${data} 个通道!').replace('${data}', data));
type='primary' await refresh();
htmlType='submit' } else {
className='btn-margin-right' showError(message);
style={{ marginRight: 8 }} }
> setLoading(false);
{t('查询')} };
</Button>
</Space> const fixChannelsAbilities = async () => {
</div> const res = await API.post(`/api/channel/fix`);
</Form> const { success, message, data } = res.data;
<Divider style={{ marginBottom: 15 }} /> if (success) {
<div showSuccess(t('已修复 ${data} 个通道!').replace('${data}', data));
style={{ await refresh();
display: 'flex', } else {
flexDirection: isMobile() ? 'column' : 'row', showError(message);
marginTop: isMobile() ? 0 : -45, }
zIndex: 999, };
pointerEvents: 'none',
}} const renderHeader = () => (
> <div className="flex flex-col w-full">
<Space <div className="flex flex-col md:flex-row justify-between gap-4">
style={{ <div className="flex flex-wrap md:flex-nowrap items-center gap-2 w-full md:w-auto order-2 md:order-1">
pointerEvents: 'auto', <Button
marginTop: isMobile() ? 0 : 45, disabled={!enableBatchDelete}
marginBottom: isMobile() ? 16 : 0, theme='light'
display: 'flex', type='danger'
flexWrap: isMobile() ? 'wrap' : 'nowrap', className="!rounded-full w-full md:w-auto"
gap: '8px', onClick={() => {
}} Modal.confirm({
> title: t('确定是否要删除所选通道?'),
<div content: t('此修改将不可逆'),
style={{ onOk: () => batchDeleteChannels(),
display: 'flex', });
alignItems: 'center',
marginRight: 16,
flexWrap: 'nowrap',
}} }}
> >
<Typography.Text strong style={{ marginRight: 8 }}> {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排序')} {t('使用ID排序')}
</Typography.Text> </Typography.Text>
<Switch <Switch
checked={idSort} checked={idSort}
label={t('使用ID排序')}
uncheckedText={t('关')}
aria-label={t('是否用ID排序')}
onChange={(v) => { onChange={(v) => {
localStorage.setItem('id-sort', v + ''); localStorage.setItem('id-sort', v + '');
setIdSort(v); setIdSort(v);
loadChannels(0, pageSize, v, enableTagMode) loadChannels(0, pageSize, v, enableTagMode);
.then()
.catch((reason) => {
showError(reason);
});
}} }}
></Switch> />
</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);
<Button
theme='light'
type='primary'
icon={<IconPlus />}
onClick={() => {
setEditingChannel({
id: undefined,
});
setShowEdit(true);
}} }}
> />
{t('添加渠道')}
</Button>
<Button
theme='light'
type='primary'
icon={<IconRefresh />}
onClick={refresh}
>
{t('刷新')}
</Button>
<Dropdown
trigger='click'
render={
<Dropdown.Menu>
<Dropdown.Item>
<Popconfirm
title={t('确定?')}
okType={'warning'}
onConfirm={testAllChannels}
position={isMobile() ? 'top' : 'top'}
>
<Button
theme='light'
type='warning'
style={{ width: '100%' }}
>
{t('测试所有通道')}
</Button>
</Popconfirm>
</Dropdown.Item>
<Dropdown.Item>
<Popconfirm
title={t('确定?')}
okType={'secondary'}
onConfirm={updateAllChannelsBalance}
>
<Button
theme='light'
type='secondary'
style={{ width: '100%' }}
>
{t('更新所有已启用通道余额')}
</Button>
</Popconfirm>
</Dropdown.Item>
<Dropdown.Item>
<Popconfirm
title={t('确定是否要删除禁用通道?')}
content={t('此修改将不可逆')}
okType={'danger'}
onConfirm={deleteAllDisabledChannels}
>
<Button
theme='light'
type='danger'
style={{ width: '100%' }}
>
{t('删除禁用通道')}
</Button>
</Popconfirm>
</Dropdown.Item>
</Dropdown.Menu>
}
>
<Button theme='light' type='tertiary' icon={<IconSetting />}>
{t('批量操作')}
</Button>
</Dropdown>
</div> </div>
</Space>
</div>
<div
style={{
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('开启批量操作')}
</Typography.Text>
<Switch
label={t('开启批量操作')}
uncheckedText={t('关')}
aria-label={t('是否开启批量操作')}
onChange={(v) => {
setEnableBatchDelete(v);
}}
/>
</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
}} checked={enableTagMode}
> onChange={(v) => {
<Popconfirm setEnableTagMode(v);
title={t('确定是否要删除所选通道?')} loadChannels(0, pageSize, idSort, v);
content={t('此修改将不可逆')} }}
okType={'danger'} />
onConfirm={batchDeleteChannels} </div>
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 <Divider margin="12px" />
style={{
marginTop: 20, <div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full">
display: 'flex', <div className="flex gap-2 w-full md:w-auto order-2 md:order-1">
flexDirection: isMobile() ? 'column' : 'row', <Button
alignItems: isMobile() ? 'flex-start' : 'center', theme='light'
gap: isMobile() ? '8px' : '16px', type='primary'
}} icon={<IconPlus />}
> className="!rounded-full w-full md:w-auto"
<div onClick={() => {
style={{ setEditingChannel({
display: 'flex', id: undefined,
alignItems: 'center', });
marginBottom: isMobile() ? 8 : 0, setShowEdit(true);
}}
>
<Typography.Text strong style={{ marginRight: 8 }}>
{t('标签聚合模式')}
</Typography.Text>
<Switch
checked={enableTagMode}
label={t('标签聚合模式')}
uncheckedText={t('关')}
aria-label={t('是否启用标签聚合')}
onChange={(v) => {
setEnableTagMode(v);
loadChannels(0, pageSize, idSort, v);
}} }}
/> >
</div> {t('添加渠道')}
</Button>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '8px',
}}
>
<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,41 +1530,128 @@ const ChannelsTable = () => { ...@@ -1580,41 +1530,128 @@ 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>
);
<Table return (
loading={loading} <>
columns={getVisibleColumns()} {renderColumnSelector()}
dataSource={pageData} <EditTagModal
pagination={{ visible={showEditTag}
currentPage: activePage, tag={editingTag}
pageSize: pageSize, handleClose={() => setShowEditTag(false)}
total: channelCount, refresh={refresh}
pageSizeOpts: [10, 20, 50, 100], />
showSizeChanger: true, <EditChannel
formatPageText: (page) => '', refresh={refresh}
onPageSizeChange: (size) => { visible={showEdit}
handlePageSizeChange(size).then(); handleClose={closeEdit}
}, editingChannel={editingChannel}
onPageChange: handlePageChange, />
}}
expandAllRows={false} <Card
onRow={handleRow} className="!rounded-2xl overflow-hidden"
rowSelection={ title={renderHeader()}
enableBatchDelete 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) => { 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} <Input
</Typography.Title> placeholder={t('搜索模型...')}
value={modelSearchKeyword}
{/* 搜索框 */} onChange={(v) => setModelSearchKeyword(v)}
<Input className="w-64 !rounded-full"
placeholder={t('搜索模型...')} prefix={<IconSearch />}
value={modelSearchKeyword} showClear
onChange={(v) => setModelSearchKeyword(v)} />
style={{ marginBottom: '16px' }} </div>
prefix={<IconFilter />}
showClear
/>
<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>
)
},
{
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(',') .split(',')
.filter((model) => .filter((model) =>
model model.toLowerCase().includes(modelSearchKeyword.toLowerCase())
.toLowerCase()
.includes(modelSearchKeyword.toLowerCase()),
) )
.map((model, index) => { .map((model) => ({
return ( model,
<Button key: model
theme='light' }))}
type='tertiary' pagination={false}
style={{ size="middle"
height: 'auto', />
padding: '10px 12px',
textAlign: 'center',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
width: '100%',
borderRadius: '6px',
}}
onClick={() => {
testChannel(currentTestChannel, model);
}}
>
{model}
</Button>
);
})}
</div>
{/* 显示搜索结果数量 */}
{modelSearchKeyword && (
<Typography.Text type='secondary' style={{ display: 'block' }}>
{t('找到')}{' '}
{
currentTestChannel.models
.split(',')
.filter((model) =>
model
.toLowerCase()
.includes(modelSearchKeyword.toLowerCase()),
).length
}{' '}
{t('个模型')}
</Typography.Text>
)}
</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);
} }
...@@ -332,4 +274,4 @@ code { ...@@ -332,4 +274,4 @@ code {
.semi-datepicker-range-input { .semi-datepicker-range-input {
border-radius: 9999px; border-radius: 9999px;
} }
\ No newline at end of file
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> <ChannelsTable />
<Layout.Header>
<h3>{t('管理渠道')}</h3>
</Layout.Header>
<Layout.Content>
<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