575 lines
14 KiB
React
575 lines
14 KiB
React
/*
|
|
Copyright (C) 2025 QuantumNous
|
|
|
|
This program is free software: you can redistribute it and/or modify
|
|
it under the terms of the GNU Affero General Public License as
|
|
published by the Free Software Foundation, either version 3 of the
|
|
License, or (at your option) any later version.
|
|
|
|
This program is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU Affero General Public License for more details.
|
|
|
|
You should have received a copy of the GNU Affero General Public License
|
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
For commercial licensing, please contact support@quantumnous.com
|
|
*/
|
|
|
|
import React from 'react';
|
|
import {
|
|
Button,
|
|
Dropdown,
|
|
Space,
|
|
SplitButtonGroup,
|
|
Tag,
|
|
AvatarGroup,
|
|
Avatar,
|
|
Tooltip,
|
|
Progress,
|
|
Popover,
|
|
Typography,
|
|
Input,
|
|
Modal,
|
|
} from '@douyinfe/semi-ui';
|
|
import {
|
|
timestamp2string,
|
|
renderGroup,
|
|
renderQuota,
|
|
getModelCategories,
|
|
showError,
|
|
} from '../../../helpers';
|
|
import {
|
|
IconTreeTriangleDown,
|
|
IconCopy,
|
|
IconEyeOpened,
|
|
IconEyeClosed,
|
|
} from '@douyinfe/semi-icons';
|
|
|
|
// progress color helper
|
|
const getProgressColor = (pct) => {
|
|
if (pct === 100) return 'var(--semi-color-success)';
|
|
if (pct <= 10) return 'var(--semi-color-danger)';
|
|
if (pct <= 30) return 'var(--semi-color-warning)';
|
|
return undefined;
|
|
};
|
|
|
|
// Render functions
|
|
function renderTimestamp(timestamp) {
|
|
return <>{timestamp2string(timestamp)}</>;
|
|
}
|
|
|
|
// Render status column only (no usage)
|
|
const renderStatus = (text, record, t) => {
|
|
const enabled = text === 1;
|
|
|
|
let tagColor = 'black';
|
|
let tagText = t('未知状态');
|
|
if (enabled) {
|
|
tagColor = 'green';
|
|
tagText = t('已启用');
|
|
} else if (text === 2) {
|
|
tagColor = 'red';
|
|
tagText = t('已禁用');
|
|
} else if (text === 3) {
|
|
tagColor = 'yellow';
|
|
tagText = t('已过期');
|
|
} else if (text === 4) {
|
|
tagColor = 'grey';
|
|
tagText = t('已耗尽');
|
|
}
|
|
|
|
return (
|
|
<Tag color={tagColor} shape='circle' size='small'>
|
|
{tagText}
|
|
</Tag>
|
|
);
|
|
};
|
|
|
|
// Render group column
|
|
const renderGroupColumn = (text, record, t, groupRatios = {}) => {
|
|
if (text === 'auto') {
|
|
return (
|
|
<Tooltip
|
|
content={t(
|
|
'当前分组为 auto,会自动选择最优分组,当一个组不可用时自动降级到下一个组(熔断机制)',
|
|
)}
|
|
position='top'
|
|
>
|
|
<Tag color='white' shape='circle'>
|
|
{t('智能熔断')}
|
|
{record && record.cross_group_retry ? `(${t('跨分组')})` : ''}
|
|
</Tag>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
const ratio = groupRatios[text];
|
|
return (
|
|
<span className='flex items-center gap-1'>
|
|
{renderGroup(text)}
|
|
{ratio !== undefined && (
|
|
<Tag size='small' color='green' shape='circle'>
|
|
{ratio}x
|
|
</Tag>
|
|
)}
|
|
</span>
|
|
);
|
|
};
|
|
|
|
// Render token key column with show/hide and copy functionality
|
|
const renderTokenKey = (
|
|
text,
|
|
record,
|
|
showKeys,
|
|
resolvedTokenKeys,
|
|
loadingTokenKeys,
|
|
toggleTokenVisibility,
|
|
copyTokenKey,
|
|
copyTokenConnectionString,
|
|
t,
|
|
) => {
|
|
const revealed = !!showKeys[record.id];
|
|
const loading = !!loadingTokenKeys[record.id];
|
|
const keyValue =
|
|
revealed && resolvedTokenKeys[record.id]
|
|
? resolvedTokenKeys[record.id]
|
|
: record.key || '';
|
|
const displayedKey = keyValue ? `sk-${keyValue}` : '';
|
|
|
|
return (
|
|
<div className='w-[200px]'>
|
|
<Input
|
|
readOnly
|
|
value={displayedKey}
|
|
size='small'
|
|
suffix={
|
|
<div className='flex items-center'>
|
|
<Button
|
|
theme='borderless'
|
|
size='small'
|
|
type='tertiary'
|
|
icon={revealed ? <IconEyeClosed /> : <IconEyeOpened />}
|
|
loading={loading}
|
|
aria-label='toggle token visibility'
|
|
onClick={async (e) => {
|
|
e.stopPropagation();
|
|
await toggleTokenVisibility(record);
|
|
}}
|
|
/>
|
|
<Dropdown
|
|
trigger='click'
|
|
position='bottomRight'
|
|
clickToHide
|
|
menu={[
|
|
{
|
|
node: 'item',
|
|
name: t('复制密钥'),
|
|
onClick: () => copyTokenKey(record),
|
|
},
|
|
{
|
|
node: 'item',
|
|
name: t('复制连接信息'),
|
|
onClick: () => copyTokenConnectionString(record),
|
|
},
|
|
]}
|
|
>
|
|
<Button
|
|
theme='borderless'
|
|
size='small'
|
|
type='tertiary'
|
|
icon={<IconCopy />}
|
|
loading={loading}
|
|
aria-label='copy token key'
|
|
onClick={async (e) => {
|
|
e.stopPropagation();
|
|
}}
|
|
/>
|
|
</Dropdown>
|
|
</div>
|
|
}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Render model limits column
|
|
const renderModelLimits = (text, record, t) => {
|
|
if (record.model_limits_enabled && text) {
|
|
const models = text.split(',').filter(Boolean);
|
|
const categories = getModelCategories(t);
|
|
|
|
const vendorAvatars = [];
|
|
const matchedModels = new Set();
|
|
Object.entries(categories).forEach(([key, category]) => {
|
|
if (key === 'all') return;
|
|
if (!category.icon || !category.filter) return;
|
|
const vendorModels = models.filter((m) =>
|
|
category.filter({ model_name: m }),
|
|
);
|
|
if (vendorModels.length > 0) {
|
|
vendorAvatars.push(
|
|
<Tooltip
|
|
key={key}
|
|
content={vendorModels.join(', ')}
|
|
position='top'
|
|
showArrow
|
|
>
|
|
<Avatar
|
|
size='extra-extra-small'
|
|
alt={category.label}
|
|
color='transparent'
|
|
>
|
|
{category.icon}
|
|
</Avatar>
|
|
</Tooltip>,
|
|
);
|
|
vendorModels.forEach((m) => matchedModels.add(m));
|
|
}
|
|
});
|
|
|
|
const unmatchedModels = models.filter((m) => !matchedModels.has(m));
|
|
if (unmatchedModels.length > 0) {
|
|
vendorAvatars.push(
|
|
<Tooltip
|
|
key='unknown'
|
|
content={unmatchedModels.join(', ')}
|
|
position='top'
|
|
showArrow
|
|
>
|
|
<Avatar size='extra-extra-small' alt='unknown'>
|
|
{t('其他')}
|
|
</Avatar>
|
|
</Tooltip>,
|
|
);
|
|
}
|
|
|
|
return <AvatarGroup size='extra-extra-small'>{vendorAvatars}</AvatarGroup>;
|
|
} else {
|
|
return (
|
|
<Tag color='white' shape='circle'>
|
|
{t('无限制')}
|
|
</Tag>
|
|
);
|
|
}
|
|
};
|
|
|
|
// Render IP restrictions column
|
|
const renderAllowIps = (text, t) => {
|
|
if (!text || text.trim() === '') {
|
|
return (
|
|
<Tag color='white' shape='circle'>
|
|
{t('无限制')}
|
|
</Tag>
|
|
);
|
|
}
|
|
|
|
const ips = text
|
|
.split('\n')
|
|
.map((ip) => ip.trim())
|
|
.filter(Boolean);
|
|
|
|
const displayIps = ips.slice(0, 1);
|
|
const extraCount = ips.length - displayIps.length;
|
|
|
|
const ipTags = displayIps.map((ip, idx) => (
|
|
<Tag key={idx} shape='circle'>
|
|
{ip}
|
|
</Tag>
|
|
));
|
|
|
|
if (extraCount > 0) {
|
|
ipTags.push(
|
|
<Tooltip
|
|
key='extra'
|
|
content={ips.slice(1).join(', ')}
|
|
position='top'
|
|
showArrow
|
|
>
|
|
<Tag shape='circle'>{'+' + extraCount}</Tag>
|
|
</Tooltip>,
|
|
);
|
|
}
|
|
|
|
return <Space wrap>{ipTags}</Space>;
|
|
};
|
|
|
|
// Render separate quota usage column
|
|
const renderQuotaUsage = (text, record, t) => {
|
|
const { Paragraph } = Typography;
|
|
const used = parseInt(record.used_quota) || 0;
|
|
const remain = parseInt(record.remain_quota) || 0;
|
|
const total = used + remain;
|
|
if (record.unlimited_quota) {
|
|
const popoverContent = (
|
|
<div className='text-xs p-2'>
|
|
<Paragraph copyable={{ content: renderQuota(used) }}>
|
|
{t('已用额度')}: {renderQuota(used)}
|
|
</Paragraph>
|
|
</div>
|
|
);
|
|
return (
|
|
<Popover content={popoverContent} position='top'>
|
|
<Tag color='white' shape='circle'>
|
|
{t('无限额度')}
|
|
</Tag>
|
|
</Popover>
|
|
);
|
|
}
|
|
const percent = total > 0 ? (remain / total) * 100 : 0;
|
|
const popoverContent = (
|
|
<div className='text-xs p-2'>
|
|
<Paragraph copyable={{ content: renderQuota(used) }}>
|
|
{t('已用额度')}: {renderQuota(used)}
|
|
</Paragraph>
|
|
<Paragraph copyable={{ content: renderQuota(remain) }}>
|
|
{t('剩余额度')}: {renderQuota(remain)} ({percent.toFixed(0)}%)
|
|
</Paragraph>
|
|
<Paragraph copyable={{ content: renderQuota(total) }}>
|
|
{t('总额度')}: {renderQuota(total)}
|
|
</Paragraph>
|
|
</div>
|
|
);
|
|
return (
|
|
<Popover content={popoverContent} position='top'>
|
|
<Tag color='white' shape='circle'>
|
|
<div className='flex flex-col items-end'>
|
|
<span className='text-xs leading-none'>{`${renderQuota(remain)} / ${renderQuota(total)}`}</span>
|
|
<Progress
|
|
percent={percent}
|
|
stroke={getProgressColor(percent)}
|
|
aria-label='quota usage'
|
|
format={() => `${percent.toFixed(0)}%`}
|
|
style={{ width: '100%', marginTop: '1px', marginBottom: 0 }}
|
|
/>
|
|
</div>
|
|
</Tag>
|
|
</Popover>
|
|
);
|
|
};
|
|
|
|
// Render operations column
|
|
const renderOperations = (
|
|
text,
|
|
record,
|
|
onOpenLink,
|
|
setEditingToken,
|
|
setShowEdit,
|
|
manageToken,
|
|
refresh,
|
|
t,
|
|
) => {
|
|
let chatsArray = [];
|
|
try {
|
|
const raw = localStorage.getItem('chats');
|
|
const parsed = JSON.parse(raw);
|
|
if (Array.isArray(parsed)) {
|
|
for (let i = 0; i < parsed.length; i++) {
|
|
const item = parsed[i];
|
|
const name = Object.keys(item)[0];
|
|
if (!name) continue;
|
|
chatsArray.push({
|
|
node: 'item',
|
|
key: i,
|
|
name,
|
|
value: item[name],
|
|
onClick: () => onOpenLink(name, item[name], record),
|
|
});
|
|
}
|
|
}
|
|
} catch (_) {
|
|
showError(t('聊天链接配置错误,请联系管理员'));
|
|
}
|
|
|
|
return (
|
|
<Space wrap>
|
|
<SplitButtonGroup
|
|
className='overflow-hidden'
|
|
aria-label={t('项目操作按钮组')}
|
|
>
|
|
<Button
|
|
size='small'
|
|
type='tertiary'
|
|
onClick={() => {
|
|
if (chatsArray.length === 0) {
|
|
showError(t('请联系管理员配置聊天链接'));
|
|
} else {
|
|
const first = chatsArray[0];
|
|
onOpenLink(first.name, first.value, record);
|
|
}
|
|
}}
|
|
>
|
|
{t('聊天')}
|
|
</Button>
|
|
<Dropdown trigger='click' position='bottomRight' menu={chatsArray}>
|
|
<Button
|
|
type='tertiary'
|
|
icon={<IconTreeTriangleDown />}
|
|
size='small'
|
|
></Button>
|
|
</Dropdown>
|
|
</SplitButtonGroup>
|
|
|
|
{record.status === 1 ? (
|
|
<Button
|
|
type='danger'
|
|
size='small'
|
|
onClick={async () => {
|
|
await manageToken(record.id, 'disable', record);
|
|
await refresh();
|
|
}}
|
|
>
|
|
{t('禁用')}
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
size='small'
|
|
onClick={async () => {
|
|
await manageToken(record.id, 'enable', record);
|
|
await refresh();
|
|
}}
|
|
>
|
|
{t('启用')}
|
|
</Button>
|
|
)}
|
|
|
|
<Button
|
|
type='tertiary'
|
|
size='small'
|
|
onClick={() => {
|
|
setEditingToken(record);
|
|
setShowEdit(true);
|
|
}}
|
|
>
|
|
{t('编辑')}
|
|
</Button>
|
|
|
|
<Button
|
|
type='danger'
|
|
size='small'
|
|
onClick={() => {
|
|
Modal.confirm({
|
|
title: t('确定是否要删除此令牌?'),
|
|
content: t('此修改将不可逆'),
|
|
onOk: () => {
|
|
(async () => {
|
|
await manageToken(record.id, 'delete', record);
|
|
await refresh();
|
|
})();
|
|
},
|
|
});
|
|
}}
|
|
>
|
|
{t('删除')}
|
|
</Button>
|
|
</Space>
|
|
);
|
|
};
|
|
|
|
export const getTokensColumns = ({
|
|
t,
|
|
showKeys,
|
|
resolvedTokenKeys,
|
|
loadingTokenKeys,
|
|
toggleTokenVisibility,
|
|
copyTokenKey,
|
|
copyTokenConnectionString,
|
|
manageToken,
|
|
onOpenLink,
|
|
setEditingToken,
|
|
setShowEdit,
|
|
refresh,
|
|
groupRatios = {},
|
|
}) => {
|
|
return [
|
|
{
|
|
title: t('名称'),
|
|
dataIndex: 'name',
|
|
},
|
|
{
|
|
title: t('状态'),
|
|
dataIndex: 'status',
|
|
key: 'status',
|
|
render: (text, record) => renderStatus(text, record, t),
|
|
},
|
|
{
|
|
title: t('剩余额度/总额度'),
|
|
key: 'quota_usage',
|
|
render: (text, record) => renderQuotaUsage(text, record, t),
|
|
},
|
|
{
|
|
title: t('分组'),
|
|
dataIndex: 'group',
|
|
key: 'group',
|
|
render: (text, record) => renderGroupColumn(text, record, t, groupRatios),
|
|
},
|
|
{
|
|
title: t('密钥'),
|
|
key: 'token_key',
|
|
render: (text, record) =>
|
|
renderTokenKey(
|
|
text,
|
|
record,
|
|
showKeys,
|
|
resolvedTokenKeys,
|
|
loadingTokenKeys,
|
|
toggleTokenVisibility,
|
|
copyTokenKey,
|
|
copyTokenConnectionString,
|
|
t,
|
|
),
|
|
},
|
|
{
|
|
title: t('可用模型'),
|
|
dataIndex: 'model_limits',
|
|
render: (text, record) => renderModelLimits(text, record, t),
|
|
},
|
|
{
|
|
title: t('IP限制'),
|
|
dataIndex: 'allow_ips',
|
|
render: (text) => renderAllowIps(text, t),
|
|
},
|
|
{
|
|
title: t('创建时间'),
|
|
dataIndex: 'created_time',
|
|
render: (text, record, index) => {
|
|
return <div>{renderTimestamp(text)}</div>;
|
|
},
|
|
},
|
|
{
|
|
title: t('最后使用时间'),
|
|
dataIndex: 'accessed_time',
|
|
render: (text, record, index) => {
|
|
return <div>{text ? renderTimestamp(text) : '-'}</div>;
|
|
},
|
|
},
|
|
{
|
|
title: t('过期时间'),
|
|
dataIndex: 'expired_time',
|
|
render: (text, record, index) => {
|
|
return (
|
|
<div>
|
|
{record.expired_time === -1 ? t('永不过期') : renderTimestamp(text)}
|
|
</div>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
title: '',
|
|
dataIndex: 'operate',
|
|
fixed: 'right',
|
|
render: (text, record, index) =>
|
|
renderOperations(
|
|
text,
|
|
record,
|
|
onOpenLink,
|
|
setEditingToken,
|
|
setShowEdit,
|
|
manageToken,
|
|
refresh,
|
|
t,
|
|
),
|
|
},
|
|
];
|
|
};
|