| | import React, { useEffect, useState } from 'react'; |
| | import { |
| | API, |
| | showError, |
| | showInfo, |
| | showSuccess, |
| | timestamp2string, |
| | renderGroup, |
| | renderNumberWithPoint, |
| | renderQuota |
| | } from '../../helpers/index.js'; |
| |
|
| | import { CHANNEL_OPTIONS, ITEMS_PER_PAGE } from '../../constants/index.js'; |
| | import { |
| | Button, |
| | Divider, |
| | Dropdown, |
| | Input, |
| | InputNumber, |
| | Modal, |
| | Space, |
| | SplitButtonGroup, |
| | Switch, |
| | Table, |
| | Tag, |
| | Tooltip, |
| | Typography, |
| | Checkbox, |
| | Card, |
| | Select |
| | } from '@douyinfe/semi-ui'; |
| | import EditChannel from '../../pages/Channel/EditChannel.js'; |
| | import { |
| | IconList, |
| | IconTreeTriangleDown, |
| | IconFilter, |
| | IconPlus, |
| | IconRefresh, |
| | IconSetting, |
| | IconSearch, |
| | IconEdit, |
| | IconDelete, |
| | IconStop, |
| | IconPlay, |
| | IconMore, |
| | IconCopy, |
| | IconSmallTriangleRight |
| | } from '@douyinfe/semi-icons'; |
| | import { loadChannelModels } from '../../helpers/index.js'; |
| | import EditTagModal from '../../pages/Channel/EditTagModal.js'; |
| | import { useTranslation } from 'react-i18next'; |
| |
|
| | const ChannelsTable = () => { |
| | const { t } = useTranslation(); |
| |
|
| | let type2label = undefined; |
| |
|
| | const renderType = (type) => { |
| | if (!type2label) { |
| | type2label = new Map(); |
| | for (let i = 0; i < CHANNEL_OPTIONS.length; i++) { |
| | type2label[CHANNEL_OPTIONS[i].value] = CHANNEL_OPTIONS[i]; |
| | } |
| | type2label[0] = { value: 0, label: t('未知类型'), color: 'grey' }; |
| | } |
| | return ( |
| | <Tag size='large' color={type2label[type]?.color} shape='circle'> |
| | {type2label[type]?.label} |
| | </Tag> |
| | ); |
| | }; |
| |
|
| | const renderTagType = () => { |
| | return ( |
| | <Tag |
| | color='light-blue' |
| | prefixIcon={<IconList />} |
| | size='large' |
| | shape='circle' |
| | type='light' |
| | > |
| | {t('标签聚合')} |
| | </Tag> |
| | ); |
| | }; |
| |
|
| | const renderStatus = (status) => { |
| | switch (status) { |
| | case 1: |
| | return ( |
| | <Tag size='large' color='green' shape='circle'> |
| | {t('已启用')} |
| | </Tag> |
| | ); |
| | case 2: |
| | return ( |
| | <Tag size='large' color='yellow' shape='circle'> |
| | {t('已禁用')} |
| | </Tag> |
| | ); |
| | case 3: |
| | return ( |
| | <Tag size='large' color='yellow' shape='circle'> |
| | {t('自动禁用')} |
| | </Tag> |
| | ); |
| | default: |
| | return ( |
| | <Tag size='large' color='grey' shape='circle'> |
| | {t('未知状态')} |
| | </Tag> |
| | ); |
| | } |
| | }; |
| |
|
| | const renderResponseTime = (responseTime) => { |
| | let time = responseTime / 1000; |
| | time = time.toFixed(2) + t(' 秒'); |
| | if (responseTime === 0) { |
| | return ( |
| | <Tag size='large' color='grey' shape='circle'> |
| | {t('未测试')} |
| | </Tag> |
| | ); |
| | } else if (responseTime <= 1000) { |
| | return ( |
| | <Tag size='large' color='green' shape='circle'> |
| | {time} |
| | </Tag> |
| | ); |
| | } else if (responseTime <= 3000) { |
| | return ( |
| | <Tag size='large' color='lime' shape='circle'> |
| | {time} |
| | </Tag> |
| | ); |
| | } else if (responseTime <= 5000) { |
| | return ( |
| | <Tag size='large' color='yellow' shape='circle'> |
| | {time} |
| | </Tag> |
| | ); |
| | } else { |
| | return ( |
| | <Tag size='large' color='red' shape='circle'> |
| | {time} |
| | </Tag> |
| | ); |
| | } |
| | }; |
| |
|
| | |
| | const COLUMN_KEYS = { |
| | ID: 'id', |
| | NAME: 'name', |
| | GROUP: 'group', |
| | TYPE: 'type', |
| | STATUS: 'status', |
| | RESPONSE_TIME: 'response_time', |
| | BALANCE: 'balance', |
| | PRIORITY: 'priority', |
| | WEIGHT: 'weight', |
| | OPERATE: 'operate', |
| | }; |
| |
|
| | |
| | const [visibleColumns, setVisibleColumns] = useState({}); |
| | const [showColumnSelector, setShowColumnSelector] = useState(false); |
| |
|
| | |
| | useEffect(() => { |
| | const savedColumns = localStorage.getItem('channels-table-columns'); |
| | if (savedColumns) { |
| | try { |
| | const parsed = JSON.parse(savedColumns); |
| | |
| | const defaults = getDefaultColumnVisibility(); |
| | const merged = { ...defaults, ...parsed }; |
| | setVisibleColumns(merged); |
| | } catch (e) { |
| | console.error('Failed to parse saved column preferences', e); |
| | initDefaultColumns(); |
| | } |
| | } else { |
| | initDefaultColumns(); |
| | } |
| | }, []); |
| |
|
| | |
| | useEffect(() => { |
| | if (Object.keys(visibleColumns).length > 0) { |
| | |
| | localStorage.setItem( |
| | 'channels-table-columns', |
| | JSON.stringify(visibleColumns), |
| | ); |
| | } |
| | }, [visibleColumns]); |
| |
|
| | |
| | const getDefaultColumnVisibility = () => { |
| | return { |
| | [COLUMN_KEYS.ID]: true, |
| | [COLUMN_KEYS.NAME]: true, |
| | [COLUMN_KEYS.GROUP]: true, |
| | [COLUMN_KEYS.TYPE]: true, |
| | [COLUMN_KEYS.STATUS]: true, |
| | [COLUMN_KEYS.RESPONSE_TIME]: true, |
| | [COLUMN_KEYS.BALANCE]: true, |
| | [COLUMN_KEYS.PRIORITY]: true, |
| | [COLUMN_KEYS.WEIGHT]: true, |
| | [COLUMN_KEYS.OPERATE]: true, |
| | }; |
| | }; |
| |
|
| | |
| | const initDefaultColumns = () => { |
| | const defaults = getDefaultColumnVisibility(); |
| | setVisibleColumns(defaults); |
| | }; |
| |
|
| | |
| | const handleColumnVisibilityChange = (columnKey, checked) => { |
| | const updatedColumns = { ...visibleColumns, [columnKey]: checked }; |
| | setVisibleColumns(updatedColumns); |
| | }; |
| |
|
| | |
| | const handleSelectAll = (checked) => { |
| | const allKeys = Object.keys(COLUMN_KEYS).map((key) => COLUMN_KEYS[key]); |
| | const updatedColumns = {}; |
| |
|
| | allKeys.forEach((key) => { |
| | updatedColumns[key] = checked; |
| | }); |
| |
|
| | setVisibleColumns(updatedColumns); |
| | }; |
| |
|
| | |
| | const allColumns = [ |
| | { |
| | key: COLUMN_KEYS.ID, |
| | title: t('ID'), |
| | dataIndex: 'id', |
| | }, |
| | { |
| | key: COLUMN_KEYS.NAME, |
| | title: t('名称'), |
| | dataIndex: 'name', |
| | }, |
| | { |
| | key: COLUMN_KEYS.GROUP, |
| | title: t('分组'), |
| | dataIndex: 'group', |
| | render: (text, record, index) => ( |
| | <div> |
| | <Space spacing={2}> |
| | {text |
| | ?.split(',') |
| | .sort((a, b) => { |
| | if (a === 'default') return -1; |
| | if (b === 'default') return 1; |
| | return a.localeCompare(b); |
| | }) |
| | .map((item, index) => renderGroup(item))} |
| | </Space> |
| | </div> |
| | ), |
| | }, |
| | { |
| | key: COLUMN_KEYS.TYPE, |
| | title: t('类型'), |
| | dataIndex: 'type', |
| | render: (text, record, index) => { |
| | if (record.children === undefined) { |
| | return <>{renderType(text)}</>; |
| | } else { |
| | return <>{renderTagType()}</>; |
| | } |
| | }, |
| | }, |
| | { |
| | key: COLUMN_KEYS.STATUS, |
| | title: t('状态'), |
| | dataIndex: 'status', |
| | render: (text, record, index) => { |
| | if (text === 3) { |
| | if (record.other_info === '') { |
| | record.other_info = '{}'; |
| | } |
| | let otherInfo = JSON.parse(record.other_info); |
| | let reason = otherInfo['status_reason']; |
| | let time = otherInfo['status_time']; |
| | return ( |
| | <div> |
| | <Tooltip |
| | content={t('原因:') + reason + t(',时间:') + timestamp2string(time)} |
| | > |
| | {renderStatus(text)} |
| | </Tooltip> |
| | </div> |
| | ); |
| | } else { |
| | return renderStatus(text); |
| | } |
| | }, |
| | }, |
| | { |
| | key: COLUMN_KEYS.RESPONSE_TIME, |
| | title: t('响应时间'), |
| | dataIndex: 'response_time', |
| | render: (text, record, index) => ( |
| | <div>{renderResponseTime(text)}</div> |
| | ), |
| | }, |
| | { |
| | key: COLUMN_KEYS.BALANCE, |
| | title: t('已用/剩余'), |
| | dataIndex: 'expired_time', |
| | render: (text, record, index) => { |
| | if (record.children === undefined) { |
| | return ( |
| | <div> |
| | <Space spacing={1}> |
| | <Tooltip content={t('已用额度')}> |
| | <Tag color='white' type='ghost' size='large' shape='circle'> |
| | {renderQuota(record.used_quota)} |
| | </Tag> |
| | </Tooltip> |
| | <Tooltip content={t('剩余额度') + record.balance + t(',点击更新')}> |
| | <Tag |
| | color='white' |
| | type='ghost' |
| | size='large' |
| | shape='circle' |
| | onClick={() => updateChannelBalance(record)} |
| | > |
| | ${renderNumberWithPoint(record.balance)} |
| | </Tag> |
| | </Tooltip> |
| | </Space> |
| | </div> |
| | ); |
| | } else { |
| | return ( |
| | <Tooltip content={t('已用额度')}> |
| | <Tag color='white' type='ghost' size='large' shape='circle'> |
| | {renderQuota(record.used_quota)} |
| | </Tag> |
| | </Tooltip> |
| | ); |
| | } |
| | }, |
| | }, |
| | { |
| | key: COLUMN_KEYS.PRIORITY, |
| | title: t('优先级'), |
| | dataIndex: 'priority', |
| | render: (text, record, index) => { |
| | if (record.children === undefined) { |
| | return ( |
| | <div> |
| | <InputNumber |
| | style={{ width: 70 }} |
| | name='priority' |
| | onBlur={(e) => { |
| | manageChannel(record.id, 'priority', record, e.target.value); |
| | }} |
| | keepFocus={true} |
| | innerButtons |
| | defaultValue={record.priority} |
| | min={-999} |
| | size="small" |
| | /> |
| | </div> |
| | ); |
| | } else { |
| | return ( |
| | <InputNumber |
| | style={{ width: 70 }} |
| | name='priority' |
| | keepFocus={true} |
| | onBlur={(e) => { |
| | Modal.warning({ |
| | title: t('修改子渠道优先级'), |
| | content: t('确定要修改所有子渠道优先级为 ') + e.target.value + t(' 吗?'), |
| | onOk: () => { |
| | if (e.target.value === '') { |
| | return; |
| | } |
| | submitTagEdit('priority', { |
| | tag: record.key, |
| | priority: e.target.value, |
| | }); |
| | }, |
| | }); |
| | }} |
| | innerButtons |
| | defaultValue={record.priority} |
| | min={-999} |
| | size="small" |
| | /> |
| | ); |
| | } |
| | }, |
| | }, |
| | { |
| | key: COLUMN_KEYS.WEIGHT, |
| | title: t('权重'), |
| | dataIndex: 'weight', |
| | render: (text, record, index) => { |
| | if (record.children === undefined) { |
| | return ( |
| | <div> |
| | <InputNumber |
| | style={{ width: 70 }} |
| | name='weight' |
| | onBlur={(e) => { |
| | manageChannel(record.id, 'weight', record, e.target.value); |
| | }} |
| | keepFocus={true} |
| | innerButtons |
| | defaultValue={record.weight} |
| | min={0} |
| | size="small" |
| | /> |
| | </div> |
| | ); |
| | } else { |
| | return ( |
| | <InputNumber |
| | style={{ width: 70 }} |
| | name='weight' |
| | keepFocus={true} |
| | onBlur={(e) => { |
| | Modal.warning({ |
| | title: t('修改子渠道权重'), |
| | content: t('确定要修改所有子渠道权重为 ') + e.target.value + t(' 吗?'), |
| | onOk: () => { |
| | if (e.target.value === '') { |
| | return; |
| | } |
| | submitTagEdit('weight', { |
| | tag: record.key, |
| | weight: e.target.value, |
| | }); |
| | }, |
| | }); |
| | }} |
| | innerButtons |
| | defaultValue={record.weight} |
| | min={-999} |
| | size="small" |
| | /> |
| | ); |
| | } |
| | }, |
| | }, |
| | { |
| | key: COLUMN_KEYS.OPERATE, |
| | title: '', |
| | dataIndex: 'operate', |
| | fixed: 'right', |
| | render: (text, record, index) => { |
| | if (record.children === undefined) { |
| | |
| | const moreMenuItems = [ |
| | { |
| | node: 'item', |
| | name: t('删除'), |
| | icon: <IconDelete />, |
| | type: 'danger', |
| | onClick: () => { |
| | Modal.confirm({ |
| | title: t('确定是否要删除此渠道?'), |
| | content: t('此修改将不可逆'), |
| | onOk: () => { |
| | manageChannel(record.id, 'delete', record).then(() => { |
| | removeRecord(record); |
| | }); |
| | }, |
| | }); |
| | }, |
| | }, |
| | { |
| | node: 'item', |
| | name: t('复制'), |
| | icon: <IconCopy />, |
| | type: 'primary', |
| | onClick: () => { |
| | Modal.confirm({ |
| | title: t('确定是否要复制此渠道?'), |
| | content: t('复制渠道的所有信息'), |
| | onOk: () => copySelectedChannel(record), |
| | }); |
| | }, |
| | }, |
| | ]; |
| |
|
| | return ( |
| | <Space wrap> |
| | <SplitButtonGroup |
| | className="!rounded-full overflow-hidden" |
| | aria-label={t('测试单个渠道操作项目组')} |
| | > |
| | <Button |
| | theme='light' |
| | size="small" |
| | onClick={() => testChannel(record, '')} |
| | > |
| | {t('测试')} |
| | </Button> |
| | <Button |
| | theme='light' |
| | size="small" |
| | icon={<IconTreeTriangleDown />} |
| | onClick={() => { |
| | setCurrentTestChannel(record); |
| | setShowModelTestModal(true); |
| | }} |
| | /> |
| | </SplitButtonGroup> |
| | |
| | {record.status === 1 ? ( |
| | <Button |
| | theme='light' |
| | type='warning' |
| | size="small" |
| | className="!rounded-full" |
| | icon={<IconStop />} |
| | onClick={() => manageChannel(record.id, 'disable', record)} |
| | > |
| | {t('禁用')} |
| | </Button> |
| | ) : ( |
| | <Button |
| | theme='light' |
| | type='secondary' |
| | size="small" |
| | className="!rounded-full" |
| | icon={<IconPlay />} |
| | onClick={() => manageChannel(record.id, 'enable', record)} |
| | > |
| | {t('启用')} |
| | </Button> |
| | )} |
| | |
| | <Button |
| | theme='light' |
| | type='tertiary' |
| | size="small" |
| | className="!rounded-full" |
| | icon={<IconEdit />} |
| | onClick={() => { |
| | setEditingChannel(record); |
| | setShowEdit(true); |
| | }} |
| | > |
| | {t('编辑')} |
| | </Button> |
| | |
| | <Dropdown |
| | trigger='click' |
| | position='bottomRight' |
| | menu={moreMenuItems} |
| | > |
| | <Button |
| | icon={<IconMore />} |
| | theme='light' |
| | type='tertiary' |
| | size="small" |
| | className="!rounded-full" |
| | /> |
| | </Dropdown> |
| | </Space> |
| | ); |
| | } else { |
| | |
| | const tagMenuItems = [ |
| | { |
| | node: 'item', |
| | name: t('编辑'), |
| | icon: <IconEdit />, |
| | onClick: () => { |
| | setShowEditTag(true); |
| | setEditingTag(record.key); |
| | }, |
| | }, |
| | ]; |
| |
|
| | return ( |
| | <Space wrap> |
| | <Button |
| | theme='light' |
| | type='secondary' |
| | size="small" |
| | className="!rounded-full" |
| | icon={<IconPlay />} |
| | onClick={() => manageTag(record.key, 'enable')} |
| | > |
| | {t('启用全部')} |
| | </Button> |
| | <Button |
| | theme='light' |
| | type='warning' |
| | size="small" |
| | className="!rounded-full" |
| | icon={<IconStop />} |
| | onClick={() => manageTag(record.key, 'disable')} |
| | > |
| | {t('禁用全部')} |
| | </Button> |
| | <Dropdown |
| | trigger='click' |
| | position='bottomRight' |
| | menu={tagMenuItems} |
| | > |
| | <Button |
| | icon={<IconMore />} |
| | theme='light' |
| | type='tertiary' |
| | size="small" |
| | className="!rounded-full" |
| | /> |
| | </Dropdown> |
| | </Space> |
| | ); |
| | } |
| | }, |
| | }, |
| | ]; |
| |
|
| | |
| | const getVisibleColumns = () => { |
| | return allColumns.filter((column) => visibleColumns[column.key]); |
| | }; |
| |
|
| | |
| | const renderColumnSelector = () => { |
| | return ( |
| | <Modal |
| | title={t('列设置')} |
| | visible={showColumnSelector} |
| | onCancel={() => setShowColumnSelector(false)} |
| | footer={ |
| | <div className="flex justify-end"> |
| | <Button |
| | theme="light" |
| | onClick={() => initDefaultColumns()} |
| | className="!rounded-full" |
| | > |
| | {t('重置')} |
| | </Button> |
| | <Button |
| | theme="light" |
| | onClick={() => setShowColumnSelector(false)} |
| | className="!rounded-full" |
| | > |
| | {t('取消')} |
| | </Button> |
| | <Button |
| | type='primary' |
| | onClick={() => setShowColumnSelector(false)} |
| | className="!rounded-full" |
| | > |
| | {t('确定')} |
| | </Button> |
| | </div> |
| | } |
| | size="middle" |
| | centered={true} |
| | > |
| | <div style={{ marginBottom: 20 }}> |
| | <Checkbox |
| | checked={Object.values(visibleColumns).every((v) => v === true)} |
| | indeterminate={ |
| | Object.values(visibleColumns).some((v) => v === true) && |
| | !Object.values(visibleColumns).every((v) => v === true) |
| | } |
| | onChange={(e) => handleSelectAll(e.target.checked)} |
| | > |
| | {t('全选')} |
| | </Checkbox> |
| | </div> |
| | <div |
| | className="flex flex-wrap max-h-96 overflow-y-auto rounded-lg p-4" |
| | style={{ border: '1px solid var(--semi-color-border)' }} |
| | > |
| | {allColumns.map((column) => { |
| | // Skip columns without title |
| | if (!column.title) { |
| | return null; |
| | } |
| | |
| | return ( |
| | <div |
| | key={column.key} |
| | className="w-1/2 mb-4 pr-2" |
| | > |
| | <Checkbox |
| | checked={!!visibleColumns[column.key]} |
| | onChange={(e) => |
| | handleColumnVisibilityChange(column.key, e.target.checked) |
| | } |
| | > |
| | {column.title} |
| | </Checkbox> |
| | </div> |
| | ); |
| | })} |
| | </div> |
| | </Modal> |
| | ); |
| | }; |
| |
|
| | const [channels, setChannels] = useState([]); |
| | const [loading, setLoading] = useState(true); |
| | const [activePage, setActivePage] = useState(1); |
| | const [idSort, setIdSort] = useState(false); |
| | const [searchKeyword, setSearchKeyword] = useState(''); |
| | const [searchGroup, setSearchGroup] = useState(''); |
| | const [searchModel, setSearchModel] = useState(''); |
| | const [searching, setSearching] = useState(false); |
| | const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE); |
| | const [channelCount, setChannelCount] = useState(pageSize); |
| | const [groupOptions, setGroupOptions] = useState([]); |
| | const [showEdit, setShowEdit] = useState(false); |
| | const [enableBatchDelete, setEnableBatchDelete] = useState(false); |
| | const [editingChannel, setEditingChannel] = useState({ |
| | id: undefined, |
| | }); |
| | const [showEditTag, setShowEditTag] = useState(false); |
| | const [editingTag, setEditingTag] = useState(''); |
| | const [selectedChannels, setSelectedChannels] = useState([]); |
| | const [enableTagMode, setEnableTagMode] = useState(false); |
| | const [showBatchSetTag, setShowBatchSetTag] = useState(false); |
| | const [batchSetTagValue, setBatchSetTagValue] = useState(''); |
| | const [showModelTestModal, setShowModelTestModal] = useState(false); |
| | const [currentTestChannel, setCurrentTestChannel] = useState(null); |
| | const [modelSearchKeyword, setModelSearchKeyword] = useState(''); |
| | const [modelTestResults, setModelTestResults] = useState({}); |
| | const [testingModels, setTestingModels] = useState(new Set()); |
| | const [isBatchTesting, setIsBatchTesting] = useState(false); |
| | const [testQueue, setTestQueue] = useState([]); |
| | const [isProcessingQueue, setIsProcessingQueue] = useState(false); |
| |
|
| | const removeRecord = (record) => { |
| | let newDataSource = [...channels]; |
| | if (record.id != null) { |
| | let idx = newDataSource.findIndex((data) => { |
| | if (data.children !== undefined) { |
| | for (let i = 0; i < data.children.length; i++) { |
| | if (data.children[i].id === record.id) { |
| | data.children.splice(i, 1); |
| | return false; |
| | } |
| | } |
| | } else { |
| | return data.id === record.id; |
| | } |
| | }); |
| |
|
| | if (idx > -1) { |
| | newDataSource.splice(idx, 1); |
| | setChannels(newDataSource); |
| | } |
| | } |
| | }; |
| |
|
| | const setChannelFormat = (channels, enableTagMode) => { |
| | let channelDates = []; |
| | let channelTags = {}; |
| | for (let i = 0; i < channels.length; i++) { |
| | channels[i].key = '' + channels[i].id; |
| | if (!enableTagMode) { |
| | channelDates.push(channels[i]); |
| | } else { |
| | let tag = channels[i].tag ? channels[i].tag : ''; |
| | |
| | let tagIndex = channelTags[tag]; |
| | let tagChannelDates = undefined; |
| | if (tagIndex === undefined) { |
| | |
| | channelTags[tag] = 1; |
| | tagChannelDates = { |
| | key: tag, |
| | id: tag, |
| | tag: tag, |
| | name: '标签:' + tag, |
| | group: '', |
| | used_quota: 0, |
| | response_time: 0, |
| | priority: -1, |
| | weight: -1, |
| | }; |
| | tagChannelDates.children = []; |
| | channelDates.push(tagChannelDates); |
| | } else { |
| | |
| | tagChannelDates = channelDates.find((item) => item.key === tag); |
| | } |
| | if (tagChannelDates.priority === -1) { |
| | tagChannelDates.priority = channels[i].priority; |
| | } else { |
| | if (tagChannelDates.priority !== channels[i].priority) { |
| | tagChannelDates.priority = ''; |
| | } |
| | } |
| | if (tagChannelDates.weight === -1) { |
| | tagChannelDates.weight = channels[i].weight; |
| | } else { |
| | if (tagChannelDates.weight !== channels[i].weight) { |
| | tagChannelDates.weight = ''; |
| | } |
| | } |
| |
|
| | if (tagChannelDates.group === '') { |
| | tagChannelDates.group = channels[i].group; |
| | } else { |
| | let channelGroupsStr = channels[i].group; |
| | channelGroupsStr.split(',').forEach((item, index) => { |
| | if (tagChannelDates.group.indexOf(item) === -1) { |
| | |
| | tagChannelDates.group += ',' + item; |
| | } |
| | }); |
| | } |
| |
|
| | tagChannelDates.children.push(channels[i]); |
| | if (channels[i].status === 1) { |
| | tagChannelDates.status = 1; |
| | } |
| | tagChannelDates.used_quota += channels[i].used_quota; |
| | tagChannelDates.response_time += channels[i].response_time; |
| | tagChannelDates.response_time = tagChannelDates.response_time / 2; |
| | } |
| | } |
| | |
| | setChannels(channelDates); |
| | if (channelDates.length >= pageSize) { |
| | setChannelCount(channelDates.length + pageSize); |
| | } else { |
| | setChannelCount(channelDates.length); |
| | } |
| | }; |
| |
|
| | const loadChannels = async (startIdx, pageSize, idSort, enableTagMode) => { |
| | setLoading(true); |
| | const res = await API.get( |
| | `/api/channel/?p=${startIdx}&page_size=${pageSize}&id_sort=${idSort}&tag_mode=${enableTagMode}`, |
| | ); |
| | if (res === undefined) { |
| | return; |
| | } |
| | const { success, message, data } = res.data; |
| | if (success) { |
| | if (startIdx === 0) { |
| | setChannelFormat(data, enableTagMode); |
| | } else { |
| | let newChannels = [...channels]; |
| | newChannels.splice(startIdx * pageSize, data.length, ...data); |
| | setChannelFormat(newChannels, enableTagMode); |
| | } |
| | } else { |
| | showError(message); |
| | } |
| | setLoading(false); |
| | }; |
| |
|
| | const copySelectedChannel = async (record) => { |
| | const channelToCopy = { ...record }; |
| | channelToCopy.name += t('_复制'); |
| | channelToCopy.created_time = null; |
| | channelToCopy.balance = 0; |
| | channelToCopy.used_quota = 0; |
| | |
| | delete channelToCopy.test_time; |
| | delete channelToCopy.response_time; |
| | if (!channelToCopy) { |
| | showError(t('渠道未找到,请刷新页面后重试。')); |
| | return; |
| | } |
| | try { |
| | const newChannel = { ...channelToCopy, id: undefined }; |
| | const response = await API.post('/api/channel/', newChannel); |
| | if (response.data.success) { |
| | showSuccess(t('渠道复制成功')); |
| | await refresh(); |
| | } else { |
| | showError(response.data.message); |
| | } |
| | } catch (error) { |
| | showError(t('渠道复制失败: ') + error.message); |
| | } |
| | }; |
| |
|
| | const refresh = async () => { |
| | if (searchKeyword === '' && searchGroup === '' && searchModel === '') { |
| | await loadChannels(activePage - 1, pageSize, idSort, enableTagMode); |
| | } else { |
| | await searchChannels( |
| | searchKeyword, |
| | searchGroup, |
| | searchModel, |
| | enableTagMode, |
| | ); |
| | } |
| | }; |
| |
|
| | useEffect(() => { |
| | |
| | const localIdSort = localStorage.getItem('id-sort') === 'true'; |
| | const localPageSize = |
| | parseInt(localStorage.getItem('page-size')) || ITEMS_PER_PAGE; |
| | const localEnableTagMode = localStorage.getItem('enable-tag-mode') === 'true'; |
| | const localEnableBatchDelete = localStorage.getItem('enable-batch-delete') === 'true'; |
| | setIdSort(localIdSort); |
| | setPageSize(localPageSize); |
| | setEnableTagMode(localEnableTagMode); |
| | setEnableBatchDelete(localEnableBatchDelete); |
| | loadChannels(0, localPageSize, localIdSort, localEnableTagMode) |
| | .then() |
| | .catch((reason) => { |
| | showError(reason); |
| | }); |
| | fetchGroups().then(); |
| | loadChannelModels().then(); |
| | }, []); |
| |
|
| | const manageChannel = async (id, action, record, value) => { |
| | let data = { id }; |
| | let res; |
| | switch (action) { |
| | case 'delete': |
| | res = await API.delete(`/api/channel/${id}/`); |
| | break; |
| | case 'enable': |
| | data.status = 1; |
| | res = await API.put('/api/channel/', data); |
| | break; |
| | case 'disable': |
| | data.status = 2; |
| | res = await API.put('/api/channel/', data); |
| | break; |
| | case 'priority': |
| | if (value === '') { |
| | return; |
| | } |
| | data.priority = parseInt(value); |
| | res = await API.put('/api/channel/', data); |
| | break; |
| | case 'weight': |
| | if (value === '') { |
| | return; |
| | } |
| | data.weight = parseInt(value); |
| | if (data.weight < 0) { |
| | data.weight = 0; |
| | } |
| | res = await API.put('/api/channel/', data); |
| | break; |
| | } |
| | const { success, message } = res.data; |
| | if (success) { |
| | showSuccess(t('操作成功完成!')); |
| | let channel = res.data.data; |
| | let newChannels = [...channels]; |
| | if (action === 'delete') { |
| | } else { |
| | record.status = channel.status; |
| | } |
| | setChannels(newChannels); |
| | } else { |
| | showError(message); |
| | } |
| | }; |
| |
|
| | const manageTag = async (tag, action) => { |
| | console.log(tag, action); |
| | let res; |
| | switch (action) { |
| | case 'enable': |
| | res = await API.post('/api/channel/tag/enabled', { |
| | tag: tag, |
| | }); |
| | break; |
| | case 'disable': |
| | res = await API.post('/api/channel/tag/disabled', { |
| | tag: tag, |
| | }); |
| | break; |
| | } |
| | const { success, message } = res.data; |
| | if (success) { |
| | showSuccess('操作成功完成!'); |
| | let newChannels = [...channels]; |
| | for (let i = 0; i < newChannels.length; i++) { |
| | if (newChannels[i].tag === tag) { |
| | let status = action === 'enable' ? 1 : 2; |
| | newChannels[i]?.children?.forEach((channel) => { |
| | channel.status = status; |
| | }); |
| | newChannels[i].status = status; |
| | } |
| | } |
| | setChannels(newChannels); |
| | } else { |
| | showError(message); |
| | } |
| | }; |
| |
|
| | const searchChannels = async ( |
| | searchKeyword, |
| | searchGroup, |
| | searchModel, |
| | enableTagMode, |
| | ) => { |
| | if (searchKeyword === '' && searchGroup === '' && searchModel === '') { |
| | await loadChannels(activePage - 1, pageSize, idSort, enableTagMode); |
| | |
| | return; |
| | } |
| | setSearching(true); |
| | const res = await API.get( |
| | `/api/channel/search?keyword=${searchKeyword}&group=${searchGroup}&model=${searchModel}&id_sort=${idSort}&tag_mode=${enableTagMode}`, |
| | ); |
| | const { success, message, data } = res.data; |
| | if (success) { |
| | setChannelFormat(data, enableTagMode); |
| | setActivePage(1); |
| | } else { |
| | showError(message); |
| | } |
| | setSearching(false); |
| | }; |
| |
|
| | const updateChannelProperty = (channelId, updateFn) => { |
| | |
| | const newChannels = [...channels]; |
| | let updated = false; |
| |
|
| | |
| | newChannels.forEach((channel) => { |
| | if (channel.children !== undefined) { |
| | |
| | channel.children.forEach((child) => { |
| | if (child.id === channelId) { |
| | updateFn(child); |
| | updated = true; |
| | } |
| | }); |
| | } else if (channel.id === channelId) { |
| | |
| | updateFn(channel); |
| | updated = true; |
| | } |
| | }); |
| |
|
| | |
| | if (updated) { |
| | setChannels(newChannels); |
| | } |
| | }; |
| |
|
| | const processTestQueue = async () => { |
| | if (!isProcessingQueue || testQueue.length === 0) return; |
| |
|
| | const { channel, model } = testQueue[0]; |
| |
|
| | try { |
| | setTestingModels(prev => new Set([...prev, model])); |
| | const res = await API.get(`/api/channel/test/${channel.id}?model=${model}`); |
| | const { success, message, time } = res.data; |
| |
|
| | setModelTestResults(prev => ({ |
| | ...prev, |
| | [`${channel.id}-${model}`]: { success, time } |
| | })); |
| |
|
| | if (success) { |
| | updateChannelProperty(channel.id, (ch) => { |
| | ch.response_time = time * 1000; |
| | ch.test_time = Date.now() / 1000; |
| | }); |
| | if (!model) { |
| | showInfo( |
| | t('通道 ${name} 测试成功,耗时 ${time.toFixed(2)} 秒。') |
| | .replace('${name}', channel.name) |
| | .replace('${time.toFixed(2)}', time.toFixed(2)), |
| | ); |
| | } |
| | } else { |
| | showError(message); |
| | } |
| | } catch (error) { |
| | showError(error.message); |
| | } finally { |
| | setTestingModels(prev => { |
| | const newSet = new Set(prev); |
| | newSet.delete(model); |
| | return newSet; |
| | }); |
| | } |
| |
|
| | |
| | setTestQueue(prev => prev.slice(1)); |
| | }; |
| |
|
| | |
| | useEffect(() => { |
| | if (testQueue.length > 0 && isProcessingQueue) { |
| | processTestQueue(); |
| | } else if (testQueue.length === 0 && isProcessingQueue) { |
| | setIsProcessingQueue(false); |
| | setIsBatchTesting(false); |
| | } |
| | }, [testQueue, isProcessingQueue]); |
| |
|
| | const testChannel = async (record, model) => { |
| | setTestQueue(prev => [...prev, { channel: record, model }]); |
| | if (!isProcessingQueue) { |
| | setIsProcessingQueue(true); |
| | } |
| | }; |
| |
|
| | const batchTestModels = async () => { |
| | if (!currentTestChannel) return; |
| |
|
| | setIsBatchTesting(true); |
| |
|
| | const models = currentTestChannel.models |
| | .split(',') |
| | .filter((model) => |
| | model.toLowerCase().includes(modelSearchKeyword.toLowerCase()) |
| | ); |
| |
|
| | setTestQueue(models.map(model => ({ |
| | channel: currentTestChannel, |
| | model |
| | }))); |
| | setIsProcessingQueue(true); |
| | }; |
| |
|
| | const handleCloseModal = () => { |
| | if (isBatchTesting) { |
| | |
| | setTestQueue([]); |
| | setIsProcessingQueue(false); |
| | setIsBatchTesting(false); |
| | showSuccess(t('已停止测试')); |
| | } else { |
| | setShowModelTestModal(false); |
| | setModelSearchKeyword(''); |
| | } |
| | }; |
| |
|
| | let pageData = channels.slice( |
| | (activePage - 1) * pageSize, |
| | activePage * pageSize, |
| | ); |
| |
|
| | const handlePageChange = (page) => { |
| | setActivePage(page); |
| | if (page === Math.ceil(channels.length / pageSize) + 1) { |
| | |
| | loadChannels(page - 1, pageSize, idSort, enableTagMode).then((r) => { }); |
| | } |
| | }; |
| |
|
| | const handlePageSizeChange = async (size) => { |
| | localStorage.setItem('page-size', size + ''); |
| | setPageSize(size); |
| | setActivePage(1); |
| | loadChannels(0, size, idSort, enableTagMode) |
| | .then() |
| | .catch((reason) => { |
| | showError(reason); |
| | }); |
| | }; |
| |
|
| | const fetchGroups = async () => { |
| | try { |
| | let res = await API.get(`/api/group/`); |
| | |
| | |
| | if (res === undefined) { |
| | return; |
| | } |
| | setGroupOptions( |
| | res.data.data.map((group) => ({ |
| | label: group, |
| | value: group, |
| | })), |
| | ); |
| | } catch (error) { |
| | showError(error.message); |
| | } |
| | }; |
| |
|
| | const submitTagEdit = async (type, data) => { |
| | switch (type) { |
| | case 'priority': |
| | if (data.priority === undefined || data.priority === '') { |
| | showInfo('优先级必须是整数!'); |
| | return; |
| | } |
| | data.priority = parseInt(data.priority); |
| | break; |
| | case 'weight': |
| | if ( |
| | data.weight === undefined || |
| | data.weight < 0 || |
| | data.weight === '' |
| | ) { |
| | showInfo('权重必须是非负整数!'); |
| | return; |
| | } |
| | data.weight = parseInt(data.weight); |
| | break; |
| | } |
| |
|
| | try { |
| | const res = await API.put('/api/channel/tag', data); |
| | if (res?.data?.success) { |
| | showSuccess('更新成功!'); |
| | await refresh(); |
| | } |
| | } catch (error) { |
| | showError(error); |
| | } |
| | }; |
| |
|
| | const closeEdit = () => { |
| | setShowEdit(false); |
| | }; |
| |
|
| | const handleRow = (record, index) => { |
| | if (record.status !== 1) { |
| | return { |
| | style: { |
| | background: 'var(--semi-color-disabled-border)', |
| | }, |
| | }; |
| | } else { |
| | return {}; |
| | } |
| | }; |
| |
|
| | const batchSetChannelTag = async () => { |
| | if (selectedChannels.length === 0) { |
| | showError(t('请先选择要设置标签的渠道!')); |
| | return; |
| | } |
| | if (batchSetTagValue === '') { |
| | showError(t('标签不能为空!')); |
| | return; |
| | } |
| | let ids = selectedChannels.map((channel) => channel.id); |
| | const res = await API.post('/api/channel/batch/tag', { |
| | ids: ids, |
| | tag: batchSetTagValue === '' ? null : batchSetTagValue, |
| | }); |
| | if (res.data.success) { |
| | showSuccess( |
| | t('已为 ${count} 个渠道设置标签!').replace('${count}', res.data.data), |
| | ); |
| | await refresh(); |
| | setShowBatchSetTag(false); |
| | } else { |
| | showError(res.data.message); |
| | } |
| | }; |
| |
|
| | const testAllChannels = async () => { |
| | const res = await API.get(`/api/channel/test`); |
| | const { success, message } = res.data; |
| | if (success) { |
| | showInfo(t('已成功开始测试所有已启用通道,请刷新页面查看结果。')); |
| | } else { |
| | showError(message); |
| | } |
| | }; |
| |
|
| | const deleteAllDisabledChannels = async () => { |
| | const res = await API.delete(`/api/channel/disabled`); |
| | const { success, message, data } = res.data; |
| | if (success) { |
| | showSuccess( |
| | t('已删除所有禁用渠道,共计 ${data} 个').replace('${data}', data), |
| | ); |
| | await refresh(); |
| | } else { |
| | showError(message); |
| | } |
| | }; |
| |
|
| | const updateAllChannelsBalance = async () => { |
| | const res = await API.get(`/api/channel/update_balance`); |
| | const { success, message } = res.data; |
| | if (success) { |
| | showInfo(t('已更新完毕所有已启用通道余额!')); |
| | } else { |
| | showError(message); |
| | } |
| | }; |
| |
|
| | const updateChannelBalance = async (record) => { |
| | const res = await API.get(`/api/channel/update_balance/${record.id}/`); |
| | const { success, message, balance } = res.data; |
| | if (success) { |
| | updateChannelProperty(record.id, (channel) => { |
| | channel.balance = balance; |
| | channel.balance_updated_time = Date.now() / 1000; |
| | }); |
| | showInfo( |
| | t('通道 ${name} 余额更新成功!').replace('${name}', record.name), |
| | ); |
| | } else { |
| | showError(message); |
| | } |
| | }; |
| |
|
| | const batchDeleteChannels = async () => { |
| | if (selectedChannels.length === 0) { |
| | showError(t('请先选择要删除的通道!')); |
| | return; |
| | } |
| | setLoading(true); |
| | let ids = []; |
| | selectedChannels.forEach((channel) => { |
| | ids.push(channel.id); |
| | }); |
| | const res = await API.post(`/api/channel/batch`, { ids: ids }); |
| | const { success, message, data } = res.data; |
| | if (success) { |
| | showSuccess(t('已删除 ${data} 个通道!').replace('${data}', data)); |
| | await refresh(); |
| | } else { |
| | showError(message); |
| | } |
| | setLoading(false); |
| | }; |
| |
|
| | const fixChannelsAbilities = async () => { |
| | const res = await API.post(`/api/channel/fix`); |
| | const { success, message, data } = res.data; |
| | if (success) { |
| | showSuccess(t('已修复 ${data} 个通道!').replace('${data}', data)); |
| | await refresh(); |
| | } else { |
| | showError(message); |
| | } |
| | }; |
| |
|
| | const renderHeader = () => ( |
| | <div className="flex flex-col w-full"> |
| | <div className="flex flex-col md:flex-row justify-between gap-4"> |
| | <div className="flex flex-wrap md:flex-nowrap items-center gap-2 w-full md:w-auto order-2 md:order-1"> |
| | <Button |
| | disabled={!enableBatchDelete} |
| | theme='light' |
| | type='danger' |
| | className="!rounded-full w-full md:w-auto" |
| | onClick={() => { |
| | Modal.confirm({ |
| | title: t('确定是否要删除所选通道?'), |
| | content: t('此修改将不可逆'), |
| | onOk: () => batchDeleteChannels(), |
| | }); |
| | }} |
| | > |
| | {t('删除所选通道')} |
| | </Button> |
| | |
| | <Button |
| | disabled={!enableBatchDelete} |
| | theme='light' |
| | type='primary' |
| | onClick={() => setShowBatchSetTag(true)} |
| | className="!rounded-full w-full md:w-auto" |
| | > |
| | {t('批量设置标签')} |
| | </Button> |
| | |
| | <Dropdown |
| | trigger='click' |
| | render={ |
| | <Dropdown.Menu> |
| | <Dropdown.Item> |
| | <Button |
| | theme='light' |
| | type='warning' |
| | className="!rounded-full w-full" |
| | onClick={() => { |
| | Modal.confirm({ |
| | title: t('确定?'), |
| | content: t('确定要测试所有通道吗?'), |
| | onOk: () => testAllChannels(), |
| | size: 'small', |
| | centered: true, |
| | }); |
| | }} |
| | > |
| | {t('测试所有通道')} |
| | </Button> |
| | </Dropdown.Item> |
| | <Dropdown.Item> |
| | <Button |
| | theme='light' |
| | type='secondary' |
| | className="!rounded-full w-full" |
| | onClick={() => { |
| | Modal.confirm({ |
| | title: t('确定?'), |
| | content: t('确定要更新所有已启用通道余额吗?'), |
| | onOk: () => updateAllChannelsBalance(), |
| | size: 'sm', |
| | centered: true, |
| | }); |
| | }} |
| | > |
| | {t('更新所有已启用通道余额')} |
| | </Button> |
| | </Dropdown.Item> |
| | <Dropdown.Item> |
| | <Button |
| | theme='light' |
| | type='danger' |
| | className="!rounded-full w-full" |
| | onClick={() => { |
| | Modal.confirm({ |
| | title: t('确定是否要删除禁用通道?'), |
| | content: t('此修改将不可逆'), |
| | onOk: () => deleteAllDisabledChannels(), |
| | size: 'sm', |
| | centered: true, |
| | }); |
| | }} |
| | > |
| | {t('删除禁用通道')} |
| | </Button> |
| | </Dropdown.Item> |
| | <Dropdown.Item> |
| | <Button |
| | theme='light' |
| | type='tertiary' |
| | className="!rounded-full w-full" |
| | onClick={() => { |
| | Modal.confirm({ |
| | title: t('确定是否要修复数据库一致性?'), |
| | content: t('进行该操作时,可能导致渠道访问错误,请仅在数据库出现问题时使用'), |
| | onOk: () => fixChannelsAbilities(), |
| | size: 'sm', |
| | centered: true, |
| | }); |
| | }} |
| | > |
| | {t('修复数据库一致性')} |
| | </Button> |
| | </Dropdown.Item> |
| | </Dropdown.Menu> |
| | } |
| | > |
| | <Button theme='light' type='tertiary' icon={<IconSetting />} className="!rounded-full w-full md:w-auto"> |
| | {t('批量操作')} |
| | </Button> |
| | </Dropdown> |
| | </div> |
| | |
| | <div className="flex flex-col md:flex-row items-start md:items-center gap-4 w-full md:w-auto order-1 md:order-2"> |
| | <div className="flex items-center justify-between w-full md:w-auto"> |
| | <Typography.Text strong className="mr-2"> |
| | {t('使用ID排序')} |
| | </Typography.Text> |
| | <Switch |
| | checked={idSort} |
| | onChange={(v) => { |
| | localStorage.setItem('id-sort', v + ''); |
| | setIdSort(v); |
| | loadChannels(0, pageSize, v, enableTagMode); |
| | }} |
| | /> |
| | </div> |
| | |
| | <div className="flex items-center justify-between w-full md:w-auto"> |
| | <Typography.Text strong className="mr-2"> |
| | {t('开启批量操作')} |
| | </Typography.Text> |
| | <Switch |
| | 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 |
| | checked={enableTagMode} |
| | onChange={(v) => { |
| | localStorage.setItem('enable-tag-mode', v + ''); |
| | setEnableTagMode(v); |
| | loadChannels(0, pageSize, idSort, v); |
| | }} |
| | /> |
| | </div> |
| | </div> |
| | </div> |
| | |
| | <Divider margin="12px" /> |
| | |
| | <div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full"> |
| | <div className="flex gap-2 w-full md:w-auto order-2 md:order-1"> |
| | <Button |
| | theme='light' |
| | type='primary' |
| | icon={<IconPlus />} |
| | className="!rounded-full w-full md:w-auto" |
| | onClick={() => { |
| | setEditingChannel({ |
| | id: undefined, |
| | }); |
| | setShowEdit(true); |
| | }} |
| | > |
| | {t('添加渠道')} |
| | </Button> |
| | |
| | <Button |
| | theme='light' |
| | type='primary' |
| | icon={<IconRefresh />} |
| | className="!rounded-full w-full md:w-auto" |
| | onClick={refresh} |
| | > |
| | {t('刷新')} |
| | </Button> |
| | |
| | <Button |
| | theme='light' |
| | type='tertiary' |
| | icon={<IconSetting />} |
| | onClick={() => setShowColumnSelector(true)} |
| | className="!rounded-full w-full md:w-auto" |
| | > |
| | {t('列设置')} |
| | </Button> |
| | </div> |
| | |
| | <div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto order-1 md:order-2"> |
| | <div className="relative w-full md:w-64"> |
| | <Input |
| | prefix={<IconSearch />} |
| | placeholder={t('搜索渠道的 ID,名称,密钥和API地址 ...')} |
| | value={searchKeyword} |
| | loading={searching} |
| | onChange={(v) => { |
| | setSearchKeyword(v.trim()); |
| | }} |
| | className="!rounded-full" |
| | showClear |
| | /> |
| | </div> |
| | <div className="w-full md:w-48"> |
| | <Input |
| | prefix={<IconFilter />} |
| | placeholder={t('模型关键字')} |
| | value={searchModel} |
| | loading={searching} |
| | onChange={(v) => { |
| | setSearchModel(v.trim()); |
| | }} |
| | className="!rounded-full" |
| | showClear |
| | /> |
| | </div> |
| | <div className="w-full md:w-48"> |
| | <Select |
| | placeholder={t('选择分组')} |
| | optionList={[ |
| | { label: t('选择分组'), value: null }, |
| | ...groupOptions, |
| | ]} |
| | value={searchGroup} |
| | onChange={(v) => { |
| | setSearchGroup(v); |
| | searchChannels(searchKeyword, v, searchModel, enableTagMode); |
| | }} |
| | className="!rounded-full w-full" |
| | showClear |
| | /> |
| | </div> |
| | <Button |
| | type="primary" |
| | onClick={() => { |
| | searchChannels(searchKeyword, searchGroup, searchModel, enableTagMode); |
| | }} |
| | loading={searching} |
| | className="!rounded-full w-full md:w-auto" |
| | > |
| | {t('查询')} |
| | </Button> |
| | </div> |
| | </div> |
| | </div> |
| | ); |
| |
|
| | return ( |
| | <> |
| | {renderColumnSelector()} |
| | <EditTagModal |
| | visible={showEditTag} |
| | tag={editingTag} |
| | handleClose={() => setShowEditTag(false)} |
| | refresh={refresh} |
| | /> |
| | <EditChannel |
| | refresh={refresh} |
| | visible={showEdit} |
| | handleClose={closeEdit} |
| | editingChannel={editingChannel} |
| | /> |
| | |
| | <Card |
| | className="!rounded-2xl" |
| | title={renderHeader()} |
| | shadows='always' |
| | bordered={false} |
| | > |
| | <Table |
| | columns={getVisibleColumns()} |
| | dataSource={pageData} |
| | scroll={{ x: 'max-content' }} |
| | pagination={{ |
| | currentPage: activePage, |
| | pageSize: pageSize, |
| | total: channelCount, |
| | pageSizeOpts: [10, 20, 50, 100], |
| | showSizeChanger: true, |
| | formatPageText: (page) => t('第 {{start}} - {{end}} 条,共 {{total}} 条', { |
| | start: page.currentStart, |
| | end: page.currentEnd, |
| | total: channels.length, |
| | }), |
| | onPageSizeChange: (size) => { |
| | handlePageSizeChange(size); |
| | }, |
| | onPageChange: handlePageChange, |
| | }} |
| | expandAllRows={false} |
| | onRow={handleRow} |
| | rowSelection={ |
| | enableBatchDelete |
| | ? { |
| | onChange: (selectedRowKeys, selectedRows) => { |
| | setSelectedChannels(selectedRows); |
| | }, |
| | } |
| | : null |
| | } |
| | className="rounded-xl overflow-hidden" |
| | size="middle" |
| | loading={loading} |
| | /> |
| | </Card> |
| | |
| | {/* 批量设置标签模态框 */} |
| | <Modal |
| | title={t('批量设置标签')} |
| | visible={showBatchSetTag} |
| | onOk={batchSetChannelTag} |
| | onCancel={() => setShowBatchSetTag(false)} |
| | maskClosable={false} |
| | centered={true} |
| | size="small" |
| | className="!rounded-lg" |
| | > |
| | <div className="mb-5"> |
| | <Typography.Text>{t('请输入要设置的标签名称')}</Typography.Text> |
| | </div> |
| | <Input |
| | placeholder={t('请输入标签名称')} |
| | value={batchSetTagValue} |
| | onChange={(v) => setBatchSetTagValue(v)} |
| | size='large' |
| | className="!rounded-full" |
| | /> |
| | <div className="mt-4"> |
| | <Typography.Text type='secondary'> |
| | {t('已选择 ${count} 个渠道').replace('${count}', selectedChannels.length)} |
| | </Typography.Text> |
| | </div> |
| | </Modal> |
| | |
| | {/* 模型测试弹窗 */} |
| | <Modal |
| | title={ |
| | currentTestChannel && ( |
| | <div className="flex items-center gap-2"> |
| | <Typography.Text strong className="!text-[var(--semi-color-text-0)] !text-base"> |
| | {currentTestChannel.name} {t('渠道的模型测试')} |
| | </Typography.Text> |
| | <Typography.Text type="tertiary" className="!text-xs flex items-center"> |
| | {t('共')} {currentTestChannel.models.split(',').length} {t('个模型')} |
| | </Typography.Text> |
| | </div> |
| | ) |
| | } |
| | visible={showModelTestModal && currentTestChannel !== null} |
| | onCancel={handleCloseModal} |
| | footer={ |
| | <div className="flex justify-end"> |
| | {isBatchTesting ? ( |
| | <Button |
| | theme='light' |
| | type='warning' |
| | className="!rounded-full" |
| | onClick={handleCloseModal} |
| | > |
| | {t('停止测试')} |
| | </Button> |
| | ) : ( |
| | <Button |
| | theme='light' |
| | type='tertiary' |
| | className="!rounded-full" |
| | onClick={handleCloseModal} |
| | > |
| | {t('取消')} |
| | </Button> |
| | )} |
| | <Button |
| | theme='light' |
| | type='primary' |
| | className="!rounded-full" |
| | onClick={batchTestModels} |
| | loading={isBatchTesting} |
| | disabled={isBatchTesting} |
| | > |
| | {isBatchTesting ? t('测试中...') : t('批量测试${count}个模型').replace( |
| | '${count}', |
| | currentTestChannel |
| | ? currentTestChannel.models |
| | .split(',') |
| | .filter((model) => |
| | model.toLowerCase().includes(modelSearchKeyword.toLowerCase()) |
| | ).length |
| | : 0 |
| | )} |
| | </Button> |
| | </div> |
| | } |
| | maskClosable={!isBatchTesting} |
| | centered={true} |
| | className="!rounded-lg" |
| | size="large" |
| | > |
| | <div className="max-h-[600px] overflow-y-auto"> |
| | {currentTestChannel && ( |
| | <div> |
| | <div className="flex items-center justify-end mb-2"> |
| | <Input |
| | placeholder={t('搜索模型...')} |
| | value={modelSearchKeyword} |
| | onChange={(v) => setModelSearchKeyword(v)} |
| | className="w-64 !rounded-full" |
| | prefix={<IconSearch />} |
| | showClear |
| | /> |
| | </div> |
| | |
| | <Table |
| | columns={[ |
| | { |
| | title: t('模型名称'), |
| | dataIndex: 'model', |
| | render: (text) => ( |
| | <div className="flex items-center"> |
| | <Typography.Text strong>{text}</Typography.Text> |
| | </div> |
| | ) |
| | }, |
| | { |
| | title: t('状态'), |
| | dataIndex: 'status', |
| | render: (text, record) => { |
| | const testResult = modelTestResults[`${currentTestChannel.id}-${record.model}`]; |
| | const isTesting = testingModels.has(record.model); |
| | |
| | if (isTesting) { |
| | return ( |
| | <Tag size='large' color='blue' className="!rounded-full"> |
| | {t('测试中')} |
| | </Tag> |
| | ); |
| | } |
| | |
| | if (!testResult) { |
| | return ( |
| | <Tag size='large' color='grey' className="!rounded-full"> |
| | {t('未开始')} |
| | </Tag> |
| | ); |
| | } |
| | |
| | return ( |
| | <div className="flex items-center gap-2"> |
| | <Tag |
| | size='large' |
| | color={testResult.success ? 'green' : 'red'} |
| | className="!rounded-full" |
| | > |
| | {testResult.success ? t('成功') : t('失败')} |
| | </Tag> |
| | {testResult.success && ( |
| | <Typography.Text type="tertiary"> |
| | {t('请求时长: ${time}s').replace('${time}', testResult.time.toFixed(2))} |
| | </Typography.Text> |
| | )} |
| | </div> |
| | ); |
| | } |
| | }, |
| | { |
| | title: '', |
| | dataIndex: 'operate', |
| | render: (text, record) => { |
| | const isTesting = testingModels.has(record.model); |
| | return ( |
| | <Button |
| | theme='light' |
| | type='primary' |
| | className="!rounded-full" |
| | onClick={() => testChannel(currentTestChannel, record.model)} |
| | loading={isTesting} |
| | size='small' |
| | icon={<IconSmallTriangleRight />} |
| | > |
| | {t('测试')} |
| | </Button> |
| | ); |
| | } |
| | } |
| | ]} |
| | dataSource={currentTestChannel.models |
| | .split(',') |
| | .filter((model) => |
| | model.toLowerCase().includes(modelSearchKeyword.toLowerCase()) |
| | ) |
| | .map((model) => ({ |
| | model, |
| | key: model |
| | }))} |
| | pagination={false} |
| | size="middle" |
| | /> |
| | </div> |
| | )} |
| | </div> |
| | </Modal> |
| | </> |
| | ); |
| | }; |
| |
|
| | export default ChannelsTable; |
| |
|