| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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, |
| ), |
| }, |
| ]; |
| }; |
|
|