Files
new-api/web/src/components/ChannelsTable.js

1747 lines
50 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useEffect, useState } from 'react';
import {
API,
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,
} 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 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>
);
}
};
// Define column keys for selection
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',
};
// State for column visibility
const [visibleColumns, setVisibleColumns] = useState({});
const [showColumnSelector, setShowColumnSelector] = useState(false);
// Load saved column preferences from localStorage
useEffect(() => {
const savedColumns = localStorage.getItem('channels-table-columns');
if (savedColumns) {
try {
const parsed = JSON.parse(savedColumns);
// Make sure all columns are accounted for
const defaults = getDefaultColumnVisibility();
const merged = { ...defaults, ...parsed };
setVisibleColumns(merged);
} catch (e) {
console.error('Failed to parse saved column preferences', e);
initDefaultColumns();
}
} else {
initDefaultColumns();
}
}, []);
// Update table when column visibility changes
useEffect(() => {
if (Object.keys(visibleColumns).length > 0) {
// Save to localStorage
localStorage.setItem(
'channels-table-columns',
JSON.stringify(visibleColumns),
);
}
}, [visibleColumns]);
// Get default column visibility
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,
};
};
// Initialize default column visibility
const initDefaultColumns = () => {
const defaults = getDefaultColumnVisibility();
setVisibleColumns(defaults);
};
// Handle column visibility change
const handleColumnVisibilityChange = (columnKey, checked) => {
const updatedColumns = { ...visibleColumns, [columnKey]: checked };
setVisibleColumns(updatedColumns);
};
// Handle "Select All" checkbox
const handleSelectAll = (checked) => {
const allKeys = Object.keys(COLUMN_KEYS).map((key) => COLUMN_KEYS[key]);
const updatedColumns = {};
allKeys.forEach((key) => {
updatedColumns[key] = checked;
});
setVisibleColumns(updatedColumns);
};
// Define all columns with keys
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>
</>
);
}
},
},
];
// Filter columns based on visibility settings
const getVisibleColumns = () => {
return allColumns.filter((column) => visibleColumns[column.key]);
};
// Column selector modal
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 [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(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 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 : '';
// find from channelTags
let tagIndex = channelTags[tag];
let tagChannelDates = undefined;
if (tagIndex === undefined) {
// not found, create a new tag
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 {
// found, add to the tag
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) {
// join
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;
}
}
// data.key = '' + data.id
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(() => {
// console.log('default effect')
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);
// setActivePage(1);
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) => {
// Create a new copy of channels array
const newChannels = [...channels];
let updated = false;
// Find and update the correct channel
newChannels.forEach((channel) => {
if (channel.children !== undefined) {
// If this is a tag group, search in its children
channel.children.forEach((child) => {
if (child.id === channelId) {
updateFn(child);
updated = true;
}
});
} else if (channel.id === channelId) {
// Direct channel match
updateFn(channel);
updated = true;
}
});
// Only update state if we actually modified a channel
if (updated) {
setChannels(newChannels);
}
};
const testChannel = async (record, model) => {
const res = await API.get(`/api/channel/test/${record.id}?model=${model}`);
const { success, message, time } = res.data;
if (success) {
// Also update the channels state to persist the change
updateChannelProperty(record.id, (channel) => {
channel.response_time = time * 1000;
channel.test_time = Date.now() / 1000;
});
showInfo(
t('通道 ${name} 测试成功,耗时 ${time.toFixed(2)} 秒。')
.replace('${name}', record.name)
.replace('${time.toFixed(2)}', time.toFixed(2)),
);
} else {
showError(message);
}
};
const updateChannelBalance = async (record) => {
const res = await API.get(`/api/channel/update_balance/${record.id}/`);
const { success, message, balance } = res.data;
if (success) {
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 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 () => {
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('已删除 ${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);
}
};
let pageData = channels.slice(
(activePage - 1) * pageSize,
activePage * pageSize,
);
const handlePageChange = (page) => {
setActivePage(page);
if (page === Math.ceil(channels.length / pageSize) + 1) {
// In this case we have to load more data and then append them.
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/`);
// add 'all' option
// res.data.data.unshift('all');
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);
}
};
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名称密钥和API地址 ...')}
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,
]}
initValue={null}
onChange={(v) => {
setSearchGroup(v);
searchChannels(searchKeyword, v, searchModel, enableTagMode);
}}
/>
<Button
label={t('查询')}
type='primary'
htmlType='submit'
className='btn-margin-right'
style={{ marginRight: 8 }}
>
{t('查询')}
</Button>
</Space>
</div>
</Form>
<Divider style={{ marginBottom: 15 }} />
<div
style={{
display: 'flex',
flexDirection: isMobile() ? 'column' : 'row',
marginTop: isMobile() ? 0 : -45,
zIndex: 999,
pointerEvents: 'none',
}}
>
<Space
style={{
pointerEvents: 'auto',
marginTop: isMobile() ? 0 : 45,
marginBottom: isMobile() ? 16 : 0,
display: 'flex',
flexWrap: isMobile() ? 'wrap' : 'nowrap',
gap: '8px',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
marginRight: 16,
flexWrap: 'nowrap',
}}
>
<Typography.Text strong style={{ marginRight: 8 }}>
{t('使用ID排序')}
</Typography.Text>
<Switch
checked={idSort}
label={t('使用ID排序')}
uncheckedText={t('关')}
aria-label={t('是否用ID排序')}
onChange={(v) => {
localStorage.setItem('id-sort', v + '');
setIdSort(v);
loadChannels(0, pageSize, v, enableTagMode)
.then()
.catch((reason) => {
showError(reason);
});
}}
></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
checked={enableBatchDelete}
label={t('开启批量操作')}
uncheckedText={t('关')}
aria-label={t('是否开启批量操作')}
onChange={(v) => {
localStorage.setItem('enable-batch-delete', 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,
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('标签聚合模式')}
uncheckedText={t('关')}
aria-label={t('是否启用标签聚合')}
onChange={(v) => {
localStorage.setItem('enable-tag-mode', 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}
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} 个渠道').replace(
'${count}',
selectedChannels.length,
)}
</Typography.Text>
</div>
</Modal>
{/* 模型测试弹窗 */}
<Modal
title={t('选择模型进行测试')}
visible={showModelTestModal && currentTestChannel !== null}
onCancel={() => {
setShowModelTestModal(false);
setModelSearchKeyword('');
}}
footer={null}
maskClosable={true}
centered={true}
>
<div style={{ maxHeight: '500px', overflowY: 'auto', padding: '10px' }}>
{currentTestChannel && (
<div>
<Typography.Title heading={6} style={{ marginBottom: '16px' }}>
{t('渠道')}: {currentTestChannel.name}
</Typography.Title>
{/* 搜索框 */}
<Input
placeholder={t('搜索模型...')}
value={modelSearchKeyword}
onChange={(v) => setModelSearchKeyword(v)}
style={{ marginBottom: '16px' }}
prefix={<IconFilter />}
showClear
/>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
gap: '12px',
marginBottom: '16px',
}}
>
{currentTestChannel.models
.split(',')
.filter((model) =>
model
.toLowerCase()
.includes(modelSearchKeyword.toLowerCase()),
)
.map((model, index) => {
return (
<Button
theme='light'
type='tertiary'
style={{
height: 'auto',
padding: '10px 12px',
textAlign: 'center',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
width: '100%',
borderRadius: '6px',
}}
onClick={() => {
testChannel(currentTestChannel, model);
}}
>
{model}
</Button>
);
})}
</div>
{/* 显示搜索结果数量 */}
{modelSearchKeyword && (
<Typography.Text type='secondary' style={{ display: 'block' }}>
{t('找到')}{' '}
{
currentTestChannel.models
.split(',')
.filter((model) =>
model
.toLowerCase()
.includes(modelSearchKeyword.toLowerCase()),
).length
}{' '}
{t('个模型')}
</Typography.Text>
)}
</div>
)}
</div>
</Modal>
</>
);
};
export default ChannelsTable;