From 870132a5cb7e1728c2333f3460fdc96843649ba8 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Sun, 10 Aug 2025 14:05:25 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20Add=20tag-based=20filtering?= =?UTF-8?q?=20&=20refactor=20filter=20counts=20logic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../model-pricing/filter/PricingTags.jsx | 100 ++++++++++ .../model-pricing/layout/PricingSidebar.jsx | 15 ++ .../layout/header/PricingTopSection.jsx | 1 + .../modal/PricingFilterModal.jsx | 1 + .../modal/components/FilterModalContent.jsx | 14 ++ web/src/helpers/utils.js | 3 + .../model-pricing/useModelPricingData.js | 21 ++- .../model-pricing/usePricingFilterCounts.js | 175 ++++++++++-------- web/src/i18n/locales/en.json | 4 +- 9 files changed, 250 insertions(+), 84 deletions(-) create mode 100644 web/src/components/table/model-pricing/filter/PricingTags.jsx diff --git a/web/src/components/table/model-pricing/filter/PricingTags.jsx b/web/src/components/table/model-pricing/filter/PricingTags.jsx new file mode 100644 index 00000000..53ad8990 --- /dev/null +++ b/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 . + +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 ( + + ); +}; + +export default PricingTags; diff --git a/web/src/components/table/model-pricing/layout/PricingSidebar.jsx b/web/src/components/table/model-pricing/layout/PricingSidebar.jsx index 732ae76f..ed96e7fc 100644 --- a/web/src/components/table/model-pricing/layout/PricingSidebar.jsx +++ b/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} /> + + diff --git a/web/src/components/table/model-pricing/modal/PricingFilterModal.jsx b/web/src/components/table/model-pricing/modal/PricingFilterModal.jsx index 9a4e5987..edad975e 100644 --- a/web/src/components/table/model-pricing/modal/PricingFilterModal.jsx +++ b/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, }); 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 94ab3c04..7cf67612 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 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} /> + + { @@ -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); }; diff --git a/web/src/hooks/model-pricing/useModelPricingData.js b/web/src/hooks/model-pricing/useModelPricingData.js index d455d953..a2df8d37 100644 --- a/web/src/hooks/model-pricing/useModelPricingData.js +++ b/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, diff --git a/web/src/hooks/model-pricing/usePricingFilterCounts.js b/web/src/hooks/model-pricing/usePricingFilterCounts.js index cd993bd5..ee7f41c7 100644 --- a/web/src/hooks/model-pricing/usePricingFilterCounts.js +++ b/web/src/hooks/model-pricing/usePricingFilterCounts.js @@ -17,115 +17,128 @@ along with this program. If not, see . 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)); + /** + * 通用过滤函数 + * @param {Object} model + * @param {Array} 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; } - if (filterEndpointType !== 'all') { - result = result.filter(m => - m.supported_endpoint_types && m.supported_endpoint_types.includes(filterEndpointType) - ); + + // 计费类型 + if (!ignore.includes('quota') && filterQuotaType !== 'all') { + if (model.quota_type !== filterQuotaType) return false; } - if (filterVendor !== 'all') { + + // 端点类型 + if (!ignore.includes('endpoint') && filterEndpointType !== 'all') { + if ( + !model.supported_endpoint_types || + !model.supported_endpoint_types.includes(filterEndpointType) + ) + return false; + } + + // 供应商 + 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, 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('tag') && filterTag !== 'all') { + const tagsArr = normalizeTags(model.tags); + if (!tagsArr.includes(filterTag.toLowerCase())) return false; } - if (filterQuotaType !== 'all') { - result = result.filter(m => m.quota_type === filterQuotaType); - } - if (filterVendor !== 'all') { - if (filterVendor === 'unknown') { - result = result.filter(m => !m.vendor_name); - } else { - result = result.filter(m => m.vendor_name === filterVendor); - } - } - 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 (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)) - ); + 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; } - 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)); - } - 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, }; }; \ No newline at end of file diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 3c361a19..267f90c3 100644 --- a/web/src/i18n/locales/en.json +++ b/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" } \ No newline at end of file