| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| import React, { useEffect, useState } from 'react'; |
| import axios from 'axios'; |
| import { |
| API, |
| copy, |
| isMobile, |
| shouldShowPrompt, |
| showError, |
| showInfo, |
| showSuccess, |
| showWarning, |
| timestamp2string, |
| } from '../helpers'; |
|
|
| import { CHANNEL_OPTIONS, ITEMS_PER_PAGE } from '../constants'; |
| import { |
| getQuotaPerUnit, |
| renderGroup, |
| renderNumberWithPoint, |
| renderQuota, |
| renderQuotaWithPrompt, |
| stringToColor, |
| } from '../helpers/render'; |
| import { |
| Button, |
| Divider, |
| Dropdown, |
| Form, |
| Input, |
| InputNumber, |
| Modal, |
| Popconfirm, |
| Space, |
| SplitButtonGroup, |
| Switch, |
| Table, |
| Tag, |
| Tooltip, |
| Typography, |
| Checkbox, |
| Layout, |
| } from '@douyinfe/semi-ui'; |
| import EditChannel from '../pages/Channel/EditChannel'; |
| import { |
| IconList, |
| IconTreeTriangleDown, |
| IconClose, |
| IconFilter, |
| IconPlus, |
| IconRefresh, |
| IconSetting, |
| IconCopy, |
| } from '@douyinfe/semi-icons'; |
| import { loadChannelModels } from './utils.js'; |
| import EditTagModal from '../pages/Channel/EditTagModal.js'; |
| import TextNumberInput from './custom/TextNumberInput.js'; |
| import { useTranslation } from 'react-i18next'; |
|
|
| function renderTimestamp(timestamp) { |
| return <>{timestamp2string(timestamp)}</>; |
| } |
|
|
| |
| const getFailedModelsStats = (batchTestResults, currentModels = null) => { |
| |
| const currentModelSet = currentModels ? new Set(currentModels.split(',').map(m => m.trim()).filter(m => m)) : null; |
| |
| |
| const relevantResults = currentModelSet |
| ? batchTestResults.filter(r => currentModelSet.has(r.model)) |
| : batchTestResults; |
| |
| |
| const failedModels = relevantResults.filter(r => !r.success); |
| |
| const retryableFailedModels = failedModels.filter(r => r.isRetryable); |
| |
| return { |
| failed: failedModels, |
| retryable: retryableFailedModels, |
| failedCount: failedModels.length, |
| retryableCount: retryableFailedModels.length |
| }; |
| }; |
|
|
| |
| const processModelData = ( |
| models, |
| searchKeyword, |
| batchTestResults, |
| isBatchTesting, |
| currentTestingModel, |
| testingModelsSet = null |
| ) => { |
| if (!models) return []; |
| |
| |
| const testResultsMap = new Map(batchTestResults.map(result => [result.model, result])); |
| |
| return models |
| .split(',') |
| .map(model => model.trim()) |
| .filter(model => model && model.toLowerCase().includes(searchKeyword.toLowerCase())) |
| .map((model, index) => { |
| const testResult = testResultsMap.get(model); |
| return { |
| key: model, |
| model: model, |
| testResult: testResult, |
| isSuccess: testResult?.success, |
| isFailed: testResult && !testResult.success, |
| isTesting: isBatchTesting |
| ? currentTestingModel.includes(model) |
| : Boolean(testingModelsSet && testingModelsSet.has(model)), |
| isRetryable: testResult?.isRetryable |
| }; |
| }); |
| }; |
|
|
| |
| const ModelTestContent = ({ |
| channel, |
| isBatchTesting, |
| concurrentLimit, |
| setConcurrentLimit, |
| testAllModels, |
| modelSearchKeyword, |
| setModelSearchKeyword, |
| batchTestResults, |
| retryFailedModels, |
| handleDeleteFailedModels, |
| testProgress, |
| currentTestingModel, |
| testChannel, |
| showSuccess, |
| showError, |
| t |
| }) => { |
| const failedStats = getFailedModelsStats(batchTestResults, channel.models); |
| |
| |
| const [testingModels, setTestingModels] = React.useState(() => new Set()); |
| |
| |
| const runSingleTest = async (model) => { |
| setTestingModels(prev => { |
| const next = new Set(prev); |
| next.add(model); |
| return next; |
| }); |
| try { |
| await testChannel(channel, model); |
| } finally { |
| setTestingModels(prev => { |
| const next = new Set(prev); |
| next.delete(model); |
| return next; |
| }); |
| } |
| }; |
| |
| |
| const modelData = React.useMemo(() => processModelData( |
| channel.models, |
| modelSearchKeyword, |
| batchTestResults, |
| isBatchTesting, |
| currentTestingModel, |
| testingModels |
| ), [channel.models, modelSearchKeyword, batchTestResults, isBatchTesting, currentTestingModel, testingModels]); |
|
|
| return ( |
| <div> |
| {/* 渠道信息和操作按钮 */} |
| <div style={{ |
| display: 'flex', |
| alignItems: 'center', |
| justifyContent: 'space-between', |
| padding: '16px 0', |
| borderBottom: '1px solid var(--semi-color-border)', |
| marginBottom: '16px' |
| }}> |
| <Typography.Title heading={6} style={{ margin: 0 }}> |
| {t('渠道')}: {channel.name} |
| </Typography.Title> |
| |
| {/* 主要操作按钮组 */} |
| <div style={{ |
| display: 'flex', |
| alignItems: 'center', |
| gap: '12px' |
| }}> |
| {!isBatchTesting && ( |
| <> |
| <Typography.Text style={{ fontSize: '14px' }}> |
| {t('并发测试模型数')}: |
| </Typography.Text> |
| <InputNumber |
| value={concurrentLimit} |
| onChange={setConcurrentLimit} |
| min={1} |
| max={10} |
| style={{ width: '80px' }} |
| size='small' |
| /> |
| </> |
| )} |
| |
| <Button |
| type='primary' |
| loading={isBatchTesting} |
| onClick={testAllModels} |
| disabled={!channel?.models} |
| > |
| {isBatchTesting ? t('测试中...') : t('测试全部')} |
| </Button> |
| </div> |
| </div> |
|
|
| {} |
| <div style={{ |
| display: 'flex', |
| alignItems: 'center', |
| marginBottom: '16px', |
| gap: '12px' |
| }}> |
| <Input |
| placeholder={t('搜索模型...')} |
| value={modelSearchKeyword} |
| onChange={(v) => setModelSearchKeyword(v)} |
| prefix={<IconFilter />} |
| showClear |
| style={{ flex: 1 }} |
| /> |
| |
| {} |
| {batchTestResults.length > 0 && failedStats.failedCount > 0 && ( |
| <div style={{ |
| display: 'flex', |
| gap: '8px', |
| alignItems: 'center' |
| }}> |
| {failedStats.retryableCount > 0 && ( |
| <Button |
| type='warning' |
| loading={isBatchTesting} |
| onClick={retryFailedModels} |
| disabled={isBatchTesting} |
| > |
| {isBatchTesting ? t('重试中...') : t('重试失败 ({{count}})', { count: failedStats.retryableCount })} |
| </Button> |
| )} |
| |
| <Button |
| type='danger' |
| onClick={() => handleDeleteFailedModels(failedStats.failed)} |
| > |
| {t('删除失败 ({{count}})', { count: failedStats.failedCount })} |
| </Button> |
| </div> |
| )} |
| </div> |
|
|
| {} |
| {isBatchTesting && ( |
| <div style={{ marginBottom: '16px' }}> |
| <div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '4px' }}> |
| <Typography.Text style={{ fontSize: '14px' }}> |
| {t('进度')}: {testProgress.completed}/{testProgress.total} |
| </Typography.Text> |
| <div style={{ |
| flex: 1, |
| height: '6px', |
| backgroundColor: 'var(--semi-color-fill-1)', |
| borderRadius: '3px', |
| overflow: 'hidden' |
| }}> |
| <div style={{ |
| width: `${testProgress.total > 0 ? (testProgress.completed / testProgress.total) * 100 : 0}%`, |
| height: '100%', |
| backgroundColor: 'var(--semi-color-primary)', |
| borderRadius: '3px', |
| transition: 'width 0.3s ease' |
| }} /> |
| </div> |
| </div> |
| {currentTestingModel && ( |
| <Typography.Text style={{ |
| fontSize: '12px', |
| color: 'var(--semi-color-text-1)' |
| }}> |
| {t('正在测试')}: {currentTestingModel.includes(':') ? currentTestingModel.split(':').slice(1).join(':').trim() : currentTestingModel} |
| </Typography.Text> |
| )} |
| </div> |
| )} |
|
|
| {} |
| <Table |
| dataSource={modelData} |
| columns={[ |
| { |
| title: t('模型名称'), |
| dataIndex: 'model', |
| key: 'model', |
| width: '40%', |
| render: (text, record) => ( |
| <Typography.Text |
| style={{ |
| fontWeight: '500', |
| color: record.testResult ? |
| (record.isSuccess ? 'var(--semi-color-success)' : 'var(--semi-color-danger)') : |
| 'var(--semi-color-text-0)', |
| whiteSpace: 'nowrap', |
| overflow: 'hidden', |
| textOverflow: 'ellipsis' |
| }} |
| ellipsis={{ showTooltip: true }} |
| > |
| {text} |
| </Typography.Text> |
| ) |
| }, |
| { |
| title: t('状态'), |
| key: 'status', |
| width: '20%', |
| render: (_, record) => { |
| if (record.isTesting) { |
| return <Tag color='blue'>{t('测试中')}</Tag>; |
| } |
| if (record.testResult) { |
| return ( |
| <Tag color={record.isSuccess ? 'green' : 'red'}> |
| {record.isSuccess ? t('成功') : t('失败')} |
| </Tag> |
| ); |
| } |
| return <Tag color='default'>{t('未测试')}</Tag>; |
| } |
| }, |
| { |
| title: t('耗时'), |
| key: 'duration', |
| width: '15%', |
| render: (_, record) => { |
| if (record.testResult?.time) { |
| return ( |
| <Typography.Text style={{ color: 'var(--semi-color-text-1)' }}> |
| {record.testResult.time.toFixed(2)}s |
| </Typography.Text> |
| ); |
| } |
| return '-'; |
| } |
| }, |
| { |
| title: t('操作'), |
| key: 'actions', |
| width: '25%', |
| render: (_, record) => ( |
| <div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}> |
| <Button |
| size='small' |
| loading={record.isTesting} |
| onClick={() => runSingleTest(record.model)} |
| > |
| {record.isTesting ? t('测试中') : t('测试')} |
| </Button> |
| <Button |
| size='small' |
| icon={<IconCopy />} |
| onClick={async () => { |
| const success = await copy(record.model); |
| if (success) { |
| showSuccess(t('模型名称已复制')); |
| } else { |
| showError(t('复制失败')); |
| } |
| }} |
| /> |
| </div> |
| ) |
| } |
| ]} |
| pagination={false} |
| size='small' |
| style={{ marginBottom: '25px' }} |
| /> |
|
|
| {} |
| {modelSearchKeyword && ( |
| <div style={{ marginTop: '16px' }}> |
| <Typography.Text style={{ |
| display: 'block', |
| fontSize: '14px', |
| textAlign: 'center', |
| color: 'var(--semi-color-text-1)' |
| }}> |
| {t('找到 {{count}} 个模型', { count: modelData.length })} |
| </Typography.Text> |
| </div> |
| )} |
| </div> |
| ); |
| }; |
|
|
| 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}> |
| {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'> |
| {t('已启用')} |
| </Tag> |
| ); |
| case 2: |
| return ( |
| <Tag size='large' color='yellow'> |
| {t('已禁用')} |
| </Tag> |
| ); |
| case 3: |
| return ( |
| <Tag size='large' color='yellow'> |
| {t('自动禁用')} |
| </Tag> |
| ); |
| default: |
| return ( |
| <Tag size='large' color='grey'> |
| {t('未知状态')} |
| </Tag> |
| ); |
| } |
| }; |
|
|
| const renderResponseTime = (responseTime) => { |
| let time = responseTime / 1000; |
| time = time.toFixed(2) + t(' 秒'); |
| if (responseTime === 0) { |
| return ( |
| <Tag size='large' color='grey'> |
| {t('未测试')} |
| </Tag> |
| ); |
| } else if (responseTime <= 1000) { |
| return ( |
| <Tag size='large' color='green'> |
| {time} |
| </Tag> |
| ); |
| } else if (responseTime <= 3000) { |
| return ( |
| <Tag size='large' color='lime'> |
| {time} |
| </Tag> |
| ); |
| } else if (responseTime <= 5000) { |
| return ( |
| <Tag size='large' color='yellow'> |
| {time} |
| </Tag> |
| ); |
| } else { |
| return ( |
| <Tag size='large' color='red'> |
| {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) => { |
| return ( |
| <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) => { |
| return 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) => { |
| return <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'> |
| {renderQuota(record.used_quota)} |
| </Tag> |
| </Tooltip> |
| <Tooltip |
| content={t('剩余额度') + record.balance + t(',点击更新')} |
| > |
| <Tag |
| color='white' |
| type='ghost' |
| size='large' |
| onClick={() => { |
| updateChannelBalance(record); |
| }} |
| > |
| ${renderNumberWithPoint(record.balance)} |
| </Tag> |
| </Tooltip> |
| </Space> |
| </div> |
| ); |
| } else { |
| return ( |
| <Tooltip content={t('已用额度')}> |
| <Tag color='white' type='ghost' size='large'> |
| {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} |
| /> |
| </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} |
| /> |
| </> |
| ); |
| } |
| }, |
| }, |
| { |
| 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} |
| /> |
| </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} |
| /> |
| ); |
| } |
| }, |
| }, |
| { |
| key: COLUMN_KEYS.OPERATE, |
| title: '', |
| dataIndex: 'operate', |
| render: (text, record, index) => { |
| if (record.children === undefined) { |
| return ( |
| <div> |
| <SplitButtonGroup |
| style={{ marginRight: 1 }} |
| aria-label={t('测试单个渠道操作项目组')} |
| > |
| <Button |
| theme='light' |
| onClick={() => { |
| testChannel(record, ''); |
| }} |
| > |
| {t('测试')} |
| </Button> |
| <Button |
| style={{ padding: '8px 4px' }} |
| type='primary' |
| icon={<IconTreeTriangleDown />} |
| onClick={() => { |
| setCurrentTestChannel(record); |
| setShowModelTestModal(true); |
| }} |
| ></Button> |
| </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 ? ( |
| <Button |
| theme='light' |
| type='warning' |
| style={{ marginRight: 1 }} |
| onClick={async () => { |
| manageChannel(record.id, 'disable', record); |
| }} |
| > |
| {t('禁用')} |
| </Button> |
| ) : ( |
| <Button |
| theme='light' |
| type='secondary' |
| style={{ marginRight: 1 }} |
| onClick={async () => { |
| manageChannel(record.id, 'enable', record); |
| }} |
| > |
| {t('启用')} |
| </Button> |
| )} |
| <Button |
| theme='light' |
| type='tertiary' |
| style={{ marginRight: 1 }} |
| onClick={() => { |
| setEditingChannel(record); |
| setShowEdit(true); |
| }} |
| > |
| {t('编辑')} |
| </Button> |
| <Popconfirm |
| title={t('确定是否要复制此渠道?')} |
| content={t('复制渠道的所有信息')} |
| okType={'danger'} |
| position={'left'} |
| onConfirm={async () => { |
| copySelectedChannel(record); |
| }} |
| > |
| <Button theme='light' type='primary' style={{ marginRight: 1 }}> |
| {t('复制')} |
| </Button> |
| </Popconfirm> |
| </div> |
| ); |
| } else { |
| return ( |
| <> |
| <Button |
| theme='light' |
| type='secondary' |
| style={{ marginRight: 1 }} |
| onClick={async () => { |
| manageTag(record.key, 'enable'); |
| }} |
| > |
| {t('启用全部')} |
| </Button> |
| <Button |
| theme='light' |
| type='warning' |
| style={{ marginRight: 1 }} |
| onClick={async () => { |
| manageTag(record.key, 'disable'); |
| }} |
| > |
| {t('禁用全部')} |
| </Button> |
| <Button |
| theme='light' |
| type='tertiary' |
| style={{ marginRight: 1 }} |
| onClick={() => { |
| setShowEditTag(true); |
| setEditingTag(record.key); |
| }} |
| > |
| {t('编辑')} |
| </Button> |
| </> |
| ); |
| } |
| }, |
| }, |
| ]; |
|
|
| |
| const getVisibleColumns = () => { |
| return allColumns.filter((column) => visibleColumns[column.key]); |
| }; |
|
|
| |
| const renderColumnSelector = () => { |
| return ( |
| <Modal |
| title={t('列设置')} |
| visible={showColumnSelector} |
| onCancel={() => setShowColumnSelector(false)} |
| footer={ |
| <> |
| <Button onClick={() => initDefaultColumns()}>{t('重置')}</Button> |
| <Button onClick={() => setShowColumnSelector(false)}> |
| {t('取消')} |
| </Button> |
| <Button type='primary' onClick={() => setShowColumnSelector(false)}> |
| {t('确定')} |
| </Button> |
| </> |
| } |
| style={{ width: isMobile() ? '90%' : 500 }} |
| bodyStyle={{ padding: '24px' }} |
| > |
| <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 |
| style={{ |
| display: 'flex', |
| flexWrap: 'wrap', |
| maxHeight: '400px', |
| overflowY: 'auto', |
| border: '1px solid var(--semi-color-border)', |
| borderRadius: '6px', |
| padding: '16px', |
| }} |
| > |
| {allColumns.map((column) => { |
| // Skip columns without title |
| if (!column.title) { |
| return null; |
| } |
| |
| return ( |
| <div |
| key={column.key} |
| style={{ |
| width: isMobile() ? '100%' : '50%', |
| marginBottom: 16, |
| paddingRight: 8, |
| }} |
| > |
| <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 [selectedTypes, setSelectedTypes] = useState([]); |
| const [selectedStatuses, setSelectedStatuses] = useState([]); |
| const [selectedTags, setSelectedTags] = useState([]); |
| const [tagOptions, setTagOptions] = useState([]); |
|
|
| const [updatingBalance, setUpdatingBalance] = useState(false); |
| const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE); |
| const [showPrompt, setShowPrompt] = useState( |
| shouldShowPrompt('channel-test'), |
| ); |
| 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 [showEditPriority, setShowEditPriority] = useState(false); |
| const [enableTagMode, setEnableTagMode] = useState(() => { |
| const savedState = localStorage.getItem('channel-tag-mode'); |
| return savedState !== null ? JSON.parse(savedState) : false; |
| }); |
| useEffect(() => { |
| localStorage.setItem('channel-tag-mode', JSON.stringify(enableTagMode)); |
| }, [enableTagMode]); |
| const [showBatchSetTag, setShowBatchSetTag] = useState(false); |
| const [batchSetTagValue, setBatchSetTagValue] = useState(''); |
| const [showModelTestModal, setShowModelTestModal] = useState(false); |
| const [currentTestChannel, setCurrentTestChannel] = useState(null); |
| const [modelSearchKeyword, setModelSearchKeyword] = useState(''); |
| |
| const [testResultsCache, setTestResultsCache] = useState(() => Object.create(null)); |
| |
| const [isBatchTesting, setIsBatchTesting] = useState(false); |
| const [batchTestResults, setBatchTestResults] = useState([]); |
| const [currentTestingModel, setCurrentTestingModel] = useState(''); |
| const [testProgress, setTestProgress] = useState({ completed: 0, total: 0 }); |
| const [concurrentLimit, setConcurrentLimit] = useState(3); |
| const [batchTestDelay, setBatchTestDelay] = useState(200); |
| |
| const [batchTestAbortController, setBatchTestAbortController] = useState(null); |
|
|
| 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].rowKey = '' + 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 = { |
| rowKey: tag, |
| 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; |
| 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; |
| setIdSort(localIdSort); |
| setPageSize(localPageSize); |
| loadChannels(0, localPageSize, localIdSort, enableTagMode) |
| .then() |
| .catch((reason) => { |
| showError(reason); |
| }); |
| fetchGroups().then(); |
| loadChannelModels().then(); |
| |
| loadTestResultsCache(); |
| }, []); |
|
|
| |
| useEffect(() => { |
| if (showModelTestModal && currentTestChannel) { |
| const cachedResults = getCachedTestResults(currentTestChannel.id); |
| if (cachedResults) { |
| setBatchTestResults(cachedResults); |
| setTestProgress({ |
| completed: cachedResults.length, |
| total: currentTestChannel.models?.split(',').filter(m => m.trim()).length || 0 |
| }); |
| } |
| } |
| }, [showModelTestModal, currentTestChannel]); |
|
|
| |
| const loadTestResultsCache = () => { |
| try { |
| const cached = localStorage.getItem('channel-test-results-cache'); |
| if (cached) { |
| const parsedCache = JSON.parse(cached); |
| |
| const now = Date.now(); |
| const validCache = Object.create(null); |
| |
| Object.keys(parsedCache).forEach(channelId => { |
| |
| if (channelId === '__proto__' || |
| channelId === 'constructor' || |
| channelId === 'prototype') { |
| return; |
| } |
| |
| const channelCache = parsedCache[channelId]; |
| |
| if (channelCache && |
| typeof channelCache === 'object' && |
| channelCache !== null && |
| typeof channelCache.timestamp === 'number' && |
| Number.isFinite(channelCache.timestamp) && |
| (now - channelCache.timestamp) < 24 * 60 * 60 * 1000) { |
| validCache[channelId] = channelCache; |
| } |
| }); |
| |
| setTestResultsCache(validCache); |
| |
| if (Object.keys(validCache).length !== Object.keys(parsedCache).length) { |
| localStorage.setItem('channel-test-results-cache', JSON.stringify(validCache)); |
| } |
| } |
| } catch (e) { |
| console.error('加载测试结果缓存失败:', e); |
| localStorage.removeItem('channel-test-results-cache'); |
| } |
| }; |
|
|
| |
| const saveTestResultsCache = (channelId, results) => { |
| try { |
| |
| const id = String(channelId); |
| if (id === '__proto__' || |
| id === 'constructor' || |
| id === 'prototype') { |
| console.warn('无效的channelId,跳过缓存保存:', channelId); |
| return; |
| } |
| |
| const newCache = Object.assign(Object.create(null), testResultsCache, { |
| [id]: { |
| results: results, |
| timestamp: Date.now() |
| } |
| }); |
| setTestResultsCache(newCache); |
| localStorage.setItem('channel-test-results-cache', JSON.stringify(newCache)); |
| } catch (e) { |
| console.error('保存测试结果缓存失败:', e); |
| } |
| }; |
|
|
| |
| const getCachedTestResults = (channelId) => { |
| |
| const id = String(channelId); |
| if (id === '__proto__' || |
| id === 'constructor' || |
| id === 'prototype') { |
| return null; |
| } |
| |
| const channelCache = testResultsCache[id]; |
| if (channelCache && (Date.now() - channelCache.timestamp) < 24 * 60 * 60 * 1000) { |
| return channelCache.results; |
| } |
| return null; |
| }; |
|
|
| 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, |
| typesOverride = null, |
| statusesOverride = null, |
| tagsOverride = null, |
| ) => { |
| const nextTypes = typesOverride !== null ? typesOverride : selectedTypes; |
| const nextStatuses = statusesOverride !== null ? statusesOverride : selectedStatuses; |
| const nextTags = tagsOverride !== null ? tagsOverride : selectedTags; |
|
|
| const noBasic = searchKeyword === '' && searchGroup === '' && searchModel === ''; |
| const noAdvanced = (!nextTypes || nextTypes.length === 0) && (!nextStatuses || nextStatuses.length === 0) && (!nextTags || nextTags.length === 0); |
| if (noBasic && noAdvanced) { |
| await loadChannels(activePage - 1, pageSize, idSort, enableTagMode); |
| return; |
| } |
| setSearching(true); |
| const params = new URLSearchParams(); |
| |
| |
| params.append('keyword', searchKeyword); |
| params.append('group', searchGroup); |
| params.append('model', searchModel); |
| params.append('id_sort', String(idSort)); |
| params.append('tag_mode', String(enableTagMode)); |
| |
| |
| if (nextTypes && nextTypes.length > 0) { |
| params.append('types', nextTypes.map(Number).join(',')); |
| } |
| if (nextStatuses && nextStatuses.length > 0) { |
| params.append('statuses', nextStatuses.map(Number).join(',')); |
| } |
| if (nextTags && nextTags.length > 0) { |
| params.append('tags', nextTags.join(',')); |
| } |
| |
| const res = await API.get(`/api/channel/search?${params.toString()}`); |
| 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 updateTestResult = (model, result) => { |
| setBatchTestResults(prevResults => { |
| const existingIndex = prevResults.findIndex(r => r.model === model); |
| if (existingIndex >= 0) { |
| const newResults = [...prevResults]; |
| newResults[existingIndex] = result; |
| return newResults; |
| } else { |
| return [...prevResults, result]; |
| } |
| }); |
| }; |
|
|
| |
| const createTestResult = (model, success, message, time = null, isRetryable = true) => ({ |
| model, |
| success, |
| message, |
| time, |
| retryCount: 0, |
| isRetryable: success ? false : isRetryable |
| }); |
|
|
| |
| const handleTestSuccess = (record, model, time) => { |
| |
| updateChannelProperty(record.id, (channel) => { |
| channel.response_time = time * 1000; |
| channel.test_time = Date.now() / 1000; |
| }); |
|
|
| |
| if (shouldUpdateModalResults(record.id)) { |
| const result = createTestResult( |
| model, |
| true, |
| t('测试成功,耗时 {{time}} 秒', { time: time.toFixed(2) }), |
| time |
| ); |
| updateTestResult(model, result); |
| } |
|
|
| showInfo( |
| t('通道 {{name}} 测试成功,耗时 {{time}} 秒。', { |
| name: record.name, |
| time: time.toFixed(2) |
| }) |
| ); |
| }; |
|
|
| |
| const handleTestFailure = (record, model, message) => { |
| if (shouldUpdateModalResults(record.id)) { |
| const result = createTestResult(model, false, message); |
| updateTestResult(model, result); |
| } |
| showError(message); |
| }; |
|
|
| |
| const shouldUpdateModalResults = (recordId) => { |
| return showModelTestModal && currentTestChannel && currentTestChannel.id === recordId; |
| }; |
|
|
| |
| const testChannel = async (record, model) => { |
| try { |
| const res = await API.get(`/api/channel/test/${record.id}?model=${model}`); |
| const { success, message, time } = res.data; |
| |
| if (success) { |
| handleTestSuccess(record, model, time); |
| } else { |
| handleTestFailure(record, model, message); |
| } |
| } catch (error) { |
| handleTestFailure(record, model, error.message || t('测试请求失败')); |
| } |
| }; |
|
|
| |
| const isCancellationError = (error) => { |
| return ( |
| error.name === 'AbortError' || |
| error.name === 'CanceledError' || |
| axios.isCancel(error) || |
| error.code === 'ERR_CANCELED' |
| ); |
| }; |
|
|
| |
| const testSingleModel = async (model, abortController, maxRetries = 2) => { |
| let lastError = null; |
| |
| for (let retryCount = 0; retryCount <= maxRetries; retryCount++) { |
| try { |
| const res = await API.get(`/api/channel/test/${currentTestChannel.id}?model=${encodeURIComponent(model)}`, { |
| signal: abortController?.signal |
| }); |
| const { success, message, time } = res.data; |
|
|
| const result = { |
| model, |
| success, |
| message: success ? `测试成功,耗时 ${time.toFixed(2)} 秒` : message, |
| time: success ? time : null, |
| retryCount, |
| isRetryable: !success && retryCount < maxRetries |
| }; |
|
|
| if (success) { |
| |
| updateChannelProperty(currentTestChannel.id, (channel) => { |
| channel.response_time = time * 1000; |
| channel.test_time = Date.now() / 1000; |
| }); |
| } |
|
|
| return result; |
| } catch (error) { |
| lastError = error; |
| |
| |
| if (isCancellationError(error) || abortController?.signal?.aborted) { |
| throw error; |
| } |
|
|
| |
| if (retryCount >= maxRetries) { |
| return { |
| model, |
| success: false, |
| message: error.message || '测试失败', |
| time: null, |
| retryCount, |
| isRetryable: true, |
| error: error |
| }; |
| } |
|
|
| |
| const delay = Math.min(1000 * Math.pow(2, retryCount), 5000); |
| await new Promise(resolve => setTimeout(resolve, delay)); |
| } |
| } |
| }; |
|
|
| |
| const processBatch = async (models, batchSize, abortController) => { |
| const results = []; |
|
|
| for (let i = 0; i < models.length; i += batchSize) { |
| |
| if (abortController?.signal?.aborted) { |
| throw new Error('测试被用户取消'); |
| } |
|
|
| const batch = models.slice(i, i + batchSize); |
| setCurrentTestingModel(`批次 ${Math.floor(i / batchSize) + 1}: ${batch.join(', ')}`); |
|
|
| |
| if (abortController?.signal?.aborted) { |
| throw new Error('测试被用户取消'); |
| } |
|
|
| |
| const batchPromises = batch.map(model => testSingleModel(model, abortController)); |
| const batchResults = await Promise.allSettled(batchPromises); |
|
|
| |
| if (abortController?.signal?.aborted) { |
| throw new Error('测试被用户取消'); |
| } |
|
|
| |
| const processedResults = batchResults.map((result, index) => { |
| if (result.status === 'fulfilled') { |
| return result.value; |
| } else { |
| |
| if (isCancellationError(result.reason) || abortController?.signal?.aborted) { |
| throw result.reason; |
| } |
| return { |
| model: batch[index], |
| success: false, |
| message: result.reason?.message || '测试失败', |
| time: null |
| }; |
| } |
| }); |
|
|
| results.push(...processedResults); |
|
|
| |
| setTestProgress({ completed: results.length, total: models.length }); |
| setBatchTestResults([...results]); |
|
|
| |
| if (i + batchSize < models.length) { |
| |
| if (abortController?.signal?.aborted) { |
| throw new Error('测试被用户取消'); |
| } |
| await new Promise(resolve => setTimeout(resolve, batchTestDelay)); |
| } |
| } |
|
|
| return results; |
| }; |
|
|
| |
| const retryFailedModels = async () => { |
| if (!currentTestChannel || isBatchTesting) { |
| return; |
| } |
|
|
| const failedModels = batchTestResults.filter(r => !r.success && r.isRetryable); |
| if (failedModels.length === 0) { |
| showInfo(t('没有可重试的失败模型')); |
| return; |
| } |
|
|
| const controller = new AbortController(); |
| setBatchTestAbortController(controller); |
| setIsBatchTesting(true); |
| setCurrentTestingModel('重试失败模型...'); |
|
|
| try { |
| const failedModelNames = failedModels.map(r => r.model); |
| const retryResults = await processBatch(failedModelNames, concurrentLimit, controller); |
| |
| |
| const updatedResults = batchTestResults.map(result => { |
| const retryResult = retryResults.find(r => r.model === result.model); |
| return retryResult || result; |
| }); |
| |
| setBatchTestResults(updatedResults); |
| |
| const newSuccessCount = retryResults.filter(r => r.success).length; |
| showInfo(t('重试完成:成功 {{successCount}} 个,失败 {{failCount}} 个', { |
| successCount: newSuccessCount, |
| failCount: failedModelNames.length - newSuccessCount |
| })); |
| } catch (error) { |
| if (isCancellationError(error)) { |
| showInfo(t('重试已取消')); |
| } else { |
| showError(t('重试过程中发生错误:') + error.message); |
| } |
| } finally { |
| setIsBatchTesting(false); |
| setCurrentTestingModel(''); |
| setBatchTestAbortController(null); |
| } |
| }; |
|
|
| |
| const handleModelTestModalClose = () => { |
| |
| if (batchTestAbortController) { |
| batchTestAbortController.abort(); |
| setBatchTestAbortController(null); |
| } |
| setShowModelTestModal(false); |
| setModelSearchKeyword(''); |
| |
| setIsBatchTesting(false); |
| setBatchTestResults([]); |
| setCurrentTestingModel(''); |
| setTestProgress({ completed: 0, total: 0 }); |
| }; |
|
|
| |
| const handleDeleteFailedModels = (failedModels) => { |
| const currentModels = currentTestChannel.models.split(',').map(m => m.trim()); |
| const failedModelNames = failedModels.map(r => r.model); |
| |
| const failedModelNamesSet = new Set(failedModelNames); |
| const remainingModels = currentModels.filter(m => !failedModelNamesSet.has(m)); |
| |
| if (remainingModels.length === 0) { |
| Modal.warning({ |
| title: t('无法删除所有模型'), |
| content: t('删除这些失败的模型会导致渠道没有任何可用模型,这将使渠道无法正常工作。请至少保留一个模型或先添加新的可用模型。'), |
| }); |
| return; |
| } |
| |
| Modal.confirm({ |
| title: t('批量删除失败模型'), |
| content: ( |
| <div> |
| <p>{t('确定要删除所有 {{count}} 个测试失败的模型吗?此操作不可撤销。', { count: failedModels.length })}</p> |
| <p style={{ color: '#52c41a', marginTop: '8px' }}> |
| {t('删除后将保留 {{remaining}} 个正常模型:', { remaining: remainingModels.length })} |
| </p> |
| <div style={{ |
| maxHeight: '100px', |
| overflowY: 'auto', |
| background: 'var(--semi-color-fill-0)', |
| padding: '8px', |
| borderRadius: '4px', |
| marginTop: '4px' |
| }}> |
| {remainingModels.map((model, index) => ( |
| <div key={index} style={{ fontSize: '12px' }}>{model}</div> |
| ))} |
| </div> |
| </div> |
| ), |
| onOk: async () => { |
| try { |
| const newModels = remainingModels; |
|
|
| const updateData = { |
| id: currentTestChannel.id, |
| models: newModels.join(','), |
| }; |
|
|
| const res = await API.put('/api/channel/', updateData); |
| if (res.data.success) { |
| updateChannelProperty(currentTestChannel.id, (channel) => { |
| channel.models = newModels.join(','); |
| }); |
| setCurrentTestChannel({ |
| ...currentTestChannel, |
| models: newModels.join(',') |
| }); |
|
|
| setBatchTestResults(prev => prev.filter(r => !failedModelNamesSet.has(r.model))); |
| await refresh(); |
| showSuccess(t('已删除 {{count}} 个失败的模型', { count: failedModels.length })); |
| } else { |
| showError(res.data.message || t('批量删除失败')); |
| } |
| } catch (error) { |
| showError(error.message || t('批量删除失败')); |
| } |
| } |
| }); |
| }; |
|
|
| |
| const testAllModels = async () => { |
| |
| if (!currentTestChannel || |
| typeof currentTestChannel !== 'object' || |
| !currentTestChannel.id || |
| !currentTestChannel.models || |
| typeof currentTestChannel.models !== 'string') { |
| showError(t('当前测试渠道无效或缺少必需属性')); |
| return; |
| } |
|
|
| |
| const models = [...new Set( |
| currentTestChannel.models |
| .split(',') |
| .map(model => model.trim()) |
| .filter(model => model.length > 0) |
| )]; |
| if (models.length === 0) { |
| showError(t('没有可测试的模型')); |
| return; |
| } |
|
|
| |
| const controller = new AbortController(); |
| setBatchTestAbortController(controller); |
|
|
| setIsBatchTesting(true); |
| setBatchTestResults([]); |
| setCurrentTestingModel(''); |
| setTestProgress({ completed: 0, total: models.length }); |
|
|
| |
| try { |
| const results = await processBatch(models, concurrentLimit, controller); |
|
|
| const successCount = results.filter(r => r.success).length; |
| const failCount = results.filter(r => !r.success).length; |
|
|
| |
| saveTestResultsCache(currentTestChannel.id, results); |
|
|
| showInfo(t('批量测试完成:成功 {{successCount}} 个,失败 {{failCount}} 个', { |
| successCount, |
| failCount |
| })); |
| } catch (error) { |
| if (isCancellationError(error) || error.message.includes('取消')) { |
| showInfo(t('测试已取消')); |
| } else { |
| showError(t('批量测试过程中发生错误:') + error.message); |
| } |
| } finally { |
| |
| setIsBatchTesting(false); |
| setCurrentTestingModel(''); |
| setBatchTestAbortController(null); |
| } |
| }; |
|
|
| 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}} 余额更新成功!', { name: record.name }), |
| ); |
| } else { |
| showError(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('已删除所有禁用渠道,共计 {{count}} 个', { count: data }), |
| ); |
| await refresh(); |
| } else { |
| showError(message); |
| } |
| }; |
|
|
| const updateAllChannelsBalance = async () => { |
| setUpdatingBalance(true); |
| const res = await API.get(`/api/channel/update_balance`); |
| const { success, message } = res.data; |
| if (success) { |
| showInfo(t('已更新完毕所有已启用通道余额!')); |
| } else { |
| showError(message); |
| } |
| setUpdatingBalance(false); |
| }; |
|
|
| 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('已删除 {{count}} 个通道!', { count: 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('已修复 {{count}} 个通道!', { count: data })); |
| await refresh(); |
| } else { |
| showError(message); |
| } |
| }; |
|
|
| 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 === '') { |
| showError(t('优先级必须是整数!')); |
| return; |
| } |
| data.priority = parseInt(data.priority); |
| break; |
| case 'weight': |
| if ( |
| data.weight === undefined || |
| data.weight < 0 || |
| data.weight === '' |
| ) { |
| showInfo(t('权重必须是非负整数!')); |
| return; |
| } |
| data.weight = parseInt(data.weight); |
| break; |
| } |
|
|
| try { |
| const res = await API.put('/api/channel/tag', data); |
| if (res?.data?.success) { |
| showSuccess(t('更新成功!')); |
| 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}} 个渠道设置标签!', { count: res.data.data }), |
| ); |
| await refresh(); |
| setShowBatchSetTag(false); |
| } else { |
| showError(res.data.message); |
| } |
| }; |
|
|
| return ( |
| <> |
| {renderColumnSelector()} |
| <EditTagModal |
| visible={showEditTag} |
| tag={editingTag} |
| handleClose={() => setShowEditTag(false)} |
| refresh={refresh} |
| /> |
| <EditChannel |
| refresh={refresh} |
| visible={showEdit} |
| handleClose={closeEdit} |
| editingChannel={editingChannel} |
| /> |
| <Form |
| onSubmit={() => { |
| searchChannels( |
| searchKeyword, |
| searchGroup, |
| searchModel, |
| enableTagMode, |
| ); |
| }} |
| labelPosition='left' |
| > |
| <div style={{ display: 'flex' }}> |
| <Space> |
| <Form.Input |
| field='search_keyword' |
| label={t('搜索渠道关键词')} |
| placeholder={t('搜索渠道的 ID,名称和密钥 ...')} |
| value={searchKeyword} |
| loading={searching} |
| onChange={(v) => { |
| setSearchKeyword(v.trim()); |
| }} |
| /> |
| <Form.Input |
| field='search_model' |
| label={t('模型')} |
| placeholder={t('模型关键字')} |
| value={searchModel} |
| loading={searching} |
| onChange={(v) => { |
| setSearchModel(v.trim()); |
| }} |
| /> |
| <Form.Select |
| field='group' |
| label={t('分组')} |
| optionList={[ |
| { label: t('选择分组'), value: null }, |
| ...groupOptions, |
| ]} |
| onChange={(v) => { |
| setSearchGroup(v); |
| searchChannels(searchKeyword, v, searchModel, enableTagMode); |
| }} |
| /> |
| {/* 新增:类型多选 */} |
| <Form.Select |
| field='types' |
| label={t('渠道类型')} |
| multiple |
| optionList={CHANNEL_OPTIONS.map(o => ({ label: o.label, value: o.value }))} |
| onChange={(v) => { |
| const next = (v || []).map(Number); |
| setSelectedTypes(next); |
| searchChannels(searchKeyword, searchGroup, searchModel, enableTagMode, next, null, null); |
| }} |
| /> |
| {/* 新增:状态多选 */} |
| <Form.Select |
| field='statuses' |
| label={t('渠道状态')} |
| multiple |
| optionList={[{label:t('已启用'), value:1}, {label:t('已禁用'), value:2}, {label:t('自动禁用'), value:3}]} |
| onChange={(v) => { |
| const next = (v || []).map(Number); |
| setSelectedStatuses(next); |
| searchChannels(searchKeyword, searchGroup, searchModel, enableTagMode, null, next, null); |
| }} |
| /> |
| {/* 新增:标签多选 */} |
| <Form.Select |
| field='tags' |
| label={t('标签')} |
| multiple |
| optionList={tagOptions} |
| filter |
| onFocus={async ()=>{ |
| if (tagOptions.length===0) { |
| const res = await API.get('/api/channel/tags?offset=0&limit=200'); |
| const opts = (res?.data?.data||[]).filter(Boolean).map(t=>({label:t, value:t})); |
| setTagOptions(opts); |
| } |
| }} |
| onChange={(v) => { |
| const next = (v || []); |
| setSelectedTags(next); |
| searchChannels(searchKeyword, searchGroup, searchModel, enableTagMode, null, null, next); |
| }} |
| /> |
| <Button |
| theme='light' |
| type='tertiary' |
| onClick={() => { |
| setSearchKeyword(''); |
| setSearchGroup(''); |
| setSearchModel(''); |
| setSelectedTypes([]); |
| setSelectedStatuses([]); |
| setSelectedTags([]); |
| loadChannels(0, pageSize, idSort, enableTagMode); |
| }} |
| >{t('重置条件')}</Button> |
| |
| |
| <Button |
| label={t('查询')} |
| type='primary' |
| htmlType='submit' |
| className='btn-margin-right' |
| style={{ marginRight: 8 }} |
| > |
| {t('查询')} |
| </Button> |
| </Space> |
| </div> |
| </Form> |
| <Divider style={{ marginBottom: 15 }} /> |
| <div |
| style={{ |
| display: 'flex', |
| flexDirection: isMobile() ? 'column' : 'row', |
| marginTop: isMobile() ? 0 : -45, |
| zIndex: 999, |
| pointerEvents: 'none', |
| }} |
| > |
| <Space |
| style={{ |
| pointerEvents: 'auto', |
| marginTop: isMobile() ? 0 : 45, |
| marginBottom: isMobile() ? 16 : 0, |
| display: 'flex', |
| flexWrap: isMobile() ? 'wrap' : 'nowrap', |
| gap: '8px', |
| }} |
| > |
| <div |
| style={{ |
| display: 'flex', |
| alignItems: 'center', |
| marginRight: 16, |
| flexWrap: 'nowrap', |
| }} |
| > |
| <Typography.Text strong style={{ marginRight: 8 }}> |
| {t('使用ID排序')} |
| </Typography.Text> |
| <Switch |
| checked={idSort} |
| label={t('使用ID排序')} |
| aria-label={t('是否用ID排序')} |
| onChange={(v) => { |
| localStorage.setItem('id-sort', v + ''); |
| setIdSort(v); |
| loadChannels(0, pageSize, v, enableTagMode) |
| .then() |
| .catch((reason) => { |
| showError(reason); |
| }); |
| }} |
| ></Switch> |
| </div> |
| |
| <div |
| style={{ |
| display: 'flex', |
| flexWrap: 'wrap', |
| gap: '8px', |
| }} |
| > |
| <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> |
| </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('开启批量操作')} |
| aria-label={t('是否开启批量操作')} |
| onChange={(v) => { |
| setEnableBatchDelete(v); |
| }} |
| /> |
| </div> |
| |
| <div |
| style={{ |
| display: 'flex', |
| flexWrap: 'wrap', |
| gap: '8px', |
| }} |
| > |
| <Popconfirm |
| title={t('确定是否要删除所选通道?')} |
| content={t('此修改将不可逆')} |
| okType={'danger'} |
| onConfirm={batchDeleteChannels} |
| disabled={!enableBatchDelete} |
| > |
| <Button disabled={!enableBatchDelete} theme='light' type='danger'> |
| {t('删除所选通道')} |
| </Button> |
| </Popconfirm> |
| <Popconfirm |
| title={t('确定是否要修复数据库一致性?')} |
| content={t( |
| '进行该操作时,可能导致渠道访问错误,请仅在数据库出现问题时使用', |
| )} |
| okType={'warning'} |
| onConfirm={fixChannelsAbilities} |
| > |
| <Button theme='light' type='secondary'> |
| {t('修复数据库一致性')} |
| </Button> |
| </Popconfirm> |
| </div> |
| </div> |
| |
| <div |
| style={{ |
| marginTop: 20, |
| marginBottom: 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 |
| checked={enableTagMode} |
| label={t('标签聚合模式')} |
| aria-label={t('是否启用标签聚合')} |
| onChange={(v) => { |
| setEnableTagMode(v); |
| loadChannels(0, pageSize, idSort, v); |
| }} |
| /> |
| </div> |
| |
| <div |
| style={{ |
| display: 'flex', |
| flexWrap: 'wrap', |
| gap: '8px', |
| }} |
| > |
| <Button |
| disabled={!enableBatchDelete} |
| theme='light' |
| type='primary' |
| onClick={() => setShowBatchSetTag(true)} |
| > |
| {t('批量设置标签')} |
| </Button> |
| |
| <Button |
| theme='light' |
| type='tertiary' |
| icon={<IconSetting />} |
| onClick={() => setShowColumnSelector(true)} |
| > |
| {t('列设置')} |
| </Button> |
| </div> |
| </div> |
| |
| <Table |
| loading={loading} |
| columns={getVisibleColumns()} |
| dataSource={pageData} |
| rowKey="rowKey" |
| pagination={{ |
| currentPage: activePage, |
| pageSize: pageSize, |
| total: channelCount, |
| pageSizeOpts: [10, 20, 50, 100], |
| showSizeChanger: true, |
| formatPageText: (page) => '', |
| onPageSizeChange: (size) => { |
| handlePageSizeChange(size).then(); |
| }, |
| onPageChange: handlePageChange, |
| }} |
| expandAllRows={false} |
| onRow={handleRow} |
| rowSelection={ |
| enableBatchDelete |
| ? { |
| onChange: (selectedRowKeys, selectedRows) => { |
| // console.log(`selectedRowKeys: ${selectedRowKeys}`, 'selectedRows: ', selectedRows); |
| setSelectedChannels(selectedRows); |
| }, |
| } |
| : null |
| } |
| /> |
| <Modal |
| title={t('批量设置标签')} |
| visible={showBatchSetTag} |
| onOk={batchSetChannelTag} |
| onCancel={() => setShowBatchSetTag(false)} |
| maskClosable={false} |
| centered={true} |
| style={{ width: isMobile() ? '90%' : 500 }} |
| > |
| <div style={{ marginBottom: 20 }}> |
| <Typography.Text>{t('请输入要设置的标签名称')}</Typography.Text> |
| </div> |
| <Input |
| placeholder={t('请输入标签名称')} |
| value={batchSetTagValue} |
| onChange={(v) => setBatchSetTagValue(v)} |
| size='large' |
| /> |
| <div style={{ marginTop: 16 }}> |
| <Typography.Text type='secondary'> |
| {t('已选择 {{count}} 个渠道', { count: selectedChannels.length })} |
| </Typography.Text> |
| </div> |
| </Modal> |
| |
| {/* 模型测试弹窗 */} |
| <Modal |
| title={t('模型测试')} |
| visible={showModelTestModal && currentTestChannel !== null} |
| onCancel={handleModelTestModalClose} |
| footer={null} |
| maskClosable={true} |
| centered={true} |
| width={800} |
| > |
| <div style={{ |
| maxHeight: '70vh', |
| overflowY: 'auto' |
| }}> |
| {currentTestChannel && ( |
| <ModelTestContent |
| channel={currentTestChannel} |
| isBatchTesting={isBatchTesting} |
| concurrentLimit={concurrentLimit} |
| setConcurrentLimit={setConcurrentLimit} |
| testAllModels={testAllModels} |
| modelSearchKeyword={modelSearchKeyword} |
| setModelSearchKeyword={setModelSearchKeyword} |
| batchTestResults={batchTestResults} |
| retryFailedModels={retryFailedModels} |
| handleDeleteFailedModels={handleDeleteFailedModels} |
| testProgress={testProgress} |
| currentTestingModel={currentTestingModel} |
| testChannel={testChannel} |
| showSuccess={showSuccess} |
| showError={showError} |
| t={t} |
| /> |
| )} |
| </div> |
| </Modal> |
| |
| |
| </> |
| ); |
| }; |
|
|
| export default ChannelsTable; |
|
|