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:
t0ng7u
2025-08-10 14:05:25 +08:00
parent ffa898c52d
commit 870132a5cb
9 changed files with 250 additions and 84 deletions

View 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;

View File

@@ -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}

View File

@@ -50,6 +50,7 @@ const PricingTopSection = ({
onCompositionEnd={handleCompositionEnd}
onChange={handleChange}
showClear
className="!bg-transparent"
/>
</div>

View File

@@ -40,6 +40,7 @@ const PricingFilterModal = ({
setFilterQuotaType: sidebarProps.setFilterQuotaType,
setFilterEndpointType: sidebarProps.setFilterEndpointType,
setFilterVendor: sidebarProps.setFilterVendor,
setFilterTag: sidebarProps.setFilterTag,
setCurrentPage: sidebarProps.setCurrentPage,
setTokenUnit: sidebarProps.setTokenUnit,
});

View File

@@ -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}

View File

@@ -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);
};

View File

@@ -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,

View File

@@ -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,
};
};

View File

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