Browse Source

✨ feat: Enhance Channel Model Management UI

Summary
• Introduced standalone `ModelSelectModal.jsx` for selecting channel models
• Fetch-list now opens modal instead of in-place select, keeping EditChannelModal lean

Modal Features
1. Search bar with `IconSearch`, keyboard clear & mobile full-screen support
2. Tab layout (“New Models” / “Existing Models”) displayed next to title, responsive wrapping
3. Models grouped by vendor via `getModelCategories` and rendered inside always-expanded `Collapse` panels
4. Per-category checkbox in panel extra area for bulk select / deselect
5. Footer checkbox for bulk select of all models in current tab, with real-time counter
6. Empty state uses `IllustrationNoResult` / `IllustrationNoResultDark` for visual consistency
7. Accessible header/footer paddings aligned with Semi UI defaults

Fixes & Improvements
• All indeterminate and full-select states handled correctly
• Consistent “selected X / Y” stats synced with active tab, not global list
• All panels now controlled via `activeKey`, ensuring they remain expanded
• Search, vendor grouping, and responsive layout tested across mobile & desktop

These changes modernise the channel model management workflow and prepare the codebase for upcoming upstream-ratio integration.
t0ng7u 5 months ago
parent
commit
4ed92a94a1

+ 18 - 3
web/src/components/table/channels/modals/EditChannelModal.jsx

@@ -47,6 +47,7 @@ import {
   Highlight,
   Highlight,
 } from '@douyinfe/semi-ui';
 } from '@douyinfe/semi-ui';
 import { getChannelModels, copy, getChannelIcon, getModelCategories, selectFilter } from '../../../../helpers';
 import { getChannelModels, copy, getChannelIcon, getModelCategories, selectFilter } from '../../../../helpers';
+import ModelSelectModal from './ModelSelectModal';
 import {
 import {
   IconSave,
   IconSave,
   IconClose,
   IconClose,
@@ -141,6 +142,8 @@ const EditChannelModal = (props) => {
   const [customModel, setCustomModel] = useState('');
   const [customModel, setCustomModel] = useState('');
   const [modalImageUrl, setModalImageUrl] = useState('');
   const [modalImageUrl, setModalImageUrl] = useState('');
   const [isModalOpenurl, setIsModalOpenurl] = useState(false);
   const [isModalOpenurl, setIsModalOpenurl] = useState(false);
+  const [modelModalVisible, setModelModalVisible] = useState(false);
+  const [fetchedModels, setFetchedModels] = useState([]);
   const formApiRef = useRef(null);
   const formApiRef = useRef(null);
   const [vertexKeys, setVertexKeys] = useState([]);
   const [vertexKeys, setVertexKeys] = useState([]);
   const [vertexFileList, setVertexFileList] = useState([]);
   const [vertexFileList, setVertexFileList] = useState([]);
@@ -378,7 +381,7 @@ const EditChannelModal = (props) => {
     //   return;
     //   return;
     // }
     // }
     setLoading(true);
     setLoading(true);
-    const models = inputs['models'] || [];
+    const models = [];
     let err = false;
     let err = false;
 
 
     if (isEdit) {
     if (isEdit) {
@@ -419,8 +422,9 @@ const EditChannelModal = (props) => {
     }
     }
 
 
     if (!err) {
     if (!err) {
-      handleInputChange(name, Array.from(new Set(models)));
-      showSuccess(t('获取模型列表成功'));
+      const uniqueModels = Array.from(new Set(models));
+      setFetchedModels(uniqueModels);
+      setModelModalVisible(true);
     } else {
     } else {
       showError(t('获取模型列表失败'));
       showError(t('获取模型列表失败'));
     }
     }
@@ -1650,6 +1654,17 @@ const EditChannelModal = (props) => {
           onVisibleChange={(visible) => setIsModalOpenurl(visible)}
           onVisibleChange={(visible) => setIsModalOpenurl(visible)}
         />
         />
       </SideSheet>
       </SideSheet>
+      <ModelSelectModal
+        visible={modelModalVisible}
+        models={fetchedModels}
+        selected={inputs.models}
+        onConfirm={(selectedModels) => {
+          handleInputChange('models', selectedModels);
+          showSuccess(t('模型列表已更新'));
+          setModelModalVisible(false);
+        }}
+        onCancel={() => setModelModalVisible(false)}
+      />
     </>
     </>
   );
   );
 };
 };

+ 272 - 0
web/src/components/table/channels/modals/ModelSelectModal.jsx

@@ -0,0 +1,272 @@
+import React, { useState, useEffect } from 'react';
+import { useIsMobile } from '../../../../hooks/common/useIsMobile.js';
+import { Modal, Checkbox, Spin, Input, Typography, Empty, Tabs, Collapse } from '@douyinfe/semi-ui';
+import {
+  IllustrationNoResult,
+  IllustrationNoResultDark
+} from '@douyinfe/semi-illustrations';
+import { IconSearch } from '@douyinfe/semi-icons';
+import { useTranslation } from 'react-i18next';
+import { getModelCategories } from '../../../../helpers/render';
+
+const ModelSelectModal = ({ visible, models = [], selected = [], onConfirm, onCancel }) => {
+  const { t } = useTranslation();
+  const [checkedList, setCheckedList] = useState(selected);
+  const [keyword, setKeyword] = useState('');
+  const [activeTab, setActiveTab] = useState('new');
+
+  const isMobile = useIsMobile();
+
+  const filteredModels = models.filter((m) => m.toLowerCase().includes(keyword.toLowerCase()));
+
+  // 分类模型:新获取的模型和已有模型
+  const newModels = filteredModels.filter(model => !selected.includes(model));
+  const existingModels = filteredModels.filter(model => selected.includes(model));
+
+  // 同步外部选中值
+  useEffect(() => {
+    if (visible) {
+      setCheckedList(selected);
+    }
+  }, [visible, selected]);
+
+  // 当模型列表变化时,设置默认tab
+  useEffect(() => {
+    if (visible) {
+      // 默认显示新获取模型tab,如果没有新模型则显示已有模型
+      const hasNewModels = newModels.length > 0;
+      setActiveTab(hasNewModels ? 'new' : 'existing');
+    }
+  }, [visible, newModels.length, selected]);
+
+  const handleOk = () => {
+    onConfirm && onConfirm(checkedList);
+  };
+
+  // 按厂商分类模型
+  const categorizeModels = (models) => {
+    const categories = getModelCategories(t);
+    const categorizedModels = {};
+    const uncategorizedModels = [];
+
+    models.forEach(model => {
+      let foundCategory = false;
+      for (const [key, category] of Object.entries(categories)) {
+        if (key !== 'all' && category.filter({ model_name: model })) {
+          if (!categorizedModels[key]) {
+            categorizedModels[key] = {
+              label: category.label,
+              icon: category.icon,
+              models: []
+            };
+          }
+          categorizedModels[key].models.push(model);
+          foundCategory = true;
+          break;
+        }
+      }
+      if (!foundCategory) {
+        uncategorizedModels.push(model);
+      }
+    });
+
+    // 如果有未分类模型,添加到"其他"分类
+    if (uncategorizedModels.length > 0) {
+      categorizedModels['other'] = {
+        label: t('其他'),
+        icon: null,
+        models: uncategorizedModels
+      };
+    }
+
+    return categorizedModels;
+  };
+
+  const newModelsByCategory = categorizeModels(newModels);
+  const existingModelsByCategory = categorizeModels(existingModels);
+
+  // Tab列表配置
+  const tabList = [
+    ...(newModels.length > 0 ? [{
+      tab: `${t('新获取的模型')} (${newModels.length})`,
+      itemKey: 'new'
+    }] : []),
+    ...(existingModels.length > 0 ? [{
+      tab: `${t('已有的模型')} (${existingModels.length})`,
+      itemKey: 'existing'
+    }] : [])
+  ];
+
+  // 处理分类全选/取消全选
+  const handleCategorySelectAll = (categoryModels, isChecked) => {
+    let newCheckedList = [...checkedList];
+
+    if (isChecked) {
+      // 全选:添加该分类下所有未选中的模型
+      categoryModels.forEach(model => {
+        if (!newCheckedList.includes(model)) {
+          newCheckedList.push(model);
+        }
+      });
+    } else {
+      // 取消全选:移除该分类下所有已选中的模型
+      newCheckedList = newCheckedList.filter(model => !categoryModels.includes(model));
+    }
+
+    setCheckedList(newCheckedList);
+  };
+
+  // 检查分类是否全选
+  const isCategoryAllSelected = (categoryModels) => {
+    return categoryModels.length > 0 && categoryModels.every(model => checkedList.includes(model));
+  };
+
+  // 检查分类是否部分选中
+  const isCategoryIndeterminate = (categoryModels) => {
+    const selectedCount = categoryModels.filter(model => checkedList.includes(model)).length;
+    return selectedCount > 0 && selectedCount < categoryModels.length;
+  };
+
+  const renderModelsByCategory = (modelsByCategory, categoryKeyPrefix) => {
+    const categoryEntries = Object.entries(modelsByCategory);
+    if (categoryEntries.length === 0) return null;
+
+    // 生成所有面板的key,确保都展开
+    const allActiveKeys = categoryEntries.map((_, index) => `${categoryKeyPrefix}_${index}`);
+
+    return (
+      <Collapse activeKey={allActiveKeys}>
+        {categoryEntries.map(([key, categoryData], index) => (
+          <Collapse.Panel
+            key={`${categoryKeyPrefix}_${index}`}
+            itemKey={`${categoryKeyPrefix}_${index}`}
+            header={`${categoryData.label} (${categoryData.models.length})`}
+            extra={
+              <Checkbox
+                checked={isCategoryAllSelected(categoryData.models)}
+                indeterminate={isCategoryIndeterminate(categoryData.models)}
+                onChange={(e) => {
+                  e.stopPropagation(); // 防止触发面板折叠
+                  handleCategorySelectAll(categoryData.models, e.target.checked);
+                }}
+                onClick={(e) => e.stopPropagation()} // 防止点击checkbox时折叠面板
+              />
+            }
+          >
+            <div className="flex items-center gap-2 mb-3">
+              {categoryData.icon}
+              <Typography.Text type="secondary" size="small">
+                {t('已选择 {{selected}} / {{total}}', {
+                  selected: categoryData.models.filter(model => checkedList.includes(model)).length,
+                  total: categoryData.models.length
+                })}
+              </Typography.Text>
+            </div>
+            <div className="grid grid-cols-2 gap-x-4">
+              {categoryData.models.map((model) => (
+                <Checkbox key={model} value={model} className="my-1">
+                  {model}
+                </Checkbox>
+              ))}
+            </div>
+          </Collapse.Panel>
+        ))}
+      </Collapse>
+    );
+  };
+
+  return (
+    <Modal
+      header={
+        <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-4 py-4">
+          <Typography.Title heading={5} className="m-0">
+            {t('选择模型')}
+          </Typography.Title>
+          <div className="flex-shrink-0">
+            <Tabs
+              type="slash"
+              size="small"
+              tabList={tabList}
+              activeKey={activeTab}
+              onChange={(key) => setActiveTab(key)}
+            />
+          </div>
+        </div>
+      }
+      visible={visible}
+      onOk={handleOk}
+      onCancel={onCancel}
+      okText={t('确定')}
+      cancelText={t('取消')}
+      size={isMobile ? 'full-width' : 'large'}
+      closeOnEsc
+      maskClosable
+      centered
+    >
+      <Input
+        prefix={<IconSearch size={14} />}
+        placeholder={t('搜索模型')}
+        value={keyword}
+        onChange={(v) => setKeyword(v)}
+        showClear
+      />
+
+      <Spin spinning={!models || models.length === 0}>
+        <div style={{ maxHeight: 400, overflowY: 'auto', paddingRight: 8 }}>
+          {filteredModels.length === 0 ? (
+            <Empty
+              image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
+              darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
+              description={t('暂无匹配模型')}
+              style={{ padding: 30 }}
+            />
+          ) : (
+            <Checkbox.Group value={checkedList} onChange={(vals) => setCheckedList(vals)}>
+              {activeTab === 'new' && newModels.length > 0 && (
+                <div>
+                  {renderModelsByCategory(newModelsByCategory, 'new')}
+                </div>
+              )}
+              {activeTab === 'existing' && existingModels.length > 0 && (
+                <div>
+                  {renderModelsByCategory(existingModelsByCategory, 'existing')}
+                </div>
+              )}
+            </Checkbox.Group>
+          )}
+        </div>
+      </Spin>
+
+      <Typography.Text type="secondary" size="small" className="block text-right mt-4">
+        <div className="flex items-center justify-end gap-2">
+          {(() => {
+            const currentModels = activeTab === 'new' ? newModels : existingModels;
+            const currentSelected = currentModels.filter(model => checkedList.includes(model)).length;
+            const isAllSelected = currentModels.length > 0 && currentSelected === currentModels.length;
+            const isIndeterminate = currentSelected > 0 && currentSelected < currentModels.length;
+
+            return (
+              <>
+                <span>
+                  {t('已选择 {{selected}} / {{total}}', {
+                    selected: currentSelected,
+                    total: currentModels.length
+                  })}
+                </span>
+                <Checkbox
+                  checked={isAllSelected}
+                  indeterminate={isIndeterminate}
+                  onChange={(e) => {
+                    handleCategorySelectAll(currentModels, e.target.checked);
+                  }}
+                />
+              </>
+            );
+          })()}
+        </div>
+      </Typography.Text>
+    </Modal>
+  );
+};
+
+export default ModelSelectModal; 

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

@@ -1799,5 +1799,10 @@
   "显示第": "Showing",
   "显示第": "Showing",
   "条 - 第": "to",
   "条 - 第": "to",
   "条,共": "of",
   "条,共": "of",
-  "条": "items"
+  "条": "items",
+  "选择模型": "Select model",
+  "已选择 {{selected}} / {{total}}": "Selected {{selected}} / {{total}}",
+  "新获取的模型": "New models",
+  "已有的模型": "Existing models",
+  "搜索模型": "Search models"
 }
 }