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

🚀 feat(web/channels): Deep modular refactor of Channels table

1. Split monolithic `ChannelsTable` (2200+ LOC) into focused components
   • `channels/index.jsx` – composition entry
   • `ChannelsTable.jsx` – pure `<Table>` rendering
   • `ChannelsActions.jsx` – bulk & settings toolbar
   • `ChannelsFilters.jsx` – search / create / column-settings form
   • `ChannelsTabs.jsx` – type tabs
   • `ChannelsColumnDefs.js` – column definitions & render helpers
   • `modals/` – BatchTag, ColumnSelector, ModelTest modals

2. Extract domain hook
   • Moved `useChannelsData.js` → `src/hooks/channels/useChannelsData.js`
     – centralises state, API calls, pagination, filters, batch ops
     – now exports `setActivePage`, fixing tab / status switch errors

3. Update wiring
   • All sub-components consume data via `useChannelsData` props
   • Adjusted import paths after hook relocation

4. Clean legacy file
   • Legacy `components/table/ChannelsTable.js` now re-exports new module

5. Bug fixes
   • Tab switching, status filter & tag aggregation restored
   • Column selector & batch actions operate via unified hook

This commit completes the first phase of modularising the Channels feature, laying groundwork for consistent, maintainable table architecture across the app.
t0ng7u 7 месяцев назад
Родитель
Сommit
6799daacd1
48 измененных файлов с 3477 добавлено и 3019 удалено
  1. 1 1
      web/src/App.js
  2. 1 1
      web/src/components/auth/OAuth2Callback.js
  3. 127 0
      web/src/components/common/ui/CardPro.js
  4. 0 0
      web/src/components/common/ui/Loading.js
  5. 2 2
      web/src/components/layout/HeaderBar.js
  6. 2 2
      web/src/components/layout/PageLayout.js
  7. 1 1
      web/src/components/layout/SiderBar.js
  8. 1 1
      web/src/components/settings/ChannelSelectorModal.js
  9. 2 2207
      web/src/components/table/ChannelsTable.js
  10. 189 195
      web/src/components/table/LogsTable.js
  11. 37 43
      web/src/components/table/MjLogsTable.js
  12. 136 146
      web/src/components/table/RedemptionsTable.js
  13. 98 104
      web/src/components/table/TaskLogsTable.js
  14. 166 163
      web/src/components/table/TokensTable.js
  15. 110 118
      web/src/components/table/UsersTable.js
  16. 240 0
      web/src/components/table/channels/ChannelsActions.jsx
  17. 604 0
      web/src/components/table/channels/ChannelsColumnDefs.js
  18. 140 0
      web/src/components/table/channels/ChannelsFilters.jsx
  19. 138 0
      web/src/components/table/channels/ChannelsTable.jsx
  20. 70 0
      web/src/components/table/channels/ChannelsTabs.jsx
  21. 49 0
      web/src/components/table/channels/index.jsx
  22. 41 0
      web/src/components/table/channels/modals/BatchTagModal.jsx
  23. 114 0
      web/src/components/table/channels/modals/ColumnSelectorModal.jsx
  24. 256 0
      web/src/components/table/channels/modals/ModelTestModal.jsx
  25. 1 1
      web/src/helpers/render.js
  26. 1 1
      web/src/helpers/utils.js
  27. 917 0
      web/src/hooks/channels/useChannelsData.js
  28. 2 2
      web/src/hooks/chat/useTokenKeys.js
  29. 0 0
      web/src/hooks/common/useIsMobile.js
  30. 0 0
      web/src/hooks/common/useSidebarCollapsed.js
  31. 2 2
      web/src/hooks/common/useTableCompactMode.js
  32. 2 2
      web/src/hooks/playground/useApiRequest.js
  33. 2 2
      web/src/hooks/playground/useDataLoader.js
  34. 2 2
      web/src/hooks/playground/useMessageActions.js
  35. 2 2
      web/src/hooks/playground/useMessageEdit.js
  36. 3 3
      web/src/hooks/playground/usePlaygroundState.js
  37. 1 1
      web/src/hooks/playground/useSyncMessageAndCustomBody.js
  38. 1 1
      web/src/pages/Channel/EditChannel.js
  39. 1 1
      web/src/pages/Chat/index.js
  40. 1 1
      web/src/pages/Chat2Link/index.js
  41. 1 1
      web/src/pages/Detail/index.js
  42. 1 1
      web/src/pages/Home/index.js
  43. 7 7
      web/src/pages/Playground/index.js
  44. 1 1
      web/src/pages/Redemption/EditRedemption.js
  45. 1 1
      web/src/pages/Setting/Ratio/UpstreamRatioSync.js
  46. 1 1
      web/src/pages/Token/EditToken.js
  47. 1 1
      web/src/pages/User/AddUser.js
  48. 1 1
      web/src/pages/User/EditUser.js

+ 1 - 1
web/src/App.js

@@ -1,6 +1,6 @@
 import React, { lazy, Suspense } from 'react';
 import { Route, Routes, useLocation } from 'react-router-dom';
-import Loading from './components/common/Loading.js';
+import Loading from './components/common/ui/Loading.js';
 import User from './pages/User';
 import { AuthRedirect, PrivateRoute } from './helpers';
 import RegisterForm from './components/auth/RegisterForm.js';

+ 1 - 1
web/src/components/auth/OAuth2Callback.js

@@ -3,7 +3,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
 import { useTranslation } from 'react-i18next';
 import { API, showError, showSuccess, updateAPI, setUserData } from '../../helpers';
 import { UserContext } from '../../context/User';
-import Loading from '../common/Loading';
+import Loading from '../common/ui/Loading';
 
 const OAuth2Callback = (props) => {
   const { t } = useTranslation();

+ 127 - 0
web/src/components/common/ui/CardPro.js

@@ -0,0 +1,127 @@
+import React from 'react';
+import { Card, Divider, Typography } from '@douyinfe/semi-ui';
+import PropTypes from 'prop-types';
+
+const { Text } = Typography;
+
+/**
+ * CardPro 高级卡片组件
+ * 
+ * 布局分为5个区域:
+ * 1. 统计信息区域 (statsArea)
+ * 2. 描述信息区域 (descriptionArea) 
+ * 3. 类型切换/标签区域 (tabsArea)
+ * 4. 操作按钮区域 (actionsArea)
+ * 5. 搜索表单区域 (searchArea)
+ * 
+ * 支持三种布局类型:
+ * - type1: 操作型 (如TokensTable) - 描述信息 + 操作按钮 + 搜索表单
+ * - type2: 查询型 (如LogsTable) - 统计信息 + 搜索表单
+ * - type3: 复杂型 (如ChannelsTable) - 描述信息 + 类型切换 + 操作按钮 + 搜索表单
+ */
+const CardPro = ({
+  type = 'type1',
+  className = '',
+  children,
+  // 各个区域的内容
+  statsArea,
+  descriptionArea,
+  tabsArea,
+  actionsArea,
+  searchArea,
+  // 卡片属性
+  shadows = 'always',
+  bordered = false,
+  // 自定义样式
+  style,
+  ...props
+}) => {
+  // 渲染头部内容
+  const renderHeader = () => {
+    const hasContent = statsArea || descriptionArea || tabsArea || actionsArea || searchArea;
+    if (!hasContent) return null;
+
+    return (
+      <div className="flex flex-col w-full">
+        {/* 统计信息区域 - 用于type2 */}
+        {type === 'type2' && statsArea && (
+          <div className="mb-4">
+            {statsArea}
+          </div>
+        )}
+
+        {/* 描述信息区域 - 用于type1和type3 */}
+        {(type === 'type1' || type === 'type3') && descriptionArea && (
+          <div className="mb-2">
+            {descriptionArea}
+          </div>
+        )}
+
+        {/* 第一个分隔线 - 在描述信息或统计信息后面 */}
+        {((type === 'type1' || type === 'type3') && descriptionArea) || 
+         (type === 'type2' && statsArea) ? (
+          <Divider margin="12px" />
+        ) : null}
+
+        {/* 类型切换/标签区域 - 主要用于type3 */}
+        {type === 'type3' && tabsArea && (
+          <div className="mb-4">
+            {tabsArea}
+          </div>
+        )}
+
+        {/* 操作按钮和搜索表单的容器 */}
+        <div className="flex flex-col gap-4">
+          {/* 操作按钮区域 - 用于type1和type3 */}
+          {(type === 'type1' || type === 'type3') && actionsArea && (
+            <div className="w-full">
+              {actionsArea}
+            </div>
+          )}
+
+          {/* 搜索表单区域 - 所有类型都可能有 */}
+          {searchArea && (
+            <div className="w-full">
+              {searchArea}
+            </div>
+          )}
+        </div>
+      </div>
+    );
+  };
+
+  const headerContent = renderHeader();
+
+  return (
+    <Card
+      className={`table-scroll-card !rounded-2xl ${className}`}
+      title={headerContent}
+      shadows={shadows}
+      bordered={bordered}
+      style={style}
+      {...props}
+    >
+      {children}
+    </Card>
+  );
+};
+
+CardPro.propTypes = {
+  // 布局类型
+  type: PropTypes.oneOf(['type1', 'type2', 'type3']),
+  // 样式相关
+  className: PropTypes.string,
+  style: PropTypes.object,
+  shadows: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]),
+  bordered: PropTypes.bool,
+  // 内容区域
+  statsArea: PropTypes.node,
+  descriptionArea: PropTypes.node,
+  tabsArea: PropTypes.node,
+  actionsArea: PropTypes.node,
+  searchArea: PropTypes.node,
+  // 表格内容
+  children: PropTypes.node,
+};
+
+export default CardPro; 

+ 0 - 0
web/src/components/common/Loading.js → web/src/components/common/ui/Loading.js


+ 2 - 2
web/src/components/layout/HeaderBar.js

@@ -31,8 +31,8 @@ import {
   Badge,
 } from '@douyinfe/semi-ui';
 import { StatusContext } from '../../context/Status/index.js';
-import { useIsMobile } from '../../hooks/useIsMobile.js';
-import { useSidebarCollapsed } from '../../hooks/useSidebarCollapsed.js';
+import { useIsMobile } from '../../hooks/common/useIsMobile.js';
+import { useSidebarCollapsed } from '../../hooks/common/useSidebarCollapsed.js';
 
 const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
   const { t, i18n } = useTranslation();

+ 2 - 2
web/src/components/layout/PageLayout.js

@@ -5,8 +5,8 @@ import App from '../../App.js';
 import FooterBar from './Footer.js';
 import { ToastContainer } from 'react-toastify';
 import React, { useContext, useEffect, useState } from 'react';
-import { useIsMobile } from '../../hooks/useIsMobile.js';
-import { useSidebarCollapsed } from '../../hooks/useSidebarCollapsed.js';
+import { useIsMobile } from '../../hooks/common/useIsMobile.js';
+import { useSidebarCollapsed } from '../../hooks/common/useSidebarCollapsed.js';
 import { useTranslation } from 'react-i18next';
 import { API, getLogo, getSystemName, showError, setStatusData } from '../../helpers/index.js';
 import { UserContext } from '../../context/User/index.js';

+ 1 - 1
web/src/components/layout/SiderBar.js

@@ -3,7 +3,7 @@ import { Link, useLocation } from 'react-router-dom';
 import { useTranslation } from 'react-i18next';
 import { getLucideIcon, sidebarIconColors } from '../../helpers/render.js';
 import { ChevronLeft } from 'lucide-react';
-import { useSidebarCollapsed } from '../../hooks/useSidebarCollapsed.js';
+import { useSidebarCollapsed } from '../../hooks/common/useSidebarCollapsed.js';
 import {
   isAdmin,
   isRoot,

+ 1 - 1
web/src/components/settings/ChannelSelectorModal.js

@@ -1,5 +1,5 @@
 import React, { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
-import { useIsMobile } from '../../hooks/useIsMobile.js';
+import { useIsMobile } from '../../hooks/common/useIsMobile.js';
 import {
   Modal,
   Table,

+ 2 - 2207
web/src/components/table/ChannelsTable.js

@@ -1,2207 +1,2 @@
-import React, { useEffect, useState, useMemo, useRef } from 'react';
-import {
-  API,
-  showError,
-  showInfo,
-  showSuccess,
-  timestamp2string,
-  renderGroup,
-  renderQuota,
-  getChannelIcon,
-  renderQuotaWithAmount
-} from '../../helpers/index.js';
-import { CHANNEL_OPTIONS, ITEMS_PER_PAGE, MODEL_TABLE_PAGE_SIZE } from '../../constants/index.js';
-import {
-  Button,
-  Divider,
-  Dropdown,
-  Empty,
-  Input,
-  InputNumber,
-  Modal,
-  Space,
-  SplitButtonGroup,
-  Switch,
-  Table,
-  Tag,
-  Tooltip,
-  Typography,
-  Checkbox,
-  Card,
-  Form,
-  Tabs,
-  TabPane,
-  Select
-} from '@douyinfe/semi-ui';
-import {
-  IllustrationNoResult,
-  IllustrationNoResultDark
-} from '@douyinfe/semi-illustrations';
-import EditChannel from '../../pages/Channel/EditChannel.js';
-import {
-  IconTreeTriangleDown,
-  IconSearch,
-  IconMore,
-  IconDescend2
-} from '@douyinfe/semi-icons';
-import { loadChannelModels, copy } from '../../helpers';
-import { useIsMobile } from '../../hooks/useIsMobile.js';
-import EditTagModal from '../../pages/Channel/EditTagModal.js';
-import { useTranslation } from 'react-i18next';
-import { useTableCompactMode } from '../../hooks/useTableCompactMode';
-import { FaRandom } from 'react-icons/fa';
-
-const ChannelsTable = () => {
-  const { t } = useTranslation();
-  const isMobile = useIsMobile();
-
-  let type2label = undefined;
-
-  const renderType = (type, channelInfo = undefined) => {
-    if (!type2label) {
-      type2label = new Map();
-      for (let i = 0; i < CHANNEL_OPTIONS.length; i++) {
-        type2label[CHANNEL_OPTIONS[i].value] = CHANNEL_OPTIONS[i];
-      }
-      type2label[0] = { value: 0, label: t('未知类型'), color: 'grey' };
-    }
-
-    let icon = getChannelIcon(type);
-
-    if (channelInfo?.is_multi_key) {
-      icon = (
-        channelInfo?.multi_key_mode === 'random' ? (
-          <div className="flex items-center gap-1">
-            <FaRandom className="text-blue-500" />
-            {icon}
-          </div>
-        ) : (
-          <div className="flex items-center gap-1">
-            <IconDescend2 className="text-blue-500" />
-            {icon}
-          </div>
-        )
-      )
-    }
-
-    return (
-      <Tag
-        color={type2label[type]?.color}
-        shape='circle'
-        prefixIcon={icon}
-      >
-        {type2label[type]?.label}
-      </Tag>
-    );
-  };
-
-  const renderTagType = () => {
-    return (
-      <Tag
-        color='light-blue'
-        shape='circle'
-        type='light'
-      >
-        {t('标签聚合')}
-      </Tag>
-    );
-  };
-
-  const renderStatus = (status, channelInfo = undefined) => {
-    if (channelInfo) {
-      if (channelInfo.is_multi_key) {
-        let keySize = channelInfo.multi_key_size;
-        let enabledKeySize = keySize;
-        if (channelInfo.multi_key_status_list) {
-          // multi_key_status_list is a map, key is key, value is status
-          // get multi_key_status_list length
-          enabledKeySize = keySize - Object.keys(channelInfo.multi_key_status_list).length;
-        }
-        return renderMultiKeyStatus(status, keySize, enabledKeySize);
-      }
-    }
-    switch (status) {
-      case 1:
-        return (
-          <Tag color='green' shape='circle'>
-            {t('已启用')}
-          </Tag>
-        );
-      case 2:
-        return (
-          <Tag color='red' shape='circle'>
-            {t('已禁用')}
-          </Tag>
-        );
-      case 3:
-        return (
-          <Tag color='yellow' shape='circle'>
-            {t('自动禁用')}
-          </Tag>
-        );
-      default:
-        return (
-          <Tag color='grey' shape='circle'>
-            {t('未知状态')}
-          </Tag>
-        );
-    }
-  };
-
-  const renderMultiKeyStatus = (status, keySize, enabledKeySize) => {
-    switch (status) {
-      case 1:
-        return (
-          <Tag color='green' shape='circle'>
-            {t('已启用')} {enabledKeySize}/{keySize}
-          </Tag>
-        );
-      case 2:
-        return (
-          <Tag color='red' shape='circle'>
-            {t('已禁用')} {enabledKeySize}/{keySize}
-          </Tag>
-        );
-      case 3:
-        return (
-          <Tag color='yellow' shape='circle'>
-            {t('自动禁用')} {enabledKeySize}/{keySize}
-          </Tag>
-        );
-      default:
-        return (
-          <Tag color='grey' shape='circle'>
-            {t('未知状态')} {enabledKeySize}/{keySize}
-          </Tag>
-        );
-    }
-  }
-
-
-  const renderResponseTime = (responseTime) => {
-    let time = responseTime / 1000;
-    time = time.toFixed(2) + t(' 秒');
-    if (responseTime === 0) {
-      return (
-        <Tag color='grey' shape='circle'>
-          {t('未测试')}
-        </Tag>
-      );
-    } else if (responseTime <= 1000) {
-      return (
-        <Tag color='green' shape='circle'>
-          {time}
-        </Tag>
-      );
-    } else if (responseTime <= 3000) {
-      return (
-        <Tag color='lime' shape='circle'>
-          {time}
-        </Tag>
-      );
-    } else if (responseTime <= 5000) {
-      return (
-        <Tag color='yellow' shape='circle'>
-          {time}
-        </Tag>
-      );
-    } else {
-      return (
-        <Tag color='red' shape='circle'>
-          {time}
-        </Tag>
-      );
-    }
-  };
-
-  // Define column keys for selection
-  const COLUMN_KEYS = {
-    ID: 'id',
-    NAME: 'name',
-    GROUP: 'group',
-    TYPE: 'type',
-    STATUS: 'status',
-    RESPONSE_TIME: 'response_time',
-    BALANCE: 'balance',
-    PRIORITY: 'priority',
-    WEIGHT: 'weight',
-    OPERATE: 'operate',
-  };
-
-  // State for column visibility
-  const [visibleColumns, setVisibleColumns] = useState({});
-  const [showColumnSelector, setShowColumnSelector] = useState(false);
-
-  // 状态筛选 all / enabled / disabled
-  const [statusFilter, setStatusFilter] = useState(
-    localStorage.getItem('channel-status-filter') || 'all'
-  );
-
-  // Load saved column preferences from localStorage
-  useEffect(() => {
-    const savedColumns = localStorage.getItem('channels-table-columns');
-    if (savedColumns) {
-      try {
-        const parsed = JSON.parse(savedColumns);
-        // Make sure all columns are accounted for
-        const defaults = getDefaultColumnVisibility();
-        const merged = { ...defaults, ...parsed };
-        setVisibleColumns(merged);
-      } catch (e) {
-        console.error('Failed to parse saved column preferences', e);
-        initDefaultColumns();
-      }
-    } else {
-      initDefaultColumns();
-    }
-  }, []);
-
-  // Update table when column visibility changes
-  useEffect(() => {
-    if (Object.keys(visibleColumns).length > 0) {
-      // Save to localStorage
-      localStorage.setItem(
-        'channels-table-columns',
-        JSON.stringify(visibleColumns),
-      );
-    }
-  }, [visibleColumns]);
-
-  // Get default column visibility
-  const getDefaultColumnVisibility = () => {
-    return {
-      [COLUMN_KEYS.ID]: true,
-      [COLUMN_KEYS.NAME]: true,
-      [COLUMN_KEYS.GROUP]: true,
-      [COLUMN_KEYS.TYPE]: true,
-      [COLUMN_KEYS.STATUS]: true,
-      [COLUMN_KEYS.RESPONSE_TIME]: true,
-      [COLUMN_KEYS.BALANCE]: true,
-      [COLUMN_KEYS.PRIORITY]: true,
-      [COLUMN_KEYS.WEIGHT]: true,
-      [COLUMN_KEYS.OPERATE]: true,
-    };
-  };
-
-  // Initialize default column visibility
-  const initDefaultColumns = () => {
-    const defaults = getDefaultColumnVisibility();
-    setVisibleColumns(defaults);
-  };
-
-  // Handle column visibility change
-  const handleColumnVisibilityChange = (columnKey, checked) => {
-    const updatedColumns = { ...visibleColumns, [columnKey]: checked };
-    setVisibleColumns(updatedColumns);
-  };
-
-  // Handle "Select All" checkbox
-  const handleSelectAll = (checked) => {
-    const allKeys = Object.keys(COLUMN_KEYS).map((key) => COLUMN_KEYS[key]);
-    const updatedColumns = {};
-
-    allKeys.forEach((key) => {
-      updatedColumns[key] = checked;
-    });
-
-    setVisibleColumns(updatedColumns);
-  };
-
-  // Define all columns with keys
-  const allColumns = [
-    {
-      key: COLUMN_KEYS.ID,
-      title: t('ID'),
-      dataIndex: 'id',
-    },
-    {
-      key: COLUMN_KEYS.NAME,
-      title: t('名称'),
-      dataIndex: 'name',
-    },
-    {
-      key: COLUMN_KEYS.GROUP,
-      title: t('分组'),
-      dataIndex: 'group',
-      render: (text, record, index) => (
-        <div>
-          <Space spacing={2}>
-            {text
-              ?.split(',')
-              .sort((a, b) => {
-                if (a === 'default') return -1;
-                if (b === 'default') return 1;
-                return a.localeCompare(b);
-              })
-              .map((item, index) => renderGroup(item))}
-          </Space>
-        </div>
-      ),
-    },
-    {
-      key: COLUMN_KEYS.TYPE,
-      title: t('类型'),
-      dataIndex: 'type',
-      render: (text, record, index) => {
-        if (record.children === undefined) {
-          if (record.channel_info) {
-            if (record.channel_info.is_multi_key) {
-              return <>{renderType(text, record.channel_info)}</>;
-            }
-          }
-          return <>{renderType(text)}</>;
-        } else {
-          return <>{renderTagType()}</>;
-        }
-      },
-    },
-    {
-      key: COLUMN_KEYS.STATUS,
-      title: t('状态'),
-      dataIndex: 'status',
-      render: (text, record, index) => {
-        if (text === 3) {
-          if (record.other_info === '') {
-            record.other_info = '{}';
-          }
-          let otherInfo = JSON.parse(record.other_info);
-          let reason = otherInfo['status_reason'];
-          let time = otherInfo['status_time'];
-          return (
-            <div>
-              <Tooltip
-                content={t('原因:') + reason + t(',时间:') + timestamp2string(time)}
-              >
-                {renderStatus(text, record.channel_info)}
-              </Tooltip>
-            </div>
-          );
-        } else {
-          return renderStatus(text, record.channel_info);
-        }
-      },
-    },
-    {
-      key: COLUMN_KEYS.RESPONSE_TIME,
-      title: t('响应时间'),
-      dataIndex: 'response_time',
-      render: (text, record, index) => (
-        <div>{renderResponseTime(text)}</div>
-      ),
-    },
-    {
-      key: COLUMN_KEYS.BALANCE,
-      title: t('已用/剩余'),
-      dataIndex: 'expired_time',
-      render: (text, record, index) => {
-        if (record.children === undefined) {
-          return (
-            <div>
-              <Space spacing={1}>
-                <Tooltip content={t('已用额度')}>
-                  <Tag color='white' type='ghost' shape='circle'>
-                    {renderQuota(record.used_quota)}
-                  </Tag>
-                </Tooltip>
-                <Tooltip content={t('剩余额度$') + record.balance + t(',点击更新')}>
-                  <Tag
-                    color='white'
-                    type='ghost'
-                    shape='circle'
-                    onClick={() => updateChannelBalance(record)}
-                  >
-                    {renderQuotaWithAmount(record.balance)}
-                  </Tag>
-                </Tooltip>
-              </Space>
-            </div>
-          );
-        } else {
-          return (
-            <Tooltip content={t('已用额度')}>
-              <Tag color='white' type='ghost' shape='circle'>
-                {renderQuota(record.used_quota)}
-              </Tag>
-            </Tooltip>
-          );
-        }
-      },
-    },
-    {
-      key: COLUMN_KEYS.PRIORITY,
-      title: t('优先级'),
-      dataIndex: 'priority',
-      render: (text, record, index) => {
-        if (record.children === undefined) {
-          return (
-            <div>
-              <InputNumber
-                style={{ width: 70 }}
-                name='priority'
-                onBlur={(e) => {
-                  manageChannel(record.id, 'priority', record, e.target.value);
-                }}
-                keepFocus={true}
-                innerButtons
-                defaultValue={record.priority}
-                min={-999}
-                size="small"
-              />
-            </div>
-          );
-        } else {
-          return (
-            <InputNumber
-              style={{ width: 70 }}
-              name='priority'
-              keepFocus={true}
-              onBlur={(e) => {
-                Modal.warning({
-                  title: t('修改子渠道优先级'),
-                  content: t('确定要修改所有子渠道优先级为 ') + e.target.value + t(' 吗?'),
-                  onOk: () => {
-                    if (e.target.value === '') {
-                      return;
-                    }
-                    submitTagEdit('priority', {
-                      tag: record.key,
-                      priority: e.target.value,
-                    });
-                  },
-                });
-              }}
-              innerButtons
-              defaultValue={record.priority}
-              min={-999}
-              size="small"
-            />
-          );
-        }
-      },
-    },
-    {
-      key: COLUMN_KEYS.WEIGHT,
-      title: t('权重'),
-      dataIndex: 'weight',
-      render: (text, record, index) => {
-        if (record.children === undefined) {
-          return (
-            <div>
-              <InputNumber
-                style={{ width: 70 }}
-                name='weight'
-                onBlur={(e) => {
-                  manageChannel(record.id, 'weight', record, e.target.value);
-                }}
-                keepFocus={true}
-                innerButtons
-                defaultValue={record.weight}
-                min={0}
-                size="small"
-              />
-            </div>
-          );
-        } else {
-          return (
-            <InputNumber
-              style={{ width: 70 }}
-              name='weight'
-              keepFocus={true}
-              onBlur={(e) => {
-                Modal.warning({
-                  title: t('修改子渠道权重'),
-                  content: t('确定要修改所有子渠道权重为 ') + e.target.value + t(' 吗?'),
-                  onOk: () => {
-                    if (e.target.value === '') {
-                      return;
-                    }
-                    submitTagEdit('weight', {
-                      tag: record.key,
-                      weight: e.target.value,
-                    });
-                  },
-                });
-              }}
-              innerButtons
-              defaultValue={record.weight}
-              min={-999}
-              size="small"
-            />
-          );
-        }
-      },
-    },
-    {
-      key: COLUMN_KEYS.OPERATE,
-      title: '',
-      dataIndex: 'operate',
-      fixed: 'right',
-      render: (text, record, index) => {
-        if (record.children === undefined) {
-          // 创建更多操作的下拉菜单项
-          const moreMenuItems = [
-            {
-              node: 'item',
-              name: t('删除'),
-              type: 'danger',
-              onClick: () => {
-                Modal.confirm({
-                  title: t('确定是否要删除此渠道?'),
-                  content: t('此修改将不可逆'),
-                  onOk: () => {
-                    (async () => {
-                      await manageChannel(record.id, 'delete', record);
-                      await refresh();
-                      setTimeout(() => {
-                        if (channels.length === 0 && activePage > 1) {
-                          refresh(activePage - 1);
-                        }
-                      }, 100);
-                    })();
-                  },
-                });
-              },
-            },
-            {
-              node: 'item',
-              name: t('复制'),
-              type: 'tertiary',
-              onClick: () => {
-                Modal.confirm({
-                  title: t('确定是否要复制此渠道?'),
-                  content: t('复制渠道的所有信息'),
-                  onOk: () => copySelectedChannel(record),
-                });
-              },
-            },
-          ];
-
-          return (
-            <Space wrap>
-              <SplitButtonGroup
-                className="overflow-hidden"
-                aria-label={t('测试单个渠道操作项目组')}
-              >
-                <Button
-                  size="small"
-                  type='tertiary'
-                  onClick={() => testChannel(record, '')}
-                >
-                  {t('测试')}
-                </Button>
-                <Button
-                  size="small"
-                  type='tertiary'
-                  icon={<IconTreeTriangleDown />}
-                  onClick={() => {
-                    setCurrentTestChannel(record);
-                    setShowModelTestModal(true);
-                  }}
-                />
-              </SplitButtonGroup>
-
-              {record.channel_info?.is_multi_key ? (
-                <SplitButtonGroup
-                  aria-label={t('多密钥渠道操作项目组')}
-                >
-                  {
-                    record.status === 1 ? (
-                      <Button
-                        type='danger'
-                        size="small"
-                        onClick={() => manageChannel(record.id, 'disable', record)}
-                      >
-                        {t('禁用')}
-                      </Button>
-                    ) : (
-                      <Button
-                        size="small"
-                        onClick={() => manageChannel(record.id, 'enable', record)}
-                      >
-                        {t('启用')}
-                      </Button>
-                    )
-                  }
-                  <Dropdown
-                    trigger='click'
-                    position='bottomRight'
-                    menu={[
-                      {
-                        node: 'item',
-                        name: t('启用全部密钥'),
-                        onClick: () => manageChannel(record.id, 'enable_all', record),
-                      }
-                    ]}
-                  >
-                    <Button
-                      type='tertiary'
-                      size="small"
-                      icon={<IconTreeTriangleDown />}
-                    />
-                  </Dropdown>
-                </SplitButtonGroup>
-              ) : (
-                record.status === 1 ? (
-                  <Button
-                    type='danger'
-                    size="small"
-                    onClick={() => manageChannel(record.id, 'disable', record)}
-                  >
-                    {t('禁用')}
-                  </Button>
-                ) : (
-                  <Button
-                    size="small"
-                    onClick={() => manageChannel(record.id, 'enable', record)}
-                  >
-                    {t('启用')}
-                  </Button>
-                )
-              )}
-
-              <Button
-                type='tertiary'
-                size="small"
-                onClick={() => {
-                  setEditingChannel(record);
-                  setShowEdit(true);
-                }}
-              >
-                {t('编辑')}
-              </Button>
-
-              <Dropdown
-                trigger='click'
-                position='bottomRight'
-                menu={moreMenuItems}
-              >
-                <Button
-                  icon={<IconMore />}
-                  type='tertiary'
-                  size="small"
-                />
-              </Dropdown>
-            </Space>
-          );
-        } else {
-          // 标签操作按钮
-          return (
-            <Space wrap>
-              <Button
-                type='tertiary'
-                size="small"
-                onClick={() => manageTag(record.key, 'enable')}
-              >
-                {t('启用全部')}
-              </Button>
-              <Button
-                type='tertiary'
-                size="small"
-                onClick={() => manageTag(record.key, 'disable')}
-              >
-                {t('禁用全部')}
-              </Button>
-              <Button
-                type='tertiary'
-                size="small"
-                onClick={() => {
-                  setShowEditTag(true);
-                  setEditingTag(record.key);
-                }}
-              >
-                {t('编辑')}
-              </Button>
-            </Space>
-          );
-        }
-      },
-    },
-  ];
-
-  const [channels, setChannels] = useState([]);
-  const [loading, setLoading] = useState(true);
-  const [activePage, setActivePage] = useState(1);
-  const [idSort, setIdSort] = useState(false);
-  const [searching, setSearching] = useState(false);
-  const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
-  const [channelCount, setChannelCount] = useState(pageSize);
-  const [groupOptions, setGroupOptions] = useState([]);
-  const [showEdit, setShowEdit] = useState(false);
-  const [enableBatchDelete, setEnableBatchDelete] = useState(false);
-  const [editingChannel, setEditingChannel] = useState({
-    id: undefined,
-  });
-  const [showEditTag, setShowEditTag] = useState(false);
-  const [editingTag, setEditingTag] = useState('');
-  const [selectedChannels, setSelectedChannels] = useState([]);
-  const [enableTagMode, setEnableTagMode] = useState(false);
-  const [showBatchSetTag, setShowBatchSetTag] = useState(false);
-  const [batchSetTagValue, setBatchSetTagValue] = useState('');
-  const [showModelTestModal, setShowModelTestModal] = useState(false);
-  const [currentTestChannel, setCurrentTestChannel] = useState(null);
-  const [modelSearchKeyword, setModelSearchKeyword] = useState('');
-  const [modelTestResults, setModelTestResults] = useState({});
-  const [testingModels, setTestingModels] = useState(new Set());
-  const [selectedModelKeys, setSelectedModelKeys] = useState([]);
-  const [isBatchTesting, setIsBatchTesting] = useState(false);
-  const [testQueue, setTestQueue] = useState([]);
-  const [isProcessingQueue, setIsProcessingQueue] = useState(false);
-  const [modelTablePage, setModelTablePage] = useState(1);
-  const [activeTypeKey, setActiveTypeKey] = useState('all');
-  const [typeCounts, setTypeCounts] = useState({});
-  const requestCounter = useRef(0);
-  const [formApi, setFormApi] = useState(null);
-  const [compactMode, setCompactMode] = useTableCompactMode('channels');
-  const formInitValues = {
-    searchKeyword: '',
-    searchGroup: '',
-    searchModel: '',
-  };
-  const allSelectingRef = useRef(false);
-
-  // Filter columns based on visibility settings
-  const getVisibleColumns = () => {
-    return allColumns.filter((column) => visibleColumns[column.key]);
-  };
-
-  // Column selector modal
-  const renderColumnSelector = () => {
-    return (
-      <Modal
-        title={t('列设置')}
-        visible={showColumnSelector}
-        onCancel={() => setShowColumnSelector(false)}
-        footer={
-          <div className="flex justify-end">
-            <Button onClick={() => initDefaultColumns()}>
-              {t('重置')}
-            </Button>
-            <Button onClick={() => setShowColumnSelector(false)}>
-              {t('取消')}
-            </Button>
-            <Button onClick={() => setShowColumnSelector(false)}>
-              {t('确定')}
-            </Button>
-          </div>
-        }
-      >
-        <div style={{ marginBottom: 20 }}>
-          <Checkbox
-            checked={Object.values(visibleColumns).every((v) => v === true)}
-            indeterminate={
-              Object.values(visibleColumns).some((v) => v === true) &&
-              !Object.values(visibleColumns).every((v) => v === true)
-            }
-            onChange={(e) => handleSelectAll(e.target.checked)}
-          >
-            {t('全选')}
-          </Checkbox>
-        </div>
-        <div
-          className="flex flex-wrap max-h-96 overflow-y-auto rounded-lg p-4"
-          style={{ border: '1px solid var(--semi-color-border)' }}
-        >
-          {allColumns.map((column) => {
-            // Skip columns without title
-            if (!column.title) {
-              return null;
-            }
-
-            return (
-              <div
-                key={column.key}
-                className="w-1/2 mb-4 pr-2"
-              >
-                <Checkbox
-                  checked={!!visibleColumns[column.key]}
-                  onChange={(e) =>
-                    handleColumnVisibilityChange(column.key, e.target.checked)
-                  }
-                >
-                  {column.title}
-                </Checkbox>
-              </div>
-            );
-          })}
-        </div>
-      </Modal>
-    );
-  };
-
-  const removeRecord = (record) => {
-    let newDataSource = [...channels];
-    if (record.id != null) {
-      let idx = newDataSource.findIndex((data) => {
-        if (data.children !== undefined) {
-          for (let i = 0; i < data.children.length; i++) {
-            if (data.children[i].id === record.id) {
-              data.children.splice(i, 1);
-              return false;
-            }
-          }
-        } else {
-          return data.id === record.id;
-        }
-      });
-
-      if (idx > -1) {
-        newDataSource.splice(idx, 1);
-        setChannels(newDataSource);
-      }
-    }
-  };
-
-  const setChannelFormat = (channels, enableTagMode) => {
-    let channelDates = [];
-    let channelTags = {};
-    for (let i = 0; i < channels.length; i++) {
-      channels[i].key = '' + channels[i].id;
-      if (!enableTagMode) {
-        channelDates.push(channels[i]);
-      } else {
-        let tag = channels[i].tag ? channels[i].tag : '';
-        // find from channelTags
-        let tagIndex = channelTags[tag];
-        let tagChannelDates = undefined;
-        if (tagIndex === undefined) {
-          // not found, create a new tag
-          channelTags[tag] = 1;
-          tagChannelDates = {
-            key: tag,
-            id: tag,
-            tag: tag,
-            name: '标签:' + tag,
-            group: '',
-            used_quota: 0,
-            response_time: 0,
-            priority: -1,
-            weight: -1,
-          };
-          tagChannelDates.children = [];
-          channelDates.push(tagChannelDates);
-        } else {
-          // found, add to the tag
-          tagChannelDates = channelDates.find((item) => item.key === tag);
-        }
-        if (tagChannelDates.priority === -1) {
-          tagChannelDates.priority = channels[i].priority;
-        } else {
-          if (tagChannelDates.priority !== channels[i].priority) {
-            tagChannelDates.priority = '';
-          }
-        }
-        if (tagChannelDates.weight === -1) {
-          tagChannelDates.weight = channels[i].weight;
-        } else {
-          if (tagChannelDates.weight !== channels[i].weight) {
-            tagChannelDates.weight = '';
-          }
-        }
-
-        if (tagChannelDates.group === '') {
-          tagChannelDates.group = channels[i].group;
-        } else {
-          let channelGroupsStr = channels[i].group;
-          channelGroupsStr.split(',').forEach((item, index) => {
-            if (tagChannelDates.group.indexOf(item) === -1) {
-              // join
-              tagChannelDates.group += ',' + item;
-            }
-          });
-        }
-
-        tagChannelDates.children.push(channels[i]);
-        if (channels[i].status === 1) {
-          tagChannelDates.status = 1;
-        }
-        tagChannelDates.used_quota += channels[i].used_quota;
-        tagChannelDates.response_time += channels[i].response_time;
-        tagChannelDates.response_time = tagChannelDates.response_time / 2;
-      }
-    }
-    setChannels(channelDates);
-  };
-
-  const loadChannels = async (
-    page,
-    pageSize,
-    idSort,
-    enableTagMode,
-    typeKey = activeTypeKey,
-    statusF,
-  ) => {
-    if (statusF === undefined) statusF = statusFilter;
-
-    const { searchKeyword, searchGroup, searchModel } = getFormValues();
-    if (searchKeyword !== '' || searchGroup !== '' || searchModel !== '') {
-      setLoading(true);
-      await searchChannels(enableTagMode, typeKey, statusF, page, pageSize, idSort);
-      setLoading(false);
-      return;
-    }
-
-    const reqId = ++requestCounter.current; // 记录当前请求序号
-    setLoading(true);
-    const typeParam = (typeKey !== 'all') ? `&type=${typeKey}` : '';
-    const statusParam = statusF !== 'all' ? `&status=${statusF}` : '';
-    const res = await API.get(
-      `/api/channel/?p=${page}&page_size=${pageSize}&id_sort=${idSort}&tag_mode=${enableTagMode}${typeParam}${statusParam}`,
-    );
-    if (res === undefined || reqId !== requestCounter.current) {
-      return;
-    }
-    const { success, message, data } = res.data;
-    if (success) {
-      const { items, total, type_counts } = data;
-      if (type_counts) {
-        const sumAll = Object.values(type_counts).reduce((acc, v) => acc + v, 0);
-        setTypeCounts({ ...type_counts, all: sumAll });
-      }
-      setChannelFormat(items, enableTagMode);
-      setChannelCount(total);
-    } else {
-      showError(message);
-    }
-    setLoading(false);
-  };
-
-  const copySelectedChannel = async (record) => {
-    try {
-      const res = await API.post(`/api/channel/copy/${record.id}`);
-      if (res?.data?.success) {
-        showSuccess(t('渠道复制成功'));
-        await refresh();
-      } else {
-        showError(res?.data?.message || t('渠道复制失败'));
-      }
-    } catch (error) {
-      showError(t('渠道复制失败: ') + (error?.response?.data?.message || error?.message || error));
-    }
-  };
-
-  const refresh = async (page = activePage) => {
-    const { searchKeyword, searchGroup, searchModel } = getFormValues();
-    if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
-      await loadChannels(page, pageSize, idSort, enableTagMode);
-    } else {
-      await searchChannels(enableTagMode, activeTypeKey, statusFilter, page, pageSize, idSort);
-    }
-  };
-
-  useEffect(() => {
-    const localIdSort = localStorage.getItem('id-sort') === 'true';
-    const localPageSize =
-      parseInt(localStorage.getItem('page-size')) || ITEMS_PER_PAGE;
-    const localEnableTagMode = localStorage.getItem('enable-tag-mode') === 'true';
-    const localEnableBatchDelete = localStorage.getItem('enable-batch-delete') === 'true';
-    setIdSort(localIdSort);
-    setPageSize(localPageSize);
-    setEnableTagMode(localEnableTagMode);
-    setEnableBatchDelete(localEnableBatchDelete);
-    loadChannels(1, localPageSize, localIdSort, localEnableTagMode)
-      .then()
-      .catch((reason) => {
-        showError(reason);
-      });
-    fetchGroups().then();
-    loadChannelModels().then();
-  }, []);
-
-  const manageChannel = async (id, action, record, value) => {
-    let data = { id };
-    let res;
-    switch (action) {
-      case 'delete':
-        res = await API.delete(`/api/channel/${id}/`);
-        break;
-      case 'enable':
-        data.status = 1;
-        res = await API.put('/api/channel/', data);
-        break;
-      case 'disable':
-        data.status = 2;
-        res = await API.put('/api/channel/', data);
-        break;
-      case 'priority':
-        if (value === '') {
-          return;
-        }
-        data.priority = parseInt(value);
-        res = await API.put('/api/channel/', data);
-        break;
-      case 'weight':
-        if (value === '') {
-          return;
-        }
-        data.weight = parseInt(value);
-        if (data.weight < 0) {
-          data.weight = 0;
-        }
-        res = await API.put('/api/channel/', data);
-        break;
-      case 'enable_all':
-        data.channel_info = record.channel_info;
-        data.channel_info.multi_key_status_list = {};
-        res = await API.put('/api/channel/', data);
-        break;
-    }
-    const { success, message } = res.data;
-    if (success) {
-      showSuccess(t('操作成功完成!'));
-      let channel = res.data.data;
-      let newChannels = [...channels];
-      if (action === 'delete') {
-      } else {
-        record.status = channel.status;
-      }
-      setChannels(newChannels);
-    } else {
-      showError(message);
-    }
-  };
-
-  const manageTag = async (tag, action) => {
-    console.log(tag, action);
-    let res;
-    switch (action) {
-      case 'enable':
-        res = await API.post('/api/channel/tag/enabled', {
-          tag: tag,
-        });
-        break;
-      case 'disable':
-        res = await API.post('/api/channel/tag/disabled', {
-          tag: tag,
-        });
-        break;
-    }
-    const { success, message } = res.data;
-    if (success) {
-      showSuccess('操作成功完成!');
-      let newChannels = [...channels];
-      for (let i = 0; i < newChannels.length; i++) {
-        if (newChannels[i].tag === tag) {
-          let status = action === 'enable' ? 1 : 2;
-          newChannels[i]?.children?.forEach((channel) => {
-            channel.status = status;
-          });
-          newChannels[i].status = status;
-        }
-      }
-      setChannels(newChannels);
-    } else {
-      showError(message);
-    }
-  };
-
-  // 获取表单值的辅助函数
-  const getFormValues = () => {
-    const formValues = formApi ? formApi.getValues() : {};
-    return {
-      searchKeyword: formValues.searchKeyword || '',
-      searchGroup: formValues.searchGroup || '',
-      searchModel: formValues.searchModel || '',
-    };
-  };
-
-  const searchChannels = async (
-    enableTagMode,
-    typeKey = activeTypeKey,
-    statusF = statusFilter,
-    page = 1,
-    pageSz = pageSize,
-    sortFlag = idSort,
-  ) => {
-    const { searchKeyword, searchGroup, searchModel } = getFormValues();
-    setSearching(true);
-    try {
-      if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
-        await loadChannels(page, pageSz, sortFlag, enableTagMode, typeKey, statusF);
-        return;
-      }
-
-      const typeParam = (typeKey !== 'all') ? `&type=${typeKey}` : '';
-      const statusParam = statusF !== 'all' ? `&status=${statusF}` : '';
-      const res = await API.get(
-        `/api/channel/search?keyword=${searchKeyword}&group=${searchGroup}&model=${searchModel}&id_sort=${sortFlag}&tag_mode=${enableTagMode}&p=${page}&page_size=${pageSz}${typeParam}${statusParam}`,
-      );
-      const { success, message, data } = res.data;
-      if (success) {
-        const { items = [], total = 0, type_counts = {} } = data;
-        const sumAll = Object.values(type_counts).reduce((acc, v) => acc + v, 0);
-        setTypeCounts({ ...type_counts, all: sumAll });
-        setChannelFormat(items, enableTagMode);
-        setChannelCount(total);
-        setActivePage(page);
-      } else {
-        showError(message);
-      }
-    } finally {
-      setSearching(false);
-    }
-  };
-
-  const updateChannelProperty = (channelId, updateFn) => {
-    // Create a new copy of channels array
-    const newChannels = [...channels];
-    let updated = false;
-
-    // Find and update the correct channel
-    newChannels.forEach((channel) => {
-      if (channel.children !== undefined) {
-        // If this is a tag group, search in its children
-        channel.children.forEach((child) => {
-          if (child.id === channelId) {
-            updateFn(child);
-            updated = true;
-          }
-        });
-      } else if (channel.id === channelId) {
-        // Direct channel match
-        updateFn(channel);
-        updated = true;
-      }
-    });
-
-    // Only update state if we actually modified a channel
-    if (updated) {
-      setChannels(newChannels);
-    }
-  };
-
-  const processTestQueue = async () => {
-    if (!isProcessingQueue || testQueue.length === 0) return;
-
-    const { channel, model, indexInFiltered } = testQueue[0];
-
-    // 自动翻页到正在测试的模型所在页
-    if (currentTestChannel && currentTestChannel.id === channel.id) {
-      let pageNo;
-      if (indexInFiltered !== undefined) {
-        pageNo = Math.floor(indexInFiltered / MODEL_TABLE_PAGE_SIZE) + 1;
-      } else {
-        const filteredModelsList = currentTestChannel.models
-          .split(',')
-          .filter((m) => m.toLowerCase().includes(modelSearchKeyword.toLowerCase()));
-        const modelIdx = filteredModelsList.indexOf(model);
-        pageNo = modelIdx !== -1 ? Math.floor(modelIdx / MODEL_TABLE_PAGE_SIZE) + 1 : 1;
-      }
-      setModelTablePage(pageNo);
-    }
-
-    try {
-      setTestingModels(prev => new Set([...prev, model]));
-      const res = await API.get(`/api/channel/test/${channel.id}?model=${model}`);
-      const { success, message, time } = res.data;
-
-      setModelTestResults(prev => ({
-        ...prev,
-        [`${channel.id}-${model}`]: { success, time }
-      }));
-
-      if (success) {
-        updateChannelProperty(channel.id, (ch) => {
-          ch.response_time = time * 1000;
-          ch.test_time = Date.now() / 1000;
-        });
-        if (!model) {
-          showInfo(
-            t('通道 ${name} 测试成功,耗时 ${time.toFixed(2)} 秒。')
-              .replace('${name}', channel.name)
-              .replace('${time.toFixed(2)}', time.toFixed(2)),
-          );
-        }
-      } else {
-        showError(message);
-      }
-    } catch (error) {
-      showError(error.message);
-    } finally {
-      setTestingModels(prev => {
-        const newSet = new Set(prev);
-        newSet.delete(model);
-        return newSet;
-      });
-    }
-
-    // 移除已处理的测试
-    setTestQueue(prev => prev.slice(1));
-  };
-
-  // 监听队列变化
-  useEffect(() => {
-    if (testQueue.length > 0 && isProcessingQueue) {
-      processTestQueue();
-    } else if (testQueue.length === 0 && isProcessingQueue) {
-      setIsProcessingQueue(false);
-      setIsBatchTesting(false);
-    }
-  }, [testQueue, isProcessingQueue]);
-
-  const testChannel = async (record, model) => {
-    setTestQueue(prev => [...prev, { channel: record, model }]);
-    if (!isProcessingQueue) {
-      setIsProcessingQueue(true);
-    }
-  };
-
-  const batchTestModels = async () => {
-    if (!currentTestChannel) return;
-
-    setIsBatchTesting(true);
-
-    // 重置分页到第一页
-    setModelTablePage(1);
-
-    const filteredModels = currentTestChannel.models
-      .split(',')
-      .filter((model) =>
-        model.toLowerCase().includes(modelSearchKeyword.toLowerCase()),
-      );
-
-    setTestQueue(
-      filteredModels.map((model, idx) => ({
-        channel: currentTestChannel,
-        model,
-        indexInFiltered: idx, // 记录在过滤列表中的顺序
-      })),
-    );
-    setIsProcessingQueue(true);
-  };
-
-  const handleCloseModal = () => {
-    if (isBatchTesting) {
-      // 清空测试队列来停止测试
-      setTestQueue([]);
-      setIsProcessingQueue(false);
-      setIsBatchTesting(false);
-      showSuccess(t('已停止测试'));
-    } else {
-      setShowModelTestModal(false);
-      setModelSearchKeyword('');
-      setSelectedModelKeys([]);
-      setModelTablePage(1);
-    }
-  };
-
-  const channelTypeCounts = useMemo(() => {
-    if (Object.keys(typeCounts).length > 0) return typeCounts;
-    // fallback 本地计算
-    const counts = { all: channels.length };
-    channels.forEach((channel) => {
-      const collect = (ch) => {
-        const type = ch.type;
-        counts[type] = (counts[type] || 0) + 1;
-      };
-      if (channel.children !== undefined) {
-        channel.children.forEach(collect);
-      } else {
-        collect(channel);
-      }
-    });
-    return counts;
-  }, [typeCounts, channels]);
-
-  const availableTypeKeys = useMemo(() => {
-    const keys = ['all'];
-    Object.entries(channelTypeCounts).forEach(([k, v]) => {
-      if (k !== 'all' && v > 0) keys.push(String(k));
-    });
-    return keys;
-  }, [channelTypeCounts]);
-
-  const renderTypeTabs = () => {
-    if (enableTagMode) return null;
-
-    return (
-      <Tabs
-        activeKey={activeTypeKey}
-        type="card"
-        collapsible
-        onChange={(key) => {
-          setActiveTypeKey(key);
-          setActivePage(1);
-          loadChannels(1, pageSize, idSort, enableTagMode, key);
-        }}
-        className="mb-4"
-      >
-        <TabPane
-          itemKey="all"
-          tab={
-            <span className="flex items-center gap-2">
-              {t('全部')}
-              <Tag color={activeTypeKey === 'all' ? 'red' : 'grey'} shape='circle'>
-                {channelTypeCounts['all'] || 0}
-              </Tag>
-            </span>
-          }
-        />
-
-        {CHANNEL_OPTIONS.filter((opt) => availableTypeKeys.includes(String(opt.value))).map((option) => {
-          const key = String(option.value);
-          const count = channelTypeCounts[option.value] || 0;
-          return (
-            <TabPane
-              key={key}
-              itemKey={key}
-              tab={
-                <span className="flex items-center gap-2">
-                  {getChannelIcon(option.value)}
-                  {option.label}
-                  <Tag color={activeTypeKey === key ? 'red' : 'grey'} shape='circle'>
-                    {count}
-                  </Tag>
-                </span>
-              }
-            />
-          );
-        })}
-      </Tabs>
-    );
-  };
-
-  let pageData = channels;
-
-  const handlePageChange = (page) => {
-    const { searchKeyword, searchGroup, searchModel } = getFormValues();
-    setActivePage(page);
-    if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
-      loadChannels(page, pageSize, idSort, enableTagMode).then(() => { });
-    } else {
-      searchChannels(enableTagMode, activeTypeKey, statusFilter, page, pageSize, idSort);
-    }
-  };
-
-  const handlePageSizeChange = async (size) => {
-    localStorage.setItem('page-size', size + '');
-    setPageSize(size);
-    setActivePage(1);
-    const { searchKeyword, searchGroup, searchModel } = getFormValues();
-    if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
-      loadChannels(1, size, idSort, enableTagMode)
-        .then()
-        .catch((reason) => {
-          showError(reason);
-        });
-    } else {
-      searchChannels(enableTagMode, activeTypeKey, statusFilter, 1, size, idSort);
-    }
-  };
-
-  const fetchGroups = async () => {
-    try {
-      let res = await API.get(`/api/group/`);
-      if (res === undefined) {
-        return;
-      }
-      setGroupOptions(
-        res.data.data.map((group) => ({
-          label: group,
-          value: group,
-        })),
-      );
-    } catch (error) {
-      showError(error.message);
-    }
-  };
-
-  const submitTagEdit = async (type, data) => {
-    switch (type) {
-      case 'priority':
-        if (data.priority === undefined || data.priority === '') {
-          showInfo('优先级必须是整数!');
-          return;
-        }
-        data.priority = parseInt(data.priority);
-        break;
-      case 'weight':
-        if (
-          data.weight === undefined ||
-          data.weight < 0 ||
-          data.weight === ''
-        ) {
-          showInfo('权重必须是非负整数!');
-          return;
-        }
-        data.weight = parseInt(data.weight);
-        break;
-    }
-
-    try {
-      const res = await API.put('/api/channel/tag', data);
-      if (res?.data?.success) {
-        showSuccess('更新成功!');
-        await refresh();
-      }
-    } catch (error) {
-      showError(error);
-    }
-  };
-
-  const closeEdit = () => {
-    setShowEdit(false);
-  };
-
-  const handleRow = (record, index) => {
-    if (record.status !== 1) {
-      return {
-        style: {
-          background: 'var(--semi-color-disabled-border)',
-        },
-      };
-    } else {
-      return {};
-    }
-  };
-
-  const batchSetChannelTag = async () => {
-    if (selectedChannels.length === 0) {
-      showError(t('请先选择要设置标签的渠道!'));
-      return;
-    }
-    if (batchSetTagValue === '') {
-      showError(t('标签不能为空!'));
-      return;
-    }
-    let ids = selectedChannels.map((channel) => channel.id);
-    const res = await API.post('/api/channel/batch/tag', {
-      ids: ids,
-      tag: batchSetTagValue === '' ? null : batchSetTagValue,
-    });
-    if (res.data.success) {
-      showSuccess(
-        t('已为 ${count} 个渠道设置标签!').replace('${count}', res.data.data),
-      );
-      await refresh();
-      setShowBatchSetTag(false);
-    } else {
-      showError(res.data.message);
-    }
-  };
-
-  const testAllChannels = async () => {
-    const res = await API.get(`/api/channel/test`);
-    const { success, message } = res.data;
-    if (success) {
-      showInfo(t('已成功开始测试所有已启用通道,请刷新页面查看结果。'));
-    } else {
-      showError(message);
-    }
-  };
-
-  const deleteAllDisabledChannels = async () => {
-    const res = await API.delete(`/api/channel/disabled`);
-    const { success, message, data } = res.data;
-    if (success) {
-      showSuccess(
-        t('已删除所有禁用渠道,共计 ${data} 个').replace('${data}', data),
-      );
-      await refresh();
-    } else {
-      showError(message);
-    }
-  };
-
-  const updateAllChannelsBalance = async () => {
-    const res = await API.get(`/api/channel/update_balance`);
-    const { success, message } = res.data;
-    if (success) {
-      showInfo(t('已更新完毕所有已启用通道余额!'));
-    } else {
-      showError(message);
-    }
-  };
-
-  const updateChannelBalance = async (record) => {
-    const res = await API.get(`/api/channel/update_balance/${record.id}/`);
-    const { success, message, balance } = res.data;
-    if (success) {
-      updateChannelProperty(record.id, (channel) => {
-        channel.balance = balance;
-        channel.balance_updated_time = Date.now() / 1000;
-      });
-      showInfo(
-        t('通道 ${name} 余额更新成功!').replace('${name}', record.name),
-      );
-    } else {
-      showError(message);
-    }
-  };
-
-  const batchDeleteChannels = async () => {
-    if (selectedChannels.length === 0) {
-      showError(t('请先选择要删除的通道!'));
-      return;
-    }
-    setLoading(true);
-    let ids = [];
-    selectedChannels.forEach((channel) => {
-      ids.push(channel.id);
-    });
-    const res = await API.post(`/api/channel/batch`, { ids: ids });
-    const { success, message, data } = res.data;
-    if (success) {
-      showSuccess(t('已删除 ${data} 个通道!').replace('${data}', data));
-      await refresh();
-      setTimeout(() => {
-        if (channels.length === 0 && activePage > 1) {
-          refresh(activePage - 1);
-        }
-      }, 100);
-    } else {
-      showError(message);
-    }
-    setLoading(false);
-  };
-
-  const fixChannelsAbilities = async () => {
-    const res = await API.post(`/api/channel/fix`);
-    const { success, message, data } = res.data;
-    if (success) {
-      showSuccess(t('已修复 ${success} 个通道,失败 ${fails} 个通道。').replace('${success}', data.success).replace('${fails}', data.fails));
-      await refresh();
-    } else {
-      showError(message);
-    }
-  };
-
-  const renderHeader = () => (
-    <div className="flex flex-col w-full">
-      {renderTypeTabs()}
-      <div className="flex flex-col md:flex-row justify-between gap-4">
-        <div className="flex flex-wrap md:flex-nowrap items-center gap-2 w-full md:w-auto order-2 md:order-1">
-          <Button
-            size='small'
-            disabled={!enableBatchDelete}
-            type='danger'
-            className="w-full md:w-auto"
-            onClick={() => {
-              Modal.confirm({
-                title: t('确定是否要删除所选通道?'),
-                content: t('此修改将不可逆'),
-                onOk: () => batchDeleteChannels(),
-              });
-            }}
-          >
-            {t('删除所选通道')}
-          </Button>
-
-          <Button
-            size='small'
-            disabled={!enableBatchDelete}
-            type='tertiary'
-            onClick={() => setShowBatchSetTag(true)}
-            className="w-full md:w-auto"
-          >
-            {t('批量设置标签')}
-          </Button>
-
-          <Dropdown
-            size='small'
-            trigger='click'
-            render={
-              <Dropdown.Menu>
-                <Dropdown.Item>
-                  <Button
-                    size='small'
-                    type='tertiary'
-                    className="w-full"
-                    onClick={() => {
-                      Modal.confirm({
-                        title: t('确定?'),
-                        content: t('确定要测试所有通道吗?'),
-                        onOk: () => testAllChannels(),
-                        size: 'small',
-                        centered: true,
-                      });
-                    }}
-                  >
-                    {t('测试所有通道')}
-                  </Button>
-                </Dropdown.Item>
-                <Dropdown.Item>
-                  <Button
-                    size='small'
-                    className="w-full"
-                    onClick={() => {
-                      Modal.confirm({
-                        title: t('确定是否要修复数据库一致性?'),
-                        content: t('进行该操作时,可能导致渠道访问错误,请仅在数据库出现问题时使用'),
-                        onOk: () => fixChannelsAbilities(),
-                        size: 'sm',
-                        centered: true,
-                      });
-                    }}
-                  >
-                    {t('修复数据库一致性')}
-                  </Button>
-                </Dropdown.Item>
-                <Dropdown.Item>
-                  <Button
-                    size='small'
-                    type='secondary'
-                    className="w-full"
-                    onClick={() => {
-                      Modal.confirm({
-                        title: t('确定?'),
-                        content: t('确定要更新所有已启用通道余额吗?'),
-                        onOk: () => updateAllChannelsBalance(),
-                        size: 'sm',
-                        centered: true,
-                      });
-                    }}
-                  >
-                    {t('更新所有已启用通道余额')}
-                  </Button>
-                </Dropdown.Item>
-                <Dropdown.Item>
-                  <Button
-                    size='small'
-                    type='danger'
-                    className="w-full"
-                    onClick={() => {
-                      Modal.confirm({
-                        title: t('确定是否要删除禁用通道?'),
-                        content: t('此修改将不可逆'),
-                        onOk: () => deleteAllDisabledChannels(),
-                        size: 'sm',
-                        centered: true,
-                      });
-                    }}
-                  >
-                    {t('删除禁用通道')}
-                  </Button>
-                </Dropdown.Item>
-              </Dropdown.Menu>
-            }
-          >
-            <Button size='small' theme='light' type='tertiary' className="w-full md:w-auto">
-              {t('批量操作')}
-            </Button>
-          </Dropdown>
-
-          <Button
-            size='small'
-            type='tertiary'
-            className="w-full md:w-auto"
-            onClick={() => setCompactMode(!compactMode)}
-          >
-            {compactMode ? t('自适应列表') : t('紧凑列表')}
-          </Button>
-        </div>
-
-        <div className="flex flex-col md:flex-row items-start md:items-center gap-4 w-full md:w-auto order-1 md:order-2">
-          <div className="flex items-center justify-between w-full md:w-auto">
-            <Typography.Text strong className="mr-2">
-              {t('使用ID排序')}
-            </Typography.Text>
-            <Switch
-              size='small'
-              checked={idSort}
-              onChange={(v) => {
-                localStorage.setItem('id-sort', v + '');
-                setIdSort(v);
-                const { searchKeyword, searchGroup, searchModel } = getFormValues();
-                if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
-                  loadChannels(activePage, pageSize, v, enableTagMode);
-                } else {
-                  searchChannels(enableTagMode, activeTypeKey, statusFilter, activePage, pageSize, v);
-                }
-              }}
-            />
-          </div>
-
-          <div className="flex items-center justify-between w-full md:w-auto">
-            <Typography.Text strong className="mr-2">
-              {t('开启批量操作')}
-            </Typography.Text>
-            <Switch
-              size='small'
-              checked={enableBatchDelete}
-              onChange={(v) => {
-                localStorage.setItem('enable-batch-delete', v + '');
-                setEnableBatchDelete(v);
-              }}
-            />
-          </div>
-
-          <div className="flex items-center justify-between w-full md:w-auto">
-            <Typography.Text strong className="mr-2">
-              {t('标签聚合模式')}
-            </Typography.Text>
-            <Switch
-              size='small'
-              checked={enableTagMode}
-              onChange={(v) => {
-                localStorage.setItem('enable-tag-mode', v + '');
-                setEnableTagMode(v);
-                setActivePage(1);
-                loadChannels(1, pageSize, idSort, v);
-              }}
-            />
-          </div>
-
-          {/* 状态筛选器 */}
-          <div className="flex items-center justify-between w-full md:w-auto">
-            <Typography.Text strong className="mr-2">
-              {t('状态筛选')}
-            </Typography.Text>
-            <Select
-              size='small'
-              value={statusFilter}
-              onChange={(v) => {
-                localStorage.setItem('channel-status-filter', v);
-                setStatusFilter(v);
-                setActivePage(1);
-                loadChannels(1, pageSize, idSort, enableTagMode, activeTypeKey, v);
-              }}
-            >
-              <Select.Option value="all">{t('全部')}</Select.Option>
-              <Select.Option value="enabled">{t('已启用')}</Select.Option>
-              <Select.Option value="disabled">{t('已禁用')}</Select.Option>
-            </Select>
-          </div>
-        </div>
-      </div>
-
-      <Divider margin="12px" />
-
-      <div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full">
-        <div className="flex gap-2 w-full md:w-auto order-2 md:order-1">
-          <Button
-            size='small'
-            theme='light'
-            type='primary'
-            className="w-full md:w-auto"
-            onClick={() => {
-              setEditingChannel({
-                id: undefined,
-              });
-              setShowEdit(true);
-            }}
-          >
-            {t('添加渠道')}
-          </Button>
-
-          <Button
-            size='small'
-            type='tertiary'
-            className="w-full md:w-auto"
-            onClick={refresh}
-          >
-            {t('刷新')}
-          </Button>
-
-          <Button
-            size='small'
-            type='tertiary'
-            onClick={() => setShowColumnSelector(true)}
-            className="w-full md:w-auto"
-          >
-            {t('列设置')}
-          </Button>
-        </div>
-
-        <div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto order-1 md:order-2">
-          <Form
-            initValues={formInitValues}
-            getFormApi={(api) => setFormApi(api)}
-            onSubmit={() => searchChannels(enableTagMode)}
-            allowEmpty={true}
-            autoComplete="off"
-            layout="horizontal"
-            trigger="change"
-            stopValidateWithError={false}
-            className="flex flex-col md:flex-row items-center gap-4 w-full"
-          >
-            <div className="relative w-full md:w-64">
-              <Form.Input
-                size='small'
-                field="searchKeyword"
-                prefix={<IconSearch />}
-                placeholder={t('渠道ID,名称,密钥,API地址')}
-                showClear
-                pure
-              />
-            </div>
-            <div className="w-full md:w-48">
-              <Form.Input
-                size='small'
-                field="searchModel"
-                prefix={<IconSearch />}
-                placeholder={t('模型关键字')}
-                showClear
-                pure
-              />
-            </div>
-            <div className="w-full md:w-32">
-              <Form.Select
-                size='small'
-                field="searchGroup"
-                placeholder={t('选择分组')}
-                optionList={[
-                  { label: t('选择分组'), value: null },
-                  ...groupOptions,
-                ]}
-                className="w-full"
-                showClear
-                pure
-                onChange={() => {
-                  // 延迟执行搜索,让表单值先更新
-                  setTimeout(() => {
-                    searchChannels(enableTagMode);
-                  }, 0);
-                }}
-              />
-            </div>
-            <Button
-              size='small'
-              type="tertiary"
-              htmlType="submit"
-              loading={loading || searching}
-              className="w-full md:w-auto"
-            >
-              {t('查询')}
-            </Button>
-            <Button
-              size='small'
-              type='tertiary'
-              onClick={() => {
-                if (formApi) {
-                  formApi.reset();
-                  // 重置后立即查询,使用setTimeout确保表单重置完成
-                  setTimeout(() => {
-                    refresh();
-                  }, 100);
-                }
-              }}
-              className="w-full md:w-auto"
-            >
-              {t('重置')}
-            </Button>
-          </Form>
-        </div>
-      </div>
-    </div>
-  );
-
-  return (
-    <>
-      {renderColumnSelector()}
-      <EditTagModal
-        visible={showEditTag}
-        tag={editingTag}
-        handleClose={() => setShowEditTag(false)}
-        refresh={refresh}
-      />
-      <EditChannel
-        refresh={refresh}
-        visible={showEdit}
-        handleClose={closeEdit}
-        editingChannel={editingChannel}
-      />
-
-      <Card
-        className="table-scroll-card !rounded-2xl"
-        title={renderHeader()}
-        shadows='always'
-        bordered={false}
-      >
-        <Table
-          columns={compactMode ? getVisibleColumns().map(({ fixed, ...rest }) => rest) : getVisibleColumns()}
-          dataSource={pageData}
-          scroll={compactMode ? undefined : { x: 'max-content' }}
-          pagination={{
-            currentPage: activePage,
-            pageSize: pageSize,
-            total: channelCount,
-            pageSizeOpts: [10, 20, 50, 100],
-            showSizeChanger: true,
-            onPageSizeChange: (size) => {
-              handlePageSizeChange(size);
-            },
-            onPageChange: handlePageChange,
-          }}
-          expandAllRows={false}
-          onRow={handleRow}
-          rowSelection={
-            enableBatchDelete
-              ? {
-                onChange: (selectedRowKeys, selectedRows) => {
-                  setSelectedChannels(selectedRows);
-                },
-              }
-              : null
-          }
-          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"
-          loading={loading || searching}
-        />
-      </Card>
-
-      {/* 批量设置标签模态框 */}
-      <Modal
-        title={t('批量设置标签')}
-        visible={showBatchSetTag}
-        onOk={batchSetChannelTag}
-        onCancel={() => setShowBatchSetTag(false)}
-        maskClosable={false}
-        centered={true}
-        size="small"
-        className="!rounded-lg"
-      >
-        <div className="mb-5">
-          <Typography.Text>{t('请输入要设置的标签名称')}</Typography.Text>
-        </div>
-        <Input
-          placeholder={t('请输入标签名称')}
-          value={batchSetTagValue}
-          onChange={(v) => setBatchSetTagValue(v)}
-        />
-        <div className="mt-4">
-          <Typography.Text type='secondary'>
-            {t('已选择 ${count} 个渠道').replace('${count}', selectedChannels.length)}
-          </Typography.Text>
-        </div>
-      </Modal>
-
-      {/* 模型测试弹窗 */}
-      <Modal
-        title={
-          currentTestChannel && (
-            <div className="flex flex-col gap-2 w-full">
-              <div className="flex items-center gap-2">
-                <Typography.Text strong className="!text-[var(--semi-color-text-0)] !text-base">
-                  {currentTestChannel.name} {t('渠道的模型测试')}
-                </Typography.Text>
-                <Typography.Text type="tertiary" className="!text-xs flex items-center">
-                  {t('共')} {currentTestChannel.models.split(',').length} {t('个模型')}
-                </Typography.Text>
-              </div>
-            </div>
-          )
-        }
-        visible={showModelTestModal && currentTestChannel !== null}
-        onCancel={handleCloseModal}
-        footer={
-          <div className="flex justify-end">
-            {isBatchTesting ? (
-              <Button
-                type='danger'
-                onClick={handleCloseModal}
-              >
-                {t('停止测试')}
-              </Button>
-            ) : (
-              <Button
-                type='tertiary'
-                onClick={handleCloseModal}
-              >
-                {t('取消')}
-              </Button>
-            )}
-            <Button
-              onClick={batchTestModels}
-              loading={isBatchTesting}
-              disabled={isBatchTesting}
-            >
-              {isBatchTesting ? t('测试中...') : t('批量测试${count}个模型').replace(
-                '${count}',
-                currentTestChannel
-                  ? currentTestChannel.models
-                    .split(',')
-                    .filter((model) =>
-                      model.toLowerCase().includes(modelSearchKeyword.toLowerCase())
-                    ).length
-                  : 0
-              )}
-            </Button>
-          </div>
-        }
-        maskClosable={!isBatchTesting}
-        className="!rounded-lg"
-        size={isMobile ? 'full-width' : 'large'}
-      >
-        <div className="model-test-scroll">
-          {currentTestChannel && (
-            <div>
-              {/* 搜索与操作按钮 */}
-              <div className="flex items-center justify-end gap-2 w-full mb-2">
-                <Input
-                  placeholder={t('搜索模型...')}
-                  value={modelSearchKeyword}
-                  onChange={(v) => {
-                    setModelSearchKeyword(v);
-                    setModelTablePage(1);
-                  }}
-                  className="!w-full"
-                  prefix={<IconSearch />}
-                  showClear
-                />
-
-                <Button
-                  onClick={() => {
-                    if (selectedModelKeys.length === 0) {
-                      showError(t('请先选择模型!'));
-                      return;
-                    }
-                    copy(selectedModelKeys.join(',')).then((ok) => {
-                      if (ok) {
-                        showSuccess(t('已复制 ${count} 个模型').replace('${count}', selectedModelKeys.length));
-                      } else {
-                        showError(t('复制失败,请手动复制'));
-                      }
-                    });
-                  }}
-                >
-                  {t('复制已选')}
-                </Button>
-
-                <Button
-                  type='tertiary'
-                  onClick={() => {
-                    if (!currentTestChannel) return;
-                    const successKeys = currentTestChannel.models
-                      .split(',')
-                      .filter((m) => m.toLowerCase().includes(modelSearchKeyword.toLowerCase()))
-                      .filter((m) => {
-                        const result = modelTestResults[`${currentTestChannel.id}-${m}`];
-                        return result && result.success;
-                      });
-                    if (successKeys.length === 0) {
-                      showInfo(t('暂无成功模型'));
-                    }
-                    setSelectedModelKeys(successKeys);
-                  }}
-                >
-                  {t('选择成功')}
-                </Button>
-              </div>
-              <Table
-                columns={[
-                  {
-                    title: t('模型名称'),
-                    dataIndex: 'model',
-                    render: (text) => (
-                      <div className="flex items-center">
-                        <Typography.Text strong>{text}</Typography.Text>
-                      </div>
-                    )
-                  },
-                  {
-                    title: t('状态'),
-                    dataIndex: 'status',
-                    render: (text, record) => {
-                      const testResult = modelTestResults[`${currentTestChannel.id}-${record.model}`];
-                      const isTesting = testingModels.has(record.model);
-
-                      if (isTesting) {
-                        return (
-                          <Tag color='blue' shape='circle'>
-                            {t('测试中')}
-                          </Tag>
-                        );
-                      }
-
-                      if (!testResult) {
-                        return (
-                          <Tag color='grey' shape='circle'>
-                            {t('未开始')}
-                          </Tag>
-                        );
-                      }
-
-                      return (
-                        <div className="flex items-center gap-2">
-                          <Tag
-                            color={testResult.success ? 'green' : 'red'}
-                            shape='circle'
-                          >
-                            {testResult.success ? t('成功') : t('失败')}
-                          </Tag>
-                          {testResult.success && (
-                            <Typography.Text type="tertiary">
-                              {t('请求时长: ${time}s').replace('${time}', testResult.time.toFixed(2))}
-                            </Typography.Text>
-                          )}
-                        </div>
-                      );
-                    }
-                  },
-                  {
-                    title: '',
-                    dataIndex: 'operate',
-                    render: (text, record) => {
-                      const isTesting = testingModels.has(record.model);
-                      return (
-                        <Button
-                          type='tertiary'
-                          onClick={() => testChannel(currentTestChannel, record.model)}
-                          loading={isTesting}
-                          size='small'
-                        >
-                          {t('测试')}
-                        </Button>
-                      );
-                    }
-                  }
-                ]}
-                dataSource={(() => {
-                  const filtered = currentTestChannel.models
-                    .split(',')
-                    .filter((model) =>
-                      model.toLowerCase().includes(modelSearchKeyword.toLowerCase()),
-                    );
-                  const start = (modelTablePage - 1) * MODEL_TABLE_PAGE_SIZE;
-                  const end = start + MODEL_TABLE_PAGE_SIZE;
-                  return filtered.slice(start, end).map((model) => ({
-                    model,
-                    key: model,
-                  }));
-                })()}
-                rowSelection={{
-                  selectedRowKeys: selectedModelKeys,
-                  onChange: (keys) => {
-                    if (allSelectingRef.current) {
-                      allSelectingRef.current = false;
-                      return;
-                    }
-                    setSelectedModelKeys(keys);
-                  },
-                  onSelectAll: (checked) => {
-                    const filtered = currentTestChannel.models
-                      .split(',')
-                      .filter((m) => m.toLowerCase().includes(modelSearchKeyword.toLowerCase()));
-                    allSelectingRef.current = true;
-                    setSelectedModelKeys(checked ? filtered : []);
-                  },
-                }}
-                pagination={{
-                  currentPage: modelTablePage,
-                  pageSize: MODEL_TABLE_PAGE_SIZE,
-                  total: currentTestChannel.models
-                    .split(',')
-                    .filter((model) =>
-                      model.toLowerCase().includes(modelSearchKeyword.toLowerCase()),
-                    ).length,
-                  showSizeChanger: false,
-                  onPageChange: (page) => setModelTablePage(page),
-                }}
-              />
-            </div>
-          )}
-        </div>
-      </Modal>
-    </>
-  );
-};
-
-export default ChannelsTable;
+// 重构后的 ChannelsTable - 使用新的模块化架构
+export { default } from './channels/index.jsx';

+ 189 - 195
web/src/components/table/LogsTable.js

@@ -36,11 +36,10 @@ import {
   Tag,
   Tooltip,
   Checkbox,
-  Card,
   Typography,
-  Divider,
   Form,
 } from '@douyinfe/semi-ui';
+import CardPro from '../common/ui/CardPro';
 import {
   IllustrationNoResult,
   IllustrationNoResultDark,
@@ -49,7 +48,7 @@ import { ITEMS_PER_PAGE } from '../../constants';
 import Paragraph from '@douyinfe/semi-ui/lib/es/typography/paragraph';
 import { IconSearch, IconHelpCircle } from '@douyinfe/semi-icons';
 import { Route } from 'lucide-react';
-import { useTableCompactMode } from '../../hooks/useTableCompactMode';
+import { useTableCompactMode } from '../../hooks/common/useTableCompactMode';
 
 const { Text } = Typography;
 
@@ -1201,216 +1200,211 @@ const LogsTable = () => {
   return (
     <>
       {renderColumnSelector()}
-      <Card
-        className='table-scroll-card !rounded-2xl mb-4'
-        title={
-          <div className='flex flex-col w-full'>
-            <Spin spinning={loadingStat}>
-              <div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full">
-                <Space>
-                  <Tag
-                    color='blue'
-                    style={{
-                      fontWeight: 500,
-                      boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
-                      padding: 13,
-                    }}
-                    className='!rounded-lg'
-                  >
-                    {t('消耗额度')}: {renderQuota(stat.quota)}
-                  </Tag>
-                  <Tag
-                    color='pink'
-                    style={{
-                      fontWeight: 500,
-                      boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
-                      padding: 13,
-                    }}
-                    className='!rounded-lg'
-                  >
-                    RPM: {stat.rpm}
-                  </Tag>
-                  <Tag
-                    color='white'
-                    style={{
-                      border: 'none',
-                      boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
-                      fontWeight: 500,
-                      padding: 13,
-                    }}
-                    className='!rounded-lg'
-                  >
-                    TPM: {stat.tpm}
-                  </Tag>
-                </Space>
+      <CardPro
+        type="type2"
+        className='mb-4'
+        statsArea={
+          <Spin spinning={loadingStat}>
+            <div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full">
+              <Space>
+                <Tag
+                  color='blue'
+                  style={{
+                    fontWeight: 500,
+                    boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
+                    padding: 13,
+                  }}
+                  className='!rounded-lg'
+                >
+                  {t('消耗额度')}: {renderQuota(stat.quota)}
+                </Tag>
+                <Tag
+                  color='pink'
+                  style={{
+                    fontWeight: 500,
+                    boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
+                    padding: 13,
+                  }}
+                  className='!rounded-lg'
+                >
+                  RPM: {stat.rpm}
+                </Tag>
+                <Tag
+                  color='white'
+                  style={{
+                    border: 'none',
+                    boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
+                    fontWeight: 500,
+                    padding: 13,
+                  }}
+                  className='!rounded-lg'
+                >
+                  TPM: {stat.tpm}
+                </Tag>
+              </Space>
+
+              <Button
+                type='tertiary'
+                className="w-full md:w-auto"
+                onClick={() => setCompactMode(!compactMode)}
+                size="small"
+              >
+                {compactMode ? t('自适应列表') : t('紧凑列表')}
+              </Button>
+            </div>
+          </Spin>
+        }
+        searchArea={
+          <Form
+            initValues={formInitValues}
+            getFormApi={(api) => setFormApi(api)}
+            onSubmit={refresh}
+            allowEmpty={true}
+            autoComplete='off'
+            layout='vertical'
+            trigger='change'
+            stopValidateWithError={false}
+          >
+            <div className='flex flex-col gap-4'>
+              <div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4'>
+                {/* 时间选择器 */}
+                <div className='col-span-1 lg:col-span-2'>
+                  <Form.DatePicker
+                    field='dateRange'
+                    className='w-full'
+                    type='dateTimeRange'
+                    placeholder={[t('开始时间'), t('结束时间')]}
+                    showClear
+                    pure
+                    size="small"
+                  />
+                </div>
 
-                <Button
-                  type='tertiary'
-                  className="w-full md:w-auto"
-                  onClick={() => setCompactMode(!compactMode)}
+                {/* 其他搜索字段 */}
+                <Form.Input
+                  field='token_name'
+                  prefix={<IconSearch />}
+                  placeholder={t('令牌名称')}
+                  showClear
+                  pure
                   size="small"
-                >
-                  {compactMode ? t('自适应列表') : t('紧凑列表')}
-                </Button>
-              </div>
-            </Spin>
+                />
 
-            <Divider margin='12px' />
+                <Form.Input
+                  field='model_name'
+                  prefix={<IconSearch />}
+                  placeholder={t('模型名称')}
+                  showClear
+                  pure
+                  size="small"
+                />
 
-            {/* 搜索表单区域 */}
-            <Form
-              initValues={formInitValues}
-              getFormApi={(api) => setFormApi(api)}
-              onSubmit={refresh}
-              allowEmpty={true}
-              autoComplete='off'
-              layout='vertical'
-              trigger='change'
-              stopValidateWithError={false}
-            >
-              <div className='flex flex-col gap-4'>
-                <div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4'>
-                  {/* 时间选择器 */}
-                  <div className='col-span-1 lg:col-span-2'>
-                    <Form.DatePicker
-                      field='dateRange'
-                      className='w-full'
-                      type='dateTimeRange'
-                      placeholder={[t('开始时间'), t('结束时间')]}
+                <Form.Input
+                  field='group'
+                  prefix={<IconSearch />}
+                  placeholder={t('分组')}
+                  showClear
+                  pure
+                  size="small"
+                />
+
+                {isAdminUser && (
+                  <>
+                    <Form.Input
+                      field='channel'
+                      prefix={<IconSearch />}
+                      placeholder={t('渠道 ID')}
                       showClear
                       pure
                       size="small"
                     />
-                  </div>
-
-                  {/* 其他搜索字段 */}
-                  <Form.Input
-                    field='token_name'
-                    prefix={<IconSearch />}
-                    placeholder={t('令牌名称')}
-                    showClear
-                    pure
-                    size="small"
-                  />
-
-                  <Form.Input
-                    field='model_name'
-                    prefix={<IconSearch />}
-                    placeholder={t('模型名称')}
-                    showClear
-                    pure
-                    size="small"
-                  />
+                    <Form.Input
+                      field='username'
+                      prefix={<IconSearch />}
+                      placeholder={t('用户名称')}
+                      showClear
+                      pure
+                      size="small"
+                    />
+                  </>
+                )}
+              </div>
 
-                  <Form.Input
-                    field='group'
-                    prefix={<IconSearch />}
-                    placeholder={t('分组')}
+              {/* 操作按钮区域 */}
+              <div className='flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3'>
+                {/* 日志类型选择器 */}
+                <div className='w-full sm:w-auto'>
+                  <Form.Select
+                    field='logType'
+                    placeholder={t('日志类型')}
+                    className='w-full sm:w-auto min-w-[120px]'
                     showClear
                     pure
+                    onChange={() => {
+                      // 延迟执行搜索,让表单值先更新
+                      setTimeout(() => {
+                        refresh();
+                      }, 0);
+                    }}
                     size="small"
-                  />
-
-                  {isAdminUser && (
-                    <>
-                      <Form.Input
-                        field='channel'
-                        prefix={<IconSearch />}
-                        placeholder={t('渠道 ID')}
-                        showClear
-                        pure
-                        size="small"
-                      />
-                      <Form.Input
-                        field='username'
-                        prefix={<IconSearch />}
-                        placeholder={t('用户名称')}
-                        showClear
-                        pure
-                        size="small"
-                      />
-                    </>
-                  )}
+                  >
+                    <Form.Select.Option value='0'>
+                      {t('全部')}
+                    </Form.Select.Option>
+                    <Form.Select.Option value='1'>
+                      {t('充值')}
+                    </Form.Select.Option>
+                    <Form.Select.Option value='2'>
+                      {t('消费')}
+                    </Form.Select.Option>
+                    <Form.Select.Option value='3'>
+                      {t('管理')}
+                    </Form.Select.Option>
+                    <Form.Select.Option value='4'>
+                      {t('系统')}
+                    </Form.Select.Option>
+                    <Form.Select.Option value='5'>
+                      {t('错误')}
+                    </Form.Select.Option>
+                  </Form.Select>
                 </div>
 
-                {/* 操作按钮区域 */}
-                <div className='flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3'>
-                  {/* 日志类型选择器 */}
-                  <div className='w-full sm:w-auto'>
-                    <Form.Select
-                      field='logType'
-                      placeholder={t('日志类型')}
-                      className='w-full sm:w-auto min-w-[120px]'
-                      showClear
-                      pure
-                      onChange={() => {
-                        // 延迟执行搜索,让表单值先更新
+                <div className='flex gap-2 w-full sm:w-auto justify-end'>
+                  <Button
+                    type='tertiary'
+                    htmlType='submit'
+                    loading={loading}
+                    size="small"
+                  >
+                    {t('查询')}
+                  </Button>
+                  <Button
+                    type='tertiary'
+                    onClick={() => {
+                      if (formApi) {
+                        formApi.reset();
+                        setLogType(0);
                         setTimeout(() => {
                           refresh();
-                        }, 0);
-                      }}
-                      size="small"
-                    >
-                      <Form.Select.Option value='0'>
-                        {t('全部')}
-                      </Form.Select.Option>
-                      <Form.Select.Option value='1'>
-                        {t('充值')}
-                      </Form.Select.Option>
-                      <Form.Select.Option value='2'>
-                        {t('消费')}
-                      </Form.Select.Option>
-                      <Form.Select.Option value='3'>
-                        {t('管理')}
-                      </Form.Select.Option>
-                      <Form.Select.Option value='4'>
-                        {t('系统')}
-                      </Form.Select.Option>
-                      <Form.Select.Option value='5'>
-                        {t('错误')}
-                      </Form.Select.Option>
-                    </Form.Select>
-                  </div>
-
-                  <div className='flex gap-2 w-full sm:w-auto justify-end'>
-                    <Button
-                      type='tertiary'
-                      htmlType='submit'
-                      loading={loading}
-                      size="small"
-                    >
-                      {t('查询')}
-                    </Button>
-                    <Button
-                      type='tertiary'
-                      onClick={() => {
-                        if (formApi) {
-                          formApi.reset();
-                          setLogType(0);
-                          setTimeout(() => {
-                            refresh();
-                          }, 100);
-                        }
-                      }}
-                      size="small"
-                    >
-                      {t('重置')}
-                    </Button>
-                    <Button
-                      type='tertiary'
-                      onClick={() => setShowColumnSelector(true)}
-                      size="small"
-                    >
-                      {t('列设置')}
-                    </Button>
-                  </div>
+                        }, 100);
+                      }
+                    }}
+                    size="small"
+                  >
+                    {t('重置')}
+                  </Button>
+                  <Button
+                    type='tertiary'
+                    onClick={() => setShowColumnSelector(true)}
+                    size="small"
+                  >
+                    {t('列设置')}
+                  </Button>
                 </div>
               </div>
-            </Form>
-          </div>
+            </div>
+          </Form>
         }
-        shadows='always'
-        bordered={false}
       >
         <Table
           columns={compactMode ? getVisibleColumns().map(({ fixed, ...rest }) => rest) : getVisibleColumns()}
@@ -1450,7 +1444,7 @@ const LogsTable = () => {
             onPageChange: handlePageChange,
           }}
         />
-      </Card>
+      </CardPro>
     </>
   );
 };

+ 37 - 43
web/src/components/table/MjLogsTable.js

@@ -37,9 +37,7 @@ import {
 
 import {
   Button,
-  Card,
   Checkbox,
-  Divider,
   Empty,
   Form,
   ImagePreview,
@@ -51,6 +49,7 @@ import {
   Tag,
   Typography
 } from '@douyinfe/semi-ui';
+import CardPro from '../common/ui/CardPro';
 import {
   IllustrationNoResult,
   IllustrationNoResultDark
@@ -60,7 +59,7 @@ import {
   IconEyeOpened,
   IconSearch,
 } from '@douyinfe/semi-icons';
-import { useTableCompactMode } from '../../hooks/useTableCompactMode';
+import { useTableCompactMode } from '../../hooks/common/useTableCompactMode';
 
 const { Text } = Typography;
 
@@ -798,42 +797,40 @@ const LogsTable = () => {
     <>
       {renderColumnSelector()}
       <Layout>
-        <Card
-          className="table-scroll-card !rounded-2xl mb-4"
-          title={
-            <div className="flex flex-col w-full">
-              <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-orange-500 mb-2 md:mb-0">
-                  <IconEyeOpened className="mr-2" />
-                  {loading ? (
-                    <Skeleton.Title
-                      style={{
-                        width: 300,
-                        marginBottom: 0,
-                        marginTop: 0
-                      }}
-                    />
-                  ) : (
-                    <Text>
-                      {isAdminUser && showBanner
-                        ? t('当前未开启Midjourney回调,部分项目可能无法获得绘图结果,可在运营设置中开启。')
-                        : t('Midjourney 任务记录')}
-                    </Text>
-                  )}
-                </div>
-                <Button
-                  type='tertiary'
-                  className="w-full md:w-auto"
-                  onClick={() => setCompactMode(!compactMode)}
-                  size="small"
-                >
-                  {compactMode ? t('自适应列表') : t('紧凑列表')}
-                </Button>
+        <CardPro
+          type="type2"
+          className="mb-4"
+          statsArea={
+            <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-orange-500 mb-2 md:mb-0">
+                <IconEyeOpened className="mr-2" />
+                {loading ? (
+                  <Skeleton.Title
+                    style={{
+                      width: 300,
+                      marginBottom: 0,
+                      marginTop: 0
+                    }}
+                  />
+                ) : (
+                  <Text>
+                    {isAdminUser && showBanner
+                      ? t('当前未开启Midjourney回调,部分项目可能无法获得绘图结果,可在运营设置中开启。')
+                      : t('Midjourney 任务记录')}
+                  </Text>
+                )}
               </div>
-
-              <Divider margin="12px" />
-
-              {/* 搜索表单区域 */}
+              <Button
+                type='tertiary'
+                className="w-full md:w-auto"
+                onClick={() => setCompactMode(!compactMode)}
+                size="small"
+              >
+                {compactMode ? t('自适应列表') : t('紧凑列表')}
+              </Button>
+            </div>
+          }
+          searchArea={
               <Form
                 initValues={formInitValues}
                 getFormApi={(api) => setFormApi(api)}
@@ -920,10 +917,7 @@ const LogsTable = () => {
                   </div>
                 </div>
               </Form>
-            </div>
           }
-          shadows='always'
-          bordered={false}
         >
           <Table
             columns={compactMode ? getVisibleColumns().map(({ fixed, ...rest }) => rest) : getVisibleColumns()}
@@ -950,8 +944,8 @@ const LogsTable = () => {
               onPageSizeChange: handlePageSizeChange,
               onPageChange: handlePageChange,
             }}
-          />
-        </Card>
+                  />
+      </CardPro>
 
         <Modal
           visible={isModalOpen}

+ 136 - 146
web/src/components/table/RedemptionsTable.js

@@ -13,8 +13,6 @@ import { Ticket } from 'lucide-react';
 import { ITEMS_PER_PAGE } from '../../constants';
 import {
   Button,
-  Card,
-  Divider,
   Dropdown,
   Empty,
   Form,
@@ -25,6 +23,7 @@ import {
   Tag,
   Typography
 } from '@douyinfe/semi-ui';
+import CardPro from '../common/ui/CardPro';
 import {
   IllustrationNoResult,
   IllustrationNoResultDark
@@ -35,7 +34,7 @@ import {
 } from '@douyinfe/semi-icons';
 import EditRedemption from '../../pages/Redemption/EditRedemption';
 import { useTranslation } from 'react-i18next';
-import { useTableCompactMode } from '../../hooks/useTableCompactMode';
+import { useTableCompactMode } from '../../hooks/common/useTableCompactMode';
 
 const { Text } = Typography;
 
@@ -422,162 +421,153 @@ const RedemptionsTable = () => {
     }
   };
 
-  const renderHeader = () => (
-    <div className="flex flex-col w-full">
-      <div className="mb-2">
-        <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-orange-500">
-            <Ticket size={16} className="mr-2" />
-            <Text>{t('兑换码可以批量生成和分发,适合用于推广活动或批量充值。')}</Text>
-          </div>
-          <Button
-            type='tertiary'
-            className="w-full md:w-auto"
-            onClick={() => setCompactMode(!compactMode)}
-            size="small"
-          >
-            {compactMode ? t('自适应列表') : t('紧凑列表')}
-          </Button>
-        </div>
-      </div>
-
-      <Divider margin="12px" />
-
-      <div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full">
-        <div className="flex flex-col sm:flex-row gap-2 w-full md:w-auto order-2 md:order-1">
-          <div className="flex gap-2 w-full sm:w-auto">
-            <Button
-              type='primary'
-              className="w-full sm:w-auto"
-              onClick={() => {
-                setEditingRedemption({
-                  id: undefined,
-                });
-                setShowEdit(true);
-              }}
-              size="small"
-            >
-              {t('添加兑换码')}
-            </Button>
+  return (
+    <>
+      <EditRedemption
+        refresh={refresh}
+        editingRedemption={editingRedemption}
+        visiable={showEdit}
+        handleClose={closeEdit}
+      ></EditRedemption>
+
+      <CardPro
+        type="type1"
+        descriptionArea={
+          <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-orange-500">
+              <Ticket size={16} className="mr-2" />
+              <Text>{t('兑换码可以批量生成和分发,适合用于推广活动或批量充值。')}</Text>
+            </div>
             <Button
               type='tertiary'
-              className="w-full sm:w-auto"
-              onClick={async () => {
-                if (selectedKeys.length === 0) {
-                  showError(t('请至少选择一个兑换码!'));
-                  return;
-                }
-                let keys = '';
-                for (let i = 0; i < selectedKeys.length; i++) {
-                  keys +=
-                    selectedKeys[i].name + '    ' + selectedKeys[i].key + '\n';
-                }
-                await copyText(keys);
-              }}
+              className="w-full md:w-auto"
+              onClick={() => setCompactMode(!compactMode)}
               size="small"
             >
-              {t('复制所选兑换码到剪贴板')}
+              {compactMode ? t('自适应列表') : t('紧凑列表')}
             </Button>
           </div>
-          <Button
-            type='danger'
-            className="w-full sm:w-auto"
-            onClick={() => {
-              Modal.confirm({
-                title: t('确定清除所有失效兑换码?'),
-                content: t('将删除已使用、已禁用及过期的兑换码,此操作不可撤销。'),
-                onOk: async () => {
-                  setLoading(true);
-                  const res = await API.delete('/api/redemption/invalid');
-                  const { success, message, data } = res.data;
-                  if (success) {
-                    showSuccess(t('已删除 {{count}} 条失效兑换码', { count: data }));
-                    await refresh();
-                  } else {
-                    showError(message);
-                  }
-                  setLoading(false);
-                },
-              });
-            }}
-            size="small"
-          >
-            {t('清除失效兑换码')}
-          </Button>
-        </div>
-
-        <Form
-          initValues={formInitValues}
-          getFormApi={(api) => setFormApi(api)}
-          onSubmit={() => {
-            setActivePage(1);
-            searchRedemptions(null, 1, pageSize);
-          }}
-          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-64">
-              <Form.Input
-                field="searchKeyword"
-                prefix={<IconSearch />}
-                placeholder={t('关键字(id或者名称)')}
-                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>
+        }
+        actionsArea={
+          <div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full">
+            <div className="flex flex-col sm:flex-row gap-2 w-full md:w-auto order-2 md:order-1">
+              <div className="flex gap-2 w-full sm:w-auto">
+                <Button
+                  type='primary'
+                  className="w-full sm:w-auto"
+                  onClick={() => {
+                    setEditingRedemption({
+                      id: undefined,
+                    });
+                    setShowEdit(true);
+                  }}
+                  size="small"
+                >
+                  {t('添加兑换码')}
+                </Button>
+                <Button
+                  type='tertiary'
+                  className="w-full sm:w-auto"
+                  onClick={async () => {
+                    if (selectedKeys.length === 0) {
+                      showError(t('请至少选择一个兑换码!'));
+                      return;
+                    }
+                    let keys = '';
+                    for (let i = 0; i < selectedKeys.length; i++) {
+                      keys +=
+                        selectedKeys[i].name + '    ' + selectedKeys[i].key + '\n';
+                    }
+                    await copyText(keys);
+                  }}
+                  size="small"
+                >
+                  {t('复制所选兑换码到剪贴板')}
+                </Button>
+              </div>
               <Button
-                type="tertiary"
+                type='danger'
+                className="w-full sm:w-auto"
                 onClick={() => {
-                  if (formApi) {
-                    formApi.reset();
-                    setTimeout(() => {
-                      setActivePage(1);
-                      loadRedemptions(1, pageSize);
-                    }, 100);
-                  }
+                  Modal.confirm({
+                    title: t('确定清除所有失效兑换码?'),
+                    content: t('将删除已使用、已禁用及过期的兑换码,此操作不可撤销。'),
+                    onOk: async () => {
+                      setLoading(true);
+                      const res = await API.delete('/api/redemption/invalid');
+                      const { success, message, data } = res.data;
+                      if (success) {
+                        showSuccess(t('已删除 {{count}} 条失效兑换码', { count: data }));
+                        await refresh();
+                      } else {
+                        showError(message);
+                      }
+                      setLoading(false);
+                    },
+                  });
                 }}
-                className="flex-1 md:flex-initial md:w-auto"
                 size="small"
               >
-                {t('重置')}
+                {t('清除失效兑换码')}
               </Button>
             </div>
-          </div>
-        </Form>
-      </div>
-    </div>
-  );
-
-  return (
-    <>
-      <EditRedemption
-        refresh={refresh}
-        editingRedemption={editingRedemption}
-        visiable={showEdit}
-        handleClose={closeEdit}
-      ></EditRedemption>
 
-      <Card
-        className="table-scroll-card !rounded-2xl"
-        title={renderHeader()}
-        shadows='always'
-        bordered={false}
+            <Form
+              initValues={formInitValues}
+              getFormApi={(api) => setFormApi(api)}
+              onSubmit={() => {
+                setActivePage(1);
+                searchRedemptions(null, 1, pageSize);
+              }}
+              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-64">
+                  <Form.Input
+                    field="searchKeyword"
+                    prefix={<IconSearch />}
+                    placeholder={t('关键字(id或者名称)')}
+                    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(() => {
+                          setActivePage(1);
+                          loadRedemptions(1, pageSize);
+                        }, 100);
+                      }
+                    }}
+                    className="flex-1 md:flex-initial md:w-auto"
+                    size="small"
+                  >
+                    {t('重置')}
+                  </Button>
+                </div>
+              </div>
+            </Form>
+          </div>
+        }
       >
         <Table
           columns={compactMode ? columns.map(({ fixed, ...rest }) => rest) : columns}
@@ -615,7 +605,7 @@ const RedemptionsTable = () => {
           className="rounded-xl overflow-hidden"
           size="middle"
         ></Table>
-      </Card>
+      </CardPro>
     </>
   );
 };

+ 98 - 104
web/src/components/table/TaskLogsTable.js

@@ -26,9 +26,7 @@ import {
 
 import {
   Button,
-  Card,
   Checkbox,
-  Divider,
   Empty,
   Form,
   Layout,
@@ -38,6 +36,7 @@ import {
   Tag,
   Typography
 } from '@douyinfe/semi-ui';
+import CardPro from '../common/ui/CardPro';
 import {
   IllustrationNoResult,
   IllustrationNoResultDark
@@ -47,7 +46,7 @@ import {
   IconEyeOpened,
   IconSearch,
 } from '@douyinfe/semi-icons';
-import { useTableCompactMode } from '../../hooks/useTableCompactMode';
+import { useTableCompactMode } from '../../hooks/common/useTableCompactMode';
 import { TASK_ACTION_GENERATE, TASK_ACTION_TEXT_GENERATE } from '../../constants/common.constant';
 
 const { Text } = Typography;
@@ -648,118 +647,113 @@ const LogsTable = () => {
     <>
       {renderColumnSelector()}
       <Layout>
-        <Card
-          className="table-scroll-card !rounded-2xl mb-4"
-          title={
-            <div className="flex flex-col w-full">
-              <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-orange-500 mb-2 md:mb-0">
-                  <IconEyeOpened className="mr-2" />
-                  <Text>{t('任务记录')}</Text>
-                </div>
-                <Button
-                  type='tertiary'
-                  className="w-full md:w-auto"
-                  onClick={() => setCompactMode(!compactMode)}
-                  size="small"
-                >
-                  {compactMode ? t('自适应列表') : t('紧凑列表')}
-                </Button>
+        <CardPro
+          type="type2"
+          className="mb-4"
+          statsArea={
+            <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-orange-500 mb-2 md:mb-0">
+                <IconEyeOpened className="mr-2" />
+                <Text>{t('任务记录')}</Text>
               </div>
-
-              <Divider margin="12px" />
-
-              {/* 搜索表单区域 */}
-              <Form
-                initValues={formInitValues}
-                getFormApi={(api) => setFormApi(api)}
-                onSubmit={refresh}
-                allowEmpty={true}
-                autoComplete="off"
-                layout="vertical"
-                trigger="change"
-                stopValidateWithError={false}
+              <Button
+                type='tertiary'
+                className="w-full md:w-auto"
+                onClick={() => setCompactMode(!compactMode)}
+                size="small"
               >
-                <div className="flex flex-col gap-4">
-                  <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
-                    {/* 时间选择器 */}
-                    <div className="col-span-1 lg:col-span-2">
-                      <Form.DatePicker
-                        field='dateRange'
-                        className="w-full"
-                        type='dateTimeRange'
-                        placeholder={[t('开始时间'), t('结束时间')]}
-                        showClear
-                        pure
-                        size="small"
-                      />
-                    </div>
-
-                    {/* 任务 ID */}
+                {compactMode ? t('自适应列表') : t('紧凑列表')}
+              </Button>
+            </div>
+          }
+          searchArea={
+            <Form
+              initValues={formInitValues}
+              getFormApi={(api) => setFormApi(api)}
+              onSubmit={refresh}
+              allowEmpty={true}
+              autoComplete="off"
+              layout="vertical"
+              trigger="change"
+              stopValidateWithError={false}
+            >
+              <div className="flex flex-col gap-4">
+                <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
+                  {/* 时间选择器 */}
+                  <div className="col-span-1 lg:col-span-2">
+                    <Form.DatePicker
+                      field='dateRange'
+                      className="w-full"
+                      type='dateTimeRange'
+                      placeholder={[t('开始时间'), t('结束时间')]}
+                      showClear
+                      pure
+                      size="small"
+                    />
+                  </div>
+
+                  {/* 任务 ID */}
+                  <Form.Input
+                    field='task_id'
+                    prefix={<IconSearch />}
+                    placeholder={t('任务 ID')}
+                    showClear
+                    pure
+                    size="small"
+                  />
+
+                  {/* 渠道 ID - 仅管理员可见 */}
+                  {isAdminUser && (
                     <Form.Input
-                      field='task_id'
+                      field='channel_id'
                       prefix={<IconSearch />}
-                      placeholder={t('任务 ID')}
+                      placeholder={t('渠道 ID')}
                       showClear
                       pure
                       size="small"
                     />
+                  )}
+                </div>
 
-                    {/* 渠道 ID - 仅管理员可见 */}
-                    {isAdminUser && (
-                      <Form.Input
-                        field='channel_id'
-                        prefix={<IconSearch />}
-                        placeholder={t('渠道 ID')}
-                        showClear
-                        pure
-                        size="small"
-                      />
-                    )}
-                  </div>
-
-                  {/* 操作按钮区域 */}
-                  <div className="flex justify-between items-center">
-                    <div></div>
-                    <div className="flex gap-2">
-                      <Button
-                        type='tertiary'
-                        htmlType='submit'
-                        loading={loading}
-                        size="small"
-                      >
-                        {t('查询')}
-                      </Button>
-                      <Button
-                        type='tertiary'
-                        onClick={() => {
-                          if (formApi) {
-                            formApi.reset();
-                            // 重置后立即查询,使用setTimeout确保表单重置完成
-                            setTimeout(() => {
-                              refresh();
-                            }, 100);
-                          }
-                        }}
-                        size="small"
-                      >
-                        {t('重置')}
-                      </Button>
-                      <Button
-                        type='tertiary'
-                        onClick={() => setShowColumnSelector(true)}
-                        size="small"
-                      >
-                        {t('列设置')}
-                      </Button>
-                    </div>
+                {/* 操作按钮区域 */}
+                <div className="flex justify-between items-center">
+                  <div></div>
+                  <div className="flex gap-2">
+                    <Button
+                      type='tertiary'
+                      htmlType='submit'
+                      loading={loading}
+                      size="small"
+                    >
+                      {t('查询')}
+                    </Button>
+                    <Button
+                      type='tertiary'
+                      onClick={() => {
+                        if (formApi) {
+                          formApi.reset();
+                          // 重置后立即查询,使用setTimeout确保表单重置完成
+                          setTimeout(() => {
+                            refresh();
+                          }, 100);
+                        }
+                      }}
+                      size="small"
+                    >
+                      {t('重置')}
+                    </Button>
+                    <Button
+                      type='tertiary'
+                      onClick={() => setShowColumnSelector(true)}
+                      size="small"
+                    >
+                      {t('列设置')}
+                    </Button>
                   </div>
                 </div>
-              </Form>
-            </div>
+              </div>
+            </Form>
           }
-          shadows='always'
-          bordered={false}
         >
           <Table
             columns={compactMode ? getVisibleColumns().map(({ fixed, ...rest }) => rest) : getVisibleColumns()}
@@ -787,7 +781,7 @@ const LogsTable = () => {
               onPageChange: handlePageChange,
             }}
           />
-        </Card>
+        </CardPro>
 
         <Modal
           visible={isModalOpen}

+ 166 - 163
web/src/components/table/TokensTable.js

@@ -12,8 +12,6 @@ import {
 import { ITEMS_PER_PAGE } from '../../constants';
 import {
   Button,
-  Card,
-  Divider,
   Dropdown,
   Empty,
   Form,
@@ -30,6 +28,7 @@ import {
   Input,
   Typography
 } from '@douyinfe/semi-ui';
+import CardPro from '../common/ui/CardPro';
 import {
   IllustrationNoResult,
   IllustrationNoResultDark
@@ -44,7 +43,7 @@ import {
 import { Key } from 'lucide-react';
 import EditToken from '../../pages/Token/EditToken';
 import { useTranslation } from 'react-i18next';
-import { useTableCompactMode } from '../../hooks/useTableCompactMode';
+import { useTableCompactMode } from '../../hooks/common/useTableCompactMode';
 
 const { Text } = Typography;
 
@@ -689,177 +688,173 @@ const TokensTable = () => {
     }
   };
 
-  const renderHeader = () => (
-    <div className="flex flex-col w-full">
-      <div className="mb-2">
-        <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 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>
+  );
 
-      <Divider margin="12px" />
+  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>
+  );
 
-      <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">
-          <Button
-            type="primary"
-            className="flex-1 md:flex-initial"
-            onClick={() => {
-              setEditingToken({
-                id: undefined,
-              });
-              setShowEdit(true);
-            }}
+  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"
-          >
-            {t('添加令牌')}
-          </Button>
+          />
+        </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'
-            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>
-                ),
-              });
-            }}
+            type="tertiary"
+            htmlType="submit"
+            loading={loading || searching}
+            className="flex-1 md:flex-initial md:w-auto"
             size="small"
           >
-            {t('复制所选令牌')}
+            {t('查询')}
           </Button>
           <Button
-            type='danger'
-            className="w-full md:w-auto"
+            type='tertiary'
             onClick={() => {
-              if (selectedKeys.length === 0) {
-                showError(t('请至少选择一个令牌!'));
-                return;
+              if (formApi) {
+                formApi.reset();
+                // 重置后立即查询,使用setTimeout确保表单重置完成
+                setTimeout(() => {
+                  searchTokens();
+                }, 100);
               }
-              Modal.confirm({
-                title: t('批量删除令牌'),
-                content: (
-                  <div>
-                    {t('确定要删除所选的 {{count}} 个令牌吗?', { count: selectedKeys.length })}
-                  </div>
-                ),
-                onOk: () => batchDeleteTokens(),
-              });
             }}
+            className="flex-1 md:flex-initial md:w-auto"
             size="small"
           >
-            {t('删除所选令牌')}
+            {t('重置')}
           </Button>
         </div>
-
-        <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>
       </div>
-    </div>
+    </Form>
   );
 
   return (
@@ -871,11 +866,19 @@ const TokensTable = () => {
         handleClose={closeEdit}
       ></EditToken>
 
-      <Card
-        className="table-scroll-card !rounded-2xl"
-        title={renderHeader()}
-        shadows='always'
-        bordered={false}
+      <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 => {
@@ -910,7 +913,7 @@ const TokensTable = () => {
           className="rounded-xl overflow-hidden"
           size="middle"
         ></Table>
-      </Card>
+      </CardPro>
     </>
   );
 };

+ 110 - 118
web/src/components/table/UsersTable.js

@@ -17,8 +17,6 @@ import {
 } from 'lucide-react';
 import {
   Button,
-  Card,
-  Divider,
   Dropdown,
   Empty,
   Form,
@@ -29,6 +27,7 @@ import {
   Tooltip,
   Typography
 } from '@douyinfe/semi-ui';
+import CardPro from '../common/ui/CardPro';
 import {
   IllustrationNoResult,
   IllustrationNoResultDark
@@ -42,7 +41,7 @@ import { ITEMS_PER_PAGE } from '../../constants';
 import AddUser from '../../pages/User/AddUser';
 import EditUser from '../../pages/User/EditUser';
 import { useTranslation } from 'react-i18next';
-import { useTableCompactMode } from '../../hooks/useTableCompactMode';
+import { useTableCompactMode } from '../../hooks/common/useTableCompactMode';
 
 const { Text } = Typography;
 
@@ -514,115 +513,7 @@ const UsersTable = () => {
     }
   };
 
-  const renderHeader = () => (
-    <div className="flex flex-col w-full">
-      <div className="mb-2">
-        <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">
-            <IconUserAdd className="mr-2" />
-            <Text>{t('用户管理页面,可以查看和管理所有注册用户的信息、权限和状态。')}</Text>
-          </div>
-          <Button
-            type='tertiary'
-            className="w-full md:w-auto"
-            onClick={() => setCompactMode(!compactMode)}
-            size="small"
-          >
-            {compactMode ? t('自适应列表') : t('紧凑列表')}
-          </Button>
-        </div>
-      </div>
-
-      <Divider margin="12px" />
-
-      <div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full">
-        <div className="flex gap-2 w-full md:w-auto order-2 md:order-1">
-          <Button
-            className="w-full md:w-auto"
-            onClick={() => {
-              setShowAddUser(true);
-            }}
-            size="small"
-          >
-            {t('添加用户')}
-          </Button>
-        </div>
-
-        <Form
-          initValues={formInitValues}
-          getFormApi={(api) => setFormApi(api)}
-          onSubmit={() => {
-            setActivePage(1);
-            searchUsers(1, pageSize);
-          }}
-          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-64">
-              <Form.Input
-                field="searchKeyword"
-                prefix={<IconSearch />}
-                placeholder={t('支持搜索用户的 ID、用户名、显示名称和邮箱地址')}
-                showClear
-                pure
-                size="small"
-              />
-            </div>
-            <div className="w-full md:w-48">
-              <Form.Select
-                field="searchGroup"
-                placeholder={t('选择分组')}
-                optionList={groupOptions}
-                onChange={(value) => {
-                  // 分组变化时自动搜索
-                  setTimeout(() => {
-                    setActivePage(1);
-                    searchUsers(1, pageSize);
-                  }, 100);
-                }}
-                className="w-full"
-                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(() => {
-                      setActivePage(1);
-                      loadUsers(1, pageSize);
-                    }, 100);
-                  }
-                }}
-                className="flex-1 md:flex-initial md:w-auto"
-                size="small"
-              >
-                {t('重置')}
-              </Button>
-            </div>
-          </div>
-        </Form>
-      </div>
-    </div>
-  );
+
 
   return (
     <>
@@ -638,11 +529,112 @@ const UsersTable = () => {
         editingUser={editingUser}
       ></EditUser>
 
-      <Card
-        className="table-scroll-card !rounded-2xl"
-        title={renderHeader()}
-        shadows='always'
-        bordered={false}
+      <CardPro
+        type="type1"
+        descriptionArea={
+          <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">
+              <IconUserAdd className="mr-2" />
+              <Text>{t('用户管理页面,可以查看和管理所有注册用户的信息、权限和状态。')}</Text>
+            </div>
+            <Button
+              type='tertiary'
+              className="w-full md:w-auto"
+              onClick={() => setCompactMode(!compactMode)}
+              size="small"
+            >
+              {compactMode ? t('自适应列表') : t('紧凑列表')}
+            </Button>
+          </div>
+        }
+        actionsArea={
+          <div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full">
+            <div className="flex gap-2 w-full md:w-auto order-2 md:order-1">
+              <Button
+                className="w-full md:w-auto"
+                onClick={() => {
+                  setShowAddUser(true);
+                }}
+                size="small"
+              >
+                {t('添加用户')}
+              </Button>
+            </div>
+
+            <Form
+              initValues={formInitValues}
+              getFormApi={(api) => setFormApi(api)}
+              onSubmit={() => {
+                setActivePage(1);
+                searchUsers(1, pageSize);
+              }}
+              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-64">
+                  <Form.Input
+                    field="searchKeyword"
+                    prefix={<IconSearch />}
+                    placeholder={t('支持搜索用户的 ID、用户名、显示名称和邮箱地址')}
+                    showClear
+                    pure
+                    size="small"
+                  />
+                </div>
+                <div className="w-full md:w-48">
+                  <Form.Select
+                    field="searchGroup"
+                    placeholder={t('选择分组')}
+                    optionList={groupOptions}
+                    onChange={(value) => {
+                      // 分组变化时自动搜索
+                      setTimeout(() => {
+                        setActivePage(1);
+                        searchUsers(1, pageSize);
+                      }, 100);
+                    }}
+                    className="w-full"
+                    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(() => {
+                          setActivePage(1);
+                          loadUsers(1, pageSize);
+                        }, 100);
+                      }
+                    }}
+                    className="flex-1 md:flex-initial md:w-auto"
+                    size="small"
+                  >
+                    {t('重置')}
+                  </Button>
+                </div>
+              </div>
+            </Form>
+          </div>
+        }
       >
         <Table
           columns={compactMode ? columns.map(({ fixed, ...rest }) => rest) : columns}
@@ -672,7 +664,7 @@ const UsersTable = () => {
           className="overflow-hidden"
           size="middle"
         />
-      </Card>
+      </CardPro>
     </>
   );
 };

+ 240 - 0
web/src/components/table/channels/ChannelsActions.jsx

@@ -0,0 +1,240 @@
+import React from 'react';
+import {
+  Button,
+  Dropdown,
+  Modal,
+  Switch,
+  Typography,
+  Select
+} from '@douyinfe/semi-ui';
+
+const ChannelsActions = ({
+  enableBatchDelete,
+  batchDeleteChannels,
+  setShowBatchSetTag,
+  testAllChannels,
+  fixChannelsAbilities,
+  updateAllChannelsBalance,
+  deleteAllDisabledChannels,
+  compactMode,
+  setCompactMode,
+  idSort,
+  setIdSort,
+  setEnableBatchDelete,
+  enableTagMode,
+  setEnableTagMode,
+  statusFilter,
+  setStatusFilter,
+  getFormValues,
+  loadChannels,
+  searchChannels,
+  activeTypeKey,
+  activePage,
+  pageSize,
+  setActivePage,
+  t
+}) => {
+  return (
+    <div className="flex flex-col gap-4">
+      {/* 第一行:批量操作按钮 + 设置开关 */}
+      <div className="flex flex-col md:flex-row justify-between gap-4">
+        {/* 左侧:批量操作按钮 */}
+        <div className="flex flex-wrap md:flex-nowrap items-center gap-2 w-full md:w-auto order-2 md:order-1">
+          <Button
+            size='small'
+            disabled={!enableBatchDelete}
+            type='danger'
+            className="w-full md:w-auto"
+            onClick={() => {
+              Modal.confirm({
+                title: t('确定是否要删除所选通道?'),
+                content: t('此修改将不可逆'),
+                onOk: () => batchDeleteChannels(),
+              });
+            }}
+          >
+            {t('删除所选通道')}
+          </Button>
+
+          <Button
+            size='small'
+            disabled={!enableBatchDelete}
+            type='tertiary'
+            onClick={() => setShowBatchSetTag(true)}
+            className="w-full md:w-auto"
+          >
+            {t('批量设置标签')}
+          </Button>
+
+          <Dropdown
+            size='small'
+            trigger='click'
+            render={
+              <Dropdown.Menu>
+                <Dropdown.Item>
+                  <Button
+                    size='small'
+                    type='tertiary'
+                    className="w-full"
+                    onClick={() => {
+                      Modal.confirm({
+                        title: t('确定?'),
+                        content: t('确定要测试所有通道吗?'),
+                        onOk: () => testAllChannels(),
+                        size: 'small',
+                        centered: true,
+                      });
+                    }}
+                  >
+                    {t('测试所有通道')}
+                  </Button>
+                </Dropdown.Item>
+                <Dropdown.Item>
+                  <Button
+                    size='small'
+                    className="w-full"
+                    onClick={() => {
+                      Modal.confirm({
+                        title: t('确定是否要修复数据库一致性?'),
+                        content: t('进行该操作时,可能导致渠道访问错误,请仅在数据库出现问题时使用'),
+                        onOk: () => fixChannelsAbilities(),
+                        size: 'sm',
+                        centered: true,
+                      });
+                    }}
+                  >
+                    {t('修复数据库一致性')}
+                  </Button>
+                </Dropdown.Item>
+                <Dropdown.Item>
+                  <Button
+                    size='small'
+                    type='secondary'
+                    className="w-full"
+                    onClick={() => {
+                      Modal.confirm({
+                        title: t('确定?'),
+                        content: t('确定要更新所有已启用通道余额吗?'),
+                        onOk: () => updateAllChannelsBalance(),
+                        size: 'sm',
+                        centered: true,
+                      });
+                    }}
+                  >
+                    {t('更新所有已启用通道余额')}
+                  </Button>
+                </Dropdown.Item>
+                <Dropdown.Item>
+                  <Button
+                    size='small'
+                    type='danger'
+                    className="w-full"
+                    onClick={() => {
+                      Modal.confirm({
+                        title: t('确定是否要删除禁用通道?'),
+                        content: t('此修改将不可逆'),
+                        onOk: () => deleteAllDisabledChannels(),
+                        size: 'sm',
+                        centered: true,
+                      });
+                    }}
+                  >
+                    {t('删除禁用通道')}
+                  </Button>
+                </Dropdown.Item>
+              </Dropdown.Menu>
+            }
+          >
+            <Button size='small' theme='light' type='tertiary' className="w-full md:w-auto">
+              {t('批量操作')}
+            </Button>
+          </Dropdown>
+
+          <Button
+            size='small'
+            type='tertiary'
+            className="w-full md:w-auto"
+            onClick={() => setCompactMode(!compactMode)}
+          >
+            {compactMode ? t('自适应列表') : t('紧凑列表')}
+          </Button>
+        </div>
+
+        {/* 右侧:设置开关区域 */}
+        <div className="flex flex-col md:flex-row items-start md:items-center gap-4 w-full md:w-auto order-1 md:order-2">
+          <div className="flex items-center justify-between w-full md:w-auto">
+            <Typography.Text strong className="mr-2">
+              {t('使用ID排序')}
+            </Typography.Text>
+            <Switch
+              size='small'
+              checked={idSort}
+              onChange={(v) => {
+                localStorage.setItem('id-sort', v + '');
+                setIdSort(v);
+                const { searchKeyword, searchGroup, searchModel } = getFormValues();
+                if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
+                  loadChannels(activePage, pageSize, v, enableTagMode);
+                } else {
+                  searchChannels(enableTagMode, activeTypeKey, statusFilter, activePage, pageSize, v);
+                }
+              }}
+            />
+          </div>
+
+          <div className="flex items-center justify-between w-full md:w-auto">
+            <Typography.Text strong className="mr-2">
+              {t('开启批量操作')}
+            </Typography.Text>
+            <Switch
+              size='small'
+              checked={enableBatchDelete}
+              onChange={(v) => {
+                localStorage.setItem('enable-batch-delete', v + '');
+                setEnableBatchDelete(v);
+              }}
+            />
+          </div>
+
+          <div className="flex items-center justify-between w-full md:w-auto">
+            <Typography.Text strong className="mr-2">
+              {t('标签聚合模式')}
+            </Typography.Text>
+            <Switch
+              size='small'
+              checked={enableTagMode}
+              onChange={(v) => {
+                localStorage.setItem('enable-tag-mode', v + '');
+                setEnableTagMode(v);
+                setActivePage(1);
+                loadChannels(1, pageSize, idSort, v);
+              }}
+            />
+          </div>
+
+          <div className="flex items-center justify-between w-full md:w-auto">
+            <Typography.Text strong className="mr-2">
+              {t('状态筛选')}
+            </Typography.Text>
+            <Select
+              size='small'
+              value={statusFilter}
+              onChange={(v) => {
+                localStorage.setItem('channel-status-filter', v);
+                setStatusFilter(v);
+                setActivePage(1);
+                loadChannels(1, pageSize, idSort, enableTagMode, activeTypeKey, v);
+              }}
+            >
+              <Select.Option value="all">{t('全部')}</Select.Option>
+              <Select.Option value="enabled">{t('已启用')}</Select.Option>
+              <Select.Option value="disabled">{t('已禁用')}</Select.Option>
+            </Select>
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+};
+
+export default ChannelsActions; 

+ 604 - 0
web/src/components/table/channels/ChannelsColumnDefs.js

@@ -0,0 +1,604 @@
+import React from 'react';
+import {
+  Button,
+  Dropdown,
+  InputNumber,
+  Modal,
+  Space,
+  SplitButtonGroup,
+  Tag,
+  Tooltip,
+  Typography
+} from '@douyinfe/semi-ui';
+import {
+  timestamp2string,
+  renderGroup,
+  renderQuota,
+  getChannelIcon,
+  renderQuotaWithAmount
+} from '../../../helpers/index.js';
+import { CHANNEL_OPTIONS } from '../../../constants/index.js';
+import { IconTreeTriangleDown, IconMore } from '@douyinfe/semi-icons';
+import { FaRandom } from 'react-icons/fa';
+
+// Render functions
+const renderType = (type, channelInfo = undefined, t) => {
+  let type2label = new Map();
+  for (let i = 0; i < CHANNEL_OPTIONS.length; i++) {
+    type2label[CHANNEL_OPTIONS[i].value] = CHANNEL_OPTIONS[i];
+  }
+  type2label[0] = { value: 0, label: t('未知类型'), color: 'grey' };
+
+  let icon = getChannelIcon(type);
+
+  if (channelInfo?.is_multi_key) {
+    icon = (
+      channelInfo?.multi_key_mode === 'random' ? (
+        <div className="flex items-center gap-1">
+          <FaRandom className="text-blue-500" />
+          {icon}
+        </div>
+      ) : (
+        <div className="flex items-center gap-1">
+          <IconTreeTriangleDown className="text-blue-500" />
+          {icon}
+        </div>
+      )
+    )
+  }
+
+  return (
+    <Tag
+      color={type2label[type]?.color}
+      shape='circle'
+      prefixIcon={icon}
+    >
+      {type2label[type]?.label}
+    </Tag>
+  );
+};
+
+const renderTagType = (t) => {
+  return (
+    <Tag
+      color='light-blue'
+      shape='circle'
+      type='light'
+    >
+      {t('标签聚合')}
+    </Tag>
+  );
+};
+
+const renderStatus = (status, channelInfo = undefined, t) => {
+  if (channelInfo) {
+    if (channelInfo.is_multi_key) {
+      let keySize = channelInfo.multi_key_size;
+      let enabledKeySize = keySize;
+      if (channelInfo.multi_key_status_list) {
+        enabledKeySize = keySize - Object.keys(channelInfo.multi_key_status_list).length;
+      }
+      return renderMultiKeyStatus(status, keySize, enabledKeySize, t);
+    }
+  }
+  switch (status) {
+    case 1:
+      return (
+        <Tag color='green' shape='circle'>
+          {t('已启用')}
+        </Tag>
+      );
+    case 2:
+      return (
+        <Tag color='red' shape='circle'>
+          {t('已禁用')}
+        </Tag>
+      );
+    case 3:
+      return (
+        <Tag color='yellow' shape='circle'>
+          {t('自动禁用')}
+        </Tag>
+      );
+    default:
+      return (
+        <Tag color='grey' shape='circle'>
+          {t('未知状态')}
+        </Tag>
+      );
+  }
+};
+
+const renderMultiKeyStatus = (status, keySize, enabledKeySize, t) => {
+  switch (status) {
+    case 1:
+      return (
+        <Tag color='green' shape='circle'>
+          {t('已启用')} {enabledKeySize}/{keySize}
+        </Tag>
+      );
+    case 2:
+      return (
+        <Tag color='red' shape='circle'>
+          {t('已禁用')} {enabledKeySize}/{keySize}
+        </Tag>
+      );
+    case 3:
+      return (
+        <Tag color='yellow' shape='circle'>
+          {t('自动禁用')} {enabledKeySize}/{keySize}
+        </Tag>
+      );
+    default:
+      return (
+        <Tag color='grey' shape='circle'>
+          {t('未知状态')} {enabledKeySize}/{keySize}
+        </Tag>
+      );
+  }
+}
+
+const renderResponseTime = (responseTime, t) => {
+  let time = responseTime / 1000;
+  time = time.toFixed(2) + t(' 秒');
+  if (responseTime === 0) {
+    return (
+      <Tag color='grey' shape='circle'>
+        {t('未测试')}
+      </Tag>
+    );
+  } else if (responseTime <= 1000) {
+    return (
+      <Tag color='green' shape='circle'>
+        {time}
+      </Tag>
+    );
+  } else if (responseTime <= 3000) {
+    return (
+      <Tag color='lime' shape='circle'>
+        {time}
+      </Tag>
+    );
+  } else if (responseTime <= 5000) {
+    return (
+      <Tag color='yellow' shape='circle'>
+        {time}
+      </Tag>
+    );
+  } else {
+    return (
+      <Tag color='red' shape='circle'>
+        {time}
+      </Tag>
+    );
+  }
+};
+
+export const getChannelsColumns = ({
+  t,
+  COLUMN_KEYS,
+  updateChannelBalance,
+  manageChannel,
+  manageTag,
+  submitTagEdit,
+  testChannel,
+  setCurrentTestChannel,
+  setShowModelTestModal,
+  setEditingChannel,
+  setShowEdit,
+  setShowEditTag,
+  setEditingTag,
+  copySelectedChannel,
+  refresh,
+  activePage,
+  channels
+}) => {
+  return [
+    {
+      key: COLUMN_KEYS.ID,
+      title: t('ID'),
+      dataIndex: 'id',
+    },
+    {
+      key: COLUMN_KEYS.NAME,
+      title: t('名称'),
+      dataIndex: 'name',
+    },
+    {
+      key: COLUMN_KEYS.GROUP,
+      title: t('分组'),
+      dataIndex: 'group',
+      render: (text, record, index) => (
+        <div>
+          <Space spacing={2}>
+            {text
+              ?.split(',')
+              .sort((a, b) => {
+                if (a === 'default') return -1;
+                if (b === 'default') return 1;
+                return a.localeCompare(b);
+              })
+              .map((item, index) => renderGroup(item))}
+          </Space>
+        </div>
+      ),
+    },
+    {
+      key: COLUMN_KEYS.TYPE,
+      title: t('类型'),
+      dataIndex: 'type',
+      render: (text, record, index) => {
+        if (record.children === undefined) {
+          if (record.channel_info) {
+            if (record.channel_info.is_multi_key) {
+              return <>{renderType(text, record.channel_info, t)}</>;
+            }
+          }
+          return <>{renderType(text, undefined, t)}</>;
+        } else {
+          return <>{renderTagType(t)}</>;
+        }
+      },
+    },
+    {
+      key: COLUMN_KEYS.STATUS,
+      title: t('状态'),
+      dataIndex: 'status',
+      render: (text, record, index) => {
+        if (text === 3) {
+          if (record.other_info === '') {
+            record.other_info = '{}';
+          }
+          let otherInfo = JSON.parse(record.other_info);
+          let reason = otherInfo['status_reason'];
+          let time = otherInfo['status_time'];
+          return (
+            <div>
+              <Tooltip
+                content={t('原因:') + reason + t(',时间:') + timestamp2string(time)}
+              >
+                {renderStatus(text, record.channel_info, t)}
+              </Tooltip>
+            </div>
+          );
+        } else {
+          return renderStatus(text, record.channel_info, t);
+        }
+      },
+    },
+    {
+      key: COLUMN_KEYS.RESPONSE_TIME,
+      title: t('响应时间'),
+      dataIndex: 'response_time',
+      render: (text, record, index) => (
+        <div>{renderResponseTime(text, t)}</div>
+      ),
+    },
+    {
+      key: COLUMN_KEYS.BALANCE,
+      title: t('已用/剩余'),
+      dataIndex: 'expired_time',
+      render: (text, record, index) => {
+        if (record.children === undefined) {
+          return (
+            <div>
+              <Space spacing={1}>
+                <Tooltip content={t('已用额度')}>
+                  <Tag color='white' type='ghost' shape='circle'>
+                    {renderQuota(record.used_quota)}
+                  </Tag>
+                </Tooltip>
+                <Tooltip content={t('剩余额度$') + record.balance + t(',点击更新')}>
+                  <Tag
+                    color='white'
+                    type='ghost'
+                    shape='circle'
+                    onClick={() => updateChannelBalance(record)}
+                  >
+                    {renderQuotaWithAmount(record.balance)}
+                  </Tag>
+                </Tooltip>
+              </Space>
+            </div>
+          );
+        } else {
+          return (
+            <Tooltip content={t('已用额度')}>
+              <Tag color='white' type='ghost' shape='circle'>
+                {renderQuota(record.used_quota)}
+              </Tag>
+            </Tooltip>
+          );
+        }
+      },
+    },
+    {
+      key: COLUMN_KEYS.PRIORITY,
+      title: t('优先级'),
+      dataIndex: 'priority',
+      render: (text, record, index) => {
+        if (record.children === undefined) {
+          return (
+            <div>
+              <InputNumber
+                style={{ width: 70 }}
+                name='priority'
+                onBlur={(e) => {
+                  manageChannel(record.id, 'priority', record, e.target.value);
+                }}
+                keepFocus={true}
+                innerButtons
+                defaultValue={record.priority}
+                min={-999}
+                size="small"
+              />
+            </div>
+          );
+        } else {
+          return (
+            <InputNumber
+              style={{ width: 70 }}
+              name='priority'
+              keepFocus={true}
+              onBlur={(e) => {
+                Modal.warning({
+                  title: t('修改子渠道优先级'),
+                  content: t('确定要修改所有子渠道优先级为 ') + e.target.value + t(' 吗?'),
+                  onOk: () => {
+                    if (e.target.value === '') {
+                      return;
+                    }
+                    submitTagEdit('priority', {
+                      tag: record.key,
+                      priority: e.target.value,
+                    });
+                  },
+                });
+              }}
+              innerButtons
+              defaultValue={record.priority}
+              min={-999}
+              size="small"
+            />
+          );
+        }
+      },
+    },
+    {
+      key: COLUMN_KEYS.WEIGHT,
+      title: t('权重'),
+      dataIndex: 'weight',
+      render: (text, record, index) => {
+        if (record.children === undefined) {
+          return (
+            <div>
+              <InputNumber
+                style={{ width: 70 }}
+                name='weight'
+                onBlur={(e) => {
+                  manageChannel(record.id, 'weight', record, e.target.value);
+                }}
+                keepFocus={true}
+                innerButtons
+                defaultValue={record.weight}
+                min={0}
+                size="small"
+              />
+            </div>
+          );
+        } else {
+          return (
+            <InputNumber
+              style={{ width: 70 }}
+              name='weight'
+              keepFocus={true}
+              onBlur={(e) => {
+                Modal.warning({
+                  title: t('修改子渠道权重'),
+                  content: t('确定要修改所有子渠道权重为 ') + e.target.value + t(' 吗?'),
+                  onOk: () => {
+                    if (e.target.value === '') {
+                      return;
+                    }
+                    submitTagEdit('weight', {
+                      tag: record.key,
+                      weight: e.target.value,
+                    });
+                  },
+                });
+              }}
+              innerButtons
+              defaultValue={record.weight}
+              min={-999}
+              size="small"
+            />
+          );
+        }
+      },
+    },
+    {
+      key: COLUMN_KEYS.OPERATE,
+      title: '',
+      dataIndex: 'operate',
+      fixed: 'right',
+      render: (text, record, index) => {
+        if (record.children === undefined) {
+          const moreMenuItems = [
+            {
+              node: 'item',
+              name: t('删除'),
+              type: 'danger',
+              onClick: () => {
+                Modal.confirm({
+                  title: t('确定是否要删除此渠道?'),
+                  content: t('此修改将不可逆'),
+                  onOk: () => {
+                    (async () => {
+                      await manageChannel(record.id, 'delete', record);
+                      await refresh();
+                      setTimeout(() => {
+                        if (channels.length === 0 && activePage > 1) {
+                          refresh(activePage - 1);
+                        }
+                      }, 100);
+                    })();
+                  },
+                });
+              },
+            },
+            {
+              node: 'item',
+              name: t('复制'),
+              type: 'tertiary',
+              onClick: () => {
+                Modal.confirm({
+                  title: t('确定是否要复制此渠道?'),
+                  content: t('复制渠道的所有信息'),
+                  onOk: () => copySelectedChannel(record),
+                });
+              },
+            },
+          ];
+
+          return (
+            <Space wrap>
+              <SplitButtonGroup
+                className="overflow-hidden"
+                aria-label={t('测试单个渠道操作项目组')}
+              >
+                <Button
+                  size="small"
+                  type='tertiary'
+                  onClick={() => testChannel(record, '')}
+                >
+                  {t('测试')}
+                </Button>
+                <Button
+                  size="small"
+                  type='tertiary'
+                  icon={<IconTreeTriangleDown />}
+                  onClick={() => {
+                    setCurrentTestChannel(record);
+                    setShowModelTestModal(true);
+                  }}
+                />
+              </SplitButtonGroup>
+
+              {record.channel_info?.is_multi_key ? (
+                <SplitButtonGroup
+                  aria-label={t('多密钥渠道操作项目组')}
+                >
+                  {
+                    record.status === 1 ? (
+                      <Button
+                        type='danger'
+                        size="small"
+                        onClick={() => manageChannel(record.id, 'disable', record)}
+                      >
+                        {t('禁用')}
+                      </Button>
+                    ) : (
+                      <Button
+                        size="small"
+                        onClick={() => manageChannel(record.id, 'enable', record)}
+                      >
+                        {t('启用')}
+                      </Button>
+                    )
+                  }
+                  <Dropdown
+                    trigger='click'
+                    position='bottomRight'
+                    menu={[
+                      {
+                        node: 'item',
+                        name: t('启用全部密钥'),
+                        onClick: () => manageChannel(record.id, 'enable_all', record),
+                      }
+                    ]}
+                  >
+                    <Button
+                      type='tertiary'
+                      size="small"
+                      icon={<IconTreeTriangleDown />}
+                    />
+                  </Dropdown>
+                </SplitButtonGroup>
+              ) : (
+                record.status === 1 ? (
+                  <Button
+                    type='danger'
+                    size="small"
+                    onClick={() => manageChannel(record.id, 'disable', record)}
+                  >
+                    {t('禁用')}
+                  </Button>
+                ) : (
+                  <Button
+                    size="small"
+                    onClick={() => manageChannel(record.id, 'enable', record)}
+                  >
+                    {t('启用')}
+                  </Button>
+                )
+              )}
+
+              <Button
+                type='tertiary'
+                size="small"
+                onClick={() => {
+                  setEditingChannel(record);
+                  setShowEdit(true);
+                }}
+              >
+                {t('编辑')}
+              </Button>
+
+              <Dropdown
+                trigger='click'
+                position='bottomRight'
+                menu={moreMenuItems}
+              >
+                <Button
+                  icon={<IconMore />}
+                  type='tertiary'
+                  size="small"
+                />
+              </Dropdown>
+            </Space>
+          );
+        } else {
+          // 标签操作按钮
+          return (
+            <Space wrap>
+              <Button
+                type='tertiary'
+                size="small"
+                onClick={() => manageTag(record.key, 'enable')}
+              >
+                {t('启用全部')}
+              </Button>
+              <Button
+                type='tertiary'
+                size="small"
+                onClick={() => manageTag(record.key, 'disable')}
+              >
+                {t('禁用全部')}
+              </Button>
+              <Button
+                type='tertiary'
+                size="small"
+                onClick={() => {
+                  setShowEditTag(true);
+                  setEditingTag(record.key);
+                }}
+              >
+                {t('编辑')}
+              </Button>
+            </Space>
+          );
+        }
+      },
+    },
+  ];
+}; 

+ 140 - 0
web/src/components/table/channels/ChannelsFilters.jsx

@@ -0,0 +1,140 @@
+import React from 'react';
+import { Button, Form } from '@douyinfe/semi-ui';
+import { IconSearch } from '@douyinfe/semi-icons';
+
+const ChannelsFilters = ({
+  setEditingChannel,
+  setShowEdit,
+  refresh,
+  setShowColumnSelector,
+  formInitValues,
+  setFormApi,
+  searchChannels,
+  enableTagMode,
+  formApi,
+  groupOptions,
+  loading,
+  searching,
+  t
+}) => {
+  return (
+    <div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full">
+      <div className="flex gap-2 w-full md:w-auto order-2 md:order-1">
+        <Button
+          size='small'
+          theme='light'
+          type='primary'
+          className="w-full md:w-auto"
+          onClick={() => {
+            setEditingChannel({
+              id: undefined,
+            });
+            setShowEdit(true);
+          }}
+        >
+          {t('添加渠道')}
+        </Button>
+
+        <Button
+          size='small'
+          type='tertiary'
+          className="w-full md:w-auto"
+          onClick={refresh}
+        >
+          {t('刷新')}
+        </Button>
+
+        <Button
+          size='small'
+          type='tertiary'
+          onClick={() => setShowColumnSelector(true)}
+          className="w-full md:w-auto"
+        >
+          {t('列设置')}
+        </Button>
+      </div>
+
+      <div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto order-1 md:order-2">
+        <Form
+          initValues={formInitValues}
+          getFormApi={(api) => setFormApi(api)}
+          onSubmit={() => searchChannels(enableTagMode)}
+          allowEmpty={true}
+          autoComplete="off"
+          layout="horizontal"
+          trigger="change"
+          stopValidateWithError={false}
+          className="flex flex-col md:flex-row items-center gap-4 w-full"
+        >
+          <div className="relative w-full md:w-64">
+            <Form.Input
+              size='small'
+              field="searchKeyword"
+              prefix={<IconSearch />}
+              placeholder={t('渠道ID,名称,密钥,API地址')}
+              showClear
+              pure
+            />
+          </div>
+          <div className="w-full md:w-48">
+            <Form.Input
+              size='small'
+              field="searchModel"
+              prefix={<IconSearch />}
+              placeholder={t('模型关键字')}
+              showClear
+              pure
+            />
+          </div>
+          <div className="w-full md:w-32">
+            <Form.Select
+              size='small'
+              field="searchGroup"
+              placeholder={t('选择分组')}
+              optionList={[
+                { label: t('选择分组'), value: null },
+                ...groupOptions,
+              ]}
+              className="w-full"
+              showClear
+              pure
+              onChange={() => {
+                // 延迟执行搜索,让表单值先更新
+                setTimeout(() => {
+                  searchChannels(enableTagMode);
+                }, 0);
+              }}
+            />
+          </div>
+          <Button
+            size='small'
+            type="tertiary"
+            htmlType="submit"
+            loading={loading || searching}
+            className="w-full md:w-auto"
+          >
+            {t('查询')}
+          </Button>
+          <Button
+            size='small'
+            type='tertiary'
+            onClick={() => {
+              if (formApi) {
+                formApi.reset();
+                // 重置后立即查询,使用setTimeout确保表单重置完成
+                setTimeout(() => {
+                  refresh();
+                }, 100);
+              }
+            }}
+            className="w-full md:w-auto"
+          >
+            {t('重置')}
+          </Button>
+        </Form>
+      </div>
+    </div>
+  );
+};
+
+export default ChannelsFilters; 

+ 138 - 0
web/src/components/table/channels/ChannelsTable.jsx

@@ -0,0 +1,138 @@
+import React, { useMemo } from 'react';
+import { Table, Empty } from '@douyinfe/semi-ui';
+import {
+  IllustrationNoResult,
+  IllustrationNoResultDark
+} from '@douyinfe/semi-illustrations';
+import { getChannelsColumns } from './ChannelsColumnDefs.js';
+
+const ChannelsTable = (channelsData) => {
+  const {
+    channels,
+    loading,
+    searching,
+    activePage,
+    pageSize,
+    channelCount,
+    enableBatchDelete,
+    compactMode,
+    visibleColumns,
+    setSelectedChannels,
+    handlePageChange,
+    handlePageSizeChange,
+    handleRow,
+    t,
+    COLUMN_KEYS,
+    // Column functions and data
+    updateChannelBalance,
+    manageChannel,
+    manageTag,
+    submitTagEdit,
+    testChannel,
+    setCurrentTestChannel,
+    setShowModelTestModal,
+    setEditingChannel,
+    setShowEdit,
+    setShowEditTag,
+    setEditingTag,
+    copySelectedChannel,
+    refresh,
+  } = channelsData;
+
+  // Get all columns
+  const allColumns = useMemo(() => {
+    return getChannelsColumns({
+      t,
+      COLUMN_KEYS,
+      updateChannelBalance,
+      manageChannel,
+      manageTag,
+      submitTagEdit,
+      testChannel,
+      setCurrentTestChannel,
+      setShowModelTestModal,
+      setEditingChannel,
+      setShowEdit,
+      setShowEditTag,
+      setEditingTag,
+      copySelectedChannel,
+      refresh,
+      activePage,
+      channels,
+    });
+  }, [
+    t,
+    COLUMN_KEYS,
+    updateChannelBalance,
+    manageChannel,
+    manageTag,
+    submitTagEdit,
+    testChannel,
+    setCurrentTestChannel,
+    setShowModelTestModal,
+    setEditingChannel,
+    setShowEdit,
+    setShowEditTag,
+    setEditingTag,
+    copySelectedChannel,
+    refresh,
+    activePage,
+    channels,
+  ]);
+
+  // Filter columns based on visibility settings
+  const getVisibleColumns = () => {
+    return allColumns.filter((column) => visibleColumns[column.key]);
+  };
+
+  const visibleColumnsList = useMemo(() => {
+    return getVisibleColumns();
+  }, [visibleColumns, allColumns]);
+
+  const tableColumns = useMemo(() => {
+    return compactMode
+      ? visibleColumnsList.map(({ fixed, ...rest }) => rest)
+      : visibleColumnsList;
+  }, [compactMode, visibleColumnsList]);
+
+  return (
+    <Table
+      columns={tableColumns}
+      dataSource={channels}
+      scroll={compactMode ? undefined : { x: 'max-content' }}
+      pagination={{
+        currentPage: activePage,
+        pageSize: pageSize,
+        total: channelCount,
+        pageSizeOpts: [10, 20, 50, 100],
+        showSizeChanger: true,
+        onPageSizeChange: handlePageSizeChange,
+        onPageChange: handlePageChange,
+      }}
+      expandAllRows={false}
+      onRow={handleRow}
+      rowSelection={
+        enableBatchDelete
+          ? {
+            onChange: (selectedRowKeys, selectedRows) => {
+              setSelectedChannels(selectedRows);
+            },
+          }
+          : null
+      }
+      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"
+      loading={loading || searching}
+    />
+  );
+};
+
+export default ChannelsTable; 

+ 70 - 0
web/src/components/table/channels/ChannelsTabs.jsx

@@ -0,0 +1,70 @@
+import React from 'react';
+import { Tabs, TabPane, Tag } from '@douyinfe/semi-ui';
+import { CHANNEL_OPTIONS } from '../../../constants/index.js';
+import { getChannelIcon } from '../../../helpers/index.js';
+
+const ChannelsTabs = ({
+  enableTagMode,
+  activeTypeKey,
+  setActiveTypeKey,
+  channelTypeCounts,
+  availableTypeKeys,
+  loadChannels,
+  activePage,
+  pageSize,
+  idSort,
+  setActivePage,
+  t
+}) => {
+  if (enableTagMode) return null;
+
+  const handleTabChange = (key) => {
+    setActiveTypeKey(key);
+    setActivePage(1);
+    loadChannels(1, pageSize, idSort, enableTagMode, key);
+  };
+
+  return (
+    <Tabs
+      activeKey={activeTypeKey}
+      type="card"
+      collapsible
+      onChange={handleTabChange}
+      className="mb-4"
+    >
+      <TabPane
+        itemKey="all"
+        tab={
+          <span className="flex items-center gap-2">
+            {t('全部')}
+            <Tag color={activeTypeKey === 'all' ? 'red' : 'grey'} shape='circle'>
+              {channelTypeCounts['all'] || 0}
+            </Tag>
+          </span>
+        }
+      />
+
+      {CHANNEL_OPTIONS.filter((opt) => availableTypeKeys.includes(String(opt.value))).map((option) => {
+        const key = String(option.value);
+        const count = channelTypeCounts[option.value] || 0;
+        return (
+          <TabPane
+            key={key}
+            itemKey={key}
+            tab={
+              <span className="flex items-center gap-2">
+                {getChannelIcon(option.value)}
+                {option.label}
+                <Tag color={activeTypeKey === key ? 'red' : 'grey'} shape='circle'>
+                  {count}
+                </Tag>
+              </span>
+            }
+          />
+        );
+      })}
+    </Tabs>
+  );
+};
+
+export default ChannelsTabs; 

+ 49 - 0
web/src/components/table/channels/index.jsx

@@ -0,0 +1,49 @@
+import React from 'react';
+import CardPro from '../../common/ui/CardPro.js';
+import ChannelsTable from './ChannelsTable.jsx';
+import ChannelsActions from './ChannelsActions.jsx';
+import ChannelsFilters from './ChannelsFilters.jsx';
+import ChannelsTabs from './ChannelsTabs.jsx';
+import { useChannelsData } from '../../../hooks/channels/useChannelsData.js';
+import BatchTagModal from './modals/BatchTagModal.jsx';
+import ModelTestModal from './modals/ModelTestModal.jsx';
+import ColumnSelectorModal from './modals/ColumnSelectorModal.jsx';
+import EditChannel from '../../../pages/Channel/EditChannel.js';
+import EditTagModal from '../../../pages/Channel/EditTagModal.js';
+
+const ChannelsPage = () => {
+  const channelsData = useChannelsData();
+
+  return (
+    <>
+      {/* Modals */}
+      <ColumnSelectorModal {...channelsData} />
+      <EditTagModal
+        visible={channelsData.showEditTag}
+        tag={channelsData.editingTag}
+        handleClose={() => channelsData.setShowEditTag(false)}
+        refresh={channelsData.refresh}
+      />
+      <EditChannel
+        refresh={channelsData.refresh}
+        visible={channelsData.showEdit}
+        handleClose={channelsData.closeEdit}
+        editingChannel={channelsData.editingChannel}
+      />
+      <BatchTagModal {...channelsData} />
+      <ModelTestModal {...channelsData} />
+
+      {/* Main Content */}
+      <CardPro
+        type="type3"
+        tabsArea={<ChannelsTabs {...channelsData} />}
+        actionsArea={<ChannelsActions {...channelsData} />}
+        searchArea={<ChannelsFilters {...channelsData} />}
+      >
+        <ChannelsTable {...channelsData} />
+      </CardPro>
+    </>
+  );
+};
+
+export default ChannelsPage; 

+ 41 - 0
web/src/components/table/channels/modals/BatchTagModal.jsx

@@ -0,0 +1,41 @@
+import React from 'react';
+import { Modal, Input, Typography } from '@douyinfe/semi-ui';
+
+const BatchTagModal = ({
+  showBatchSetTag,
+  setShowBatchSetTag,
+  batchSetChannelTag,
+  batchSetTagValue,
+  setBatchSetTagValue,
+  selectedChannels,
+  t
+}) => {
+  return (
+    <Modal
+      title={t('批量设置标签')}
+      visible={showBatchSetTag}
+      onOk={batchSetChannelTag}
+      onCancel={() => setShowBatchSetTag(false)}
+      maskClosable={false}
+      centered={true}
+      size="small"
+      className="!rounded-lg"
+    >
+      <div className="mb-5">
+        <Typography.Text>{t('请输入要设置的标签名称')}</Typography.Text>
+      </div>
+      <Input
+        placeholder={t('请输入标签名称')}
+        value={batchSetTagValue}
+        onChange={(v) => setBatchSetTagValue(v)}
+      />
+      <div className="mt-4">
+        <Typography.Text type='secondary'>
+          {t('已选择 ${count} 个渠道').replace('${count}', selectedChannels.length)}
+        </Typography.Text>
+      </div>
+    </Modal>
+  );
+};
+
+export default BatchTagModal; 

+ 114 - 0
web/src/components/table/channels/modals/ColumnSelectorModal.jsx

@@ -0,0 +1,114 @@
+import React from 'react';
+import { Modal, Button, Checkbox } from '@douyinfe/semi-ui';
+import { getChannelsColumns } from '../ChannelsColumnDefs.js';
+
+const ColumnSelectorModal = ({
+  showColumnSelector,
+  setShowColumnSelector,
+  visibleColumns,
+  handleColumnVisibilityChange,
+  handleSelectAll,
+  initDefaultColumns,
+  COLUMN_KEYS,
+  t,
+  // Props needed for getChannelsColumns
+  updateChannelBalance,
+  manageChannel,
+  manageTag,
+  submitTagEdit,
+  testChannel,
+  setCurrentTestChannel,
+  setShowModelTestModal,
+  setEditingChannel,
+  setShowEdit,
+  setShowEditTag,
+  setEditingTag,
+  copySelectedChannel,
+  refresh,
+  activePage,
+  channels,
+}) => {
+  // Get all columns for display in selector
+  const allColumns = getChannelsColumns({
+    t,
+    COLUMN_KEYS,
+    updateChannelBalance,
+    manageChannel,
+    manageTag,
+    submitTagEdit,
+    testChannel,
+    setCurrentTestChannel,
+    setShowModelTestModal,
+    setEditingChannel,
+    setShowEdit,
+    setShowEditTag,
+    setEditingTag,
+    copySelectedChannel,
+    refresh,
+    activePage,
+    channels,
+  });
+
+  return (
+    <Modal
+      title={t('列设置')}
+      visible={showColumnSelector}
+      onCancel={() => setShowColumnSelector(false)}
+      footer={
+        <div className="flex justify-end">
+          <Button onClick={() => initDefaultColumns()}>
+            {t('重置')}
+          </Button>
+          <Button onClick={() => setShowColumnSelector(false)}>
+            {t('取消')}
+          </Button>
+          <Button onClick={() => setShowColumnSelector(false)}>
+            {t('确定')}
+          </Button>
+        </div>
+      }
+    >
+      <div style={{ marginBottom: 20 }}>
+        <Checkbox
+          checked={Object.values(visibleColumns).every((v) => v === true)}
+          indeterminate={
+            Object.values(visibleColumns).some((v) => v === true) &&
+            !Object.values(visibleColumns).every((v) => v === true)
+          }
+          onChange={(e) => handleSelectAll(e.target.checked)}
+        >
+          {t('全选')}
+        </Checkbox>
+      </div>
+      <div
+        className="flex flex-wrap max-h-96 overflow-y-auto rounded-lg p-4"
+        style={{ border: '1px solid var(--semi-color-border)' }}
+      >
+        {allColumns.map((column) => {
+          // Skip columns without title
+          if (!column.title) {
+            return null;
+          }
+
+          return (
+            <div
+              key={column.key}
+              className="w-1/2 mb-4 pr-2"
+            >
+              <Checkbox
+                checked={!!visibleColumns[column.key]}
+                onChange={(e) =>
+                  handleColumnVisibilityChange(column.key, e.target.checked)
+                }
+              >
+                {column.title}
+              </Checkbox>
+            </div>
+          );
+        })}
+      </div>
+    </Modal>
+  );
+};
+
+export default ColumnSelectorModal; 

+ 256 - 0
web/src/components/table/channels/modals/ModelTestModal.jsx

@@ -0,0 +1,256 @@
+import React from 'react';
+import {
+  Modal,
+  Button,
+  Input,
+  Table,
+  Tag,
+  Typography
+} from '@douyinfe/semi-ui';
+import { IconSearch } from '@douyinfe/semi-icons';
+import { copy, showError, showInfo, showSuccess } from '../../../../helpers/index.js';
+import { MODEL_TABLE_PAGE_SIZE } from '../../../../constants/index.js';
+
+const ModelTestModal = ({
+  showModelTestModal,
+  currentTestChannel,
+  handleCloseModal,
+  isBatchTesting,
+  batchTestModels,
+  modelSearchKeyword,
+  setModelSearchKeyword,
+  selectedModelKeys,
+  setSelectedModelKeys,
+  modelTestResults,
+  testingModels,
+  testChannel,
+  modelTablePage,
+  setModelTablePage,
+  allSelectingRef,
+  isMobile,
+  t
+}) => {
+  if (!showModelTestModal || !currentTestChannel) {
+    return null;
+  }
+
+  const filteredModels = currentTestChannel.models
+    .split(',')
+    .filter((model) =>
+      model.toLowerCase().includes(modelSearchKeyword.toLowerCase())
+    );
+
+  const handleCopySelected = () => {
+    if (selectedModelKeys.length === 0) {
+      showError(t('请先选择模型!'));
+      return;
+    }
+    copy(selectedModelKeys.join(',')).then((ok) => {
+      if (ok) {
+        showSuccess(t('已复制 ${count} 个模型').replace('${count}', selectedModelKeys.length));
+      } else {
+        showError(t('复制失败,请手动复制'));
+      }
+    });
+  };
+
+  const handleSelectSuccess = () => {
+    if (!currentTestChannel) return;
+    const successKeys = currentTestChannel.models
+      .split(',')
+      .filter((m) => m.toLowerCase().includes(modelSearchKeyword.toLowerCase()))
+      .filter((m) => {
+        const result = modelTestResults[`${currentTestChannel.id}-${m}`];
+        return result && result.success;
+      });
+    if (successKeys.length === 0) {
+      showInfo(t('暂无成功模型'));
+    }
+    setSelectedModelKeys(successKeys);
+  };
+
+  const columns = [
+    {
+      title: t('模型名称'),
+      dataIndex: 'model',
+      render: (text) => (
+        <div className="flex items-center">
+          <Typography.Text strong>{text}</Typography.Text>
+        </div>
+      )
+    },
+    {
+      title: t('状态'),
+      dataIndex: 'status',
+      render: (text, record) => {
+        const testResult = modelTestResults[`${currentTestChannel.id}-${record.model}`];
+        const isTesting = testingModels.has(record.model);
+
+        if (isTesting) {
+          return (
+            <Tag color='blue' shape='circle'>
+              {t('测试中')}
+            </Tag>
+          );
+        }
+
+        if (!testResult) {
+          return (
+            <Tag color='grey' shape='circle'>
+              {t('未开始')}
+            </Tag>
+          );
+        }
+
+        return (
+          <div className="flex items-center gap-2">
+            <Tag
+              color={testResult.success ? 'green' : 'red'}
+              shape='circle'
+            >
+              {testResult.success ? t('成功') : t('失败')}
+            </Tag>
+            {testResult.success && (
+              <Typography.Text type="tertiary">
+                {t('请求时长: ${time}s').replace('${time}', testResult.time.toFixed(2))}
+              </Typography.Text>
+            )}
+          </div>
+        );
+      }
+    },
+    {
+      title: '',
+      dataIndex: 'operate',
+      render: (text, record) => {
+        const isTesting = testingModels.has(record.model);
+        return (
+          <Button
+            type='tertiary'
+            onClick={() => testChannel(currentTestChannel, record.model)}
+            loading={isTesting}
+            size='small'
+          >
+            {t('测试')}
+          </Button>
+        );
+      }
+    }
+  ];
+
+  const dataSource = (() => {
+    const start = (modelTablePage - 1) * MODEL_TABLE_PAGE_SIZE;
+    const end = start + MODEL_TABLE_PAGE_SIZE;
+    return filteredModels.slice(start, end).map((model) => ({
+      model,
+      key: model,
+    }));
+  })();
+
+  return (
+    <Modal
+      title={
+        <div className="flex flex-col gap-2 w-full">
+          <div className="flex items-center gap-2">
+            <Typography.Text strong className="!text-[var(--semi-color-text-0)] !text-base">
+              {currentTestChannel.name} {t('渠道的模型测试')}
+            </Typography.Text>
+            <Typography.Text type="tertiary" className="!text-xs flex items-center">
+              {t('共')} {currentTestChannel.models.split(',').length} {t('个模型')}
+            </Typography.Text>
+          </div>
+        </div>
+      }
+      visible={showModelTestModal}
+      onCancel={handleCloseModal}
+      footer={
+        <div className="flex justify-end">
+          {isBatchTesting ? (
+            <Button
+              type='danger'
+              onClick={handleCloseModal}
+            >
+              {t('停止测试')}
+            </Button>
+          ) : (
+            <Button
+              type='tertiary'
+              onClick={handleCloseModal}
+            >
+              {t('取消')}
+            </Button>
+          )}
+          <Button
+            onClick={batchTestModels}
+            loading={isBatchTesting}
+            disabled={isBatchTesting}
+          >
+            {isBatchTesting ? t('测试中...') : t('批量测试${count}个模型').replace(
+              '${count}',
+              filteredModels.length
+            )}
+          </Button>
+        </div>
+      }
+      maskClosable={!isBatchTesting}
+      className="!rounded-lg"
+      size={isMobile ? 'full-width' : 'large'}
+    >
+      <div className="model-test-scroll">
+        {/* 搜索与操作按钮 */}
+        <div className="flex items-center justify-end gap-2 w-full mb-2">
+          <Input
+            placeholder={t('搜索模型...')}
+            value={modelSearchKeyword}
+            onChange={(v) => {
+              setModelSearchKeyword(v);
+              setModelTablePage(1);
+            }}
+            className="!w-full"
+            prefix={<IconSearch />}
+            showClear
+          />
+
+          <Button onClick={handleCopySelected}>
+            {t('复制已选')}
+          </Button>
+
+          <Button
+            type='tertiary'
+            onClick={handleSelectSuccess}
+          >
+            {t('选择成功')}
+          </Button>
+        </div>
+
+        <Table
+          columns={columns}
+          dataSource={dataSource}
+          rowSelection={{
+            selectedRowKeys: selectedModelKeys,
+            onChange: (keys) => {
+              if (allSelectingRef.current) {
+                allSelectingRef.current = false;
+                return;
+              }
+              setSelectedModelKeys(keys);
+            },
+            onSelectAll: (checked) => {
+              allSelectingRef.current = true;
+              setSelectedModelKeys(checked ? filteredModels : []);
+            },
+          }}
+          pagination={{
+            currentPage: modelTablePage,
+            pageSize: MODEL_TABLE_PAGE_SIZE,
+            total: filteredModels.length,
+            showSizeChanger: false,
+            onPageChange: (page) => setModelTablePage(page),
+          }}
+        />
+      </div>
+    </Modal>
+  );
+};
+
+export default ModelTestModal; 

+ 1 - 1
web/src/helpers/render.js

@@ -1,7 +1,7 @@
 import i18next from 'i18next';
 import { Modal, Tag, Typography } from '@douyinfe/semi-ui';
 import { copy, showSuccess } from './utils';
-import { MOBILE_BREAKPOINT } from '../hooks/useIsMobile.js';
+import { MOBILE_BREAKPOINT } from '../hooks/common/useIsMobile.js';
 import { visit } from 'unist-util-visit';
 import {
   OpenAI,

+ 1 - 1
web/src/helpers/utils.js

@@ -4,7 +4,7 @@ import React from 'react';
 import { toast } from 'react-toastify';
 import { THINK_TAG_REGEX, MESSAGE_ROLES } from '../constants/playground.constants';
 import { TABLE_COMPACT_MODES_KEY } from '../constants';
-import { MOBILE_BREAKPOINT } from '../hooks/useIsMobile.js';
+import { MOBILE_BREAKPOINT } from '../hooks/common/useIsMobile.js';
 
 const HTMLToastContent = ({ htmlContent }) => {
   return <div dangerouslySetInnerHTML={{ __html: htmlContent }} />;

+ 917 - 0
web/src/hooks/channels/useChannelsData.js

@@ -0,0 +1,917 @@
+import { useState, useEffect, useRef, useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import {
+  API,
+  showError,
+  showInfo,
+  showSuccess,
+  loadChannelModels,
+  copy
+} from '../../helpers/index.js';
+import { CHANNEL_OPTIONS, ITEMS_PER_PAGE, MODEL_TABLE_PAGE_SIZE } from '../../constants/index.js';
+import { useIsMobile } from '../common/useIsMobile.js';
+import { useTableCompactMode } from '../common/useTableCompactMode.js';
+import { Modal } from '@douyinfe/semi-ui';
+
+export const useChannelsData = () => {
+  const { t } = useTranslation();
+  const isMobile = useIsMobile();
+
+  // Basic states
+  const [channels, setChannels] = useState([]);
+  const [loading, setLoading] = useState(true);
+  const [activePage, setActivePage] = useState(1);
+  const [idSort, setIdSort] = useState(false);
+  const [searching, setSearching] = useState(false);
+  const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
+  const [channelCount, setChannelCount] = useState(ITEMS_PER_PAGE);
+  const [groupOptions, setGroupOptions] = useState([]);
+
+  // UI states
+  const [showEdit, setShowEdit] = useState(false);
+  const [enableBatchDelete, setEnableBatchDelete] = useState(false);
+  const [editingChannel, setEditingChannel] = useState({ id: undefined });
+  const [showEditTag, setShowEditTag] = useState(false);
+  const [editingTag, setEditingTag] = useState('');
+  const [selectedChannels, setSelectedChannels] = useState([]);
+  const [enableTagMode, setEnableTagMode] = useState(false);
+  const [showBatchSetTag, setShowBatchSetTag] = useState(false);
+  const [batchSetTagValue, setBatchSetTagValue] = useState('');
+  const [compactMode, setCompactMode] = useTableCompactMode('channels');
+
+  // Column visibility states
+  const [visibleColumns, setVisibleColumns] = useState({});
+  const [showColumnSelector, setShowColumnSelector] = useState(false);
+
+  // Status filter
+  const [statusFilter, setStatusFilter] = useState(
+    localStorage.getItem('channel-status-filter') || 'all'
+  );
+
+  // Type tabs states
+  const [activeTypeKey, setActiveTypeKey] = useState('all');
+  const [typeCounts, setTypeCounts] = useState({});
+
+  // Model test states
+  const [showModelTestModal, setShowModelTestModal] = useState(false);
+  const [currentTestChannel, setCurrentTestChannel] = useState(null);
+  const [modelSearchKeyword, setModelSearchKeyword] = useState('');
+  const [modelTestResults, setModelTestResults] = useState({});
+  const [testingModels, setTestingModels] = useState(new Set());
+  const [selectedModelKeys, setSelectedModelKeys] = useState([]);
+  const [isBatchTesting, setIsBatchTesting] = useState(false);
+  const [testQueue, setTestQueue] = useState([]);
+  const [isProcessingQueue, setIsProcessingQueue] = useState(false);
+  const [modelTablePage, setModelTablePage] = useState(1);
+
+  // Refs
+  const requestCounter = useRef(0);
+  const allSelectingRef = useRef(false);
+  const [formApi, setFormApi] = useState(null);
+
+  const formInitValues = {
+    searchKeyword: '',
+    searchGroup: '',
+    searchModel: '',
+  };
+
+  // Column keys
+  const COLUMN_KEYS = {
+    ID: 'id',
+    NAME: 'name',
+    GROUP: 'group',
+    TYPE: 'type',
+    STATUS: 'status',
+    RESPONSE_TIME: 'response_time',
+    BALANCE: 'balance',
+    PRIORITY: 'priority',
+    WEIGHT: 'weight',
+    OPERATE: 'operate',
+  };
+
+  // Initialize from localStorage
+  useEffect(() => {
+    const localIdSort = localStorage.getItem('id-sort') === 'true';
+    const localPageSize = parseInt(localStorage.getItem('page-size')) || ITEMS_PER_PAGE;
+    const localEnableTagMode = localStorage.getItem('enable-tag-mode') === 'true';
+    const localEnableBatchDelete = localStorage.getItem('enable-batch-delete') === 'true';
+
+    setIdSort(localIdSort);
+    setPageSize(localPageSize);
+    setEnableTagMode(localEnableTagMode);
+    setEnableBatchDelete(localEnableBatchDelete);
+
+    loadChannels(1, localPageSize, localIdSort, localEnableTagMode)
+      .then()
+      .catch((reason) => {
+        showError(reason);
+      });
+    fetchGroups().then();
+    loadChannelModels().then();
+  }, []);
+
+  // Column visibility management
+  const getDefaultColumnVisibility = () => {
+    return {
+      [COLUMN_KEYS.ID]: true,
+      [COLUMN_KEYS.NAME]: true,
+      [COLUMN_KEYS.GROUP]: true,
+      [COLUMN_KEYS.TYPE]: true,
+      [COLUMN_KEYS.STATUS]: true,
+      [COLUMN_KEYS.RESPONSE_TIME]: true,
+      [COLUMN_KEYS.BALANCE]: true,
+      [COLUMN_KEYS.PRIORITY]: true,
+      [COLUMN_KEYS.WEIGHT]: true,
+      [COLUMN_KEYS.OPERATE]: true,
+    };
+  };
+
+  const initDefaultColumns = () => {
+    const defaults = getDefaultColumnVisibility();
+    setVisibleColumns(defaults);
+  };
+
+  // Load saved column preferences
+  useEffect(() => {
+    const savedColumns = localStorage.getItem('channels-table-columns');
+    if (savedColumns) {
+      try {
+        const parsed = JSON.parse(savedColumns);
+        const defaults = getDefaultColumnVisibility();
+        const merged = { ...defaults, ...parsed };
+        setVisibleColumns(merged);
+      } catch (e) {
+        console.error('Failed to parse saved column preferences', e);
+        initDefaultColumns();
+      }
+    } else {
+      initDefaultColumns();
+    }
+  }, []);
+
+  // Save column preferences
+  useEffect(() => {
+    if (Object.keys(visibleColumns).length > 0) {
+      localStorage.setItem('channels-table-columns', JSON.stringify(visibleColumns));
+    }
+  }, [visibleColumns]);
+
+  const handleColumnVisibilityChange = (columnKey, checked) => {
+    const updatedColumns = { ...visibleColumns, [columnKey]: checked };
+    setVisibleColumns(updatedColumns);
+  };
+
+  const handleSelectAll = (checked) => {
+    const allKeys = Object.keys(COLUMN_KEYS).map((key) => COLUMN_KEYS[key]);
+    const updatedColumns = {};
+    allKeys.forEach((key) => {
+      updatedColumns[key] = checked;
+    });
+    setVisibleColumns(updatedColumns);
+  };
+
+  // Data formatting
+  const setChannelFormat = (channels, enableTagMode) => {
+    let channelDates = [];
+    let channelTags = {};
+
+    for (let i = 0; i < channels.length; i++) {
+      channels[i].key = '' + channels[i].id;
+      if (!enableTagMode) {
+        channelDates.push(channels[i]);
+      } else {
+        let tag = channels[i].tag ? channels[i].tag : '';
+        let tagIndex = channelTags[tag];
+        let tagChannelDates = undefined;
+
+        if (tagIndex === undefined) {
+          channelTags[tag] = 1;
+          tagChannelDates = {
+            key: tag,
+            id: tag,
+            tag: tag,
+            name: '标签:' + tag,
+            group: '',
+            used_quota: 0,
+            response_time: 0,
+            priority: -1,
+            weight: -1,
+          };
+          tagChannelDates.children = [];
+          channelDates.push(tagChannelDates);
+        } else {
+          tagChannelDates = channelDates.find((item) => item.key === tag);
+        }
+
+        if (tagChannelDates.priority === -1) {
+          tagChannelDates.priority = channels[i].priority;
+        } else {
+          if (tagChannelDates.priority !== channels[i].priority) {
+            tagChannelDates.priority = '';
+          }
+        }
+
+        if (tagChannelDates.weight === -1) {
+          tagChannelDates.weight = channels[i].weight;
+        } else {
+          if (tagChannelDates.weight !== channels[i].weight) {
+            tagChannelDates.weight = '';
+          }
+        }
+
+        if (tagChannelDates.group === '') {
+          tagChannelDates.group = channels[i].group;
+        } else {
+          let channelGroupsStr = channels[i].group;
+          channelGroupsStr.split(',').forEach((item, index) => {
+            if (tagChannelDates.group.indexOf(item) === -1) {
+              tagChannelDates.group += ',' + item;
+            }
+          });
+        }
+
+        tagChannelDates.children.push(channels[i]);
+        if (channels[i].status === 1) {
+          tagChannelDates.status = 1;
+        }
+        tagChannelDates.used_quota += channels[i].used_quota;
+        tagChannelDates.response_time += channels[i].response_time;
+        tagChannelDates.response_time = tagChannelDates.response_time / 2;
+      }
+    }
+    setChannels(channelDates);
+  };
+
+  // Get form values helper
+  const getFormValues = () => {
+    const formValues = formApi ? formApi.getValues() : {};
+    return {
+      searchKeyword: formValues.searchKeyword || '',
+      searchGroup: formValues.searchGroup || '',
+      searchModel: formValues.searchModel || '',
+    };
+  };
+
+  // Load channels
+  const loadChannels = async (
+    page,
+    pageSize,
+    idSort,
+    enableTagMode,
+    typeKey = activeTypeKey,
+    statusF,
+  ) => {
+    if (statusF === undefined) statusF = statusFilter;
+
+    const { searchKeyword, searchGroup, searchModel } = getFormValues();
+    if (searchKeyword !== '' || searchGroup !== '' || searchModel !== '') {
+      setLoading(true);
+      await searchChannels(enableTagMode, typeKey, statusF, page, pageSize, idSort);
+      setLoading(false);
+      return;
+    }
+
+    const reqId = ++requestCounter.current;
+    setLoading(true);
+    const typeParam = (typeKey !== 'all') ? `&type=${typeKey}` : '';
+    const statusParam = statusF !== 'all' ? `&status=${statusF}` : '';
+    const res = await API.get(
+      `/api/channel/?p=${page}&page_size=${pageSize}&id_sort=${idSort}&tag_mode=${enableTagMode}${typeParam}${statusParam}`,
+    );
+
+    if (res === undefined || reqId !== requestCounter.current) {
+      return;
+    }
+
+    const { success, message, data } = res.data;
+    if (success) {
+      const { items, total, type_counts } = data;
+      if (type_counts) {
+        const sumAll = Object.values(type_counts).reduce((acc, v) => acc + v, 0);
+        setTypeCounts({ ...type_counts, all: sumAll });
+      }
+      setChannelFormat(items, enableTagMode);
+      setChannelCount(total);
+    } else {
+      showError(message);
+    }
+    setLoading(false);
+  };
+
+  // Search channels
+  const searchChannels = async (
+    enableTagMode,
+    typeKey = activeTypeKey,
+    statusF = statusFilter,
+    page = 1,
+    pageSz = pageSize,
+    sortFlag = idSort,
+  ) => {
+    const { searchKeyword, searchGroup, searchModel } = getFormValues();
+    setSearching(true);
+    try {
+      if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
+        await loadChannels(page, pageSz, sortFlag, enableTagMode, typeKey, statusF);
+        return;
+      }
+
+      const typeParam = (typeKey !== 'all') ? `&type=${typeKey}` : '';
+      const statusParam = statusF !== 'all' ? `&status=${statusF}` : '';
+      const res = await API.get(
+        `/api/channel/search?keyword=${searchKeyword}&group=${searchGroup}&model=${searchModel}&id_sort=${sortFlag}&tag_mode=${enableTagMode}&p=${page}&page_size=${pageSz}${typeParam}${statusParam}`,
+      );
+      const { success, message, data } = res.data;
+      if (success) {
+        const { items = [], total = 0, type_counts = {} } = data;
+        const sumAll = Object.values(type_counts).reduce((acc, v) => acc + v, 0);
+        setTypeCounts({ ...type_counts, all: sumAll });
+        setChannelFormat(items, enableTagMode);
+        setChannelCount(total);
+        setActivePage(page);
+      } else {
+        showError(message);
+      }
+    } finally {
+      setSearching(false);
+    }
+  };
+
+  // Refresh
+  const refresh = async (page = activePage) => {
+    const { searchKeyword, searchGroup, searchModel } = getFormValues();
+    if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
+      await loadChannels(page, pageSize, idSort, enableTagMode);
+    } else {
+      await searchChannels(enableTagMode, activeTypeKey, statusFilter, page, pageSize, idSort);
+    }
+  };
+
+  // Channel management
+  const manageChannel = async (id, action, record, value) => {
+    let data = { id };
+    let res;
+    switch (action) {
+      case 'delete':
+        res = await API.delete(`/api/channel/${id}/`);
+        break;
+      case 'enable':
+        data.status = 1;
+        res = await API.put('/api/channel/', data);
+        break;
+      case 'disable':
+        data.status = 2;
+        res = await API.put('/api/channel/', data);
+        break;
+      case 'priority':
+        if (value === '') return;
+        data.priority = parseInt(value);
+        res = await API.put('/api/channel/', data);
+        break;
+      case 'weight':
+        if (value === '') return;
+        data.weight = parseInt(value);
+        if (data.weight < 0) data.weight = 0;
+        res = await API.put('/api/channel/', data);
+        break;
+      case 'enable_all':
+        data.channel_info = record.channel_info;
+        data.channel_info.multi_key_status_list = {};
+        res = await API.put('/api/channel/', data);
+        break;
+    }
+    const { success, message } = res.data;
+    if (success) {
+      showSuccess(t('操作成功完成!'));
+      let channel = res.data.data;
+      let newChannels = [...channels];
+      if (action !== 'delete') {
+        record.status = channel.status;
+      }
+      setChannels(newChannels);
+    } else {
+      showError(message);
+    }
+  };
+
+  // Tag management
+  const manageTag = async (tag, action) => {
+    let res;
+    switch (action) {
+      case 'enable':
+        res = await API.post('/api/channel/tag/enabled', { tag: tag });
+        break;
+      case 'disable':
+        res = await API.post('/api/channel/tag/disabled', { tag: tag });
+        break;
+    }
+    const { success, message } = res.data;
+    if (success) {
+      showSuccess('操作成功完成!');
+      let newChannels = [...channels];
+      for (let i = 0; i < newChannels.length; i++) {
+        if (newChannels[i].tag === tag) {
+          let status = action === 'enable' ? 1 : 2;
+          newChannels[i]?.children?.forEach((channel) => {
+            channel.status = status;
+          });
+          newChannels[i].status = status;
+        }
+      }
+      setChannels(newChannels);
+    } else {
+      showError(message);
+    }
+  };
+
+  // Page handlers
+  const handlePageChange = (page) => {
+    const { searchKeyword, searchGroup, searchModel } = getFormValues();
+    setActivePage(page);
+    if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
+      loadChannels(page, pageSize, idSort, enableTagMode).then(() => { });
+    } else {
+      searchChannels(enableTagMode, activeTypeKey, statusFilter, page, pageSize, idSort);
+    }
+  };
+
+  const handlePageSizeChange = async (size) => {
+    localStorage.setItem('page-size', size + '');
+    setPageSize(size);
+    setActivePage(1);
+    const { searchKeyword, searchGroup, searchModel } = getFormValues();
+    if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
+      loadChannels(1, size, idSort, enableTagMode)
+        .then()
+        .catch((reason) => {
+          showError(reason);
+        });
+    } else {
+      searchChannels(enableTagMode, activeTypeKey, statusFilter, 1, size, idSort);
+    }
+  };
+
+  // Fetch groups
+  const fetchGroups = async () => {
+    try {
+      let res = await API.get(`/api/group/`);
+      if (res === undefined) return;
+      setGroupOptions(
+        res.data.data.map((group) => ({
+          label: group,
+          value: group,
+        })),
+      );
+    } catch (error) {
+      showError(error.message);
+    }
+  };
+
+  // Copy channel
+  const copySelectedChannel = async (record) => {
+    try {
+      const res = await API.post(`/api/channel/copy/${record.id}`);
+      if (res?.data?.success) {
+        showSuccess(t('渠道复制成功'));
+        await refresh();
+      } else {
+        showError(res?.data?.message || t('渠道复制失败'));
+      }
+    } catch (error) {
+      showError(t('渠道复制失败: ') + (error?.response?.data?.message || error?.message || error));
+    }
+  };
+
+  // Update channel property
+  const updateChannelProperty = (channelId, updateFn) => {
+    const newChannels = [...channels];
+    let updated = false;
+
+    newChannels.forEach((channel) => {
+      if (channel.children !== undefined) {
+        channel.children.forEach((child) => {
+          if (child.id === channelId) {
+            updateFn(child);
+            updated = true;
+          }
+        });
+      } else if (channel.id === channelId) {
+        updateFn(channel);
+        updated = true;
+      }
+    });
+
+    if (updated) {
+      setChannels(newChannels);
+    }
+  };
+
+  // Tag edit
+  const submitTagEdit = async (type, data) => {
+    switch (type) {
+      case 'priority':
+        if (data.priority === undefined || data.priority === '') {
+          showInfo('优先级必须是整数!');
+          return;
+        }
+        data.priority = parseInt(data.priority);
+        break;
+      case 'weight':
+        if (data.weight === undefined || data.weight < 0 || data.weight === '') {
+          showInfo('权重必须是非负整数!');
+          return;
+        }
+        data.weight = parseInt(data.weight);
+        break;
+    }
+
+    try {
+      const res = await API.put('/api/channel/tag', data);
+      if (res?.data?.success) {
+        showSuccess('更新成功!');
+        await refresh();
+      }
+    } catch (error) {
+      showError(error);
+    }
+  };
+
+  // Close edit
+  const closeEdit = () => {
+    setShowEdit(false);
+  };
+
+  // Row style
+  const handleRow = (record, index) => {
+    if (record.status !== 1) {
+      return {
+        style: {
+          background: 'var(--semi-color-disabled-border)',
+        },
+      };
+    } else {
+      return {};
+    }
+  };
+
+  // Batch operations
+  const batchSetChannelTag = async () => {
+    if (selectedChannels.length === 0) {
+      showError(t('请先选择要设置标签的渠道!'));
+      return;
+    }
+    if (batchSetTagValue === '') {
+      showError(t('标签不能为空!'));
+      return;
+    }
+    let ids = selectedChannels.map((channel) => channel.id);
+    const res = await API.post('/api/channel/batch/tag', {
+      ids: ids,
+      tag: batchSetTagValue === '' ? null : batchSetTagValue,
+    });
+    if (res.data.success) {
+      showSuccess(
+        t('已为 ${count} 个渠道设置标签!').replace('${count}', res.data.data),
+      );
+      await refresh();
+      setShowBatchSetTag(false);
+    } else {
+      showError(res.data.message);
+    }
+  };
+
+  const batchDeleteChannels = async () => {
+    if (selectedChannels.length === 0) {
+      showError(t('请先选择要删除的通道!'));
+      return;
+    }
+    setLoading(true);
+    let ids = [];
+    selectedChannels.forEach((channel) => {
+      ids.push(channel.id);
+    });
+    const res = await API.post(`/api/channel/batch`, { ids: ids });
+    const { success, message, data } = res.data;
+    if (success) {
+      showSuccess(t('已删除 ${data} 个通道!').replace('${data}', data));
+      await refresh();
+      setTimeout(() => {
+        if (channels.length === 0 && activePage > 1) {
+          refresh(activePage - 1);
+        }
+      }, 100);
+    } else {
+      showError(message);
+    }
+    setLoading(false);
+  };
+
+  // Channel operations
+  const testAllChannels = async () => {
+    const res = await API.get(`/api/channel/test`);
+    const { success, message } = res.data;
+    if (success) {
+      showInfo(t('已成功开始测试所有已启用通道,请刷新页面查看结果。'));
+    } else {
+      showError(message);
+    }
+  };
+
+  const deleteAllDisabledChannels = async () => {
+    const res = await API.delete(`/api/channel/disabled`);
+    const { success, message, data } = res.data;
+    if (success) {
+      showSuccess(
+        t('已删除所有禁用渠道,共计 ${data} 个').replace('${data}', data),
+      );
+      await refresh();
+    } else {
+      showError(message);
+    }
+  };
+
+  const updateAllChannelsBalance = async () => {
+    const res = await API.get(`/api/channel/update_balance`);
+    const { success, message } = res.data;
+    if (success) {
+      showInfo(t('已更新完毕所有已启用通道余额!'));
+    } else {
+      showError(message);
+    }
+  };
+
+  const updateChannelBalance = async (record) => {
+    const res = await API.get(`/api/channel/update_balance/${record.id}/`);
+    const { success, message, balance } = res.data;
+    if (success) {
+      updateChannelProperty(record.id, (channel) => {
+        channel.balance = balance;
+        channel.balance_updated_time = Date.now() / 1000;
+      });
+      showInfo(
+        t('通道 ${name} 余额更新成功!').replace('${name}', record.name),
+      );
+    } else {
+      showError(message);
+    }
+  };
+
+  const fixChannelsAbilities = async () => {
+    const res = await API.post(`/api/channel/fix`);
+    const { success, message, data } = res.data;
+    if (success) {
+      showSuccess(t('已修复 ${success} 个通道,失败 ${fails} 个通道。').replace('${success}', data.success).replace('${fails}', data.fails));
+      await refresh();
+    } else {
+      showError(message);
+    }
+  };
+
+  // Test channel
+  const testChannel = async (record, model) => {
+    setTestQueue(prev => [...prev, { channel: record, model }]);
+    if (!isProcessingQueue) {
+      setIsProcessingQueue(true);
+    }
+  };
+
+  // Process test queue
+  const processTestQueue = async () => {
+    if (!isProcessingQueue || testQueue.length === 0) return;
+
+    const { channel, model, indexInFiltered } = testQueue[0];
+
+    if (currentTestChannel && currentTestChannel.id === channel.id) {
+      let pageNo;
+      if (indexInFiltered !== undefined) {
+        pageNo = Math.floor(indexInFiltered / MODEL_TABLE_PAGE_SIZE) + 1;
+      } else {
+        const filteredModelsList = currentTestChannel.models
+          .split(',')
+          .filter((m) => m.toLowerCase().includes(modelSearchKeyword.toLowerCase()));
+        const modelIdx = filteredModelsList.indexOf(model);
+        pageNo = modelIdx !== -1 ? Math.floor(modelIdx / MODEL_TABLE_PAGE_SIZE) + 1 : 1;
+      }
+      setModelTablePage(pageNo);
+    }
+
+    try {
+      setTestingModels(prev => new Set([...prev, model]));
+      const res = await API.get(`/api/channel/test/${channel.id}?model=${model}`);
+      const { success, message, time } = res.data;
+
+      setModelTestResults(prev => ({
+        ...prev,
+        [`${channel.id}-${model}`]: { success, time }
+      }));
+
+      if (success) {
+        updateChannelProperty(channel.id, (ch) => {
+          ch.response_time = time * 1000;
+          ch.test_time = Date.now() / 1000;
+        });
+        if (!model) {
+          showInfo(
+            t('通道 ${name} 测试成功,耗时 ${time.toFixed(2)} 秒。')
+              .replace('${name}', channel.name)
+              .replace('${time.toFixed(2)}', time.toFixed(2)),
+          );
+        }
+      } else {
+        showError(message);
+      }
+    } catch (error) {
+      showError(error.message);
+    } finally {
+      setTestingModels(prev => {
+        const newSet = new Set(prev);
+        newSet.delete(model);
+        return newSet;
+      });
+    }
+
+    setTestQueue(prev => prev.slice(1));
+  };
+
+  // Monitor queue changes
+  useEffect(() => {
+    if (testQueue.length > 0 && isProcessingQueue) {
+      processTestQueue();
+    } else if (testQueue.length === 0 && isProcessingQueue) {
+      setIsProcessingQueue(false);
+      setIsBatchTesting(false);
+    }
+  }, [testQueue, isProcessingQueue]);
+
+  // Batch test models
+  const batchTestModels = async () => {
+    if (!currentTestChannel) return;
+
+    setIsBatchTesting(true);
+    setModelTablePage(1);
+
+    const filteredModels = currentTestChannel.models
+      .split(',')
+      .filter((model) =>
+        model.toLowerCase().includes(modelSearchKeyword.toLowerCase()),
+      );
+
+    setTestQueue(
+      filteredModels.map((model, idx) => ({
+        channel: currentTestChannel,
+        model,
+        indexInFiltered: idx,
+      })),
+    );
+    setIsProcessingQueue(true);
+  };
+
+  // Handle close modal
+  const handleCloseModal = () => {
+    if (isBatchTesting) {
+      setTestQueue([]);
+      setIsProcessingQueue(false);
+      setIsBatchTesting(false);
+      showSuccess(t('已停止测试'));
+    } else {
+      setShowModelTestModal(false);
+      setModelSearchKeyword('');
+      setSelectedModelKeys([]);
+      setModelTablePage(1);
+    }
+  };
+
+  // Type counts
+  const channelTypeCounts = useMemo(() => {
+    if (Object.keys(typeCounts).length > 0) return typeCounts;
+    const counts = { all: channels.length };
+    channels.forEach((channel) => {
+      const collect = (ch) => {
+        const type = ch.type;
+        counts[type] = (counts[type] || 0) + 1;
+      };
+      if (channel.children !== undefined) {
+        channel.children.forEach(collect);
+      } else {
+        collect(channel);
+      }
+    });
+    return counts;
+  }, [typeCounts, channels]);
+
+  const availableTypeKeys = useMemo(() => {
+    const keys = ['all'];
+    Object.entries(channelTypeCounts).forEach(([k, v]) => {
+      if (k !== 'all' && v > 0) keys.push(String(k));
+    });
+    return keys;
+  }, [channelTypeCounts]);
+
+  return {
+    // Basic states
+    channels,
+    loading,
+    searching,
+    activePage,
+    pageSize,
+    channelCount,
+    groupOptions,
+    idSort,
+    enableTagMode,
+    enableBatchDelete,
+    statusFilter,
+    compactMode,
+
+    // UI states
+    showEdit,
+    setShowEdit,
+    editingChannel,
+    setEditingChannel,
+    showEditTag,
+    setShowEditTag,
+    editingTag,
+    setEditingTag,
+    selectedChannels,
+    setSelectedChannels,
+    showBatchSetTag,
+    setShowBatchSetTag,
+    batchSetTagValue,
+    setBatchSetTagValue,
+
+    // Column states
+    visibleColumns,
+    showColumnSelector,
+    setShowColumnSelector,
+    COLUMN_KEYS,
+
+    // Type tab states
+    activeTypeKey,
+    setActiveTypeKey,
+    typeCounts,
+    channelTypeCounts,
+    availableTypeKeys,
+
+    // Model test states
+    showModelTestModal,
+    setShowModelTestModal,
+    currentTestChannel,
+    setCurrentTestChannel,
+    modelSearchKeyword,
+    setModelSearchKeyword,
+    modelTestResults,
+    testingModels,
+    selectedModelKeys,
+    setSelectedModelKeys,
+    isBatchTesting,
+    modelTablePage,
+    setModelTablePage,
+    allSelectingRef,
+
+    // Form
+    formApi,
+    setFormApi,
+    formInitValues,
+
+    // Helpers
+    t,
+    isMobile,
+
+    // Functions
+    loadChannels,
+    searchChannels,
+    refresh,
+    manageChannel,
+    manageTag,
+    handlePageChange,
+    handlePageSizeChange,
+    copySelectedChannel,
+    updateChannelProperty,
+    submitTagEdit,
+    closeEdit,
+    handleRow,
+    batchSetChannelTag,
+    batchDeleteChannels,
+    testAllChannels,
+    deleteAllDisabledChannels,
+    updateAllChannelsBalance,
+    updateChannelBalance,
+    fixChannelsAbilities,
+    testChannel,
+    batchTestModels,
+    handleCloseModal,
+    getFormValues,
+
+    // Column functions
+    handleColumnVisibilityChange,
+    handleSelectAll,
+    initDefaultColumns,
+    getDefaultColumnVisibility,
+
+    // Setters
+    setIdSort,
+    setEnableTagMode,
+    setEnableBatchDelete,
+    setStatusFilter,
+    setCompactMode,
+    setActivePage,
+  };
+}; 

+ 2 - 2
web/src/hooks/useTokenKeys.js → web/src/hooks/chat/useTokenKeys.js

@@ -1,6 +1,6 @@
 import { useEffect, useState } from 'react';
-import { fetchTokenKeys, getServerAddress } from '../helpers/token';
-import { showError } from '../helpers';
+import { fetchTokenKeys, getServerAddress } from '../../helpers/token';
+import { showError } from '../../helpers';
 
 export function useTokenKeys(id) {
   const [keys, setKeys] = useState([]);

+ 0 - 0
web/src/hooks/useIsMobile.js → web/src/hooks/common/useIsMobile.js


+ 0 - 0
web/src/hooks/useSidebarCollapsed.js → web/src/hooks/common/useSidebarCollapsed.js


+ 2 - 2
web/src/hooks/useTableCompactMode.js → web/src/hooks/common/useTableCompactMode.js

@@ -1,6 +1,6 @@
 import { useState, useEffect, useCallback } from 'react';
-import { getTableCompactMode, setTableCompactMode } from '../helpers';
-import { TABLE_COMPACT_MODES_KEY } from '../constants';
+import { getTableCompactMode, setTableCompactMode } from '../../helpers';
+import { TABLE_COMPACT_MODES_KEY } from '../../constants';
 
 /**
  * 自定义 Hook:管理表格紧凑/自适应模式

+ 2 - 2
web/src/hooks/useApiRequest.js → web/src/hooks/playground/useApiRequest.js

@@ -5,13 +5,13 @@ import {
   API_ENDPOINTS,
   MESSAGE_STATUS,
   DEBUG_TABS
-} from '../constants/playground.constants';
+} from '../../constants/playground.constants';
 import {
   getUserIdFromLocalStorage,
   handleApiError,
   processThinkTags,
   processIncompleteThinkTags
-} from '../helpers';
+} from '../../helpers';
 
 export const useApiRequest = (
   setMessage,

+ 2 - 2
web/src/hooks/useDataLoader.js → web/src/hooks/playground/useDataLoader.js

@@ -1,7 +1,7 @@
 import { useCallback, useEffect } from 'react';
 import { useTranslation } from 'react-i18next';
-import { API, processModelsData, processGroupsData } from '../helpers';
-import { API_ENDPOINTS } from '../constants/playground.constants';
+import { API, processModelsData, processGroupsData } from '../../helpers';
+import { API_ENDPOINTS } from '../../constants/playground.constants';
 
 export const useDataLoader = (
   userState,

+ 2 - 2
web/src/hooks/useMessageActions.js → web/src/hooks/playground/useMessageActions.js

@@ -1,8 +1,8 @@
 import { useCallback } from 'react';
 import { Toast, Modal } from '@douyinfe/semi-ui';
 import { useTranslation } from 'react-i18next';
-import { getTextContent } from '../helpers';
-import { ERROR_MESSAGES } from '../constants/playground.constants';
+import { getTextContent } from '../../helpers';
+import { ERROR_MESSAGES } from '../../constants/playground.constants';
 
 export const useMessageActions = (message, setMessage, onMessageSend, saveMessages) => {
   const { t } = useTranslation();

+ 2 - 2
web/src/hooks/useMessageEdit.js → web/src/hooks/playground/useMessageEdit.js

@@ -1,8 +1,8 @@
 import { useCallback, useState, useRef } from 'react';
 import { Toast, Modal } from '@douyinfe/semi-ui';
 import { useTranslation } from 'react-i18next';
-import { getTextContent, buildApiPayload, createLoadingAssistantMessage } from '../helpers';
-import { MESSAGE_ROLES } from '../constants/playground.constants';
+import { getTextContent, buildApiPayload, createLoadingAssistantMessage } from '../../helpers';
+import { MESSAGE_ROLES } from '../../constants/playground.constants';
 
 export const useMessageEdit = (
   setMessage,

+ 3 - 3
web/src/hooks/usePlaygroundState.js → web/src/hooks/playground/usePlaygroundState.js

@@ -1,7 +1,7 @@
 import { useState, useCallback, useRef, useEffect } from 'react';
-import { DEFAULT_MESSAGES, DEFAULT_CONFIG, DEBUG_TABS, MESSAGE_STATUS } from '../constants/playground.constants';
-import { loadConfig, saveConfig, loadMessages, saveMessages } from '../components/playground/configStorage';
-import { processIncompleteThinkTags } from '../helpers';
+import { DEFAULT_MESSAGES, DEFAULT_CONFIG, DEBUG_TABS, MESSAGE_STATUS } from '../../constants/playground.constants';
+import { loadConfig, saveConfig, loadMessages, saveMessages } from '../../components/playground/configStorage';
+import { processIncompleteThinkTags } from '../../helpers';
 
 export const usePlaygroundState = () => {
   // 使用惰性初始化,确保只在组件首次挂载时加载配置和消息

+ 1 - 1
web/src/hooks/useSyncMessageAndCustomBody.js → web/src/hooks/playground/useSyncMessageAndCustomBody.js

@@ -1,5 +1,5 @@
 import { useCallback, useRef } from 'react';
-import { MESSAGE_ROLES } from '../constants/playground.constants';
+import { MESSAGE_ROLES } from '../../constants/playground.constants';
 
 export const useSyncMessageAndCustomBody = (
   customRequestMode,

+ 1 - 1
web/src/pages/Channel/EditChannel.js

@@ -8,7 +8,7 @@ import {
   showSuccess,
   verifyJSON,
 } from '../../helpers';
-import { useIsMobile } from '../../hooks/useIsMobile.js';
+import { useIsMobile } from '../../hooks/common/useIsMobile.js';
 import { CHANNEL_OPTIONS } from '../../constants';
 import {
   SideSheet,

+ 1 - 1
web/src/pages/Chat/index.js

@@ -1,5 +1,5 @@
 import React from 'react';
-import { useTokenKeys } from '../../hooks/useTokenKeys';
+import { useTokenKeys } from '../../hooks/chat/useTokenKeys';
 import { Spin } from '@douyinfe/semi-ui';
 import { useParams } from 'react-router-dom';
 import { useTranslation } from 'react-i18next';

+ 1 - 1
web/src/pages/Chat2Link/index.js

@@ -1,5 +1,5 @@
 import React from 'react';
-import { useTokenKeys } from '../../hooks/useTokenKeys';
+import { useTokenKeys } from '../../hooks/chat/useTokenKeys';
 
 const chat2page = () => {
   const { keys, chatLink, serverAddress, isLoading } = useTokenKeys();

+ 1 - 1
web/src/pages/Detail/index.js

@@ -54,7 +54,7 @@ import {
   copy,
   getRelativeTime
 } from '../../helpers';
-import { useIsMobile } from '../../hooks/useIsMobile.js';
+import { useIsMobile } from '../../hooks/common/useIsMobile.js';
 import { UserContext } from '../../context/User/index.js';
 import { StatusContext } from '../../context/Status/index.js';
 import { useTranslation } from 'react-i18next';

+ 1 - 1
web/src/pages/Home/index.js

@@ -1,7 +1,7 @@
 import React, { useContext, useEffect, useState } from 'react';
 import { Button, Typography, Tag, Input, ScrollList, ScrollItem } from '@douyinfe/semi-ui';
 import { API, showError, copy, showSuccess } from '../../helpers';
-import { useIsMobile } from '../../hooks/useIsMobile.js';
+import { useIsMobile } from '../../hooks/common/useIsMobile.js';
 import { API_ENDPOINTS } from '../../constants/common.constant';
 import { StatusContext } from '../../context/Status';
 import { marked } from 'marked';

+ 7 - 7
web/src/pages/Playground/index.js

@@ -5,15 +5,15 @@ import { Layout, Toast, Modal } from '@douyinfe/semi-ui';
 
 // Context
 import { UserContext } from '../../context/User/index.js';
-import { useIsMobile } from '../../hooks/useIsMobile.js';
+import { useIsMobile } from '../../hooks/common/useIsMobile.js';
 
 // hooks
-import { usePlaygroundState } from '../../hooks/usePlaygroundState.js';
-import { useMessageActions } from '../../hooks/useMessageActions.js';
-import { useApiRequest } from '../../hooks/useApiRequest.js';
-import { useSyncMessageAndCustomBody } from '../../hooks/useSyncMessageAndCustomBody.js';
-import { useMessageEdit } from '../../hooks/useMessageEdit.js';
-import { useDataLoader } from '../../hooks/useDataLoader.js';
+import { usePlaygroundState } from '../../hooks/playground/usePlaygroundState.js';
+import { useMessageActions } from '../../hooks/playground/useMessageActions.js';
+import { useApiRequest } from '../../hooks/playground/useApiRequest.js';
+import { useSyncMessageAndCustomBody } from '../../hooks/playground/useSyncMessageAndCustomBody.js';
+import { useMessageEdit } from '../../hooks/playground/useMessageEdit.js';
+import { useDataLoader } from '../../hooks/playground/useDataLoader.js';
 
 // Constants and utils
 import {

+ 1 - 1
web/src/pages/Redemption/EditRedemption.js

@@ -8,7 +8,7 @@ import {
   renderQuota,
   renderQuotaWithPrompt,
 } from '../../helpers';
-import { useIsMobile } from '../../hooks/useIsMobile.js';
+import { useIsMobile } from '../../hooks/common/useIsMobile.js';
 import {
   Button,
   Modal,

+ 1 - 1
web/src/pages/Setting/Ratio/UpstreamRatioSync.js

@@ -19,7 +19,7 @@ import {
   CheckCircle,
 } from 'lucide-react';
 import { API, showError, showSuccess, showWarning, stringToColor } from '../../../helpers';
-import { useIsMobile } from '../../../hooks/useIsMobile.js';
+import { useIsMobile } from '../../../hooks/common/useIsMobile.js';
 import { DEFAULT_ENDPOINT } from '../../../constants';
 import { useTranslation } from 'react-i18next';
 import {

+ 1 - 1
web/src/pages/Token/EditToken.js

@@ -8,7 +8,7 @@ import {
   renderQuotaWithPrompt,
   getModelCategories,
 } from '../../helpers';
-import { useIsMobile } from '../../hooks/useIsMobile.js';
+import { useIsMobile } from '../../hooks/common/useIsMobile.js';
 import {
   Button,
   SideSheet,

+ 1 - 1
web/src/pages/User/AddUser.js

@@ -1,6 +1,6 @@
 import React, { useState, useRef } from 'react';
 import { API, showError, showSuccess } from '../../helpers';
-import { useIsMobile } from '../../hooks/useIsMobile.js';
+import { useIsMobile } from '../../hooks/common/useIsMobile.js';
 import {
   Button,
   SideSheet,

+ 1 - 1
web/src/pages/User/EditUser.js

@@ -7,7 +7,7 @@ import {
   renderQuota,
   renderQuotaWithPrompt,
 } from '../../helpers';
-import { useIsMobile } from '../../hooks/useIsMobile.js';
+import { useIsMobile } from '../../hooks/common/useIsMobile.js';
 import {
   Button,
   Modal,