Explorar o código

🚀 feat: enhance model testing UI with bulk selection, copy & success-filter buttons (#1288)

* ChannelsTable
  - Added row-level checkboxes to the model-testing table for multi-selection
  - Implemented cross-page “Select All / Deselect All” via rowSelection.onSelectAll
  - Introduced allSelectingRef to ignore redundant onChange after onSelectAll
  - Added “Copy Selected” button to copy chosen model names (comma-separated) using helpers.copy
  - Added “Select Successful” button to auto-tick all models that passed testing
  - Moved search bar and new action buttons into the modal title for better UX
  - Centralised page size constant MODEL_TABLE_PAGE_SIZE in channel.constants.js
  - Fixed pagination slicing and auto-page-switch logic during batch testing

* channel.constants
  - Exported MODEL_TABLE_PAGE_SIZE (default 10) for unified pagination control

This commit enables users to conveniently copy or filter successful models, fully supports cross-page bulk operations, and resolves previous selection inconsistencies.

Refs: #1288
t0ng7u hai 8 meses
pai
achega
44495b153a

+ 144 - 38
web/src/components/table/ChannelsTable.js

@@ -20,7 +20,7 @@ import {
   Tags,
   Tags,
 } from 'lucide-react';
 } from 'lucide-react';
 
 
-import { CHANNEL_OPTIONS, ITEMS_PER_PAGE } from '../../constants/index.js';
+import { CHANNEL_OPTIONS, ITEMS_PER_PAGE, MODEL_TABLE_PAGE_SIZE } from '../../constants/index.js';
 import {
 import {
   Button,
   Button,
   Divider,
   Divider,
@@ -63,7 +63,7 @@ import {
   IconCopy,
   IconCopy,
   IconSmallTriangleRight
   IconSmallTriangleRight
 } from '@douyinfe/semi-icons';
 } from '@douyinfe/semi-icons';
-import { loadChannelModels } from '../../helpers/index.js';
+import { loadChannelModels, isMobile, copy } from '../../helpers';
 import EditTagModal from '../../pages/Channel/EditTagModal.js';
 import EditTagModal from '../../pages/Channel/EditTagModal.js';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { useTableCompactMode } from '../../hooks/useTableCompactMode';
 import { useTableCompactMode } from '../../hooks/useTableCompactMode';
@@ -684,9 +684,11 @@ const ChannelsTable = () => {
   const [modelSearchKeyword, setModelSearchKeyword] = useState('');
   const [modelSearchKeyword, setModelSearchKeyword] = useState('');
   const [modelTestResults, setModelTestResults] = useState({});
   const [modelTestResults, setModelTestResults] = useState({});
   const [testingModels, setTestingModels] = useState(new Set());
   const [testingModels, setTestingModels] = useState(new Set());
+  const [selectedModelKeys, setSelectedModelKeys] = useState([]);
   const [isBatchTesting, setIsBatchTesting] = useState(false);
   const [isBatchTesting, setIsBatchTesting] = useState(false);
   const [testQueue, setTestQueue] = useState([]);
   const [testQueue, setTestQueue] = useState([]);
   const [isProcessingQueue, setIsProcessingQueue] = useState(false);
   const [isProcessingQueue, setIsProcessingQueue] = useState(false);
+  const [modelTablePage, setModelTablePage] = useState(1);
   const [activeTypeKey, setActiveTypeKey] = useState('all');
   const [activeTypeKey, setActiveTypeKey] = useState('all');
   const [typeCounts, setTypeCounts] = useState({});
   const [typeCounts, setTypeCounts] = useState({});
   const requestCounter = useRef(0);
   const requestCounter = useRef(0);
@@ -697,6 +699,7 @@ const ChannelsTable = () => {
     searchGroup: '',
     searchGroup: '',
     searchModel: '',
     searchModel: '',
   };
   };
+  const allSelectingRef = useRef(false);
 
 
   // Filter columns based on visibility settings
   // Filter columns based on visibility settings
   const getVisibleColumns = () => {
   const getVisibleColumns = () => {
@@ -1131,7 +1134,22 @@ const ChannelsTable = () => {
   const processTestQueue = async () => {
   const processTestQueue = async () => {
     if (!isProcessingQueue || testQueue.length === 0) return;
     if (!isProcessingQueue || testQueue.length === 0) return;
 
 
-    const { channel, model } = testQueue[0];
+    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 {
     try {
       setTestingModels(prev => new Set([...prev, model]));
       setTestingModels(prev => new Set([...prev, model]));
@@ -1194,16 +1212,22 @@ const ChannelsTable = () => {
 
 
     setIsBatchTesting(true);
     setIsBatchTesting(true);
 
 
-    const models = currentTestChannel.models
+    // 重置分页到第一页
+    setModelTablePage(1);
+
+    const filteredModels = currentTestChannel.models
       .split(',')
       .split(',')
       .filter((model) =>
       .filter((model) =>
-        model.toLowerCase().includes(modelSearchKeyword.toLowerCase())
+        model.toLowerCase().includes(modelSearchKeyword.toLowerCase()),
       );
       );
 
 
-    setTestQueue(models.map(model => ({
-      channel: currentTestChannel,
-      model
-    })));
+    setTestQueue(
+      filteredModels.map((model, idx) => ({
+        channel: currentTestChannel,
+        model,
+        indexInFiltered: idx, // 记录在过滤列表中的顺序
+      })),
+    );
     setIsProcessingQueue(true);
     setIsProcessingQueue(true);
   };
   };
 
 
@@ -1217,6 +1241,8 @@ const ChannelsTable = () => {
     } else {
     } else {
       setShowModelTestModal(false);
       setShowModelTestModal(false);
       setModelSearchKeyword('');
       setModelSearchKeyword('');
+      setSelectedModelKeys([]);
+      setModelTablePage(1);
     }
     }
   };
   };
 
 
@@ -1912,13 +1938,73 @@ const ChannelsTable = () => {
       <Modal
       <Modal
         title={
         title={
           currentTestChannel && (
           currentTestChannel && (
-            <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 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 className="flex items-center justify-end gap-2 w-full">
+                <Input
+                  placeholder={t('搜索模型...')}
+                  value={modelSearchKeyword}
+                  onChange={(v) => {
+                    setModelSearchKeyword(v);
+                    setModelTablePage(1);
+                  }}
+                  className="!w-full !rounded-full"
+                  prefix={<IconSearch />}
+                  showClear
+                />
+
+                <Button
+                  theme='light'
+                  icon={<IconCopy />}
+                  className="!rounded-full"
+                  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
+                  theme='light'
+                  type='primary'
+                  className="!rounded-full"
+                  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>
             </div>
             </div>
           )
           )
         }
         }
@@ -1968,22 +2054,11 @@ const ChannelsTable = () => {
         }
         }
         maskClosable={!isBatchTesting}
         maskClosable={!isBatchTesting}
         className="!rounded-lg"
         className="!rounded-lg"
-        size="large"
+        size={isMobile() ? 'full-width' : 'large'}
       >
       >
-        <div className="max-h-[600px] overflow-y-auto">
+        <div className="model-test-scroll">
           {currentTestChannel && (
           {currentTestChannel && (
             <div>
             <div>
-              <div className="flex items-center justify-end mb-2">
-                <Input
-                  placeholder={t('搜索模型...')}
-                  value={modelSearchKeyword}
-                  onChange={(v) => setModelSearchKeyword(v)}
-                  className="w-64 !rounded-full"
-                  prefix={<IconSearch />}
-                  showClear
-                />
-              </div>
-
               <Table
               <Table
                 columns={[
                 columns={[
                   {
                   {
@@ -2057,16 +2132,47 @@ const ChannelsTable = () => {
                     }
                     }
                   }
                   }
                 ]}
                 ]}
-                dataSource={currentTestChannel.models
-                  .split(',')
-                  .filter((model) =>
-                    model.toLowerCase().includes(modelSearchKeyword.toLowerCase())
-                  )
-                  .map((model) => ({
+                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,
                     model,
-                    key: model
-                  }))}
-                pagination={false}
+                    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>
           )}
           )}

+ 2 - 0
web/src/constants/channel.constants.js

@@ -131,3 +131,5 @@ export const CHANNEL_OPTIONS = [
     label: '可灵',
     label: '可灵',
   },
   },
 ];
 ];
+
+export const MODEL_TABLE_PAGE_SIZE = 10;

+ 7 - 1
web/src/i18n/locales/en.json

@@ -1737,5 +1737,11 @@
   "状态筛选": "Status filter",
   "状态筛选": "Status filter",
   "没有模型可以复制": "No models to copy",
   "没有模型可以复制": "No models to copy",
   "模型列表已复制到剪贴板": "Model list copied to clipboard",
   "模型列表已复制到剪贴板": "Model list copied to clipboard",
-  "复制失败": "Copy failed"
+  "复制失败": "Copy failed",
+  "复制已选": "Copy selected",
+  "选择成功": "Selection successful",
+  "暂无成功模型": "No successful models",
+  "请先选择模型!": "Please select a model first!",
+  "已复制 ${count} 个模型": "Copied ${count} models",
+  "复制失败,请手动复制": "Copy failed, please copy manually"
 }
 }

+ 2 - 0
web/src/index.css

@@ -375,6 +375,7 @@ code {
 }
 }
 
 
 /* 隐藏卡片内容区域的滚动条 */
 /* 隐藏卡片内容区域的滚动条 */
+.model-test-scroll,
 .card-content-scroll,
 .card-content-scroll,
 .model-settings-scroll,
 .model-settings-scroll,
 .thinking-content-scroll,
 .thinking-content-scroll,
@@ -385,6 +386,7 @@ code {
   scrollbar-width: none;
   scrollbar-width: none;
 }
 }
 
 
+.model-test-scroll::-webkit-scrollbar,
 .card-content-scroll::-webkit-scrollbar,
 .card-content-scroll::-webkit-scrollbar,
 .model-settings-scroll::-webkit-scrollbar,
 .model-settings-scroll::-webkit-scrollbar,
 .thinking-content-scroll::-webkit-scrollbar,
 .thinking-content-scroll::-webkit-scrollbar,