|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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'; |
|
|
|
|
|
|
|
|
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; |
|
|
}; |
|
|
|
|
|
|
|
|
function renderTimestamp(timestamp) { |
|
|
return <>{timestamp2string(timestamp)}</>; |
|
|
} |
|
|
|
|
|
|
|
|
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> |
|
|
); |
|
|
}; |
|
|
|
|
|
|
|
|
const renderGroupColumn = (text, t) => { |
|
|
if (text === 'auto') { |
|
|
return ( |
|
|
<Tooltip |
|
|
content={t( |
|
|
'当前分组为 auto,会自动选择最优分组,当一个组不可用时自动降级到下一个组(熔断机制)', |
|
|
)} |
|
|
position='top' |
|
|
> |
|
|
<Tag color='white' shape='circle'> |
|
|
{' '} |
|
|
{t('智能熔断')}{' '} |
|
|
</Tag> |
|
|
</Tooltip> |
|
|
); |
|
|
} |
|
|
return renderGroup(text); |
|
|
}; |
|
|
|
|
|
|
|
|
const renderTokenKey = (text, record, showKeys, setShowKeys, copyText) => { |
|
|
const fullKey = 'sk-' + record.key; |
|
|
const maskedKey = |
|
|
'sk-' + record.key.slice(0, 4) + '**********' + record.key.slice(-4); |
|
|
const revealed = !!showKeys[record.id]; |
|
|
|
|
|
return ( |
|
|
<div className='w-[200px]'> |
|
|
<Input |
|
|
readOnly |
|
|
value={revealed ? fullKey : maskedKey} |
|
|
size='small' |
|
|
suffix={ |
|
|
<div className='flex items-center'> |
|
|
<Button |
|
|
theme='borderless' |
|
|
size='small' |
|
|
type='tertiary' |
|
|
icon={revealed ? <IconEyeClosed /> : <IconEyeOpened />} |
|
|
aria-label='toggle token visibility' |
|
|
onClick={(e) => { |
|
|
e.stopPropagation(); |
|
|
setShowKeys((prev) => ({ ...prev, [record.id]: !revealed })); |
|
|
}} |
|
|
/> |
|
|
<Button |
|
|
theme='borderless' |
|
|
size='small' |
|
|
type='tertiary' |
|
|
icon={<IconCopy />} |
|
|
aria-label='copy token key' |
|
|
onClick={async (e) => { |
|
|
e.stopPropagation(); |
|
|
await copyText(fullKey); |
|
|
}} |
|
|
/> |
|
|
</div> |
|
|
} |
|
|
/> |
|
|
</div> |
|
|
); |
|
|
}; |
|
|
|
|
|
|
|
|
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> |
|
|
); |
|
|
} |
|
|
}; |
|
|
|
|
|
|
|
|
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>; |
|
|
}; |
|
|
|
|
|
|
|
|
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> |
|
|
); |
|
|
}; |
|
|
|
|
|
|
|
|
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, |
|
|
setShowKeys, |
|
|
copyText, |
|
|
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) => renderGroupColumn(text, t), |
|
|
}, |
|
|
{ |
|
|
title: t('密钥'), |
|
|
key: 'token_key', |
|
|
render: (text, record) => |
|
|
renderTokenKey(text, record, showKeys, setShowKeys, copyText), |
|
|
}, |
|
|
{ |
|
|
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, |
|
|
), |
|
|
}, |
|
|
]; |
|
|
}; |
|
|
|