| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| | 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, 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); |
| | }; |
| |
|
| | |
| | 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, record) => renderGroupColumn(text, record, 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, |
| | ), |
| | }, |
| | ]; |
| | }; |
| |
|