🚀 feat(web/channels): Deep modular refactor of Channels table

1. Split monolithic `ChannelsTable` (2200+ LOC) into focused components
   • `channels/index.jsx` – composition entry
   • `ChannelsTable.jsx` – pure `<Table>` rendering
   • `ChannelsActions.jsx` – bulk & settings toolbar
   • `ChannelsFilters.jsx` – search / create / column-settings form
   • `ChannelsTabs.jsx` – type tabs
   • `ChannelsColumnDefs.js` – column definitions & render helpers
   • `modals/` – BatchTag, ColumnSelector, ModelTest modals

2. Extract domain hook
   • Moved `useChannelsData.js` → `src/hooks/channels/useChannelsData.js`
     – centralises state, API calls, pagination, filters, batch ops
     – now exports `setActivePage`, fixing tab / status switch errors

3. Update wiring
   • All sub-components consume data via `useChannelsData` props
   • Adjusted import paths after hook relocation

4. Clean legacy file
   • Legacy `components/table/ChannelsTable.js` now re-exports new module

5. Bug fixes
   • Tab switching, status filter & tag aggregation restored
   • Column selector & batch actions operate via unified hook

This commit completes the first phase of modularising the Channels feature, laying groundwork for consistent, maintainable table architecture across the app.
This commit is contained in:
t0ng7u
2025-07-18 21:05:36 +08:00
parent f43c695527
commit 6799daacd1
48 changed files with 3489 additions and 3031 deletions

View File

@@ -0,0 +1,240 @@
import React from 'react';
import {
Button,
Dropdown,
Modal,
Switch,
Typography,
Select
} from '@douyinfe/semi-ui';
const ChannelsActions = ({
enableBatchDelete,
batchDeleteChannels,
setShowBatchSetTag,
testAllChannels,
fixChannelsAbilities,
updateAllChannelsBalance,
deleteAllDisabledChannels,
compactMode,
setCompactMode,
idSort,
setIdSort,
setEnableBatchDelete,
enableTagMode,
setEnableTagMode,
statusFilter,
setStatusFilter,
getFormValues,
loadChannels,
searchChannels,
activeTypeKey,
activePage,
pageSize,
setActivePage,
t
}) => {
return (
<div className="flex flex-col gap-4">
{/* 第一行:批量操作按钮 + 设置开关 */}
<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
size='small'
disabled={!enableBatchDelete}
type='danger'
className="w-full md:w-auto"
onClick={() => {
Modal.confirm({
title: t('确定是否要删除所选通道?'),
content: t('此修改将不可逆'),
onOk: () => batchDeleteChannels(),
});
}}
>
{t('删除所选通道')}
</Button>
<Button
size='small'
disabled={!enableBatchDelete}
type='tertiary'
onClick={() => setShowBatchSetTag(true)}
className="w-full md:w-auto"
>
{t('批量设置标签')}
</Button>
<Dropdown
size='small'
trigger='click'
render={
<Dropdown.Menu>
<Dropdown.Item>
<Button
size='small'
type='tertiary'
className="w-full"
onClick={() => {
Modal.confirm({
title: t('确定?'),
content: t('确定要测试所有通道吗?'),
onOk: () => testAllChannels(),
size: 'small',
centered: true,
});
}}
>
{t('测试所有通道')}
</Button>
</Dropdown.Item>
<Dropdown.Item>
<Button
size='small'
className="w-full"
onClick={() => {
Modal.confirm({
title: t('确定是否要修复数据库一致性?'),
content: t('进行该操作时,可能导致渠道访问错误,请仅在数据库出现问题时使用'),
onOk: () => fixChannelsAbilities(),
size: 'sm',
centered: true,
});
}}
>
{t('修复数据库一致性')}
</Button>
</Dropdown.Item>
<Dropdown.Item>
<Button
size='small'
type='secondary'
className="w-full"
onClick={() => {
Modal.confirm({
title: t('确定?'),
content: t('确定要更新所有已启用通道余额吗?'),
onOk: () => updateAllChannelsBalance(),
size: 'sm',
centered: true,
});
}}
>
{t('更新所有已启用通道余额')}
</Button>
</Dropdown.Item>
<Dropdown.Item>
<Button
size='small'
type='danger'
className="w-full"
onClick={() => {
Modal.confirm({
title: t('确定是否要删除禁用通道?'),
content: t('此修改将不可逆'),
onOk: () => deleteAllDisabledChannels(),
size: 'sm',
centered: true,
});
}}
>
{t('删除禁用通道')}
</Button>
</Dropdown.Item>
</Dropdown.Menu>
}
>
<Button size='small' theme='light' type='tertiary' className="w-full md:w-auto">
{t('批量操作')}
</Button>
</Dropdown>
<Button
size='small'
type='tertiary'
className="w-full md:w-auto"
onClick={() => setCompactMode(!compactMode)}
>
{compactMode ? t('自适应列表') : t('紧凑列表')}
</Button>
</div>
{/* 右侧:设置开关区域 */}
<div className="flex flex-col md:flex-row items-start md:items-center gap-4 w-full md:w-auto order-1 md:order-2">
<div className="flex items-center justify-between w-full md:w-auto">
<Typography.Text strong className="mr-2">
{t('使用ID排序')}
</Typography.Text>
<Switch
size='small'
checked={idSort}
onChange={(v) => {
localStorage.setItem('id-sort', v + '');
setIdSort(v);
const { searchKeyword, searchGroup, searchModel } = getFormValues();
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
loadChannels(activePage, pageSize, v, enableTagMode);
} else {
searchChannels(enableTagMode, activeTypeKey, statusFilter, activePage, pageSize, v);
}
}}
/>
</div>
<div className="flex items-center justify-between w-full md:w-auto">
<Typography.Text strong className="mr-2">
{t('开启批量操作')}
</Typography.Text>
<Switch
size='small'
checked={enableBatchDelete}
onChange={(v) => {
localStorage.setItem('enable-batch-delete', v + '');
setEnableBatchDelete(v);
}}
/>
</div>
<div className="flex items-center justify-between w-full md:w-auto">
<Typography.Text strong className="mr-2">
{t('标签聚合模式')}
</Typography.Text>
<Switch
size='small'
checked={enableTagMode}
onChange={(v) => {
localStorage.setItem('enable-tag-mode', v + '');
setEnableTagMode(v);
setActivePage(1);
loadChannels(1, pageSize, idSort, v);
}}
/>
</div>
<div className="flex items-center justify-between w-full md:w-auto">
<Typography.Text strong className="mr-2">
{t('状态筛选')}
</Typography.Text>
<Select
size='small'
value={statusFilter}
onChange={(v) => {
localStorage.setItem('channel-status-filter', v);
setStatusFilter(v);
setActivePage(1);
loadChannels(1, pageSize, idSort, enableTagMode, activeTypeKey, v);
}}
>
<Select.Option value="all">{t('全部')}</Select.Option>
<Select.Option value="enabled">{t('已启用')}</Select.Option>
<Select.Option value="disabled">{t('已禁用')}</Select.Option>
</Select>
</div>
</div>
</div>
</div>
);
};
export default ChannelsActions;

View File

@@ -0,0 +1,604 @@
import React from 'react';
import {
Button,
Dropdown,
InputNumber,
Modal,
Space,
SplitButtonGroup,
Tag,
Tooltip,
Typography
} from '@douyinfe/semi-ui';
import {
timestamp2string,
renderGroup,
renderQuota,
getChannelIcon,
renderQuotaWithAmount
} from '../../../helpers/index.js';
import { CHANNEL_OPTIONS } from '../../../constants/index.js';
import { IconTreeTriangleDown, IconMore } from '@douyinfe/semi-icons';
import { FaRandom } from 'react-icons/fa';
// Render functions
const renderType = (type, channelInfo = undefined, t) => {
let type2label = new Map();
for (let i = 0; i < CHANNEL_OPTIONS.length; i++) {
type2label[CHANNEL_OPTIONS[i].value] = CHANNEL_OPTIONS[i];
}
type2label[0] = { value: 0, label: t('未知类型'), color: 'grey' };
let icon = getChannelIcon(type);
if (channelInfo?.is_multi_key) {
icon = (
channelInfo?.multi_key_mode === 'random' ? (
<div className="flex items-center gap-1">
<FaRandom className="text-blue-500" />
{icon}
</div>
) : (
<div className="flex items-center gap-1">
<IconTreeTriangleDown className="text-blue-500" />
{icon}
</div>
)
)
}
return (
<Tag
color={type2label[type]?.color}
shape='circle'
prefixIcon={icon}
>
{type2label[type]?.label}
</Tag>
);
};
const renderTagType = (t) => {
return (
<Tag
color='light-blue'
shape='circle'
type='light'
>
{t('标签聚合')}
</Tag>
);
};
const renderStatus = (status, channelInfo = undefined, t) => {
if (channelInfo) {
if (channelInfo.is_multi_key) {
let keySize = channelInfo.multi_key_size;
let enabledKeySize = keySize;
if (channelInfo.multi_key_status_list) {
enabledKeySize = keySize - Object.keys(channelInfo.multi_key_status_list).length;
}
return renderMultiKeyStatus(status, keySize, enabledKeySize, t);
}
}
switch (status) {
case 1:
return (
<Tag color='green' shape='circle'>
{t('已启用')}
</Tag>
);
case 2:
return (
<Tag color='red' shape='circle'>
{t('已禁用')}
</Tag>
);
case 3:
return (
<Tag color='yellow' shape='circle'>
{t('自动禁用')}
</Tag>
);
default:
return (
<Tag color='grey' shape='circle'>
{t('未知状态')}
</Tag>
);
}
};
const renderMultiKeyStatus = (status, keySize, enabledKeySize, t) => {
switch (status) {
case 1:
return (
<Tag color='green' shape='circle'>
{t('已启用')} {enabledKeySize}/{keySize}
</Tag>
);
case 2:
return (
<Tag color='red' shape='circle'>
{t('已禁用')} {enabledKeySize}/{keySize}
</Tag>
);
case 3:
return (
<Tag color='yellow' shape='circle'>
{t('自动禁用')} {enabledKeySize}/{keySize}
</Tag>
);
default:
return (
<Tag color='grey' shape='circle'>
{t('未知状态')} {enabledKeySize}/{keySize}
</Tag>
);
}
}
const renderResponseTime = (responseTime, t) => {
let time = responseTime / 1000;
time = time.toFixed(2) + t(' 秒');
if (responseTime === 0) {
return (
<Tag color='grey' shape='circle'>
{t('未测试')}
</Tag>
);
} else if (responseTime <= 1000) {
return (
<Tag color='green' shape='circle'>
{time}
</Tag>
);
} else if (responseTime <= 3000) {
return (
<Tag color='lime' shape='circle'>
{time}
</Tag>
);
} else if (responseTime <= 5000) {
return (
<Tag color='yellow' shape='circle'>
{time}
</Tag>
);
} else {
return (
<Tag color='red' shape='circle'>
{time}
</Tag>
);
}
};
export const getChannelsColumns = ({
t,
COLUMN_KEYS,
updateChannelBalance,
manageChannel,
manageTag,
submitTagEdit,
testChannel,
setCurrentTestChannel,
setShowModelTestModal,
setEditingChannel,
setShowEdit,
setShowEditTag,
setEditingTag,
copySelectedChannel,
refresh,
activePage,
channels
}) => {
return [
{
key: COLUMN_KEYS.ID,
title: t('ID'),
dataIndex: 'id',
},
{
key: COLUMN_KEYS.NAME,
title: t('名称'),
dataIndex: 'name',
},
{
key: COLUMN_KEYS.GROUP,
title: t('分组'),
dataIndex: 'group',
render: (text, record, index) => (
<div>
<Space spacing={2}>
{text
?.split(',')
.sort((a, b) => {
if (a === 'default') return -1;
if (b === 'default') return 1;
return a.localeCompare(b);
})
.map((item, index) => renderGroup(item))}
</Space>
</div>
),
},
{
key: COLUMN_KEYS.TYPE,
title: t('类型'),
dataIndex: 'type',
render: (text, record, index) => {
if (record.children === undefined) {
if (record.channel_info) {
if (record.channel_info.is_multi_key) {
return <>{renderType(text, record.channel_info, t)}</>;
}
}
return <>{renderType(text, undefined, t)}</>;
} else {
return <>{renderTagType(t)}</>;
}
},
},
{
key: COLUMN_KEYS.STATUS,
title: t('状态'),
dataIndex: 'status',
render: (text, record, index) => {
if (text === 3) {
if (record.other_info === '') {
record.other_info = '{}';
}
let otherInfo = JSON.parse(record.other_info);
let reason = otherInfo['status_reason'];
let time = otherInfo['status_time'];
return (
<div>
<Tooltip
content={t('原因:') + reason + t(',时间:') + timestamp2string(time)}
>
{renderStatus(text, record.channel_info, t)}
</Tooltip>
</div>
);
} else {
return renderStatus(text, record.channel_info, t);
}
},
},
{
key: COLUMN_KEYS.RESPONSE_TIME,
title: t('响应时间'),
dataIndex: 'response_time',
render: (text, record, index) => (
<div>{renderResponseTime(text, t)}</div>
),
},
{
key: COLUMN_KEYS.BALANCE,
title: t('已用/剩余'),
dataIndex: 'expired_time',
render: (text, record, index) => {
if (record.children === undefined) {
return (
<div>
<Space spacing={1}>
<Tooltip content={t('已用额度')}>
<Tag color='white' type='ghost' shape='circle'>
{renderQuota(record.used_quota)}
</Tag>
</Tooltip>
<Tooltip content={t('剩余额度$') + record.balance + t(',点击更新')}>
<Tag
color='white'
type='ghost'
shape='circle'
onClick={() => updateChannelBalance(record)}
>
{renderQuotaWithAmount(record.balance)}
</Tag>
</Tooltip>
</Space>
</div>
);
} else {
return (
<Tooltip content={t('已用额度')}>
<Tag color='white' type='ghost' shape='circle'>
{renderQuota(record.used_quota)}
</Tag>
</Tooltip>
);
}
},
},
{
key: COLUMN_KEYS.PRIORITY,
title: t('优先级'),
dataIndex: 'priority',
render: (text, record, index) => {
if (record.children === undefined) {
return (
<div>
<InputNumber
style={{ width: 70 }}
name='priority'
onBlur={(e) => {
manageChannel(record.id, 'priority', record, e.target.value);
}}
keepFocus={true}
innerButtons
defaultValue={record.priority}
min={-999}
size="small"
/>
</div>
);
} else {
return (
<InputNumber
style={{ width: 70 }}
name='priority'
keepFocus={true}
onBlur={(e) => {
Modal.warning({
title: t('修改子渠道优先级'),
content: t('确定要修改所有子渠道优先级为 ') + e.target.value + t(' 吗?'),
onOk: () => {
if (e.target.value === '') {
return;
}
submitTagEdit('priority', {
tag: record.key,
priority: e.target.value,
});
},
});
}}
innerButtons
defaultValue={record.priority}
min={-999}
size="small"
/>
);
}
},
},
{
key: COLUMN_KEYS.WEIGHT,
title: t('权重'),
dataIndex: 'weight',
render: (text, record, index) => {
if (record.children === undefined) {
return (
<div>
<InputNumber
style={{ width: 70 }}
name='weight'
onBlur={(e) => {
manageChannel(record.id, 'weight', record, e.target.value);
}}
keepFocus={true}
innerButtons
defaultValue={record.weight}
min={0}
size="small"
/>
</div>
);
} else {
return (
<InputNumber
style={{ width: 70 }}
name='weight'
keepFocus={true}
onBlur={(e) => {
Modal.warning({
title: t('修改子渠道权重'),
content: t('确定要修改所有子渠道权重为 ') + e.target.value + t(' 吗?'),
onOk: () => {
if (e.target.value === '') {
return;
}
submitTagEdit('weight', {
tag: record.key,
weight: e.target.value,
});
},
});
}}
innerButtons
defaultValue={record.weight}
min={-999}
size="small"
/>
);
}
},
},
{
key: COLUMN_KEYS.OPERATE,
title: '',
dataIndex: 'operate',
fixed: 'right',
render: (text, record, index) => {
if (record.children === undefined) {
const moreMenuItems = [
{
node: 'item',
name: t('删除'),
type: 'danger',
onClick: () => {
Modal.confirm({
title: t('确定是否要删除此渠道?'),
content: t('此修改将不可逆'),
onOk: () => {
(async () => {
await manageChannel(record.id, 'delete', record);
await refresh();
setTimeout(() => {
if (channels.length === 0 && activePage > 1) {
refresh(activePage - 1);
}
}, 100);
})();
},
});
},
},
{
node: 'item',
name: t('复制'),
type: 'tertiary',
onClick: () => {
Modal.confirm({
title: t('确定是否要复制此渠道?'),
content: t('复制渠道的所有信息'),
onOk: () => copySelectedChannel(record),
});
},
},
];
return (
<Space wrap>
<SplitButtonGroup
className="overflow-hidden"
aria-label={t('测试单个渠道操作项目组')}
>
<Button
size="small"
type='tertiary'
onClick={() => testChannel(record, '')}
>
{t('测试')}
</Button>
<Button
size="small"
type='tertiary'
icon={<IconTreeTriangleDown />}
onClick={() => {
setCurrentTestChannel(record);
setShowModelTestModal(true);
}}
/>
</SplitButtonGroup>
{record.channel_info?.is_multi_key ? (
<SplitButtonGroup
aria-label={t('多密钥渠道操作项目组')}
>
{
record.status === 1 ? (
<Button
type='danger'
size="small"
onClick={() => manageChannel(record.id, 'disable', record)}
>
{t('禁用')}
</Button>
) : (
<Button
size="small"
onClick={() => manageChannel(record.id, 'enable', record)}
>
{t('启用')}
</Button>
)
}
<Dropdown
trigger='click'
position='bottomRight'
menu={[
{
node: 'item',
name: t('启用全部密钥'),
onClick: () => manageChannel(record.id, 'enable_all', record),
}
]}
>
<Button
type='tertiary'
size="small"
icon={<IconTreeTriangleDown />}
/>
</Dropdown>
</SplitButtonGroup>
) : (
record.status === 1 ? (
<Button
type='danger'
size="small"
onClick={() => manageChannel(record.id, 'disable', record)}
>
{t('禁用')}
</Button>
) : (
<Button
size="small"
onClick={() => manageChannel(record.id, 'enable', record)}
>
{t('启用')}
</Button>
)
)}
<Button
type='tertiary'
size="small"
onClick={() => {
setEditingChannel(record);
setShowEdit(true);
}}
>
{t('编辑')}
</Button>
<Dropdown
trigger='click'
position='bottomRight'
menu={moreMenuItems}
>
<Button
icon={<IconMore />}
type='tertiary'
size="small"
/>
</Dropdown>
</Space>
);
} else {
// 标签操作按钮
return (
<Space wrap>
<Button
type='tertiary'
size="small"
onClick={() => manageTag(record.key, 'enable')}
>
{t('启用全部')}
</Button>
<Button
type='tertiary'
size="small"
onClick={() => manageTag(record.key, 'disable')}
>
{t('禁用全部')}
</Button>
<Button
type='tertiary'
size="small"
onClick={() => {
setShowEditTag(true);
setEditingTag(record.key);
}}
>
{t('编辑')}
</Button>
</Space>
);
}
},
},
];
};

View File

@@ -0,0 +1,140 @@
import React from 'react';
import { Button, Form } from '@douyinfe/semi-ui';
import { IconSearch } from '@douyinfe/semi-icons';
const ChannelsFilters = ({
setEditingChannel,
setShowEdit,
refresh,
setShowColumnSelector,
formInitValues,
setFormApi,
searchChannels,
enableTagMode,
formApi,
groupOptions,
loading,
searching,
t
}) => {
return (
<div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full">
<div className="flex gap-2 w-full md:w-auto order-2 md:order-1">
<Button
size='small'
theme='light'
type='primary'
className="w-full md:w-auto"
onClick={() => {
setEditingChannel({
id: undefined,
});
setShowEdit(true);
}}
>
{t('添加渠道')}
</Button>
<Button
size='small'
type='tertiary'
className="w-full md:w-auto"
onClick={refresh}
>
{t('刷新')}
</Button>
<Button
size='small'
type='tertiary'
onClick={() => setShowColumnSelector(true)}
className="w-full md:w-auto"
>
{t('列设置')}
</Button>
</div>
<div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto order-1 md:order-2">
<Form
initValues={formInitValues}
getFormApi={(api) => setFormApi(api)}
onSubmit={() => searchChannels(enableTagMode)}
allowEmpty={true}
autoComplete="off"
layout="horizontal"
trigger="change"
stopValidateWithError={false}
className="flex flex-col md:flex-row items-center gap-4 w-full"
>
<div className="relative w-full md:w-64">
<Form.Input
size='small'
field="searchKeyword"
prefix={<IconSearch />}
placeholder={t('渠道ID名称密钥API地址')}
showClear
pure
/>
</div>
<div className="w-full md:w-48">
<Form.Input
size='small'
field="searchModel"
prefix={<IconSearch />}
placeholder={t('模型关键字')}
showClear
pure
/>
</div>
<div className="w-full md:w-32">
<Form.Select
size='small'
field="searchGroup"
placeholder={t('选择分组')}
optionList={[
{ label: t('选择分组'), value: null },
...groupOptions,
]}
className="w-full"
showClear
pure
onChange={() => {
// 延迟执行搜索,让表单值先更新
setTimeout(() => {
searchChannels(enableTagMode);
}, 0);
}}
/>
</div>
<Button
size='small'
type="tertiary"
htmlType="submit"
loading={loading || searching}
className="w-full md:w-auto"
>
{t('查询')}
</Button>
<Button
size='small'
type='tertiary'
onClick={() => {
if (formApi) {
formApi.reset();
// 重置后立即查询使用setTimeout确保表单重置完成
setTimeout(() => {
refresh();
}, 100);
}
}}
className="w-full md:w-auto"
>
{t('重置')}
</Button>
</Form>
</div>
</div>
);
};
export default ChannelsFilters;

View File

@@ -0,0 +1,138 @@
import React, { useMemo } from 'react';
import { Table, Empty } from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
IllustrationNoResultDark
} from '@douyinfe/semi-illustrations';
import { getChannelsColumns } from './ChannelsColumnDefs.js';
const ChannelsTable = (channelsData) => {
const {
channels,
loading,
searching,
activePage,
pageSize,
channelCount,
enableBatchDelete,
compactMode,
visibleColumns,
setSelectedChannels,
handlePageChange,
handlePageSizeChange,
handleRow,
t,
COLUMN_KEYS,
// Column functions and data
updateChannelBalance,
manageChannel,
manageTag,
submitTagEdit,
testChannel,
setCurrentTestChannel,
setShowModelTestModal,
setEditingChannel,
setShowEdit,
setShowEditTag,
setEditingTag,
copySelectedChannel,
refresh,
} = channelsData;
// Get all columns
const allColumns = useMemo(() => {
return getChannelsColumns({
t,
COLUMN_KEYS,
updateChannelBalance,
manageChannel,
manageTag,
submitTagEdit,
testChannel,
setCurrentTestChannel,
setShowModelTestModal,
setEditingChannel,
setShowEdit,
setShowEditTag,
setEditingTag,
copySelectedChannel,
refresh,
activePage,
channels,
});
}, [
t,
COLUMN_KEYS,
updateChannelBalance,
manageChannel,
manageTag,
submitTagEdit,
testChannel,
setCurrentTestChannel,
setShowModelTestModal,
setEditingChannel,
setShowEdit,
setShowEditTag,
setEditingTag,
copySelectedChannel,
refresh,
activePage,
channels,
]);
// Filter columns based on visibility settings
const getVisibleColumns = () => {
return allColumns.filter((column) => visibleColumns[column.key]);
};
const visibleColumnsList = useMemo(() => {
return getVisibleColumns();
}, [visibleColumns, allColumns]);
const tableColumns = useMemo(() => {
return compactMode
? visibleColumnsList.map(({ fixed, ...rest }) => rest)
: visibleColumnsList;
}, [compactMode, visibleColumnsList]);
return (
<Table
columns={tableColumns}
dataSource={channels}
scroll={compactMode ? undefined : { x: 'max-content' }}
pagination={{
currentPage: activePage,
pageSize: pageSize,
total: channelCount,
pageSizeOpts: [10, 20, 50, 100],
showSizeChanger: true,
onPageSizeChange: handlePageSizeChange,
onPageChange: handlePageChange,
}}
expandAllRows={false}
onRow={handleRow}
rowSelection={
enableBatchDelete
? {
onChange: (selectedRowKeys, selectedRows) => {
setSelectedChannels(selectedRows);
},
}
: null
}
empty={
<Empty
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
description={t('搜索无结果')}
style={{ padding: 30 }}
/>
}
className="rounded-xl overflow-hidden"
size="middle"
loading={loading || searching}
/>
);
};
export default ChannelsTable;

View File

@@ -0,0 +1,70 @@
import React from 'react';
import { Tabs, TabPane, Tag } from '@douyinfe/semi-ui';
import { CHANNEL_OPTIONS } from '../../../constants/index.js';
import { getChannelIcon } from '../../../helpers/index.js';
const ChannelsTabs = ({
enableTagMode,
activeTypeKey,
setActiveTypeKey,
channelTypeCounts,
availableTypeKeys,
loadChannels,
activePage,
pageSize,
idSort,
setActivePage,
t
}) => {
if (enableTagMode) return null;
const handleTabChange = (key) => {
setActiveTypeKey(key);
setActivePage(1);
loadChannels(1, pageSize, idSort, enableTagMode, key);
};
return (
<Tabs
activeKey={activeTypeKey}
type="card"
collapsible
onChange={handleTabChange}
className="mb-4"
>
<TabPane
itemKey="all"
tab={
<span className="flex items-center gap-2">
{t('全部')}
<Tag color={activeTypeKey === 'all' ? 'red' : 'grey'} shape='circle'>
{channelTypeCounts['all'] || 0}
</Tag>
</span>
}
/>
{CHANNEL_OPTIONS.filter((opt) => availableTypeKeys.includes(String(opt.value))).map((option) => {
const key = String(option.value);
const count = channelTypeCounts[option.value] || 0;
return (
<TabPane
key={key}
itemKey={key}
tab={
<span className="flex items-center gap-2">
{getChannelIcon(option.value)}
{option.label}
<Tag color={activeTypeKey === key ? 'red' : 'grey'} shape='circle'>
{count}
</Tag>
</span>
}
/>
);
})}
</Tabs>
);
};
export default ChannelsTabs;

View File

@@ -0,0 +1,49 @@
import React from 'react';
import CardPro from '../../common/ui/CardPro.js';
import ChannelsTable from './ChannelsTable.jsx';
import ChannelsActions from './ChannelsActions.jsx';
import ChannelsFilters from './ChannelsFilters.jsx';
import ChannelsTabs from './ChannelsTabs.jsx';
import { useChannelsData } from '../../../hooks/channels/useChannelsData.js';
import BatchTagModal from './modals/BatchTagModal.jsx';
import ModelTestModal from './modals/ModelTestModal.jsx';
import ColumnSelectorModal from './modals/ColumnSelectorModal.jsx';
import EditChannel from '../../../pages/Channel/EditChannel.js';
import EditTagModal from '../../../pages/Channel/EditTagModal.js';
const ChannelsPage = () => {
const channelsData = useChannelsData();
return (
<>
{/* Modals */}
<ColumnSelectorModal {...channelsData} />
<EditTagModal
visible={channelsData.showEditTag}
tag={channelsData.editingTag}
handleClose={() => channelsData.setShowEditTag(false)}
refresh={channelsData.refresh}
/>
<EditChannel
refresh={channelsData.refresh}
visible={channelsData.showEdit}
handleClose={channelsData.closeEdit}
editingChannel={channelsData.editingChannel}
/>
<BatchTagModal {...channelsData} />
<ModelTestModal {...channelsData} />
{/* Main Content */}
<CardPro
type="type3"
tabsArea={<ChannelsTabs {...channelsData} />}
actionsArea={<ChannelsActions {...channelsData} />}
searchArea={<ChannelsFilters {...channelsData} />}
>
<ChannelsTable {...channelsData} />
</CardPro>
</>
);
};
export default ChannelsPage;

View File

@@ -0,0 +1,41 @@
import React from 'react';
import { Modal, Input, Typography } from '@douyinfe/semi-ui';
const BatchTagModal = ({
showBatchSetTag,
setShowBatchSetTag,
batchSetChannelTag,
batchSetTagValue,
setBatchSetTagValue,
selectedChannels,
t
}) => {
return (
<Modal
title={t('批量设置标签')}
visible={showBatchSetTag}
onOk={batchSetChannelTag}
onCancel={() => setShowBatchSetTag(false)}
maskClosable={false}
centered={true}
size="small"
className="!rounded-lg"
>
<div className="mb-5">
<Typography.Text>{t('请输入要设置的标签名称')}</Typography.Text>
</div>
<Input
placeholder={t('请输入标签名称')}
value={batchSetTagValue}
onChange={(v) => setBatchSetTagValue(v)}
/>
<div className="mt-4">
<Typography.Text type='secondary'>
{t('已选择 ${count} 个渠道').replace('${count}', selectedChannels.length)}
</Typography.Text>
</div>
</Modal>
);
};
export default BatchTagModal;

View File

@@ -0,0 +1,114 @@
import React from 'react';
import { Modal, Button, Checkbox } from '@douyinfe/semi-ui';
import { getChannelsColumns } from '../ChannelsColumnDefs.js';
const ColumnSelectorModal = ({
showColumnSelector,
setShowColumnSelector,
visibleColumns,
handleColumnVisibilityChange,
handleSelectAll,
initDefaultColumns,
COLUMN_KEYS,
t,
// Props needed for getChannelsColumns
updateChannelBalance,
manageChannel,
manageTag,
submitTagEdit,
testChannel,
setCurrentTestChannel,
setShowModelTestModal,
setEditingChannel,
setShowEdit,
setShowEditTag,
setEditingTag,
copySelectedChannel,
refresh,
activePage,
channels,
}) => {
// Get all columns for display in selector
const allColumns = getChannelsColumns({
t,
COLUMN_KEYS,
updateChannelBalance,
manageChannel,
manageTag,
submitTagEdit,
testChannel,
setCurrentTestChannel,
setShowModelTestModal,
setEditingChannel,
setShowEdit,
setShowEditTag,
setEditingTag,
copySelectedChannel,
refresh,
activePage,
channels,
});
return (
<Modal
title={t('列设置')}
visible={showColumnSelector}
onCancel={() => setShowColumnSelector(false)}
footer={
<div className="flex justify-end">
<Button onClick={() => initDefaultColumns()}>
{t('重置')}
</Button>
<Button onClick={() => setShowColumnSelector(false)}>
{t('取消')}
</Button>
<Button onClick={() => setShowColumnSelector(false)}>
{t('确定')}
</Button>
</div>
}
>
<div style={{ marginBottom: 20 }}>
<Checkbox
checked={Object.values(visibleColumns).every((v) => v === true)}
indeterminate={
Object.values(visibleColumns).some((v) => v === true) &&
!Object.values(visibleColumns).every((v) => v === true)
}
onChange={(e) => handleSelectAll(e.target.checked)}
>
{t('全选')}
</Checkbox>
</div>
<div
className="flex flex-wrap max-h-96 overflow-y-auto rounded-lg p-4"
style={{ border: '1px solid var(--semi-color-border)' }}
>
{allColumns.map((column) => {
// Skip columns without title
if (!column.title) {
return null;
}
return (
<div
key={column.key}
className="w-1/2 mb-4 pr-2"
>
<Checkbox
checked={!!visibleColumns[column.key]}
onChange={(e) =>
handleColumnVisibilityChange(column.key, e.target.checked)
}
>
{column.title}
</Checkbox>
</div>
);
})}
</div>
</Modal>
);
};
export default ColumnSelectorModal;

View File

@@ -0,0 +1,256 @@
import React from 'react';
import {
Modal,
Button,
Input,
Table,
Tag,
Typography
} from '@douyinfe/semi-ui';
import { IconSearch } from '@douyinfe/semi-icons';
import { copy, showError, showInfo, showSuccess } from '../../../../helpers/index.js';
import { MODEL_TABLE_PAGE_SIZE } from '../../../../constants/index.js';
const ModelTestModal = ({
showModelTestModal,
currentTestChannel,
handleCloseModal,
isBatchTesting,
batchTestModels,
modelSearchKeyword,
setModelSearchKeyword,
selectedModelKeys,
setSelectedModelKeys,
modelTestResults,
testingModels,
testChannel,
modelTablePage,
setModelTablePage,
allSelectingRef,
isMobile,
t
}) => {
if (!showModelTestModal || !currentTestChannel) {
return null;
}
const filteredModels = currentTestChannel.models
.split(',')
.filter((model) =>
model.toLowerCase().includes(modelSearchKeyword.toLowerCase())
);
const handleCopySelected = () => {
if (selectedModelKeys.length === 0) {
showError(t('请先选择模型!'));
return;
}
copy(selectedModelKeys.join(',')).then((ok) => {
if (ok) {
showSuccess(t('已复制 ${count} 个模型').replace('${count}', selectedModelKeys.length));
} else {
showError(t('复制失败,请手动复制'));
}
});
};
const handleSelectSuccess = () => {
if (!currentTestChannel) return;
const successKeys = currentTestChannel.models
.split(',')
.filter((m) => m.toLowerCase().includes(modelSearchKeyword.toLowerCase()))
.filter((m) => {
const result = modelTestResults[`${currentTestChannel.id}-${m}`];
return result && result.success;
});
if (successKeys.length === 0) {
showInfo(t('暂无成功模型'));
}
setSelectedModelKeys(successKeys);
};
const columns = [
{
title: t('模型名称'),
dataIndex: 'model',
render: (text) => (
<div className="flex items-center">
<Typography.Text strong>{text}</Typography.Text>
</div>
)
},
{
title: t('状态'),
dataIndex: 'status',
render: (text, record) => {
const testResult = modelTestResults[`${currentTestChannel.id}-${record.model}`];
const isTesting = testingModels.has(record.model);
if (isTesting) {
return (
<Tag color='blue' shape='circle'>
{t('测试中')}
</Tag>
);
}
if (!testResult) {
return (
<Tag color='grey' shape='circle'>
{t('未开始')}
</Tag>
);
}
return (
<div className="flex items-center gap-2">
<Tag
color={testResult.success ? 'green' : 'red'}
shape='circle'
>
{testResult.success ? t('成功') : t('失败')}
</Tag>
{testResult.success && (
<Typography.Text type="tertiary">
{t('请求时长: ${time}s').replace('${time}', testResult.time.toFixed(2))}
</Typography.Text>
)}
</div>
);
}
},
{
title: '',
dataIndex: 'operate',
render: (text, record) => {
const isTesting = testingModels.has(record.model);
return (
<Button
type='tertiary'
onClick={() => testChannel(currentTestChannel, record.model)}
loading={isTesting}
size='small'
>
{t('测试')}
</Button>
);
}
}
];
const dataSource = (() => {
const start = (modelTablePage - 1) * MODEL_TABLE_PAGE_SIZE;
const end = start + MODEL_TABLE_PAGE_SIZE;
return filteredModels.slice(start, end).map((model) => ({
model,
key: model,
}));
})();
return (
<Modal
title={
<div className="flex flex-col gap-2 w-full">
<div className="flex items-center gap-2">
<Typography.Text strong className="!text-[var(--semi-color-text-0)] !text-base">
{currentTestChannel.name} {t('渠道的模型测试')}
</Typography.Text>
<Typography.Text type="tertiary" className="!text-xs flex items-center">
{t('共')} {currentTestChannel.models.split(',').length} {t('个模型')}
</Typography.Text>
</div>
</div>
}
visible={showModelTestModal}
onCancel={handleCloseModal}
footer={
<div className="flex justify-end">
{isBatchTesting ? (
<Button
type='danger'
onClick={handleCloseModal}
>
{t('停止测试')}
</Button>
) : (
<Button
type='tertiary'
onClick={handleCloseModal}
>
{t('取消')}
</Button>
)}
<Button
onClick={batchTestModels}
loading={isBatchTesting}
disabled={isBatchTesting}
>
{isBatchTesting ? t('测试中...') : t('批量测试${count}个模型').replace(
'${count}',
filteredModels.length
)}
</Button>
</div>
}
maskClosable={!isBatchTesting}
className="!rounded-lg"
size={isMobile ? 'full-width' : 'large'}
>
<div className="model-test-scroll">
{/* 搜索与操作按钮 */}
<div className="flex items-center justify-end gap-2 w-full mb-2">
<Input
placeholder={t('搜索模型...')}
value={modelSearchKeyword}
onChange={(v) => {
setModelSearchKeyword(v);
setModelTablePage(1);
}}
className="!w-full"
prefix={<IconSearch />}
showClear
/>
<Button onClick={handleCopySelected}>
{t('复制已选')}
</Button>
<Button
type='tertiary'
onClick={handleSelectSuccess}
>
{t('选择成功')}
</Button>
</div>
<Table
columns={columns}
dataSource={dataSource}
rowSelection={{
selectedRowKeys: selectedModelKeys,
onChange: (keys) => {
if (allSelectingRef.current) {
allSelectingRef.current = false;
return;
}
setSelectedModelKeys(keys);
},
onSelectAll: (checked) => {
allSelectingRef.current = true;
setSelectedModelKeys(checked ? filteredModels : []);
},
}}
pagination={{
currentPage: modelTablePage,
pageSize: MODEL_TABLE_PAGE_SIZE,
total: filteredModels.length,
showSizeChanger: false,
onPageChange: (page) => setModelTablePage(page),
}}
/>
</div>
</Modal>
);
};
export default ModelTestModal;