Browse Source

✨ feat: Add tag-based filtering & refactor filter counts logic

Overview:
• Introduced a new “Model Tag” filter across pricing screens
• Refactored `usePricingFilterCounts` to eliminate duplicated logic
• Improved tag handling to be case-insensitive and deduplicated
• Extended utilities to reset & persist the new filter

Details:
1. Added `filterTag` state to `useModelPricingData` and integrated it into all filtering paths.
2. Created reusable `PricingTags` component using `SelectableButtonGroup`.
3. Incorporated tag filter into `PricingSidebar` and mobile `PricingFilterModal`, including reset support.
4. Enhanced `resetPricingFilters` (helpers/utils) to restore tag filter defaults.
5. Refactored `usePricingFilterCounts.js`:
   • Centralized predicate `matchesFilters` to remove redundancy
   • Normalized tag parsing via `normalizeTags` helper
   • Memoized model subsets with concise filter calls
6. Updated lints – zero errors after refactor.

Result:
Users can now filter models by custom tags with consistent UX, and internal logic is cleaner, faster, and easier to extend.
t0ng7u 6 months ago
parent
commit
870132a5cb

+ 100 - 0
web/src/components/table/model-pricing/filter/PricingTags.jsx

@@ -0,0 +1,100 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+import SelectableButtonGroup from '../../../common/ui/SelectableButtonGroup';
+
+/**
+ * 模型标签筛选组件
+ * @param {string|'all'} filterTag 当前选中的标签
+ * @param {Function} setFilterTag setter
+ * @param {Array} models 当前过滤后模型列表(用于计数)
+ * @param {Array} allModels 所有模型列表(用于获取所有标签)
+ * @param {boolean} loading 是否加载中
+ * @param {Function} t i18n
+ */
+const PricingTags = ({ filterTag, setFilterTag, models = [], allModels = [], loading = false, t }) => {
+  // 提取系统所有标签
+  const getAllTags = React.useMemo(() => {
+    const tagSet = new Set();
+
+    (allModels.length > 0 ? allModels : models).forEach(model => {
+      if (model.tags) {
+        model.tags
+          .split(/[,;|\s]+/) // 逗号、分号、竖线或空白字符
+          .map(tag => tag.trim())
+          .filter(Boolean)
+          .forEach(tag => tagSet.add(tag.toLowerCase()));
+      }
+    });
+
+    return Array.from(tagSet).sort((a, b) => a.localeCompare(b));
+  }, [allModels, models]);
+
+  // 计算标签对应的模型数量
+  const getTagCount = React.useCallback((tag) => {
+    if (tag === 'all') return models.length;
+
+    const tagLower = tag.toLowerCase();
+    return models.filter(model => {
+      if (!model.tags) return false;
+      return model.tags
+        .toLowerCase()
+        .split(/[,;|\s]+/)
+        .map(tg => tg.trim())
+        .includes(tagLower);
+    }).length;
+  }, [models]);
+
+  const items = React.useMemo(() => {
+    const result = [
+      {
+        value: 'all',
+        label: t('全部标签'),
+        tagCount: getTagCount('all'),
+        disabled: models.length === 0,
+      }
+    ];
+
+    getAllTags.forEach(tag => {
+      const count = getTagCount(tag);
+      result.push({
+        value: tag,
+        label: tag,
+        tagCount: count,
+        disabled: count === 0,
+      });
+    });
+
+    return result;
+  }, [getAllTags, getTagCount, t, models.length]);
+
+  return (
+    <SelectableButtonGroup
+      title={t('标签')}
+      items={items}
+      activeValue={filterTag}
+      onChange={setFilterTag}
+      loading={loading}
+      t={t}
+    />
+  );
+};
+
+export default PricingTags;

+ 15 - 0
web/src/components/table/model-pricing/layout/PricingSidebar.jsx

@@ -23,6 +23,7 @@ import PricingGroups from '../filter/PricingGroups';
 import PricingQuotaTypes from '../filter/PricingQuotaTypes';
 import PricingEndpointTypes from '../filter/PricingEndpointTypes';
 import PricingVendors from '../filter/PricingVendors';
+import PricingTags from '../filter/PricingTags';
 import PricingDisplaySettings from '../filter/PricingDisplaySettings';
 import { resetPricingFilters } from '../../../../helpers/utils';
 import { usePricingFilterCounts } from '../../../../hooks/model-pricing/usePricingFilterCounts';
@@ -47,6 +48,8 @@ const PricingSidebar = ({
   setFilterEndpointType,
   filterVendor,
   setFilterVendor,
+  filterTag,
+  setFilterTag,
   currentPage,
   setCurrentPage,
   tokenUnit,
@@ -60,6 +63,7 @@ const PricingSidebar = ({
     quotaTypeModels,
     endpointTypeModels,
     vendorModels,
+    tagModels,
     groupCountModels,
   } = usePricingFilterCounts({
     models: categoryProps.models,
@@ -67,6 +71,7 @@ const PricingSidebar = ({
     filterQuotaType,
     filterEndpointType,
     filterVendor,
+    filterTag,
     searchValue: categoryProps.searchValue,
   });
 
@@ -81,6 +86,7 @@ const PricingSidebar = ({
       setFilterQuotaType,
       setFilterEndpointType,
       setFilterVendor,
+      setFilterTag,
       setCurrentPage,
       setTokenUnit,
     });
@@ -125,6 +131,15 @@ const PricingSidebar = ({
         t={t}
       />
 
+      <PricingTags
+        filterTag={filterTag}
+        setFilterTag={setFilterTag}
+        models={tagModels}
+        allModels={categoryProps.models}
+        loading={loading}
+        t={t}
+      />
+
       <PricingGroups
         filterGroup={filterGroup}
         setFilterGroup={handleGroupClick}

+ 1 - 0
web/src/components/table/model-pricing/layout/header/PricingTopSection.jsx

@@ -50,6 +50,7 @@ const PricingTopSection = ({
           onCompositionEnd={handleCompositionEnd}
           onChange={handleChange}
           showClear
+          className="!bg-transparent"
         />
       </div>
 

+ 1 - 0
web/src/components/table/model-pricing/modal/PricingFilterModal.jsx

@@ -40,6 +40,7 @@ const PricingFilterModal = ({
       setFilterQuotaType: sidebarProps.setFilterQuotaType,
       setFilterEndpointType: sidebarProps.setFilterEndpointType,
       setFilterVendor: sidebarProps.setFilterVendor,
+      setFilterTag: sidebarProps.setFilterTag,
       setCurrentPage: sidebarProps.setCurrentPage,
       setTokenUnit: sidebarProps.setTokenUnit,
     });

+ 14 - 0
web/src/components/table/model-pricing/modal/components/FilterModalContent.jsx

@@ -23,6 +23,7 @@ import PricingGroups from '../../filter/PricingGroups';
 import PricingQuotaTypes from '../../filter/PricingQuotaTypes';
 import PricingEndpointTypes from '../../filter/PricingEndpointTypes';
 import PricingVendors from '../../filter/PricingVendors';
+import PricingTags from '../../filter/PricingTags';
 import { usePricingFilterCounts } from '../../../../../hooks/model-pricing/usePricingFilterCounts';
 
 const FilterModalContent = ({ sidebarProps, t }) => {
@@ -45,6 +46,8 @@ const FilterModalContent = ({ sidebarProps, t }) => {
     setFilterEndpointType,
     filterVendor,
     setFilterVendor,
+    filterTag,
+    setFilterTag,
     tokenUnit,
     setTokenUnit,
     loading,
@@ -55,6 +58,7 @@ const FilterModalContent = ({ sidebarProps, t }) => {
     quotaTypeModels,
     endpointTypeModels,
     vendorModels,
+    tagModels,
     groupCountModels,
   } = usePricingFilterCounts({
     models: categoryProps.models,
@@ -62,6 +66,7 @@ const FilterModalContent = ({ sidebarProps, t }) => {
     filterQuotaType,
     filterEndpointType,
     filterVendor,
+    filterTag,
     searchValue: sidebarProps.searchValue,
   });
 
@@ -91,6 +96,15 @@ const FilterModalContent = ({ sidebarProps, t }) => {
         t={t}
       />
 
+      <PricingTags
+        filterTag={filterTag}
+        setFilterTag={setFilterTag}
+        models={tagModels}
+        allModels={categoryProps.models}
+        loading={loading}
+        t={t}
+      />
+
       <PricingGroups
         filterGroup={filterGroup}
         setFilterGroup={setFilterGroup}

+ 3 - 0
web/src/helpers/utils.js

@@ -699,6 +699,7 @@ const DEFAULT_PRICING_FILTERS = {
   filterQuotaType: 'all',
   filterEndpointType: 'all',
   filterVendor: 'all',
+  filterTag: 'all',
   currentPage: 1,
 };
 
@@ -713,6 +714,7 @@ export const resetPricingFilters = ({
   setFilterQuotaType,
   setFilterEndpointType,
   setFilterVendor,
+  setFilterTag,
   setCurrentPage,
   setTokenUnit,
 }) => {
@@ -726,5 +728,6 @@ export const resetPricingFilters = ({
   setFilterQuotaType?.(DEFAULT_PRICING_FILTERS.filterQuotaType);
   setFilterEndpointType?.(DEFAULT_PRICING_FILTERS.filterEndpointType);
   setFilterVendor?.(DEFAULT_PRICING_FILTERS.filterVendor);
+  setFilterTag?.(DEFAULT_PRICING_FILTERS.filterTag);
   setCurrentPage?.(DEFAULT_PRICING_FILTERS.currentPage);
 };

+ 19 - 2
web/src/hooks/model-pricing/useModelPricingData.js

@@ -38,6 +38,7 @@ export const useModelPricingData = () => {
   const [filterQuotaType, setFilterQuotaType] = useState('all'); // 计费类型筛选: 'all' | 0 | 1
   const [filterEndpointType, setFilterEndpointType] = useState('all'); // 端点类型筛选: 'all' | string
   const [filterVendor, setFilterVendor] = useState('all'); // 供应商筛选: 'all' | 'unknown' | string
+  const [filterTag, setFilterTag] = useState('all'); // 模型标签筛选: 'all' | string
   const [pageSize, setPageSize] = useState(10);
   const [currentPage, setCurrentPage] = useState(1);
   const [currency, setCurrency] = useState('USD');
@@ -88,6 +89,20 @@ export const useModelPricingData = () => {
       }
     }
 
+    // 标签筛选
+    if (filterTag !== 'all') {
+      const tagLower = filterTag.toLowerCase();
+      result = result.filter(model => {
+        if (!model.tags) return false;
+        const tagsArr = model.tags
+          .toLowerCase()
+          .split(/[,;|\s]+/)
+          .map(tag => tag.trim())
+          .filter(Boolean);
+        return tagsArr.includes(tagLower);
+      });
+    }
+
     // 搜索筛选
     if (searchValue.length > 0) {
       const searchTerm = searchValue.toLowerCase();
@@ -100,7 +115,7 @@ export const useModelPricingData = () => {
     }
 
     return result;
-  }, [models, searchValue, filterGroup, filterQuotaType, filterEndpointType, filterVendor]);
+  }, [models, searchValue, filterGroup, filterQuotaType, filterEndpointType, filterVendor, filterTag]);
 
   const rowSelection = useMemo(
     () => ({
@@ -245,7 +260,7 @@ export const useModelPricingData = () => {
   // 当筛选条件变化时重置到第一页
   useEffect(() => {
     setCurrentPage(1);
-  }, [filterGroup, filterQuotaType, filterEndpointType, filterVendor, searchValue]);
+  }, [filterGroup, filterQuotaType, filterEndpointType, filterVendor, filterTag, searchValue]);
 
   return {
     // 状态
@@ -271,6 +286,8 @@ export const useModelPricingData = () => {
     setFilterEndpointType,
     filterVendor,
     setFilterVendor,
+    filterTag,
+    setFilterTag,
     pageSize,
     setPageSize,
     currentPage,

+ 95 - 82
web/src/hooks/model-pricing/usePricingFilterCounts.js

@@ -17,115 +17,128 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
 For commercial licensing, please contact support@quantumnous.com
 */
 
-/*
-  统一计算模型筛选后的各种集合与动态计数,供多个组件复用
-*/
 import { useMemo } from 'react';
 
+// 工具函数:将 tags 字符串转为小写去重数组
+const normalizeTags = (tags = '') =>
+  tags
+    .toLowerCase()
+    .split(/[,;|\s]+/)
+    .map((t) => t.trim())
+    .filter(Boolean);
+
+/**
+ * 统一计算模型筛选后的各种集合与动态计数,供多个组件复用
+ */
 export const usePricingFilterCounts = ({
   models = [],
   filterGroup = 'all',
   filterQuotaType = 'all',
   filterEndpointType = 'all',
   filterVendor = 'all',
+  filterTag = 'all',
   searchValue = '',
 }) => {
-  // 所有模型(不再需要分类过滤)
+  // 均使用同一份模型列表,避免创建新引用
   const allModels = models;
 
-  // 针对计费类型按钮计数
-  const quotaTypeModels = useMemo(() => {
-    let result = allModels;
-    if (filterGroup !== 'all') {
-      result = result.filter(m => m.enable_groups && m.enable_groups.includes(filterGroup));
-    }
-    if (filterEndpointType !== 'all') {
-      result = result.filter(m =>
-        m.supported_endpoint_types && m.supported_endpoint_types.includes(filterEndpointType)
-      );
-    }
-    if (filterVendor !== 'all') {
-      if (filterVendor === 'unknown') {
-        result = result.filter(m => !m.vendor_name);
-      } else {
-        result = result.filter(m => m.vendor_name === filterVendor);
-      }
+  /**
+   * 通用过滤函数
+   * @param {Object} model
+   * @param {Array<string>} ignore 需要忽略的过滤条件 key
+   * @returns {boolean}
+   */
+  const matchesFilters = (model, ignore = []) => {
+    // 分组
+    if (!ignore.includes('group') && filterGroup !== 'all') {
+      if (!model.enable_groups || !model.enable_groups.includes(filterGroup)) return false;
     }
-    return result;
-  }, [allModels, filterGroup, filterEndpointType, filterVendor]);
-
-  // 针对端点类型按钮计数
-  const endpointTypeModels = useMemo(() => {
-    let result = allModels;
-    if (filterGroup !== 'all') {
-      result = result.filter(m => m.enable_groups && m.enable_groups.includes(filterGroup));
+
+    // 计费类型
+    if (!ignore.includes('quota') && filterQuotaType !== 'all') {
+      if (model.quota_type !== filterQuotaType) return false;
     }
-    if (filterQuotaType !== 'all') {
-      result = result.filter(m => m.quota_type === filterQuotaType);
+
+    // 端点类型
+    if (!ignore.includes('endpoint') && filterEndpointType !== 'all') {
+      if (
+        !model.supported_endpoint_types ||
+        !model.supported_endpoint_types.includes(filterEndpointType)
+      )
+        return false;
     }
-    if (filterVendor !== 'all') {
+
+    // 供应商
+    if (!ignore.includes('vendor') && filterVendor !== 'all') {
       if (filterVendor === 'unknown') {
-        result = result.filter(m => !m.vendor_name);
-      } else {
-        result = result.filter(m => m.vendor_name === filterVendor);
+        if (model.vendor_name) return false;
+      } else if (model.vendor_name !== filterVendor) {
+        return false;
       }
     }
-    return result;
-  }, [allModels, filterGroup, filterQuotaType, filterVendor]);
 
-  // === 可用令牌分组计数模型(排除 group 过滤,保留其余过滤) ===
-  const groupCountModels = useMemo(() => {
-    let result = allModels;
-
-    // 不应用 filterGroup 本身
-    if (filterQuotaType !== 'all') {
-      result = result.filter(m => m.quota_type === filterQuotaType);
-    }
-    if (filterEndpointType !== 'all') {
-      result = result.filter(m =>
-        m.supported_endpoint_types && m.supported_endpoint_types.includes(filterEndpointType)
-      );
-    }
-    if (filterVendor !== 'all') {
-      if (filterVendor === 'unknown') {
-        result = result.filter(m => !m.vendor_name);
-      } else {
-        result = result.filter(m => m.vendor_name === filterVendor);
-      }
+    // 标签
+    if (!ignore.includes('tag') && filterTag !== 'all') {
+      const tagsArr = normalizeTags(model.tags);
+      if (!tagsArr.includes(filterTag.toLowerCase())) return false;
     }
-    if (searchValue && searchValue.length > 0) {
+
+    // 搜索
+    if (!ignore.includes('search') && searchValue) {
       const term = searchValue.toLowerCase();
-      result = result.filter(m =>
-        m.model_name.toLowerCase().includes(term) ||
-        (m.description && m.description.toLowerCase().includes(term)) ||
-        (m.tags && m.tags.toLowerCase().includes(term)) ||
-        (m.vendor_name && m.vendor_name.toLowerCase().includes(term))
-      );
-    }
-    return result;
-  }, [allModels, filterQuotaType, filterEndpointType, filterVendor, searchValue]);
-
-  // 针对供应商按钮计数
-  const vendorModels = useMemo(() => {
-    let result = allModels;
-    if (filterGroup !== 'all') {
-      result = result.filter(m => m.enable_groups && m.enable_groups.includes(filterGroup));
+      const tags = model.tags ? model.tags.toLowerCase() : '';
+      if (
+        !(
+          model.model_name.toLowerCase().includes(term) ||
+          (model.description && model.description.toLowerCase().includes(term)) ||
+          tags.includes(term) ||
+          (model.vendor_name && model.vendor_name.toLowerCase().includes(term))
+        )
+      )
+        return false;
     }
-    if (filterQuotaType !== 'all') {
-      result = result.filter(m => m.quota_type === filterQuotaType);
-    }
-    if (filterEndpointType !== 'all') {
-      result = result.filter(m =>
-        m.supported_endpoint_types && m.supported_endpoint_types.includes(filterEndpointType)
-      );
-    }
-    return result;
-  }, [allModels, filterGroup, filterQuotaType, filterEndpointType]);
+
+    return true;
+  };
+
+  // 生成不同视图所需的模型集合
+  const quotaTypeModels = useMemo(
+    () => allModels.filter((m) => matchesFilters(m, ['quota'])),
+    [allModels, filterGroup, filterEndpointType, filterVendor, filterTag]
+  );
+
+  const endpointTypeModels = useMemo(
+    () => allModels.filter((m) => matchesFilters(m, ['endpoint'])),
+    [allModels, filterGroup, filterQuotaType, filterVendor, filterTag]
+  );
+
+  const vendorModels = useMemo(
+    () => allModels.filter((m) => matchesFilters(m, ['vendor'])),
+    [allModels, filterGroup, filterQuotaType, filterEndpointType, filterTag]
+  );
+
+  const tagModels = useMemo(
+    () => allModels.filter((m) => matchesFilters(m, ['tag'])),
+    [allModels, filterGroup, filterQuotaType, filterEndpointType, filterVendor]
+  );
+
+  const groupCountModels = useMemo(
+    () => allModels.filter((m) => matchesFilters(m, ['group'])),
+    [
+      allModels,
+      filterQuotaType,
+      filterEndpointType,
+      filterVendor,
+      filterTag,
+      searchValue,
+    ]
+  );
 
   return {
     quotaTypeModels,
     endpointTypeModels,
     vendorModels,
     groupCountModels,
+    tagModels,
   };
 }; 

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

@@ -1876,6 +1876,7 @@
   "全部分组": "All groups",
   "全部类型": "All types",
   "全部端点": "All endpoints",
+  "全部标签": "All tags",
   "显示倍率": "Show ratio",
   "表格视图": "Table view",
   "模型的详细描述和基本特性": "Detailed description and basic characteristics of the model",
@@ -1914,5 +1915,6 @@
   "精确名称匹配": "Exact name matching",
   "前缀名称匹配": "Prefix name matching",
   "后缀名称匹配": "Suffix name matching",
-  "包含名称匹配": "Contains name matching"
+  "包含名称匹配": "Contains name matching",
+  "展开更多": "Expand more"
 }