mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-04-18 15:47:27 +00:00
535 lines
13 KiB
JavaScript
535 lines
13 KiB
JavaScript
/*
|
||
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) => {
|
||
if (text === 'auto') {
|
||
return (
|
||
<Tooltip
|
||
content={t(
|
||
'当前分组为 auto,会自动选择最优分组,当一个组不可用时自动降级到下一个组(熔断机制)',
|
||
)}
|
||
position='top'
|
||
>
|
||
<Tag color='white' shape='circle'>
|
||
{t('智能熔断')}
|
||
{record && record.cross_group_retry ? `(${t('跨分组')})` : ''}
|
||
</Tag>
|
||
</Tooltip>
|
||
);
|
||
}
|
||
return renderGroup(text);
|
||
};
|
||
|
||
// Render token key column with show/hide and copy functionality
|
||
const renderTokenKey = (
|
||
text,
|
||
record,
|
||
showKeys,
|
||
resolvedTokenKeys,
|
||
loadingTokenKeys,
|
||
toggleTokenVisibility,
|
||
copyTokenKey,
|
||
) => {
|
||
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);
|
||
}}
|
||
/>
|
||
<Button
|
||
theme='borderless'
|
||
size='small'
|
||
type='tertiary'
|
||
icon={<IconCopy />}
|
||
loading={loading}
|
||
aria-label='copy token key'
|
||
onClick={async (e) => {
|
||
e.stopPropagation();
|
||
await copyTokenKey(record);
|
||
}}
|
||
/>
|
||
</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,
|
||
manageToken,
|
||
onOpenLink,
|
||
setEditingToken,
|
||
setShowEdit,
|
||
refresh,
|
||
}) => {
|
||
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),
|
||
},
|
||
{
|
||
title: t('密钥'),
|
||
key: 'token_key',
|
||
render: (text, record) =>
|
||
renderTokenKey(
|
||
text,
|
||
record,
|
||
showKeys,
|
||
resolvedTokenKeys,
|
||
loadingTokenKeys,
|
||
toggleTokenVisibility,
|
||
copyTokenKey,
|
||
),
|
||
},
|
||
{
|
||
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: '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,
|
||
),
|
||
},
|
||
];
|
||
};
|