- Split monolithic 922-line TokensTable.js into modular components: * useTokensData.js: Custom hook for centralized state and logic management * TokensColumnDefs.js: Column definitions and rendering functions * TokensTable.jsx: Pure table component for rendering * TokensActions.jsx: Actions area (add, copy, delete tokens) * TokensFilters.jsx: Search form component with keyword and token filters * TokensDescription.jsx: Description area with compact mode toggle * index.jsx: Main orchestrator component - Features preserved: * Token status management with switch controls * Quota progress bars and visual indicators * Model limitations display with vendor avatars * IP restrictions handling and display * Chat integrations with dropdown menu * Batch operations (copy, delete) with confirmations * Key visibility toggle and copy functionality * Compact mode for responsive layouts * Search and filtering capabilities * Pagination and loading states - Improvements: * Better separation of concerns * Enhanced reusability and testability * Simplified maintenance and debugging * Consistent modular architecture pattern * Performance optimizations with useMemo * Backward compatibility maintained This refactoring follows the same successful pattern used for LogsTable, MjLogsTable, and TaskLogsTable, significantly improving code maintainability while preserving all existing functionality.
453 lines
11 KiB
JavaScript
453 lines
11 KiB
JavaScript
import React from 'react';
|
||
import {
|
||
Button,
|
||
Dropdown,
|
||
Space,
|
||
SplitButtonGroup,
|
||
Tag,
|
||
AvatarGroup,
|
||
Avatar,
|
||
Tooltip,
|
||
Progress,
|
||
Switch,
|
||
Input,
|
||
Modal
|
||
} from '@douyinfe/semi-ui';
|
||
import {
|
||
timestamp2string,
|
||
renderGroup,
|
||
renderQuota,
|
||
getModelCategories,
|
||
showError
|
||
} from '../../../helpers';
|
||
import {
|
||
IconTreeTriangleDown,
|
||
IconCopy,
|
||
IconEyeOpened,
|
||
IconEyeClosed,
|
||
} from '@douyinfe/semi-icons';
|
||
|
||
// Render functions
|
||
function renderTimestamp(timestamp) {
|
||
return <>{timestamp2string(timestamp)}</>;
|
||
}
|
||
|
||
// Render status column with switch and progress bar
|
||
const renderStatus = (text, record, manageToken, t) => {
|
||
const enabled = text === 1;
|
||
const handleToggle = (checked) => {
|
||
if (checked) {
|
||
manageToken(record.id, 'enable', record);
|
||
} else {
|
||
manageToken(record.id, 'disable', record);
|
||
}
|
||
};
|
||
|
||
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('已耗尽');
|
||
}
|
||
|
||
const used = parseInt(record.used_quota) || 0;
|
||
const remain = parseInt(record.remain_quota) || 0;
|
||
const total = used + remain;
|
||
const percent = total > 0 ? (remain / total) * 100 : 0;
|
||
|
||
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;
|
||
};
|
||
|
||
const quotaSuffix = record.unlimited_quota ? (
|
||
<div className='text-xs'>{t('无限额度')}</div>
|
||
) : (
|
||
<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>
|
||
);
|
||
|
||
const content = (
|
||
<Tag
|
||
color={tagColor}
|
||
shape='circle'
|
||
size='large'
|
||
prefixIcon={
|
||
<Switch
|
||
size='small'
|
||
checked={enabled}
|
||
onChange={handleToggle}
|
||
aria-label='token status switch'
|
||
/>
|
||
}
|
||
suffixIcon={quotaSuffix}
|
||
>
|
||
{tagText}
|
||
</Tag>
|
||
);
|
||
|
||
if (record.unlimited_quota) {
|
||
return content;
|
||
}
|
||
|
||
return (
|
||
<Tooltip
|
||
content={
|
||
<div className='text-xs'>
|
||
<div>{t('已用额度')}: {renderQuota(used)}</div>
|
||
<div>{t('剩余额度')}: {renderQuota(remain)} ({percent.toFixed(0)}%)</div>
|
||
<div>{t('总额度')}: {renderQuota(total)}</div>
|
||
</div>
|
||
}
|
||
>
|
||
{content}
|
||
</Tooltip>
|
||
);
|
||
};
|
||
|
||
// Render group column
|
||
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);
|
||
};
|
||
|
||
// Render token key column with show/hide and copy functionality
|
||
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>
|
||
);
|
||
};
|
||
|
||
// 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 operations column
|
||
const renderOperations = (text, record, onOpenLink, setEditingToken, setShowEdit, manageToken, refresh, t) => {
|
||
let chats = localStorage.getItem('chats');
|
||
let chatsArray = [];
|
||
let shouldUseCustom = true;
|
||
|
||
if (shouldUseCustom) {
|
||
try {
|
||
chats = JSON.parse(chats);
|
||
if (Array.isArray(chats)) {
|
||
for (let i = 0; i < chats.length; i++) {
|
||
let chat = {};
|
||
chat.node = 'item';
|
||
for (let key in chats[i]) {
|
||
if (chats[i].hasOwnProperty(key)) {
|
||
chat.key = i;
|
||
chat.name = key;
|
||
chat.onClick = () => {
|
||
onOpenLink(key, chats[i][key], record);
|
||
};
|
||
}
|
||
}
|
||
chatsArray.push(chat);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.log(e);
|
||
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 {
|
||
onOpenLink(
|
||
'default',
|
||
chats[0][Object.keys(chats[0])[0]],
|
||
record,
|
||
);
|
||
}
|
||
}}
|
||
>
|
||
{t('聊天')}
|
||
</Button>
|
||
<Dropdown
|
||
trigger='click'
|
||
position='bottomRight'
|
||
menu={chatsArray}
|
||
>
|
||
<Button
|
||
type='tertiary'
|
||
icon={<IconTreeTriangleDown />}
|
||
size="small"
|
||
></Button>
|
||
</Dropdown>
|
||
</SplitButtonGroup>
|
||
|
||
<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, manageToken, 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
|
||
),
|
||
},
|
||
];
|
||
};
|