| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
|
|
| import React from 'react';
|
| import {
|
| Button,
|
| Dropdown,
|
| InputNumber,
|
| Modal,
|
| Space,
|
| SplitButtonGroup,
|
| Tag,
|
| Tooltip,
|
| Typography,
|
| } from '@douyinfe/semi-ui';
|
| import {
|
| timestamp2string,
|
| renderGroup,
|
| renderQuota,
|
| getChannelIcon,
|
| renderQuotaWithAmount,
|
| showSuccess,
|
| showError,
|
| } from '../../../helpers';
|
| import { CHANNEL_OPTIONS } from '../../../constants';
|
| import {
|
| IconTreeTriangleDown,
|
| IconMore,
|
| IconAlertTriangle,
|
| } from '@douyinfe/semi-icons';
|
| import { FaRandom } from 'react-icons/fa';
|
|
|
|
|
| const renderType = (type, record = {}, t) => {
|
| const channelInfo = record?.channel_info;
|
| let type2label = new Map();
|
| for (let i = 0; i < CHANNEL_OPTIONS.length; i++) {
|
| type2label[CHANNEL_OPTIONS[i].value] = CHANNEL_OPTIONS[i];
|
| }
|
| type2label[0] = { value: 0, label: t('未知类型'), color: 'grey' };
|
|
|
| let icon = getChannelIcon(type);
|
|
|
| if (channelInfo?.is_multi_key) {
|
| icon =
|
| channelInfo?.multi_key_mode === 'random' ? (
|
| <div className='flex items-center gap-1'>
|
| <FaRandom className='text-blue-500' />
|
| {icon}
|
| </div>
|
| ) : (
|
| <div className='flex items-center gap-1'>
|
| <IconTreeTriangleDown className='text-blue-500' />
|
| {icon}
|
| </div>
|
| );
|
| }
|
|
|
| const typeTag = (
|
| <Tag color={type2label[type]?.color} shape='circle' prefixIcon={icon}>
|
| {type2label[type]?.label}
|
| </Tag>
|
| );
|
|
|
| let ionetMeta = null;
|
| if (record?.other_info) {
|
| try {
|
| const parsed = JSON.parse(record.other_info);
|
| if (parsed && typeof parsed === 'object' && parsed.source === 'ionet') {
|
| ionetMeta = parsed;
|
| }
|
| } catch (error) {
|
|
|
| }
|
| }
|
|
|
| if (!ionetMeta) {
|
| return typeTag;
|
| }
|
|
|
| const handleNavigate = (event) => {
|
| event?.stopPropagation?.();
|
| if (!ionetMeta?.deployment_id) {
|
| return;
|
| }
|
| const targetUrl = `/console/deployment?deployment_id=${ionetMeta.deployment_id}`;
|
| window.open(targetUrl, '_blank', 'noopener');
|
| };
|
|
|
| return (
|
| <Space spacing={6}>
|
| {typeTag}
|
| <Tooltip
|
| content={
|
| <div className='max-w-xs'>
|
| <div className='text-xs text-gray-600'>
|
| {t('来源于 IO.NET 部署')}
|
| </div>
|
| {ionetMeta?.deployment_id && (
|
| <div className='text-xs text-gray-500 mt-1'>
|
| {t('部署 ID')}: {ionetMeta.deployment_id}
|
| </div>
|
| )}
|
| </div>
|
| }
|
| >
|
| <span>
|
| <Tag
|
| color='purple'
|
| type='light'
|
| className='cursor-pointer'
|
| onClick={handleNavigate}
|
| >
|
| IO.NET
|
| </Tag>
|
| </span>
|
| </Tooltip>
|
| </Space>
|
| );
|
| };
|
|
|
| const renderTagType = (t) => {
|
| return (
|
| <Tag color='light-blue' shape='circle' type='light'>
|
| {t('标签聚合')}
|
| </Tag>
|
| );
|
| };
|
|
|
| const renderStatus = (status, channelInfo = undefined, t) => {
|
| if (channelInfo) {
|
| if (channelInfo.is_multi_key) {
|
| let keySize = channelInfo.multi_key_size;
|
| let enabledKeySize = keySize;
|
| if (channelInfo.multi_key_status_list) {
|
| enabledKeySize =
|
| keySize - Object.keys(channelInfo.multi_key_status_list).length;
|
| }
|
| return renderMultiKeyStatus(status, keySize, enabledKeySize, t);
|
| }
|
| }
|
| switch (status) {
|
| case 1:
|
| return (
|
| <Tag color='green' shape='circle'>
|
| {t('已启用')}
|
| </Tag>
|
| );
|
| case 2:
|
| return (
|
| <Tag color='red' shape='circle'>
|
| {t('已禁用')}
|
| </Tag>
|
| );
|
| case 3:
|
| return (
|
| <Tag color='yellow' shape='circle'>
|
| {t('自动禁用')}
|
| </Tag>
|
| );
|
| default:
|
| return (
|
| <Tag color='grey' shape='circle'>
|
| {t('未知状态')}
|
| </Tag>
|
| );
|
| }
|
| };
|
|
|
| const renderMultiKeyStatus = (status, keySize, enabledKeySize, t) => {
|
| switch (status) {
|
| case 1:
|
| return (
|
| <Tag color='green' shape='circle'>
|
| {t('已启用')} {enabledKeySize}/{keySize}
|
| </Tag>
|
| );
|
| case 2:
|
| return (
|
| <Tag color='red' shape='circle'>
|
| {t('已禁用')} {enabledKeySize}/{keySize}
|
| </Tag>
|
| );
|
| case 3:
|
| return (
|
| <Tag color='yellow' shape='circle'>
|
| {t('自动禁用')} {enabledKeySize}/{keySize}
|
| </Tag>
|
| );
|
| default:
|
| return (
|
| <Tag color='grey' shape='circle'>
|
| {t('未知状态')} {enabledKeySize}/{keySize}
|
| </Tag>
|
| );
|
| }
|
| };
|
|
|
| const renderResponseTime = (responseTime, t) => {
|
| let time = responseTime / 1000;
|
| time = time.toFixed(2) + t(' 秒');
|
| if (responseTime === 0) {
|
| return (
|
| <Tag color='grey' shape='circle'>
|
| {t('未测试')}
|
| </Tag>
|
| );
|
| } else if (responseTime <= 1000) {
|
| return (
|
| <Tag color='green' shape='circle'>
|
| {time}
|
| </Tag>
|
| );
|
| } else if (responseTime <= 3000) {
|
| return (
|
| <Tag color='lime' shape='circle'>
|
| {time}
|
| </Tag>
|
| );
|
| } else if (responseTime <= 5000) {
|
| return (
|
| <Tag color='yellow' shape='circle'>
|
| {time}
|
| </Tag>
|
| );
|
| } else {
|
| return (
|
| <Tag color='red' shape='circle'>
|
| {time}
|
| </Tag>
|
| );
|
| }
|
| };
|
|
|
| const isRequestPassThroughEnabled = (record) => {
|
| if (!record || record.children !== undefined) {
|
| return false;
|
| }
|
| const settingValue = record.setting;
|
| if (!settingValue) {
|
| return false;
|
| }
|
| if (typeof settingValue === 'object') {
|
| return settingValue.pass_through_body_enabled === true;
|
| }
|
| if (typeof settingValue !== 'string') {
|
| return false;
|
| }
|
| try {
|
| const parsed = JSON.parse(settingValue);
|
| return parsed?.pass_through_body_enabled === true;
|
| } catch (error) {
|
| return false;
|
| }
|
| };
|
|
|
| export const getChannelsColumns = ({
|
| t,
|
| COLUMN_KEYS,
|
| updateChannelBalance,
|
| manageChannel,
|
| manageTag,
|
| submitTagEdit,
|
| testChannel,
|
| setCurrentTestChannel,
|
| setShowModelTestModal,
|
| setEditingChannel,
|
| setShowEdit,
|
| setShowEditTag,
|
| setEditingTag,
|
| copySelectedChannel,
|
| refresh,
|
| activePage,
|
| channels,
|
| checkOllamaVersion,
|
| setShowMultiKeyManageModal,
|
| setCurrentMultiKeyChannel,
|
| }) => {
|
| return [
|
| {
|
| key: COLUMN_KEYS.ID,
|
| title: t('ID'),
|
| dataIndex: 'id',
|
| },
|
| {
|
| key: COLUMN_KEYS.NAME,
|
| title: t('名称'),
|
| dataIndex: 'name',
|
| render: (text, record, index) => {
|
| const passThroughEnabled = isRequestPassThroughEnabled(record);
|
| const nameNode =
|
| record.remark && record.remark.trim() !== '' ? (
|
| <Tooltip
|
| content={
|
| <div className='flex flex-col gap-2 max-w-xs'>
|
| <div className='text-sm'>{record.remark}</div>
|
| <Button
|
| size='small'
|
| type='primary'
|
| theme='outline'
|
| onClick={(e) => {
|
| e.stopPropagation();
|
| navigator.clipboard
|
| .writeText(record.remark)
|
| .then(() => {
|
| showSuccess(t('复制成功'));
|
| })
|
| .catch(() => {
|
| showError(t('复制失败'));
|
| });
|
| }}
|
| >
|
| {t('复制')}
|
| </Button>
|
| </div>
|
| }
|
| trigger='hover'
|
| position='topLeft'
|
| >
|
| <span>{text}</span>
|
| </Tooltip>
|
| ) : (
|
| <span>{text}</span>
|
| );
|
|
|
| if (!passThroughEnabled) {
|
| return nameNode;
|
| }
|
|
|
| return (
|
| <Space spacing={6} align='center'>
|
| {nameNode}
|
| <Tooltip
|
| content={t(
|
| '该渠道已开启请求透传:参数覆写、模型重定向、渠道适配等 NewAPI 内置功能将失效,非最佳实践;如因此产生问题,请勿提交 issue 反馈。',
|
| )}
|
| trigger='hover'
|
| position='topLeft'
|
| >
|
| <span className='inline-flex items-center'>
|
| <IconAlertTriangle
|
| style={{ color: 'var(--semi-color-warning)' }}
|
| />
|
| </span>
|
| </Tooltip>
|
| </Space>
|
| );
|
| },
|
| },
|
| {
|
| key: COLUMN_KEYS.GROUP,
|
| title: t('分组'),
|
| dataIndex: 'group',
|
| render: (text, record, index) => (
|
| <div>
|
| <Space spacing={2}>
|
| {text
|
| ?.split(',')
|
| .sort((a, b) => {
|
| if (a === 'default') return -1;
|
| if (b === 'default') return 1;
|
| return a.localeCompare(b);
|
| })
|
| .map((item, index) => renderGroup(item))}
|
| </Space>
|
| </div>
|
| ),
|
| },
|
| {
|
| key: COLUMN_KEYS.TYPE,
|
| title: t('类型'),
|
| dataIndex: 'type',
|
| render: (text, record, index) => {
|
| if (record.children === undefined) {
|
| return <>{renderType(text, record, t)}</>;
|
| } else {
|
| return <>{renderTagType(t)}</>;
|
| }
|
| },
|
| },
|
| {
|
| key: COLUMN_KEYS.STATUS,
|
| title: t('状态'),
|
| dataIndex: 'status',
|
| render: (text, record, index) => {
|
| if (text === 3) {
|
| if (record.other_info === '') {
|
| record.other_info = '{}';
|
| }
|
| let otherInfo = JSON.parse(record.other_info);
|
| let reason = otherInfo['status_reason'];
|
| let time = otherInfo['status_time'];
|
| return (
|
| <div>
|
| <Tooltip
|
| content={
|
| t('原因:') + reason + t(',时间:') + timestamp2string(time)
|
| }
|
| >
|
| {renderStatus(text, record.channel_info, t)}
|
| </Tooltip>
|
| </div>
|
| );
|
| } else {
|
| return renderStatus(text, record.channel_info, t);
|
| }
|
| },
|
| },
|
| {
|
| key: COLUMN_KEYS.RESPONSE_TIME,
|
| title: t('响应时间'),
|
| dataIndex: 'response_time',
|
| render: (text, record, index) => <div>{renderResponseTime(text, t)}</div>,
|
| },
|
| {
|
| key: COLUMN_KEYS.BALANCE,
|
| title: t('已用/剩余'),
|
| dataIndex: 'expired_time',
|
| render: (text, record, index) => {
|
| if (record.children === undefined) {
|
| return (
|
| <div>
|
| <Space spacing={1}>
|
| <Tooltip content={t('已用额度')}>
|
| <Tag color='white' type='ghost' shape='circle'>
|
| {renderQuota(record.used_quota)}
|
| </Tag>
|
| </Tooltip>
|
| <Tooltip
|
| content={t('剩余额度$') + record.balance + t(',点击更新')}
|
| >
|
| <Tag
|
| color='white'
|
| type='ghost'
|
| shape='circle'
|
| onClick={() => updateChannelBalance(record)}
|
| >
|
| {renderQuotaWithAmount(record.balance)}
|
| </Tag>
|
| </Tooltip>
|
| </Space>
|
| </div>
|
| );
|
| } else {
|
| return (
|
| <Tooltip content={t('已用额度')}>
|
| <Tag color='white' type='ghost' shape='circle'>
|
| {renderQuota(record.used_quota)}
|
| </Tag>
|
| </Tooltip>
|
| );
|
| }
|
| },
|
| },
|
| {
|
| key: COLUMN_KEYS.PRIORITY,
|
| title: t('优先级'),
|
| dataIndex: 'priority',
|
| render: (text, record, index) => {
|
| if (record.children === undefined) {
|
| return (
|
| <div>
|
| <InputNumber
|
| style={{ width: 70 }}
|
| name='priority'
|
| onBlur={(e) => {
|
| manageChannel(record.id, 'priority', record, e.target.value);
|
| }}
|
| keepFocus={true}
|
| innerButtons
|
| defaultValue={record.priority}
|
| min={-999}
|
| size='small'
|
| />
|
| </div>
|
| );
|
| } else {
|
| return (
|
| <InputNumber
|
| style={{ width: 70 }}
|
| name='priority'
|
| keepFocus={true}
|
| onBlur={(e) => {
|
| Modal.warning({
|
| title: t('修改子渠道优先级'),
|
| content:
|
| t('确定要修改所有子渠道优先级为 ') +
|
| e.target.value +
|
| t(' 吗?'),
|
| onOk: () => {
|
| if (e.target.value === '') {
|
| return;
|
| }
|
| submitTagEdit('priority', {
|
| tag: record.key,
|
| priority: e.target.value,
|
| });
|
| },
|
| });
|
| }}
|
| innerButtons
|
| defaultValue={record.priority}
|
| min={-999}
|
| size='small'
|
| />
|
| );
|
| }
|
| },
|
| },
|
| {
|
| key: COLUMN_KEYS.WEIGHT,
|
| title: t('权重'),
|
| dataIndex: 'weight',
|
| render: (text, record, index) => {
|
| if (record.children === undefined) {
|
| return (
|
| <div>
|
| <InputNumber
|
| style={{ width: 70 }}
|
| name='weight'
|
| onBlur={(e) => {
|
| manageChannel(record.id, 'weight', record, e.target.value);
|
| }}
|
| keepFocus={true}
|
| innerButtons
|
| defaultValue={record.weight}
|
| min={0}
|
| size='small'
|
| />
|
| </div>
|
| );
|
| } else {
|
| return (
|
| <InputNumber
|
| style={{ width: 70 }}
|
| name='weight'
|
| keepFocus={true}
|
| onBlur={(e) => {
|
| Modal.warning({
|
| title: t('修改子渠道权重'),
|
| content:
|
| t('确定要修改所有子渠道权重为 ') +
|
| e.target.value +
|
| t(' 吗?'),
|
| onOk: () => {
|
| if (e.target.value === '') {
|
| return;
|
| }
|
| submitTagEdit('weight', {
|
| tag: record.key,
|
| weight: e.target.value,
|
| });
|
| },
|
| });
|
| }}
|
| innerButtons
|
| defaultValue={record.weight}
|
| min={-999}
|
| size='small'
|
| />
|
| );
|
| }
|
| },
|
| },
|
| {
|
| key: COLUMN_KEYS.OPERATE,
|
| title: '',
|
| dataIndex: 'operate',
|
| fixed: 'right',
|
| render: (text, record, index) => {
|
| if (record.children === undefined) {
|
| const moreMenuItems = [
|
| {
|
| node: 'item',
|
| name: t('删除'),
|
| type: 'danger',
|
| onClick: () => {
|
| Modal.confirm({
|
| title: t('确定是否要删除此渠道?'),
|
| content: t('此修改将不可逆'),
|
| onOk: () => {
|
| (async () => {
|
| await manageChannel(record.id, 'delete', record);
|
| await refresh();
|
| setTimeout(() => {
|
| if (channels.length === 0 && activePage > 1) {
|
| refresh(activePage - 1);
|
| }
|
| }, 100);
|
| })();
|
| },
|
| });
|
| },
|
| },
|
| {
|
| node: 'item',
|
| name: t('复制'),
|
| type: 'tertiary',
|
| onClick: () => {
|
| Modal.confirm({
|
| title: t('确定是否要复制此渠道?'),
|
| content: t('复制渠道的所有信息'),
|
| onOk: () => copySelectedChannel(record),
|
| });
|
| },
|
| },
|
| ];
|
|
|
| if (record.type === 4) {
|
| moreMenuItems.unshift({
|
| node: 'item',
|
| name: t('测活'),
|
| type: 'tertiary',
|
| onClick: () => checkOllamaVersion(record),
|
| });
|
| }
|
|
|
| return (
|
| <Space wrap>
|
| <SplitButtonGroup
|
| className='overflow-hidden'
|
| aria-label={t('测试单个渠道操作项目组')}
|
| >
|
| <Button
|
| size='small'
|
| type='tertiary'
|
| onClick={() => testChannel(record, '')}
|
| >
|
| {t('测试')}
|
| </Button>
|
| <Button
|
| size='small'
|
| type='tertiary'
|
| icon={<IconTreeTriangleDown />}
|
| onClick={() => {
|
| setCurrentTestChannel(record);
|
| setShowModelTestModal(true);
|
| }}
|
| />
|
| </SplitButtonGroup>
|
|
|
| {record.status === 1 ? (
|
| <Button
|
| type='danger'
|
| size='small'
|
| onClick={() => manageChannel(record.id, 'disable', record)}
|
| >
|
| {t('禁用')}
|
| </Button>
|
| ) : (
|
| <Button
|
| size='small'
|
| onClick={() => manageChannel(record.id, 'enable', record)}
|
| >
|
| {t('启用')}
|
| </Button>
|
| )}
|
|
|
| {record.channel_info?.is_multi_key ? (
|
| <SplitButtonGroup aria-label={t('多密钥渠道操作项目组')}>
|
| <Button
|
| type='tertiary'
|
| size='small'
|
| onClick={() => {
|
| setEditingChannel(record);
|
| setShowEdit(true);
|
| }}
|
| >
|
| {t('编辑')}
|
| </Button>
|
| <Dropdown
|
| trigger='click'
|
| position='bottomRight'
|
| menu={[
|
| {
|
| node: 'item',
|
| name: t('多密钥管理'),
|
| onClick: () => {
|
| setCurrentMultiKeyChannel(record);
|
| setShowMultiKeyManageModal(true);
|
| },
|
| },
|
| ]}
|
| >
|
| <Button
|
| type='tertiary'
|
| size='small'
|
| icon={<IconTreeTriangleDown />}
|
| />
|
| </Dropdown>
|
| </SplitButtonGroup>
|
| ) : (
|
| <Button
|
| type='tertiary'
|
| size='small'
|
| onClick={() => {
|
| setEditingChannel(record);
|
| setShowEdit(true);
|
| }}
|
| >
|
| {t('编辑')}
|
| </Button>
|
| )}
|
|
|
| <Dropdown
|
| trigger='click'
|
| position='bottomRight'
|
| menu={moreMenuItems}
|
| >
|
| <Button icon={<IconMore />} type='tertiary' size='small' />
|
| </Dropdown>
|
| </Space>
|
| );
|
| } else {
|
|
|
| return (
|
| <Space wrap>
|
| <Button
|
| type='tertiary'
|
| size='small'
|
| onClick={() => manageTag(record.key, 'enable')}
|
| >
|
| {t('启用全部')}
|
| </Button>
|
| <Button
|
| type='tertiary'
|
| size='small'
|
| onClick={() => manageTag(record.key, 'disable')}
|
| >
|
| {t('禁用全部')}
|
| </Button>
|
| <Button
|
| type='tertiary'
|
| size='small'
|
| onClick={() => {
|
| setShowEditTag(true);
|
| setEditingTag(record.key);
|
| }}
|
| >
|
| {t('编辑')}
|
| </Button>
|
| </Space>
|
| );
|
| }
|
| },
|
| },
|
| ];
|
| };
|
|
|