From 42a26f076a25cf2c0248ffa62762ed94be6ed51a Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Fri, 18 Jul 2025 22:56:34 +0800 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20modularize=20T?= =?UTF-8?q?okensTable=20component=20into=20maintainable=20architecture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- web/src/components/table/TokensTable.js | 922 +----------------- .../components/table/tokens/TokensActions.jsx | 113 +++ .../table/tokens/TokensColumnDefs.js | 453 +++++++++ .../table/tokens/TokensDescription.jsx | 27 + .../components/table/tokens/TokensFilters.jsx | 84 ++ .../components/table/tokens/TokensTable.jsx | 99 ++ web/src/components/table/tokens/index.jsx | 90 ++ web/src/hooks/task-logs/useTaskLogsData.js | 10 +- web/src/hooks/tokens/useTokensData.js | 369 +++++++ 9 files changed, 1244 insertions(+), 923 deletions(-) create mode 100644 web/src/components/table/tokens/TokensActions.jsx create mode 100644 web/src/components/table/tokens/TokensColumnDefs.js create mode 100644 web/src/components/table/tokens/TokensDescription.jsx create mode 100644 web/src/components/table/tokens/TokensFilters.jsx create mode 100644 web/src/components/table/tokens/TokensTable.jsx create mode 100644 web/src/components/table/tokens/index.jsx create mode 100644 web/src/hooks/tokens/useTokensData.js diff --git a/web/src/components/table/TokensTable.js b/web/src/components/table/TokensTable.js index e0b29df8..a30cb36d 100644 --- a/web/src/components/table/TokensTable.js +++ b/web/src/components/table/TokensTable.js @@ -1,921 +1,7 @@ -import React, { useEffect, useState } from 'react'; -import { - API, - copy, - showError, - showSuccess, - timestamp2string, - renderGroup, - renderQuota, - getModelCategories -} from '../../helpers'; -import { ITEMS_PER_PAGE } from '../../constants'; -import { - Button, - Dropdown, - Empty, - Form, - Modal, - Space, - SplitButtonGroup, - Table, - Tag, - AvatarGroup, - Avatar, - Tooltip, - Progress, - Switch, - Input, - Typography -} from '@douyinfe/semi-ui'; -import CardPro from '../common/ui/CardPro'; -import { - IllustrationNoResult, - IllustrationNoResultDark -} from '@douyinfe/semi-illustrations'; -import { - IconSearch, - IconTreeTriangleDown, - IconCopy, - IconEyeOpened, - IconEyeClosed, -} from '@douyinfe/semi-icons'; -import { Key } from 'lucide-react'; -import EditToken from '../../pages/Token/EditToken'; -import { useTranslation } from 'react-i18next'; -import { useTableCompactMode } from '../../hooks/common/useTableCompactMode'; +// Import the new modular tokens table +import TokensPage from './tokens'; -const { Text } = Typography; - -function renderTimestamp(timestamp) { - return <>{timestamp2string(timestamp)}; -} - -const TokensTable = () => { - const { t } = useTranslation(); - - const columns = [ - { - title: t('名称'), - dataIndex: 'name', - }, - { - title: t('状态'), - dataIndex: 'status', - key: 'status', - render: (text, record) => { - 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 ? ( -
{t('无限额度')}
- ) : ( -
- {`${renderQuota(remain)} / ${renderQuota(total)}`} - `${percent.toFixed(0)}%`} - style={{ width: '100%', marginTop: '1px', marginBottom: 0 }} - /> -
- ); - - const content = ( - - } - suffixIcon={quotaSuffix} - > - {tagText} - - ); - - if (record.unlimited_quota) { - return content; - } - - return ( - -
{t('已用额度')}: {renderQuota(used)}
-
{t('剩余额度')}: {renderQuota(remain)} ({percent.toFixed(0)}%)
-
{t('总额度')}: {renderQuota(total)}
- - } - > - {content} -
- ); - }, - }, - { - title: t('分组'), - dataIndex: 'group', - key: 'group', - render: (text) => { - if (text === 'auto') { - return ( - - {t('智能熔断')} - - ); - } - return renderGroup(text); - }, - }, - { - title: t('密钥'), - key: 'token_key', - render: (text, record) => { - const fullKey = 'sk-' + record.key; - const maskedKey = 'sk-' + record.key.slice(0, 4) + '**********' + record.key.slice(-4); - const revealed = !!showKeys[record.id]; - - return ( -
- -
- } - /> - - ); - }, - }, - { - title: t('可用模型'), - dataIndex: 'model_limits', - render: (text, record) => { - 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( - - - {category.icon} - - - ); - vendorModels.forEach((m) => matchedModels.add(m)); - } - }); - - const unmatchedModels = models.filter((m) => !matchedModels.has(m)); - if (unmatchedModels.length > 0) { - vendorAvatars.push( - - - {t('其他')} - - - ); - } - - return ( - - {vendorAvatars} - - ); - } else { - return ( - - {t('无限制')} - - ); - } - }, - }, - { - title: t('IP限制'), - dataIndex: 'allow_ips', - render: (text) => { - if (!text || text.trim() === '') { - return ( - - {t('无限制')} - - ); - } - - 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) => ( - - {ip} - - )); - - if (extraCount > 0) { - ipTags.push( - - - {'+' + extraCount} - - - ); - } - - return {ipTags}; - }, - }, - { - title: t('创建时间'), - dataIndex: 'created_time', - render: (text, record, index) => { - return
{renderTimestamp(text)}
; - }, - }, - { - title: t('过期时间'), - dataIndex: 'expired_time', - render: (text, record, index) => { - return ( -
- {record.expired_time === -1 ? t('永不过期') : renderTimestamp(text)} -
- ); - }, - }, - { - title: '', - dataIndex: 'operate', - fixed: 'right', - render: (text, record, index) => { - 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 ( - - - - - - - - - - - - - ); - }, - }, - ]; - - const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE); - const [showEdit, setShowEdit] = useState(false); - const [tokens, setTokens] = useState([]); - const [selectedKeys, setSelectedKeys] = useState([]); - const [tokenCount, setTokenCount] = useState(pageSize); - const [loading, setLoading] = useState(true); - const [activePage, setActivePage] = useState(1); - const [searching, setSearching] = useState(false); - const [editingToken, setEditingToken] = useState({ - id: undefined, - }); - const [compactMode, setCompactMode] = useTableCompactMode('tokens'); - const [showKeys, setShowKeys] = useState({}); - - // Form 初始值 - const formInitValues = { - searchKeyword: '', - searchToken: '', - }; - - // Form API 引用 - const [formApi, setFormApi] = useState(null); - - // 获取表单值的辅助函数 - const getFormValues = () => { - const formValues = formApi ? formApi.getValues() : {}; - return { - searchKeyword: formValues.searchKeyword || '', - searchToken: formValues.searchToken || '', - }; - }; - - const closeEdit = () => { - setShowEdit(false); - setTimeout(() => { - setEditingToken({ - id: undefined, - }); - }, 500); - }; - - // 将后端返回的数据写入状态 - const syncPageData = (payload) => { - setTokens(payload.items || []); - setTokenCount(payload.total || 0); - setActivePage(payload.page || 1); - setPageSize(payload.page_size || pageSize); - }; - - const loadTokens = async (page = 1, size = pageSize) => { - setLoading(true); - const res = await API.get(`/api/token/?p=${page}&size=${size}`); - const { success, message, data } = res.data; - if (success) { - syncPageData(data); - } else { - showError(message); - } - setLoading(false); - }; - - const refresh = async (page = activePage) => { - await loadTokens(page); - setSelectedKeys([]); - }; - - const copyText = async (text) => { - if (await copy(text)) { - showSuccess(t('已复制到剪贴板!')); - } else { - Modal.error({ - title: t('无法复制到剪贴板,请手动复制'), - content: text, - size: 'large', - }); - } - }; - - const onOpenLink = async (type, url, record) => { - let status = localStorage.getItem('status'); - let serverAddress = ''; - if (status) { - status = JSON.parse(status); - serverAddress = status.server_address; - } - if (serverAddress === '') { - serverAddress = window.location.origin; - } - if (url.includes('{cherryConfig}') === true) { - let cherryConfig = { - id: 'new-api', - baseUrl: serverAddress, - apiKey: 'sk-' + record.key, - } - // 替换 {cherryConfig} 为base64编码的JSON字符串 - let encodedConfig = encodeURIComponent( - btoa(JSON.stringify(cherryConfig)) - ); - url = url.replaceAll('{cherryConfig}', encodedConfig); - } else { - let encodedServerAddress = encodeURIComponent(serverAddress); - url = url.replaceAll('{address}', encodedServerAddress); - url = url.replaceAll('{key}', 'sk-' + record.key); - } - - window.open(url, '_blank'); - }; - - useEffect(() => { - loadTokens(1) - .then() - .catch((reason) => { - showError(reason); - }); - }, [pageSize]); - - const removeRecord = (key) => { - let newDataSource = [...tokens]; - if (key != null) { - let idx = newDataSource.findIndex((data) => data.key === key); - - if (idx > -1) { - newDataSource.splice(idx, 1); - setTokens(newDataSource); - } - } - }; - - const manageToken = async (id, action, record) => { - setLoading(true); - let data = { id }; - let res; - switch (action) { - case 'delete': - res = await API.delete(`/api/token/${id}/`); - break; - case 'enable': - data.status = 1; - res = await API.put('/api/token/?status_only=true', data); - break; - case 'disable': - data.status = 2; - res = await API.put('/api/token/?status_only=true', data); - break; - } - const { success, message } = res.data; - if (success) { - showSuccess('操作成功完成!'); - let token = res.data.data; - let newTokens = [...tokens]; - if (action === 'delete') { - } else { - record.status = token.status; - } - setTokens(newTokens); - } else { - showError(message); - } - setLoading(false); - }; - - const searchTokens = async () => { - const { searchKeyword, searchToken } = getFormValues(); - if (searchKeyword === '' && searchToken === '') { - await loadTokens(1); - return; - } - setSearching(true); - const res = await API.get( - `/api/token/search?keyword=${searchKeyword}&token=${searchToken}`, - ); - const { success, message, data } = res.data; - if (success) { - setTokens(data); - setTokenCount(data.length); - setActivePage(1); - } else { - showError(message); - } - setSearching(false); - }; - - const sortToken = (key) => { - if (tokens.length === 0) return; - setLoading(true); - let sortedTokens = [...tokens]; - sortedTokens.sort((a, b) => { - return ('' + a[key]).localeCompare(b[key]); - }); - if (sortedTokens[0].id === tokens[0].id) { - sortedTokens.reverse(); - } - setTokens(sortedTokens); - setLoading(false); - }; - - const handlePageChange = (page) => { - loadTokens(page, pageSize).then(); - }; - - const handlePageSizeChange = async (size) => { - setPageSize(size); - await loadTokens(1, size); - }; - - const rowSelection = { - onSelect: (record, selected) => { }, - onSelectAll: (selected, selectedRows) => { }, - onChange: (selectedRowKeys, selectedRows) => { - setSelectedKeys(selectedRows); - }, - }; - - const handleRow = (record, index) => { - if (record.status !== 1) { - return { - style: { - background: 'var(--semi-color-disabled-border)', - }, - }; - } else { - return {}; - } - }; - - const batchDeleteTokens = async () => { - if (selectedKeys.length === 0) { - showError(t('请先选择要删除的令牌!')); - return; - } - setLoading(true); - try { - const ids = selectedKeys.map((token) => token.id); - const res = await API.post('/api/token/batch', { ids }); - if (res?.data?.success) { - const count = res.data.data || 0; - showSuccess(t('已删除 {{count}} 个令牌!', { count })); - await refresh(); - setTimeout(() => { - if (tokens.length === 0 && activePage > 1) { - refresh(activePage - 1); - } - }, 100); - } else { - showError(res?.data?.message || t('删除失败')); - } - } catch (error) { - showError(error.message); - } finally { - setLoading(false); - } - }; - - const renderDescriptionArea = () => ( -
-
- - {t('令牌用于API访问认证,可以设置额度限制和模型权限。')} -
- -
- ); - - const renderActionsArea = () => ( -
- - - - - ), - }); - }} - size="small" - > - {t('复制所选令牌')} - - -
- ); - - const renderSearchArea = () => ( -
setFormApi(api)} - onSubmit={searchTokens} - allowEmpty={true} - autoComplete="off" - layout="horizontal" - trigger="change" - stopValidateWithError={false} - className="w-full md:w-auto order-1 md:order-2" - > -
-
- } - placeholder={t('搜索关键字')} - showClear - pure - size="small" - /> -
-
- } - placeholder={t('密钥')} - showClear - pure - size="small" - /> -
-
- - -
-
-
- ); - - return ( - <> - - - -
- {renderActionsArea()} -
-
- {renderSearchArea()} -
- - } - > - { - if (col.dataIndex === 'operate') { - const { fixed, ...rest } = col; - return rest; - } - return col; - }) : columns} - dataSource={tokens} - scroll={compactMode ? undefined : { x: 'max-content' }} - pagination={{ - currentPage: activePage, - pageSize: pageSize, - total: tokenCount, - showSizeChanger: true, - pageSizeOptions: [10, 20, 50, 100], - onPageSizeChange: handlePageSizeChange, - onPageChange: handlePageChange, - }} - loading={loading} - rowSelection={rowSelection} - onRow={handleRow} - empty={ - } - darkModeImage={} - description={t('搜索无结果')} - style={{ padding: 30 }} - /> - } - className="rounded-xl overflow-hidden" - size="middle" - >
-
- - ); -}; +// Export the new component for backward compatibility +const TokensTable = TokensPage; export default TokensTable; diff --git a/web/src/components/table/tokens/TokensActions.jsx b/web/src/components/table/tokens/TokensActions.jsx new file mode 100644 index 00000000..09cb29eb --- /dev/null +++ b/web/src/components/table/tokens/TokensActions.jsx @@ -0,0 +1,113 @@ +import React from 'react'; +import { Button, Modal, Space } from '@douyinfe/semi-ui'; +import { showError } from '../../../helpers'; + +const TokensActions = ({ + selectedKeys, + setEditingToken, + setShowEdit, + batchCopyTokens, + batchDeleteTokens, + copyText, + t, +}) => { + // Handle copy selected tokens with options + const handleCopySelectedTokens = () => { + if (selectedKeys.length === 0) { + showError(t('请至少选择一个令牌!')); + return; + } + + Modal.info({ + title: t('复制令牌'), + icon: null, + content: t('请选择你的复制方式'), + footer: ( + + + + + ), + }); + }; + + // Handle delete selected tokens with confirmation + const handleDeleteSelectedTokens = () => { + if (selectedKeys.length === 0) { + showError(t('请至少选择一个令牌!')); + return; + } + + Modal.confirm({ + title: t('批量删除令牌'), + content: ( +
+ {t('确定要删除所选的 {{count}} 个令牌吗?', { count: selectedKeys.length })} +
+ ), + onOk: () => batchDeleteTokens(), + }); + }; + + return ( +
+ + + + + +
+ ); +}; + +export default TokensActions; \ No newline at end of file diff --git a/web/src/components/table/tokens/TokensColumnDefs.js b/web/src/components/table/tokens/TokensColumnDefs.js new file mode 100644 index 00000000..dc53eb74 --- /dev/null +++ b/web/src/components/table/tokens/TokensColumnDefs.js @@ -0,0 +1,453 @@ +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 ? ( +
{t('无限额度')}
+ ) : ( +
+ {`${renderQuota(remain)} / ${renderQuota(total)}`} + `${percent.toFixed(0)}%`} + style={{ width: '100%', marginTop: '1px', marginBottom: 0 }} + /> +
+ ); + + const content = ( + + } + suffixIcon={quotaSuffix} + > + {tagText} + + ); + + if (record.unlimited_quota) { + return content; + } + + return ( + +
{t('已用额度')}: {renderQuota(used)}
+
{t('剩余额度')}: {renderQuota(remain)} ({percent.toFixed(0)}%)
+
{t('总额度')}: {renderQuota(total)}
+ + } + > + {content} +
+ ); +}; + +// Render group column +const renderGroupColumn = (text, t) => { + if (text === 'auto') { + return ( + + {t('智能熔断')} + + ); + } + 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 ( +
+ +
+ } + /> + + ); +}; + +// 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( + + + {category.icon} + + + ); + vendorModels.forEach((m) => matchedModels.add(m)); + } + }); + + const unmatchedModels = models.filter((m) => !matchedModels.has(m)); + if (unmatchedModels.length > 0) { + vendorAvatars.push( + + + {t('其他')} + + + ); + } + + return ( + + {vendorAvatars} + + ); + } else { + return ( + + {t('无限制')} + + ); + } +}; + +// Render IP restrictions column +const renderAllowIps = (text, t) => { + if (!text || text.trim() === '') { + return ( + + {t('无限制')} + + ); + } + + 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) => ( + + {ip} + + )); + + if (extraCount > 0) { + ipTags.push( + + + {'+' + extraCount} + + + ); + } + + return {ipTags}; +}; + +// 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 ( + + + + + + + + + + + + + ); +}; + +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
{renderTimestamp(text)}
; + }, + }, + { + title: t('过期时间'), + dataIndex: 'expired_time', + render: (text, record, index) => { + return ( +
+ {record.expired_time === -1 ? t('永不过期') : renderTimestamp(text)} +
+ ); + }, + }, + { + title: '', + dataIndex: 'operate', + fixed: 'right', + render: (text, record, index) => renderOperations( + text, + record, + onOpenLink, + setEditingToken, + setShowEdit, + manageToken, + refresh, + t + ), + }, + ]; +}; \ No newline at end of file diff --git a/web/src/components/table/tokens/TokensDescription.jsx b/web/src/components/table/tokens/TokensDescription.jsx new file mode 100644 index 00000000..d56d769c --- /dev/null +++ b/web/src/components/table/tokens/TokensDescription.jsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { Button, Typography } from '@douyinfe/semi-ui'; +import { Key } from 'lucide-react'; + +const { Text } = Typography; + +const TokensDescription = ({ compactMode, setCompactMode, t }) => { + return ( +
+
+ + {t('令牌用于API访问认证,可以设置额度限制和模型权限。')} +
+ + +
+ ); +}; + +export default TokensDescription; \ No newline at end of file diff --git a/web/src/components/table/tokens/TokensFilters.jsx b/web/src/components/table/tokens/TokensFilters.jsx new file mode 100644 index 00000000..63912c1b --- /dev/null +++ b/web/src/components/table/tokens/TokensFilters.jsx @@ -0,0 +1,84 @@ +import React from 'react'; +import { Form, Button } from '@douyinfe/semi-ui'; +import { IconSearch } from '@douyinfe/semi-icons'; + +const TokensFilters = ({ + formInitValues, + setFormApi, + searchTokens, + loading, + searching, + t, +}) => { + // Handle form reset and immediate search + const handleReset = (formApi) => { + if (formApi) { + formApi.reset(); + // Reset and search immediately + setTimeout(() => { + searchTokens(); + }, 100); + } + }; + + return ( +
setFormApi(api)} + onSubmit={searchTokens} + allowEmpty={true} + autoComplete="off" + layout="horizontal" + trigger="change" + stopValidateWithError={false} + className="w-full md:w-auto order-1 md:order-2" + > +
+
+ } + placeholder={t('搜索关键字')} + showClear + pure + size="small" + /> +
+ +
+ } + placeholder={t('密钥')} + showClear + pure + size="small" + /> +
+ +
+ + + +
+
+
+ ); +}; + +export default TokensFilters; \ No newline at end of file diff --git a/web/src/components/table/tokens/TokensTable.jsx b/web/src/components/table/tokens/TokensTable.jsx new file mode 100644 index 00000000..ae1e8d0a --- /dev/null +++ b/web/src/components/table/tokens/TokensTable.jsx @@ -0,0 +1,99 @@ +import React, { useMemo } from 'react'; +import { Table, Empty } from '@douyinfe/semi-ui'; +import { + IllustrationNoResult, + IllustrationNoResultDark, +} from '@douyinfe/semi-illustrations'; +import { getTokensColumns } from './TokensColumnDefs.js'; + +const TokensTable = (tokensData) => { + const { + tokens, + loading, + activePage, + pageSize, + tokenCount, + compactMode, + handlePageChange, + handlePageSizeChange, + rowSelection, + handleRow, + showKeys, + setShowKeys, + copyText, + manageToken, + onOpenLink, + setEditingToken, + setShowEdit, + refresh, + t, + } = tokensData; + + // Get all columns + const columns = useMemo(() => { + return getTokensColumns({ + t, + showKeys, + setShowKeys, + copyText, + manageToken, + onOpenLink, + setEditingToken, + setShowEdit, + refresh, + }); + }, [ + t, + showKeys, + setShowKeys, + copyText, + manageToken, + onOpenLink, + setEditingToken, + setShowEdit, + refresh, + ]); + + // Handle compact mode by removing fixed positioning + const tableColumns = useMemo(() => { + return compactMode ? columns.map(col => { + if (col.dataIndex === 'operate') { + const { fixed, ...rest } = col; + return rest; + } + return col; + }) : columns; + }, [compactMode, columns]); + + return ( + } + darkModeImage={} + description={t('搜索无结果')} + style={{ padding: 30 }} + /> + } + className="rounded-xl overflow-hidden" + size="middle" + /> + ); +}; + +export default TokensTable; \ No newline at end of file diff --git a/web/src/components/table/tokens/index.jsx b/web/src/components/table/tokens/index.jsx new file mode 100644 index 00000000..3a3a8fb7 --- /dev/null +++ b/web/src/components/table/tokens/index.jsx @@ -0,0 +1,90 @@ +import React from 'react'; +import CardPro from '../../common/ui/CardPro'; +import TokensTable from './TokensTable.jsx'; +import TokensActions from './TokensActions.jsx'; +import TokensFilters from './TokensFilters.jsx'; +import TokensDescription from './TokensDescription.jsx'; +import EditToken from '../../../pages/Token/EditToken'; +import { useTokensData } from '../../../hooks/tokens/useTokensData'; + +const TokensPage = () => { + const tokensData = useTokensData(); + + const { + // Edit state + showEdit, + editingToken, + closeEdit, + refresh, + + // Actions state + selectedKeys, + setEditingToken, + setShowEdit, + batchDeleteTokens, + copyText, + + // Filters state + formInitValues, + setFormApi, + searchTokens, + loading, + searching, + + // Description state + compactMode, + setCompactMode, + + // Translation + t, + } = tokensData; + + return ( + <> + + + + } + actionsArea={ +
+ + +
+ +
+
+ } + > + +
+ + ); +}; + +export default TokensPage; \ No newline at end of file diff --git a/web/src/hooks/task-logs/useTaskLogsData.js b/web/src/hooks/task-logs/useTaskLogsData.js index 64f1cc93..479d3c46 100644 --- a/web/src/hooks/task-logs/useTaskLogsData.js +++ b/web/src/hooks/task-logs/useTaskLogsData.js @@ -14,7 +14,7 @@ import { useTableCompactMode } from '../common/useTableCompactMode'; export const useTaskLogsData = () => { const { t } = useTranslation(); - + // Define column keys for selection const COLUMN_KEYS = { SUBMIT_TIME: 'submit_time', @@ -36,10 +36,10 @@ export const useTaskLogsData = () => { const [activePage, setActivePage] = useState(1); const [logCount, setLogCount] = useState(0); const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE); - + // User and admin const isAdminUser = isAdmin(); - + // Modal state const [isModalOpen, setIsModalOpen] = useState(false); const [modalContent, setModalContent] = useState(''); @@ -48,7 +48,7 @@ export const useTaskLogsData = () => { const [formApi, setFormApi] = useState(null); let now = new Date(); let zeroNow = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - + const formInitValues = { channel_id: '', task_id: '', @@ -239,7 +239,7 @@ export const useTaskLogsData = () => { logCount, pageSize, isAdminUser, - + // Modal state isModalOpen, setIsModalOpen, diff --git a/web/src/hooks/tokens/useTokensData.js b/web/src/hooks/tokens/useTokensData.js new file mode 100644 index 00000000..fc035ee5 --- /dev/null +++ b/web/src/hooks/tokens/useTokensData.js @@ -0,0 +1,369 @@ +import { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Modal } from '@douyinfe/semi-ui'; +import { + API, + copy, + showError, + showSuccess, +} from '../../helpers'; +import { ITEMS_PER_PAGE } from '../../constants'; +import { useTableCompactMode } from '../common/useTableCompactMode'; + +export const useTokensData = () => { + const { t } = useTranslation(); + + // Basic state + const [tokens, setTokens] = useState([]); + const [loading, setLoading] = useState(true); + const [activePage, setActivePage] = useState(1); + const [tokenCount, setTokenCount] = useState(ITEMS_PER_PAGE); + const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE); + const [searching, setSearching] = useState(false); + + // Selection state + const [selectedKeys, setSelectedKeys] = useState([]); + + // Edit state + const [showEdit, setShowEdit] = useState(false); + const [editingToken, setEditingToken] = useState({ + id: undefined, + }); + + // UI state + const [compactMode, setCompactMode] = useTableCompactMode('tokens'); + const [showKeys, setShowKeys] = useState({}); + + // Form state + const [formApi, setFormApi] = useState(null); + const formInitValues = { + searchKeyword: '', + searchToken: '', + }; + + // Get form values helper function + const getFormValues = () => { + const formValues = formApi ? formApi.getValues() : {}; + return { + searchKeyword: formValues.searchKeyword || '', + searchToken: formValues.searchToken || '', + }; + }; + + // Close edit modal + const closeEdit = () => { + setShowEdit(false); + setTimeout(() => { + setEditingToken({ + id: undefined, + }); + }, 500); + }; + + // Sync page data from API response + const syncPageData = (payload) => { + setTokens(payload.items || []); + setTokenCount(payload.total || 0); + setActivePage(payload.page || 1); + setPageSize(payload.page_size || pageSize); + }; + + // Load tokens function + const loadTokens = async (page = 1, size = pageSize) => { + setLoading(true); + const res = await API.get(`/api/token/?p=${page}&size=${size}`); + const { success, message, data } = res.data; + if (success) { + syncPageData(data); + } else { + showError(message); + } + setLoading(false); + }; + + // Refresh function + const refresh = async (page = activePage) => { + await loadTokens(page); + setSelectedKeys([]); + }; + + // Copy text function + const copyText = async (text) => { + if (await copy(text)) { + showSuccess(t('已复制到剪贴板!')); + } else { + Modal.error({ + title: t('无法复制到剪贴板,请手动复制'), + content: text, + size: 'large', + }); + } + }; + + // Open link function for chat integrations + const onOpenLink = async (type, url, record) => { + let status = localStorage.getItem('status'); + let serverAddress = ''; + if (status) { + status = JSON.parse(status); + serverAddress = status.server_address; + } + if (serverAddress === '') { + serverAddress = window.location.origin; + } + if (url.includes('{cherryConfig}') === true) { + let cherryConfig = { + id: 'new-api', + baseUrl: serverAddress, + apiKey: 'sk-' + record.key, + } + let encodedConfig = encodeURIComponent( + btoa(JSON.stringify(cherryConfig)) + ); + url = url.replaceAll('{cherryConfig}', encodedConfig); + } else { + let encodedServerAddress = encodeURIComponent(serverAddress); + url = url.replaceAll('{address}', encodedServerAddress); + url = url.replaceAll('{key}', 'sk-' + record.key); + } + + window.open(url, '_blank'); + }; + + // Manage token function (delete, enable, disable) + const manageToken = async (id, action, record) => { + setLoading(true); + let data = { id }; + let res; + switch (action) { + case 'delete': + res = await API.delete(`/api/token/${id}/`); + break; + case 'enable': + data.status = 1; + res = await API.put('/api/token/?status_only=true', data); + break; + case 'disable': + data.status = 2; + res = await API.put('/api/token/?status_only=true', data); + break; + } + const { success, message } = res.data; + if (success) { + showSuccess('操作成功完成!'); + let token = res.data.data; + let newTokens = [...tokens]; + if (action !== 'delete') { + record.status = token.status; + } + setTokens(newTokens); + } else { + showError(message); + } + setLoading(false); + }; + + // Search tokens function + const searchTokens = async () => { + const { searchKeyword, searchToken } = getFormValues(); + if (searchKeyword === '' && searchToken === '') { + await loadTokens(1); + return; + } + setSearching(true); + const res = await API.get( + `/api/token/search?keyword=${searchKeyword}&token=${searchToken}`, + ); + const { success, message, data } = res.data; + if (success) { + setTokens(data); + setTokenCount(data.length); + setActivePage(1); + } else { + showError(message); + } + setSearching(false); + }; + + // Sort tokens function + const sortToken = (key) => { + if (tokens.length === 0) return; + setLoading(true); + let sortedTokens = [...tokens]; + sortedTokens.sort((a, b) => { + return ('' + a[key]).localeCompare(b[key]); + }); + if (sortedTokens[0].id === tokens[0].id) { + sortedTokens.reverse(); + } + setTokens(sortedTokens); + setLoading(false); + }; + + // Page handlers + const handlePageChange = (page) => { + loadTokens(page, pageSize).then(); + }; + + const handlePageSizeChange = async (size) => { + setPageSize(size); + await loadTokens(1, size); + }; + + // Row selection handlers + const rowSelection = { + onSelect: (record, selected) => { }, + onSelectAll: (selected, selectedRows) => { }, + onChange: (selectedRowKeys, selectedRows) => { + setSelectedKeys(selectedRows); + }, + }; + + // Handle row styling + const handleRow = (record, index) => { + if (record.status !== 1) { + return { + style: { + background: 'var(--semi-color-disabled-border)', + }, + }; + } else { + return {}; + } + }; + + // Batch delete tokens + const batchDeleteTokens = async () => { + if (selectedKeys.length === 0) { + showError(t('请先选择要删除的令牌!')); + return; + } + setLoading(true); + try { + const ids = selectedKeys.map((token) => token.id); + const res = await API.post('/api/token/batch', { ids }); + if (res?.data?.success) { + const count = res.data.data || 0; + showSuccess(t('已删除 {{count}} 个令牌!', { count })); + await refresh(); + setTimeout(() => { + if (tokens.length === 0 && activePage > 1) { + refresh(activePage - 1); + } + }, 100); + } else { + showError(res?.data?.message || t('删除失败')); + } + } catch (error) { + showError(error.message); + } finally { + setLoading(false); + } + }; + + // Batch copy tokens + const batchCopyTokens = (copyType) => { + if (selectedKeys.length === 0) { + showError(t('请至少选择一个令牌!')); + return; + } + + Modal.info({ + title: t('复制令牌'), + icon: null, + content: t('请选择你的复制方式'), + footer: ( +
+ + +
+ ), + }); + }; + + // Initialize data + useEffect(() => { + loadTokens(1) + .then() + .catch((reason) => { + showError(reason); + }); + }, [pageSize]); + + return { + // Basic state + tokens, + loading, + activePage, + tokenCount, + pageSize, + searching, + + // Selection state + selectedKeys, + setSelectedKeys, + + // Edit state + showEdit, + setShowEdit, + editingToken, + setEditingToken, + closeEdit, + + // UI state + compactMode, + setCompactMode, + showKeys, + setShowKeys, + + // Form state + formApi, + setFormApi, + formInitValues, + getFormValues, + + // Functions + loadTokens, + refresh, + copyText, + onOpenLink, + manageToken, + searchTokens, + sortToken, + handlePageChange, + handlePageSizeChange, + rowSelection, + handleRow, + batchDeleteTokens, + batchCopyTokens, + syncPageData, + + // Translation + t, + }; +}; \ No newline at end of file