From 4ed92a94a17c9dbd347ca5ffb8f0d6ffc2347645 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Mon, 28 Jul 2025 01:33:23 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20Enhance=20Channel=20Model?= =?UTF-8?q?=20Management=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../channels/modals/EditChannelModal.jsx | 21 +- .../channels/modals/ModelSelectModal.jsx | 272 ++++++++++++++++++ web/src/i18n/locales/en.json | 7 +- 3 files changed, 296 insertions(+), 4 deletions(-) create mode 100644 web/src/components/table/channels/modals/ModelSelectModal.jsx diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index a62ec286..a3f09166 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/src/components/table/channels/modals/EditChannelModal.jsx @@ -47,6 +47,7 @@ import { Highlight, } from '@douyinfe/semi-ui'; import { getChannelModels, copy, getChannelIcon, getModelCategories, selectFilter } from '../../../../helpers'; +import ModelSelectModal from './ModelSelectModal'; import { IconSave, IconClose, @@ -141,6 +142,8 @@ const EditChannelModal = (props) => { const [customModel, setCustomModel] = useState(''); const [modalImageUrl, setModalImageUrl] = useState(''); const [isModalOpenurl, setIsModalOpenurl] = useState(false); + const [modelModalVisible, setModelModalVisible] = useState(false); + const [fetchedModels, setFetchedModels] = useState([]); const formApiRef = useRef(null); const [vertexKeys, setVertexKeys] = useState([]); const [vertexFileList, setVertexFileList] = useState([]); @@ -378,7 +381,7 @@ const EditChannelModal = (props) => { // return; // } setLoading(true); - const models = inputs['models'] || []; + const models = []; let err = false; if (isEdit) { @@ -419,8 +422,9 @@ const EditChannelModal = (props) => { } if (!err) { - handleInputChange(name, Array.from(new Set(models))); - showSuccess(t('获取模型列表成功')); + const uniqueModels = Array.from(new Set(models)); + setFetchedModels(uniqueModels); + setModelModalVisible(true); } else { showError(t('获取模型列表失败')); } @@ -1650,6 +1654,17 @@ const EditChannelModal = (props) => { onVisibleChange={(visible) => setIsModalOpenurl(visible)} /> + { + handleInputChange('models', selectedModels); + showSuccess(t('模型列表已更新')); + setModelModalVisible(false); + }} + onCancel={() => setModelModalVisible(false)} + /> ); }; diff --git a/web/src/components/table/channels/modals/ModelSelectModal.jsx b/web/src/components/table/channels/modals/ModelSelectModal.jsx new file mode 100644 index 00000000..253d7254 --- /dev/null +++ b/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 ( + + {categoryEntries.map(([key, categoryData], index) => ( + { + e.stopPropagation(); // 防止触发面板折叠 + handleCategorySelectAll(categoryData.models, e.target.checked); + }} + onClick={(e) => e.stopPropagation()} // 防止点击checkbox时折叠面板 + /> + } + > +
+ {categoryData.icon} + + {t('已选择 {{selected}} / {{total}}', { + selected: categoryData.models.filter(model => checkedList.includes(model)).length, + total: categoryData.models.length + })} + +
+
+ {categoryData.models.map((model) => ( + + {model} + + ))} +
+
+ ))} +
+ ); + }; + + return ( + + + {t('选择模型')} + +
+ setActiveTab(key)} + /> +
+ + } + visible={visible} + onOk={handleOk} + onCancel={onCancel} + okText={t('确定')} + cancelText={t('取消')} + size={isMobile ? 'full-width' : 'large'} + closeOnEsc + maskClosable + centered + > + } + placeholder={t('搜索模型')} + value={keyword} + onChange={(v) => setKeyword(v)} + showClear + /> + + +
+ {filteredModels.length === 0 ? ( + } + darkModeImage={} + description={t('暂无匹配模型')} + style={{ padding: 30 }} + /> + ) : ( + setCheckedList(vals)}> + {activeTab === 'new' && newModels.length > 0 && ( +
+ {renderModelsByCategory(newModelsByCategory, 'new')} +
+ )} + {activeTab === 'existing' && existingModels.length > 0 && ( +
+ {renderModelsByCategory(existingModelsByCategory, 'existing')} +
+ )} +
+ )} +
+
+ + +
+ {(() => { + 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 ( + <> + + {t('已选择 {{selected}} / {{total}}', { + selected: currentSelected, + total: currentModels.length + })} + + { + handleCategorySelectAll(currentModels, e.target.checked); + }} + /> + + ); + })()} +
+
+
+ ); +}; + +export default ModelSelectModal; \ No newline at end of file diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index a1bf619d..29190b13 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1799,5 +1799,10 @@ "显示第": "Showing", "条 - 第": "to", "条,共": "of", - "条": "items" + "条": "items", + "选择模型": "Select model", + "已选择 {{selected}} / {{total}}": "Selected {{selected}} / {{total}}", + "新获取的模型": "New models", + "已有的模型": "Existing models", + "搜索模型": "Search models" } \ No newline at end of file