Просмотр исходного кода

✨ refactor: pricing filters for dynamic counting & cleaner logic

This commit introduces a unified, maintainable solution for all model-pricing filter buttons and removes redundant code.

Key points
• Added `usePricingFilterCounts` hook
  - Centralises filtering logic and returns:
    - `quotaTypeModels`, `endpointTypeModels`, `dynamicCategoryCounts`, `groupCountModels`
  - Keeps internal helpers private (removed public `modelsAfterCategory`).

• Updated components to consume the new hook
  - `PricingSidebar.jsx`
  - `FilterModalContent.jsx`

• Improved button UI/UX
  - `SelectableButtonGroup.jsx` now respects `item.disabled` and auto-disables when `tagCount === 0`.
  - `PricingGroups.jsx` counts models per group (after all other filters) and disables groups with zero matches.
  - `PricingEndpointTypes.jsx` enumerates all endpoint types, computes filtered counts, and disables entries with zero matches.

• Removed obsolete / duplicate calculations and comments to keep components lean.

The result is consistent, real-time tag counts across all filter groups, automatic disabling of unavailable options, and a single source of truth for filter computations, making future extensions straightforward.
t0ng7u 7 месяцев назад
Родитель
Сommit
75548c449b

+ 4 - 0
web/src/components/common/ui/SelectableButtonGroup.jsx

@@ -133,6 +133,7 @@ const SelectableButtonGroup = ({
   const contentElement = showSkeleton ? renderSkeletonButtons() : (
     <Row gutter={[8, 8]} style={{ lineHeight: '32px', ...style }} ref={contentRef}>
       {items.map((item) => {
+        const isDisabled = item.disabled || (typeof item.tagCount === 'number' && item.tagCount === 0);
         const isActive = Array.isArray(activeValue)
           ? activeValue.includes(item.value)
           : activeValue === item.value;
@@ -150,10 +151,12 @@ const SelectableButtonGroup = ({
                 onClick={() => { /* disabled */ }}
                 theme={isActive ? 'light' : 'outline'}
                 type={isActive ? 'primary' : 'tertiary'}
+                disabled={isDisabled}
                 icon={
                   <Checkbox
                     checked={isActive}
                     onChange={() => onChange(item.value)}
+                    disabled={isDisabled}
                     style={{ pointerEvents: 'auto' }}
                   />
                 }
@@ -190,6 +193,7 @@ const SelectableButtonGroup = ({
               theme={isActive ? 'light' : 'outline'}
               type={isActive ? 'primary' : 'tertiary'}
               icon={item.icon}
+              disabled={isDisabled}
               style={{ width: '100%' }}
             >
               <span style={{ marginRight: item.tagCount !== undefined ? 4 : 0 }}>{item.label}</span>

+ 13 - 9
web/src/components/table/model-pricing/filter/PricingEndpointTypes.jsx

@@ -28,11 +28,11 @@ import SelectableButtonGroup from '../../../common/ui/SelectableButtonGroup';
  * @param {boolean} loading 是否加载中
  * @param {Function} t i18n
  */
-const PricingEndpointTypes = ({ filterEndpointType, setFilterEndpointType, models = [], loading = false, t }) => {
-  // 获取所有可用的端点类型
+const PricingEndpointTypes = ({ filterEndpointType, setFilterEndpointType, models = [], allModels = [], loading = false, t }) => {
+  // 获取系统中所有端点类型(基于 allModels,如果未提供则退化为 models)
   const getAllEndpointTypes = () => {
     const endpointTypes = new Set();
-    models.forEach(model => {
+    (allModels.length > 0 ? allModels : models).forEach(model => {
       if (model.supported_endpoint_types && Array.isArray(model.supported_endpoint_types)) {
         model.supported_endpoint_types.forEach(endpoint => {
           endpointTypes.add(endpoint);
@@ -61,12 +61,16 @@ const PricingEndpointTypes = ({ filterEndpointType, setFilterEndpointType, model
   const availableEndpointTypes = getAllEndpointTypes();
 
   const items = [
-    { value: 'all', label: t('全部端点'), tagCount: getEndpointTypeCount('all') },
-    ...availableEndpointTypes.map(endpointType => ({
-      value: endpointType,
-      label: getEndpointTypeLabel(endpointType),
-      tagCount: getEndpointTypeCount(endpointType)
-    }))
+    { value: 'all', label: t('全部端点'), tagCount: getEndpointTypeCount('all'), disabled: models.length === 0 },
+    ...availableEndpointTypes.map(endpointType => {
+      const count = getEndpointTypeCount(endpointType);
+      return ({
+        value: endpointType,
+        label: getEndpointTypeLabel(endpointType),
+        tagCount: count,
+        disabled: count === 0
+      });
+    })
   ];
 
   return (

+ 4 - 0
web/src/components/table/model-pricing/filter/PricingGroups.jsx

@@ -34,6 +34,9 @@ const PricingGroups = ({ filterGroup, setFilterGroup, usableGroup = {}, groupRat
   const groups = ['all', ...Object.keys(usableGroup).filter(key => key !== '')];
 
   const items = groups.map((g) => {
+    const modelCount = g === 'all'
+      ? models.length
+      : models.filter(m => m.enable_groups && m.enable_groups.includes(g)).length;
     let ratioDisplay = '';
     if (g === 'all') {
       ratioDisplay = t('全部');
@@ -49,6 +52,7 @@ const PricingGroups = ({ filterGroup, setFilterGroup, usableGroup = {}, groupRat
       value: g,
       label: g === 'all' ? t('全部分组') : g,
       tagCount: ratioDisplay,
+      disabled: modelCount === 0
     };
   });
 

+ 21 - 3
web/src/components/table/model-pricing/layout/PricingSidebar.jsx

@@ -25,6 +25,7 @@ import PricingQuotaTypes from '../filter/PricingQuotaTypes';
 import PricingEndpointTypes from '../filter/PricingEndpointTypes';
 import PricingDisplaySettings from '../filter/PricingDisplaySettings';
 import { resetPricingFilters } from '../../../../helpers/utils';
+import { usePricingFilterCounts } from '../../../../hooks/model-pricing/usePricingFilterCounts';
 
 const PricingSidebar = ({
   showWithRecharge,
@@ -52,6 +53,21 @@ const PricingSidebar = ({
   ...categoryProps
 }) => {
 
+  const {
+    quotaTypeModels,
+    endpointTypeModels,
+    dynamicCategoryCounts,
+    groupCountModels,
+  } = usePricingFilterCounts({
+    models: categoryProps.models,
+    modelCategories: categoryProps.modelCategories,
+    activeKey: categoryProps.activeKey,
+    filterGroup,
+    filterQuotaType,
+    filterEndpointType,
+    searchValue: categoryProps.searchValue,
+  });
+
   const handleResetFilters = () =>
     resetPricingFilters({
       handleChange,
@@ -101,6 +117,7 @@ const PricingSidebar = ({
 
       <PricingCategories
         {...categoryProps}
+        categoryCounts={dynamicCategoryCounts}
         setActiveKey={setActiveKey}
         loading={loading}
         t={t}
@@ -111,7 +128,7 @@ const PricingSidebar = ({
         setFilterGroup={setFilterGroup}
         usableGroup={categoryProps.usableGroup}
         groupRatio={categoryProps.groupRatio}
-        models={categoryProps.models}
+        models={groupCountModels}
         loading={loading}
         t={t}
       />
@@ -119,7 +136,7 @@ const PricingSidebar = ({
       <PricingQuotaTypes
         filterQuotaType={filterQuotaType}
         setFilterQuotaType={setFilterQuotaType}
-        models={categoryProps.models}
+        models={quotaTypeModels}
         loading={loading}
         t={t}
       />
@@ -127,7 +144,8 @@ const PricingSidebar = ({
       <PricingEndpointTypes
         filterEndpointType={filterEndpointType}
         setFilterEndpointType={setFilterEndpointType}
-        models={categoryProps.models}
+        models={endpointTypeModels}
+        allModels={categoryProps.models}
         loading={loading}
         t={t}
       />

+ 27 - 4
web/src/components/table/model-pricing/modal/components/FilterModalContent.jsx

@@ -23,6 +23,7 @@ import PricingCategories from '../../filter/PricingCategories';
 import PricingGroups from '../../filter/PricingGroups';
 import PricingQuotaTypes from '../../filter/PricingQuotaTypes';
 import PricingEndpointTypes from '../../filter/PricingEndpointTypes';
+import { usePricingFilterCounts } from '../../../../../hooks/model-pricing/usePricingFilterCounts';
 
 const FilterModalContent = ({ sidebarProps, t }) => {
   const {
@@ -48,6 +49,21 @@ const FilterModalContent = ({ sidebarProps, t }) => {
     ...categoryProps
   } = sidebarProps;
 
+  const {
+    quotaTypeModels,
+    endpointTypeModels,
+    dynamicCategoryCounts,
+    groupCountModels,
+  } = usePricingFilterCounts({
+    models: categoryProps.models,
+    modelCategories: categoryProps.modelCategories,
+    activeKey: categoryProps.activeKey,
+    filterGroup,
+    filterQuotaType,
+    filterEndpointType,
+    searchValue: sidebarProps.searchValue,
+  });
+
   return (
     <div className="p-2">
       <PricingDisplaySettings
@@ -65,14 +81,20 @@ const FilterModalContent = ({ sidebarProps, t }) => {
         t={t}
       />
 
-      <PricingCategories {...categoryProps} setActiveKey={setActiveKey} loading={loading} t={t} />
+      <PricingCategories
+        {...categoryProps}
+        categoryCounts={dynamicCategoryCounts}
+        setActiveKey={setActiveKey}
+        loading={loading}
+        t={t}
+      />
 
       <PricingGroups
         filterGroup={filterGroup}
         setFilterGroup={setFilterGroup}
         usableGroup={categoryProps.usableGroup}
         groupRatio={categoryProps.groupRatio}
-        models={categoryProps.models}
+        models={groupCountModels}
         loading={loading}
         t={t}
       />
@@ -80,7 +102,7 @@ const FilterModalContent = ({ sidebarProps, t }) => {
       <PricingQuotaTypes
         filterQuotaType={filterQuotaType}
         setFilterQuotaType={setFilterQuotaType}
-        models={categoryProps.models}
+        models={quotaTypeModels}
         loading={loading}
         t={t}
       />
@@ -88,7 +110,8 @@ const FilterModalContent = ({ sidebarProps, t }) => {
       <PricingEndpointTypes
         filterEndpointType={filterEndpointType}
         setFilterEndpointType={setFilterEndpointType}
-        models={categoryProps.models}
+        models={endpointTypeModels}
+        allModels={categoryProps.models}
         loading={loading}
         t={t}
       />

+ 131 - 0
web/src/hooks/model-pricing/usePricingFilterCounts.js

@@ -0,0 +1,131 @@
+/*
+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 { useMemo } from 'react';
+
+export const usePricingFilterCounts = ({
+  models = [],
+  modelCategories = {},
+  activeKey = 'all',
+  filterGroup = 'all',
+  filterQuotaType = 'all',
+  filterEndpointType = 'all',
+  searchValue = '',
+}) => {
+  // 根据分类过滤后的模型
+  const modelsAfterCategory = useMemo(() => {
+    if (activeKey === 'all') return models;
+    const category = modelCategories[activeKey];
+    if (category && typeof category.filter === 'function') {
+      return models.filter(category.filter);
+    }
+    return models;
+  }, [models, activeKey, modelCategories]);
+
+  // 根据除分类外其它过滤条件后的模型 (用于动态分类计数)
+  const modelsAfterOtherFilters = useMemo(() => {
+    let result = models;
+    if (filterGroup !== 'all') {
+      result = result.filter(m => m.enable_groups && m.enable_groups.includes(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 (searchValue && searchValue.length > 0) {
+      const term = searchValue.toLowerCase();
+      result = result.filter(m => m.model_name.toLowerCase().includes(term));
+    }
+    return result;
+  }, [models, filterGroup, filterQuotaType, filterEndpointType, searchValue]);
+
+  // 动态分类计数
+  const dynamicCategoryCounts = useMemo(() => {
+    const counts = { all: modelsAfterOtherFilters.length };
+    Object.entries(modelCategories).forEach(([key, category]) => {
+      if (key === 'all') return;
+      if (typeof category.filter === 'function') {
+        counts[key] = modelsAfterOtherFilters.filter(category.filter).length;
+      } else {
+        counts[key] = 0;
+      }
+    });
+    return counts;
+  }, [modelsAfterOtherFilters, modelCategories]);
+
+  // 针对计费类型按钮计数
+  const quotaTypeModels = useMemo(() => {
+    let result = modelsAfterCategory;
+    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)
+      );
+    }
+    return result;
+  }, [modelsAfterCategory, filterGroup, filterEndpointType]);
+
+  // 针对端点类型按钮计数
+  const endpointTypeModels = useMemo(() => {
+    let result = modelsAfterCategory;
+    if (filterGroup !== 'all') {
+      result = result.filter(m => m.enable_groups && m.enable_groups.includes(filterGroup));
+    }
+    if (filterQuotaType !== 'all') {
+      result = result.filter(m => m.quota_type === filterQuotaType);
+    }
+    return result;
+  }, [modelsAfterCategory, filterGroup, filterQuotaType]);
+
+  // === 可用令牌分组计数模型(排除 group 过滤,保留其余过滤) ===
+  const groupCountModels = useMemo(() => {
+    let result = modelsAfterCategory; // 已包含分类筛选
+
+    // 不应用 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 (searchValue && searchValue.length > 0) {
+      const term = searchValue.toLowerCase();
+      result = result.filter(m => m.model_name.toLowerCase().includes(term));
+    }
+    return result;
+  }, [modelsAfterCategory, filterQuotaType, filterEndpointType, searchValue]);
+
+  return {
+    quotaTypeModels,
+    endpointTypeModels,
+    dynamicCategoryCounts,
+    groupCountModels,
+  };
+};