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