Просмотр исходного кода

♻️ refactor: modularize TokensTable component into maintainable architecture

- 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.
t0ng7u 7 месяцев назад
Родитель
Сommit
42a26f076a

+ 4 - 918
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 ? (
-          <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>
-        );
-      },
-    },
-    {
-      title: t('分组'),
-      dataIndex: 'group',
-      key: 'group',
-      render: (text) => {
-        if (text === 'auto') {
-          return (
-            <Tooltip
-              content={t('当前分组为 auto,会自动选择最优分组,当一个组不可用时自动降级到下一个组(熔断机制)')}
-              position='top'
-            >
-              <Tag color='white' shape='circle'> {t('智能熔断')} </Tag>
-            </Tooltip>
-          );
-        }
-        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 (
-          <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>
-        );
-      },
-    },
-    {
-      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(
-                <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>
-          );
-        }
-      },
-    },
-    {
-      title: t('IP限制'),
-      dataIndex: 'allow_ips',
-      render: (text) => {
-        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>;
-      },
-    },
-    {
-      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) => {
-        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>
-        );
-      },
-    },
-  ];
-
-  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 = () => (
-    <div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full">
-      <div className="flex items-center text-blue-500">
-        <Key size={16} className="mr-2" />
-        <Text>{t('令牌用于API访问认证,可以设置额度限制和模型权限。')}</Text>
-      </div>
-      <Button
-        type="tertiary"
-        className="w-full md:w-auto"
-        onClick={() => setCompactMode(!compactMode)}
-        size="small"
-      >
-        {compactMode ? t('自适应列表') : t('紧凑列表')}
-      </Button>
-    </div>
-  );
-
-  const renderActionsArea = () => (
-    <div className="flex flex-wrap gap-2 w-full md:w-auto order-2 md:order-1">
-      <Button
-        type="primary"
-        className="flex-1 md:flex-initial"
-        onClick={() => {
-          setEditingToken({
-            id: undefined,
-          });
-          setShowEdit(true);
-        }}
-        size="small"
-      >
-        {t('添加令牌')}
-      </Button>
-      <Button
-        type='tertiary'
-        className="flex-1 md:flex-initial"
-        onClick={() => {
-          if (selectedKeys.length === 0) {
-            showError(t('请至少选择一个令牌!'));
-            return;
-          }
-          Modal.info({
-            title: t('复制令牌'),
-            icon: null,
-            content: t('请选择你的复制方式'),
-            footer: (
-              <Space>
-                <Button
-                  type='tertiary'
-                  onClick={async () => {
-                    let content = '';
-                    for (let i = 0; i < selectedKeys.length; i++) {
-                      content +=
-                        selectedKeys[i].name + '    sk-' + selectedKeys[i].key + '\n';
-                    }
-                    await copyText(content);
-                    Modal.destroyAll();
-                  }}
-                >
-                  {t('名称+密钥')}
-                </Button>
-                <Button
-                  onClick={async () => {
-                    let content = '';
-                    for (let i = 0; i < selectedKeys.length; i++) {
-                      content += 'sk-' + selectedKeys[i].key + '\n';
-                    }
-                    await copyText(content);
-                    Modal.destroyAll();
-                  }}
-                >
-                  {t('仅密钥')}
-                </Button>
-              </Space>
-            ),
-          });
-        }}
-        size="small"
-      >
-        {t('复制所选令牌')}
-      </Button>
-      <Button
-        type='danger'
-        className="w-full md:w-auto"
-        onClick={() => {
-          if (selectedKeys.length === 0) {
-            showError(t('请至少选择一个令牌!'));
-            return;
-          }
-          Modal.confirm({
-            title: t('批量删除令牌'),
-            content: (
-              <div>
-                {t('确定要删除所选的 {{count}} 个令牌吗?', { count: selectedKeys.length })}
-              </div>
-            ),
-            onOk: () => batchDeleteTokens(),
-          });
-        }}
-        size="small"
-      >
-        {t('删除所选令牌')}
-      </Button>
-    </div>
-  );
-
-  const renderSearchArea = () => (
-    <Form
-      initValues={formInitValues}
-      getFormApi={(api) => 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"
-    >
-      <div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto">
-        <div className="relative w-full md:w-56">
-          <Form.Input
-            field="searchKeyword"
-            prefix={<IconSearch />}
-            placeholder={t('搜索关键字')}
-            showClear
-            pure
-            size="small"
-          />
-        </div>
-        <div className="relative w-full md:w-56">
-          <Form.Input
-            field="searchToken"
-            prefix={<IconSearch />}
-            placeholder={t('密钥')}
-            showClear
-            pure
-            size="small"
-          />
-        </div>
-        <div className="flex gap-2 w-full md:w-auto">
-          <Button
-            type="tertiary"
-            htmlType="submit"
-            loading={loading || searching}
-            className="flex-1 md:flex-initial md:w-auto"
-            size="small"
-          >
-            {t('查询')}
-          </Button>
-          <Button
-            type='tertiary'
-            onClick={() => {
-              if (formApi) {
-                formApi.reset();
-                // 重置后立即查询,使用setTimeout确保表单重置完成
-                setTimeout(() => {
-                  searchTokens();
-                }, 100);
-              }
-            }}
-            className="flex-1 md:flex-initial md:w-auto"
-            size="small"
-          >
-            {t('重置')}
-          </Button>
-        </div>
-      </div>
-    </Form>
-  );
-
-  return (
-    <>
-      <EditToken
-        refresh={refresh}
-        editingToken={editingToken}
-        visiable={showEdit}
-        handleClose={closeEdit}
-      ></EditToken>
-
-      <CardPro
-        type="type1"
-        descriptionArea={renderDescriptionArea()}
-        actionsArea={
-          <div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full">
-            <div className="flex flex-wrap gap-2 w-full md:w-auto order-2 md:order-1">
-              {renderActionsArea()}
-            </div>
-            <div className="flex-1 md:flex-initial order-1 md:order-2">
-              {renderSearchArea()}
-            </div>
-          </div>
-        }
-      >
-        <Table
-          columns={compactMode ? columns.map(col => {
-            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={
-            <Empty
-              image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
-              darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
-              description={t('搜索无结果')}
-              style={{ padding: 30 }}
-            />
-          }
-          className="rounded-xl overflow-hidden"
-          size="middle"
-        ></Table>
-      </CardPro>
-    </>
-  );
-};
+// Export the new component for backward compatibility
+const TokensTable = TokensPage;
 
 
 export default TokensTable;
 export default TokensTable;

+ 113 - 0
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: (
+        <Space>
+          <Button
+            type='tertiary'
+            onClick={async () => {
+              let content = '';
+              for (let i = 0; i < selectedKeys.length; i++) {
+                content +=
+                  selectedKeys[i].name + '    sk-' + selectedKeys[i].key + '\n';
+              }
+              await copyText(content);
+              Modal.destroyAll();
+            }}
+          >
+            {t('名称+密钥')}
+          </Button>
+          <Button
+            onClick={async () => {
+              let content = '';
+              for (let i = 0; i < selectedKeys.length; i++) {
+                content += 'sk-' + selectedKeys[i].key + '\n';
+              }
+              await copyText(content);
+              Modal.destroyAll();
+            }}
+          >
+            {t('仅密钥')}
+          </Button>
+        </Space>
+      ),
+    });
+  };
+
+  // Handle delete selected tokens with confirmation
+  const handleDeleteSelectedTokens = () => {
+    if (selectedKeys.length === 0) {
+      showError(t('请至少选择一个令牌!'));
+      return;
+    }
+
+    Modal.confirm({
+      title: t('批量删除令牌'),
+      content: (
+        <div>
+          {t('确定要删除所选的 {{count}} 个令牌吗?', { count: selectedKeys.length })}
+        </div>
+      ),
+      onOk: () => batchDeleteTokens(),
+    });
+  };
+
+  return (
+    <div className="flex flex-wrap gap-2 w-full md:w-auto order-2 md:order-1">
+      <Button
+        type="primary"
+        className="flex-1 md:flex-initial"
+        onClick={() => {
+          setEditingToken({
+            id: undefined,
+          });
+          setShowEdit(true);
+        }}
+        size="small"
+      >
+        {t('添加令牌')}
+      </Button>
+
+      <Button
+        type='tertiary'
+        className="flex-1 md:flex-initial"
+        onClick={handleCopySelectedTokens}
+        size="small"
+      >
+        {t('复制所选令牌')}
+      </Button>
+
+      <Button
+        type='danger'
+        className="w-full md:w-auto"
+        onClick={handleDeleteSelectedTokens}
+        size="small"
+      >
+        {t('删除所选令牌')}
+      </Button>
+    </div>
+  );
+};
+
+export default TokensActions; 

+ 453 - 0
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 ? (
+    <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
+      ),
+    },
+  ];
+}; 

+ 27 - 0
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 (
+    <div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full">
+      <div className="flex items-center text-blue-500">
+        <Key size={16} className="mr-2" />
+        <Text>{t('令牌用于API访问认证,可以设置额度限制和模型权限。')}</Text>
+      </div>
+
+      <Button
+        type="tertiary"
+        className="w-full md:w-auto"
+        onClick={() => setCompactMode(!compactMode)}
+        size="small"
+      >
+        {compactMode ? t('自适应列表') : t('紧凑列表')}
+      </Button>
+    </div>
+  );
+};
+
+export default TokensDescription; 

+ 84 - 0
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 (
+    <Form
+      initValues={formInitValues}
+      getFormApi={(api) => 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"
+    >
+      <div className="flex flex-col md:flex-row items-center gap-2 w-full md:w-auto">
+        <div className="relative w-full md:w-56">
+          <Form.Input
+            field="searchKeyword"
+            prefix={<IconSearch />}
+            placeholder={t('搜索关键字')}
+            showClear
+            pure
+            size="small"
+          />
+        </div>
+
+        <div className="relative w-full md:w-56">
+          <Form.Input
+            field="searchToken"
+            prefix={<IconSearch />}
+            placeholder={t('密钥')}
+            showClear
+            pure
+            size="small"
+          />
+        </div>
+
+        <div className="flex gap-2 w-full md:w-auto">
+          <Button
+            type="tertiary"
+            htmlType="submit"
+            loading={loading || searching}
+            className="flex-1 md:flex-initial md:w-auto"
+            size="small"
+          >
+            {t('查询')}
+          </Button>
+
+          <Button
+            type='tertiary'
+            onClick={(_, formApi) => handleReset(formApi)}
+            className="flex-1 md:flex-initial md:w-auto"
+            size="small"
+          >
+            {t('重置')}
+          </Button>
+        </div>
+      </div>
+    </Form>
+  );
+};
+
+export default TokensFilters; 

+ 99 - 0
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 (
+    <Table
+      columns={tableColumns}
+      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={
+        <Empty
+          image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
+          darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
+          description={t('搜索无结果')}
+          style={{ padding: 30 }}
+        />
+      }
+      className="rounded-xl overflow-hidden"
+      size="middle"
+    />
+  );
+};
+
+export default TokensTable; 

+ 90 - 0
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 (
+    <>
+      <EditToken
+        refresh={refresh}
+        editingToken={editingToken}
+        visiable={showEdit}
+        handleClose={closeEdit}
+      />
+
+      <CardPro
+        type="type1"
+        descriptionArea={
+          <TokensDescription
+            compactMode={compactMode}
+            setCompactMode={setCompactMode}
+            t={t}
+          />
+        }
+        actionsArea={
+          <div className="flex flex-col md:flex-row justify-between items-center gap-2 w-full">
+            <TokensActions
+              selectedKeys={selectedKeys}
+              setEditingToken={setEditingToken}
+              setShowEdit={setShowEdit}
+              batchDeleteTokens={batchDeleteTokens}
+              copyText={copyText}
+              t={t}
+            />
+
+            <div className="w-full md:w-full lg:w-auto order-1 md:order-2">
+              <TokensFilters
+                formInitValues={formInitValues}
+                setFormApi={setFormApi}
+                searchTokens={searchTokens}
+                loading={loading}
+                searching={searching}
+                t={t}
+              />
+            </div>
+          </div>
+        }
+      >
+        <TokensTable {...tokensData} />
+      </CardPro>
+    </>
+  );
+};
+
+export default TokensPage; 

+ 5 - 5
web/src/hooks/task-logs/useTaskLogsData.js

@@ -14,7 +14,7 @@ import { useTableCompactMode } from '../common/useTableCompactMode';
 
 
 export const useTaskLogsData = () => {
 export const useTaskLogsData = () => {
   const { t } = useTranslation();
   const { t } = useTranslation();
-  
+
   // Define column keys for selection
   // Define column keys for selection
   const COLUMN_KEYS = {
   const COLUMN_KEYS = {
     SUBMIT_TIME: 'submit_time',
     SUBMIT_TIME: 'submit_time',
@@ -36,10 +36,10 @@ export const useTaskLogsData = () => {
   const [activePage, setActivePage] = useState(1);
   const [activePage, setActivePage] = useState(1);
   const [logCount, setLogCount] = useState(0);
   const [logCount, setLogCount] = useState(0);
   const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
   const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
-  
+
   // User and admin
   // User and admin
   const isAdminUser = isAdmin();
   const isAdminUser = isAdmin();
-  
+
   // Modal state
   // Modal state
   const [isModalOpen, setIsModalOpen] = useState(false);
   const [isModalOpen, setIsModalOpen] = useState(false);
   const [modalContent, setModalContent] = useState('');
   const [modalContent, setModalContent] = useState('');
@@ -48,7 +48,7 @@ export const useTaskLogsData = () => {
   const [formApi, setFormApi] = useState(null);
   const [formApi, setFormApi] = useState(null);
   let now = new Date();
   let now = new Date();
   let zeroNow = new Date(now.getFullYear(), now.getMonth(), now.getDate());
   let zeroNow = new Date(now.getFullYear(), now.getMonth(), now.getDate());
-  
+
   const formInitValues = {
   const formInitValues = {
     channel_id: '',
     channel_id: '',
     task_id: '',
     task_id: '',
@@ -239,7 +239,7 @@ export const useTaskLogsData = () => {
     logCount,
     logCount,
     pageSize,
     pageSize,
     isAdminUser,
     isAdminUser,
-    
+
     // Modal state
     // Modal state
     isModalOpen,
     isModalOpen,
     setIsModalOpen,
     setIsModalOpen,

+ 369 - 0
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: (
+        <div className="flex gap-2">
+          <button
+            className="px-3 py-1 bg-gray-200 rounded"
+            onClick={async () => {
+              let content = '';
+              for (let i = 0; i < selectedKeys.length; i++) {
+                content +=
+                  selectedKeys[i].name + '    sk-' + selectedKeys[i].key + '\n';
+              }
+              await copyText(content);
+              Modal.destroyAll();
+            }}
+          >
+            {t('名称+密钥')}
+          </button>
+          <button
+            className="px-3 py-1 bg-blue-500 text-white rounded"
+            onClick={async () => {
+              let content = '';
+              for (let i = 0; i < selectedKeys.length; i++) {
+                content += 'sk-' + selectedKeys[i].key + '\n';
+              }
+              await copyText(content);
+              Modal.destroyAll();
+            }}
+          >
+            {t('仅密钥')}
+          </button>
+        </div>
+      ),
+    });
+  };
+
+  // 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,
+  };
+};