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 11 tháng trước cách đây
mục cha
commit
b6dd701ce8

+ 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"
 }