✨ 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.
This commit is contained in:
100
web/src/components/table/model-pricing/filter/PricingTags.jsx
Normal file
100
web/src/components/table/model-pricing/filter/PricingTags.jsx
Normal file
@@ -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;
|
||||
@@ -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}
|
||||
|
||||
@@ -50,6 +50,7 @@ const PricingTopSection = ({
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
onChange={handleChange}
|
||||
showClear
|
||||
className="!bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ const PricingFilterModal = ({
|
||||
setFilterQuotaType: sidebarProps.setFilterQuotaType,
|
||||
setFilterEndpointType: sidebarProps.setFilterEndpointType,
|
||||
setFilterVendor: sidebarProps.setFilterVendor,
|
||||
setFilterTag: sidebarProps.setFilterTag,
|
||||
setCurrentPage: sidebarProps.setCurrentPage,
|
||||
setTokenUnit: sidebarProps.setTokenUnit,
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
/**
|
||||
* 通用过滤函数
|
||||
* @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;
|
||||
}
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user