diff --git a/web/src/components/common/ui/SelectableButtonGroup.jsx b/web/src/components/common/ui/SelectableButtonGroup.jsx index 6792c5aa..591634a5 100644 --- a/web/src/components/common/ui/SelectableButtonGroup.jsx +++ b/web/src/components/common/ui/SelectableButtonGroup.jsx @@ -133,6 +133,7 @@ const SelectableButtonGroup = ({ const contentElement = showSkeleton ? renderSkeletonButtons() : ( {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={ 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%' }} > {item.label} diff --git a/web/src/components/table/model-pricing/filter/PricingEndpointTypes.jsx b/web/src/components/table/model-pricing/filter/PricingEndpointTypes.jsx index c60f0ef2..c4258f67 100644 --- a/web/src/components/table/model-pricing/filter/PricingEndpointTypes.jsx +++ b/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 ( diff --git a/web/src/components/table/model-pricing/filter/PricingGroups.jsx b/web/src/components/table/model-pricing/filter/PricingGroups.jsx index e389bd12..432d23ab 100644 --- a/web/src/components/table/model-pricing/filter/PricingGroups.jsx +++ b/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 }; }); diff --git a/web/src/components/table/model-pricing/layout/PricingSidebar.jsx b/web/src/components/table/model-pricing/layout/PricingSidebar.jsx index a3e275c6..d6b5df79 100644 --- a/web/src/components/table/model-pricing/layout/PricingSidebar.jsx +++ b/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 = ({ @@ -119,7 +136,7 @@ const PricingSidebar = ({ @@ -127,7 +144,8 @@ const PricingSidebar = ({ diff --git a/web/src/components/table/model-pricing/modal/components/FilterModalContent.jsx b/web/src/components/table/model-pricing/modal/components/FilterModalContent.jsx index aa9646fe..e9f3178e 100644 --- a/web/src/components/table/model-pricing/modal/components/FilterModalContent.jsx +++ b/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 (
{ t={t} /> - + @@ -80,7 +102,7 @@ const FilterModalContent = ({ sidebarProps, t }) => { @@ -88,7 +110,8 @@ const FilterModalContent = ({ sidebarProps, t }) => { diff --git a/web/src/hooks/model-pricing/usePricingFilterCounts.js b/web/src/hooks/model-pricing/usePricingFilterCounts.js new file mode 100644 index 00000000..e23111f3 --- /dev/null +++ b/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 . + +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, + }; +}; \ No newline at end of file