diff --git a/controller/pricing.go b/controller/pricing.go index f27336b7..7205cb03 100644 --- a/controller/pricing.go +++ b/controller/pricing.go @@ -41,6 +41,7 @@ func GetPricing(c *gin.Context) { c.JSON(200, gin.H{ "success": true, "data": pricing, + "vendors": model.GetVendors(), "group_ratio": groupRatio, "usable_group": usableGroup, }) diff --git a/model/pricing.go b/model/pricing.go index a280b524..53fd0e89 100644 --- a/model/pricing.go +++ b/model/pricing.go @@ -2,6 +2,7 @@ package model import ( "fmt" + "strings" "one-api/common" "one-api/constant" "one-api/setting/ratio_setting" @@ -12,6 +13,9 @@ import ( type Pricing struct { ModelName string `json:"model_name"` + Description string `json:"description,omitempty"` + Tags string `json:"tags,omitempty"` + VendorID int `json:"vendor_id,omitempty"` QuotaType int `json:"quota_type"` ModelRatio float64 `json:"model_ratio"` ModelPrice float64 `json:"model_price"` @@ -21,8 +25,16 @@ type Pricing struct { SupportedEndpointTypes []constant.EndpointType `json:"supported_endpoint_types"` } +type PricingVendor struct { + ID int `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Icon string `json:"icon,omitempty"` +} + var ( pricingMap []Pricing + vendorsList []PricingVendor lastGetPricingTime time.Time updatePricingLock sync.Mutex ) @@ -46,6 +58,15 @@ func GetPricing() []Pricing { return pricingMap } +// GetVendors 返回当前定价接口使用到的供应商信息 +func GetVendors() []PricingVendor { + if time.Since(lastGetPricingTime) > time.Minute*1 || len(pricingMap) == 0 { + // 保证先刷新一次 + GetPricing() + } + return vendorsList +} + func GetModelSupportEndpointTypes(model string) []constant.EndpointType { if model == "" { return make([]constant.EndpointType, 0) @@ -65,6 +86,73 @@ func updatePricing() { common.SysError(fmt.Sprintf("GetAllEnableAbilityWithChannels error: %v", err)) return } + // 预加载模型元数据与供应商一次,避免循环查询 + var allMeta []Model + _ = DB.Find(&allMeta).Error + metaMap := make(map[string]*Model) + prefixList := make([]*Model, 0) + suffixList := make([]*Model, 0) + containsList := make([]*Model, 0) + for i := range allMeta { + m := &allMeta[i] + if m.NameRule == NameRuleExact { + metaMap[m.ModelName] = m + } else { + switch m.NameRule { + case NameRulePrefix: + prefixList = append(prefixList, m) + case NameRuleSuffix: + suffixList = append(suffixList, m) + case NameRuleContains: + containsList = append(containsList, m) + } + } + } + + // 将非精确规则模型匹配到 metaMap + for _, m := range prefixList { + for _, pricingModel := range enableAbilities { + if strings.HasPrefix(pricingModel.Model, m.ModelName) { + metaMap[pricingModel.Model] = m + } + } + } + for _, m := range suffixList { + for _, pricingModel := range enableAbilities { + if strings.HasSuffix(pricingModel.Model, m.ModelName) { + metaMap[pricingModel.Model] = m + } + } + } + for _, m := range containsList { + for _, pricingModel := range enableAbilities { + if strings.Contains(pricingModel.Model, m.ModelName) { + if _, exists := metaMap[pricingModel.Model]; !exists { + metaMap[pricingModel.Model] = m + } + } + } + } + + // 预加载供应商 + var vendors []Vendor + _ = DB.Find(&vendors).Error + vendorMap := make(map[int]*Vendor) + for i := range vendors { + vendorMap[vendors[i].Id] = &vendors[i] + } + + // 构建对前端友好的供应商列表 + vendorsList = make([]PricingVendor, 0, len(vendors)) + for _, v := range vendors { + vendorsList = append(vendorsList, PricingVendor{ + ID: v.Id, + Name: v.Name, + Description: v.Description, + Icon: v.Icon, + }) + } + modelGroupsMap := make(map[string]*types.Set[string]) for _, ability := range enableAbilities { @@ -111,6 +199,13 @@ func updatePricing() { EnableGroup: groups.Items(), SupportedEndpointTypes: modelSupportEndpointTypes[model], } + + // 补充模型元数据(描述、标签、供应商等) + if meta, ok := metaMap[model]; ok { + pricing.Description = meta.Description + pricing.Tags = meta.Tags + pricing.VendorID = meta.VendorID + } modelPrice, findPrice := ratio_setting.GetModelPrice(model, false) if findPrice { pricing.ModelPrice = modelPrice diff --git a/web/src/components/table/models/ui/RenderUtils.jsx b/web/src/components/common/ui/RenderUtils.jsx similarity index 100% rename from web/src/components/table/models/ui/RenderUtils.jsx rename to web/src/components/common/ui/RenderUtils.jsx diff --git a/web/src/components/table/model-pricing/filter/PricingCategories.jsx b/web/src/components/table/model-pricing/filter/PricingCategories.jsx deleted file mode 100644 index 7a979508..00000000 --- a/web/src/components/table/model-pricing/filter/PricingCategories.jsx +++ /dev/null @@ -1,45 +0,0 @@ -/* -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'; - -const PricingCategories = ({ activeKey, setActiveKey, modelCategories, categoryCounts, availableCategories, loading = false, t }) => { - const items = Object.entries(modelCategories) - .filter(([key]) => availableCategories.includes(key)) - .map(([key, category]) => ({ - value: key, - label: category.label, - icon: category.icon, - tagCount: categoryCounts[key] || 0, - })); - - return ( - - ); -}; - -export default PricingCategories; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/filter/PricingVendors.jsx b/web/src/components/table/model-pricing/filter/PricingVendors.jsx new file mode 100644 index 00000000..632ddb0c --- /dev/null +++ b/web/src/components/table/model-pricing/filter/PricingVendors.jsx @@ -0,0 +1,119 @@ +/* +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'; +import { getLobeHubIcon } from '../../../../helpers'; + +/** + * 供应商筛选组件 + * @param {string|'all'} filterVendor 当前值 + * @param {Function} setFilterVendor setter + * @param {Array} models 模型列表 + * @param {Array} allModels 所有模型列表(用于获取全部供应商) + * @param {boolean} loading 是否加载中 + * @param {Function} t i18n + */ +const PricingVendors = ({ filterVendor, setFilterVendor, models = [], allModels = [], loading = false, t }) => { + // 获取系统中所有供应商(基于 allModels,如果未提供则退化为 models) + const getAllVendors = React.useMemo(() => { + const vendors = new Set(); + const vendorIcons = new Map(); + let hasUnknownVendor = false; + + (allModels.length > 0 ? allModels : models).forEach(model => { + if (model.vendor_name) { + vendors.add(model.vendor_name); + if (model.vendor_icon && !vendorIcons.has(model.vendor_name)) { + vendorIcons.set(model.vendor_name, model.vendor_icon); + } + } else { + hasUnknownVendor = true; + } + }); + + return { + vendors: Array.from(vendors).sort(), + vendorIcons, + hasUnknownVendor + }; + }, [allModels, models]); + + // 计算每个供应商的模型数量(基于当前过滤后的 models) + const getVendorCount = React.useCallback((vendor) => { + if (vendor === 'all') { + return models.length; + } + if (vendor === 'unknown') { + return models.filter(model => !model.vendor_name).length; + } + return models.filter(model => model.vendor_name === vendor).length; + }, [models]); + + // 生成供应商选项 + const items = React.useMemo(() => { + const result = [ + { + value: 'all', + label: t('全部供应商'), + tagCount: getVendorCount('all'), + disabled: models.length === 0 + } + ]; + + // 添加所有已知供应商 + getAllVendors.vendors.forEach(vendor => { + const count = getVendorCount(vendor); + const icon = getAllVendors.vendorIcons.get(vendor); + result.push({ + value: vendor, + label: vendor, + icon: icon ? getLobeHubIcon(icon, 16) : null, + tagCount: count, + disabled: count === 0 + }); + }); + + // 如果系统中存在未知供应商,添加"未知供应商"选项 + if (getAllVendors.hasUnknownVendor) { + const count = getVendorCount('unknown'); + result.push({ + value: 'unknown', + label: t('未知供应商'), + tagCount: count, + disabled: count === 0 + }); + } + + return result; + }, [getAllVendors, getVendorCount, t]); + + return ( + + ); +}; + +export default PricingVendors; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/layout/PricingPage.jsx b/web/src/components/table/model-pricing/layout/PricingPage.jsx index 76c31e81..74f47dc0 100644 --- a/web/src/components/table/model-pricing/layout/PricingPage.jsx +++ b/web/src/components/table/model-pricing/layout/PricingPage.jsx @@ -79,6 +79,7 @@ const PricingPage = () => { tokenUnit={pricingData.tokenUnit} displayPrice={pricingData.displayPrice} showRatio={allProps.showRatio} + vendorsMap={pricingData.vendorsMap} t={pricingData.t} /> diff --git a/web/src/components/table/model-pricing/layout/PricingSidebar.jsx b/web/src/components/table/model-pricing/layout/PricingSidebar.jsx index d6b5df79..ea9ab700 100644 --- a/web/src/components/table/model-pricing/layout/PricingSidebar.jsx +++ b/web/src/components/table/model-pricing/layout/PricingSidebar.jsx @@ -19,10 +19,10 @@ For commercial licensing, please contact support@quantumnous.com import React from 'react'; import { Button } from '@douyinfe/semi-ui'; -import PricingCategories from '../filter/PricingCategories'; import PricingGroups from '../filter/PricingGroups'; import PricingQuotaTypes from '../filter/PricingQuotaTypes'; import PricingEndpointTypes from '../filter/PricingEndpointTypes'; +import PricingVendors from '../filter/PricingVendors'; import PricingDisplaySettings from '../filter/PricingDisplaySettings'; import { resetPricingFilters } from '../../../../helpers/utils'; import { usePricingFilterCounts } from '../../../../hooks/model-pricing/usePricingFilterCounts'; @@ -44,6 +44,8 @@ const PricingSidebar = ({ setFilterQuotaType, filterEndpointType, setFilterEndpointType, + filterVendor, + setFilterVendor, currentPage, setCurrentPage, tokenUnit, @@ -56,23 +58,20 @@ const PricingSidebar = ({ const { quotaTypeModels, endpointTypeModels, - dynamicCategoryCounts, + vendorModels, groupCountModels, } = usePricingFilterCounts({ models: categoryProps.models, - modelCategories: categoryProps.modelCategories, - activeKey: categoryProps.activeKey, filterGroup, filterQuotaType, filterEndpointType, + filterVendor, searchValue: categoryProps.searchValue, }); const handleResetFilters = () => resetPricingFilters({ handleChange, - setActiveKey, - availableCategories: categoryProps.availableCategories, setShowWithRecharge, setCurrency, setShowRatio, @@ -80,6 +79,7 @@ const PricingSidebar = ({ setFilterGroup, setFilterQuotaType, setFilterEndpointType, + setFilterVendor, setCurrentPage, setTokenUnit, }); @@ -115,10 +115,11 @@ const PricingSidebar = ({ t={t} /> - diff --git a/web/src/components/table/model-pricing/layout/header/PricingCategoryIntro.jsx b/web/src/components/table/model-pricing/layout/header/PricingCategoryIntro.jsx deleted file mode 100644 index 47cac58c..00000000 --- a/web/src/components/table/model-pricing/layout/header/PricingCategoryIntro.jsx +++ /dev/null @@ -1,232 +0,0 @@ -/* -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, { useState, useEffect } from 'react'; -import { Card, Tag, Avatar, AvatarGroup } from '@douyinfe/semi-ui'; - -const PricingCategoryIntro = ({ - activeKey, - modelCategories, - categoryCounts, - availableCategories, - t -}) => { - // 轮播动效状态(只对全部模型生效) - const [currentOffset, setCurrentOffset] = useState(0); - - // 获取除了 'all' 之外的可用分类 - const validCategories = (availableCategories || []).filter(key => key !== 'all'); - - // 设置轮播定时器(只对全部模型且有足够头像时生效) - useEffect(() => { - if (activeKey !== 'all' || validCategories.length <= 3) { - setCurrentOffset(0); // 重置偏移 - return; - } - - const interval = setInterval(() => { - setCurrentOffset(prev => (prev + 1) % validCategories.length); - }, 2000); // 每2秒切换一次 - - return () => clearInterval(interval); - }, [activeKey, validCategories.length]); - - // 如果没有有效的分类键或分类数据,不显示 - if (!activeKey || !modelCategories) { - return null; - } - - const modelCount = categoryCounts[activeKey] || 0; - - // 获取分类描述信息 - const getCategoryDescription = (categoryKey) => { - const descriptions = { - all: t('查看所有可用的AI模型,包括文本生成、图像处理、音频转换等多种类型的模型。'), - openai: t('令牌分发介绍:SSVIP 为纯OpenAI官方。SVIP 为纯Azure。Default 为Azure 消费。VIP为近似的复数。VVIP为近似的书发。'), - anthropic: t('Anthropic Claude系列模型,以安全性和可靠性著称,擅长对话、分析和创作任务。'), - gemini: t('Google Gemini系列模型,具备强大的多模态能力,支持文本、图像和代码理解。'), - moonshot: t('月之暗面Moonshot系列模型,专注于长文本处理和深度理解能力。'), - zhipu: t('智谱AI ChatGLM系列模型,在中文理解和生成方面表现优秀。'), - qwen: t('阿里云通义千问系列模型,覆盖多个领域的智能问答和内容生成。'), - deepseek: t('DeepSeek系列模型,在代码生成和数学推理方面具有出色表现。'), - minimax: t('MiniMax ABAB系列模型,专注于对话和内容创作的AI助手。'), - baidu: t('百度文心一言系列模型,在中文自然语言处理方面具有强大能力。'), - xunfei: t('科大讯飞星火系列模型,在语音识别和自然语言理解方面领先。'), - midjourney: t('Midjourney图像生成模型,专业的AI艺术创作和图像生成服务。'), - tencent: t('腾讯混元系列模型,提供全面的AI能力和企业级服务。'), - cohere: t('Cohere Command系列模型,专注于企业级自然语言处理应用。'), - cloudflare: t('Cloudflare Workers AI模型,提供边缘计算和高性能AI服务。'), - ai360: t('360智脑系列模型,在安全和智能助手方面具有独特优势。'), - yi: t('零一万物Yi系列模型,提供高质量的多语言理解和生成能力。'), - jina: t('Jina AI模型,专注于嵌入和向量搜索的AI解决方案。'), - mistral: t('Mistral AI系列模型,欧洲领先的开源大语言模型。'), - xai: t('xAI Grok系列模型,具有独特的幽默感和实时信息处理能力。'), - llama: t('Meta Llama系列模型,开源的大语言模型,在各种任务中表现优秀。'), - doubao: t('字节跳动豆包系列模型,在内容创作和智能对话方面表现出色。'), - }; - return descriptions[categoryKey] || t('该分类包含多种AI模型,适用于不同的应用场景。'); - }; - - // 为全部模型创建特殊的头像组合 - const renderAllModelsAvatar = () => { - // 重新排列数组,让当前偏移量的头像在第一位 - const rotatedCategories = validCategories.length > 3 ? [ - ...validCategories.slice(currentOffset), - ...validCategories.slice(0, currentOffset) - ] : validCategories; - - // 如果没有有效分类,使用模型分类名称的前两个字符 - if (validCategories.length === 0) { - // 获取所有分类(除了 'all')的名称前两个字符 - const fallbackCategories = Object.entries(modelCategories) - .filter(([key]) => key !== 'all') - .slice(0, 3) - .map(([key, category]) => ({ - key, - label: category.label, - text: category.label.slice(0, 2) || key.slice(0, 2).toUpperCase() - })); - - return ( -
- - {fallbackCategories.map((item) => ( - - {item.text} - - ))} - -
- ); - } - - return ( -
- ( - - {`+${restNumber}`} - - )} - > - {rotatedCategories.map((categoryKey) => { - const category = modelCategories[categoryKey]; - - return ( - - {category?.icon ? - React.cloneElement(category.icon, { size: 20 }) : - (category?.label?.charAt(0) || categoryKey.charAt(0).toUpperCase()) - } - - ); - })} - -
- ); - }; - - // 为具体分类渲染单个图标 - const renderCategoryAvatar = (category) => ( -
- {category.icon && React.cloneElement(category.icon, { size: 40 })} -
- ); - - // 如果是全部模型分类 - if (activeKey === 'all') { - return ( -
- -
- {/* 全部模型的头像组合 */} -
- {renderAllModelsAvatar()} -
- - {/* 分类信息 */} -
-
-

{modelCategories.all.label}

- - {t('共 {{count}} 个模型', { count: modelCount })} - -
-

- {getCategoryDescription(activeKey)} -

-
-
-
-
- ); - } - - // 具体分类 - const currentCategory = modelCategories[activeKey]; - if (!currentCategory) { - return null; - } - - return ( -
- -
- {/* 分类图标 */} -
- {renderCategoryAvatar(currentCategory)} -
- - {/* 分类信息 */} -
-
-

{currentCategory.label}

- - {t('共 {{count}} 个模型', { count: modelCount })} - -
-

- {getCategoryDescription(activeKey)} -

-
-
-
-
- ); -}; - -export default PricingCategoryIntro; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/layout/header/PricingTopSection.jsx b/web/src/components/table/model-pricing/layout/header/PricingTopSection.jsx index dbdee4f9..f50a2ee6 100644 --- a/web/src/components/table/model-pricing/layout/header/PricingTopSection.jsx +++ b/web/src/components/table/model-pricing/layout/header/PricingTopSection.jsx @@ -21,7 +21,7 @@ import React, { useMemo, useState } from 'react'; import { Input, Button } from '@douyinfe/semi-ui'; import { IconSearch, IconCopy, IconFilter } from '@douyinfe/semi-icons'; import PricingFilterModal from '../../modal/PricingFilterModal'; -import PricingCategoryIntroWithSkeleton from './PricingCategoryIntroWithSkeleton'; +import PricingVendorIntroWithSkeleton from './PricingVendorIntroWithSkeleton'; const PricingTopSection = ({ selectedRowKeys, @@ -31,10 +31,9 @@ const PricingTopSection = ({ handleCompositionEnd, isMobile, sidebarProps, - activeKey, - modelCategories, - categoryCounts, - availableCategories, + filterVendor, + models, + filteredModels, loading, t }) => { @@ -82,13 +81,12 @@ const PricingTopSection = ({ return ( <> - {/* 分类介绍区域(含骨架屏) */} - diff --git a/web/src/components/table/model-pricing/layout/header/PricingVendorIntro.jsx b/web/src/components/table/model-pricing/layout/header/PricingVendorIntro.jsx new file mode 100644 index 00000000..89922384 --- /dev/null +++ b/web/src/components/table/model-pricing/layout/header/PricingVendorIntro.jsx @@ -0,0 +1,247 @@ +/* +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, { useState, useEffect, useMemo } from 'react'; +import { Card, Tag, Avatar, AvatarGroup, Typography } from '@douyinfe/semi-ui'; +import { getLobeHubIcon } from '../../../../../helpers'; + +const { Paragraph } = Typography; + +const PricingVendorIntro = ({ + filterVendor, + models = [], + allModels = [], + t +}) => { + // 轮播动效状态(只对全部供应商生效) + const [currentOffset, setCurrentOffset] = useState(0); + + // 获取所有供应商信息 + const vendorInfo = useMemo(() => { + const vendors = new Map(); + let unknownCount = 0; + + (allModels.length > 0 ? allModels : models).forEach(model => { + if (model.vendor_name) { + if (!vendors.has(model.vendor_name)) { + vendors.set(model.vendor_name, { + name: model.vendor_name, + icon: model.vendor_icon, + description: model.vendor_description, + count: 0 + }); + } + vendors.get(model.vendor_name).count++; + } else { + unknownCount++; + } + }); + + const vendorList = Array.from(vendors.values()).sort((a, b) => a.name.localeCompare(b.name)); + + if (unknownCount > 0) { + vendorList.push({ + name: 'unknown', + icon: null, + description: t('包含来自未知或未标明供应商的AI模型,这些模型可能来自小型供应商或开源项目。'), + count: unknownCount + }); + } + + return vendorList; + }, [allModels, models]); + + // 计算当前过滤器的模型数量 + const currentModelCount = models.length; + + // 设置轮播定时器(只对全部供应商且有足够头像时生效) + useEffect(() => { + if (filterVendor !== 'all' || vendorInfo.length <= 3) { + setCurrentOffset(0); // 重置偏移 + return; + } + + const interval = setInterval(() => { + setCurrentOffset(prev => (prev + 1) % vendorInfo.length); + }, 2000); // 每2秒切换一次 + + return () => clearInterval(interval); + }, [filterVendor, vendorInfo.length]); + + // 获取供应商描述信息(从后端数据中) + const getVendorDescription = (vendorKey) => { + if (vendorKey === 'all') { + return t('查看所有可用的AI模型供应商,包括众多知名供应商的模型。'); + } + if (vendorKey === 'unknown') { + return t('包含来自未知或未标明供应商的AI模型,这些模型可能来自小型供应商或开源项目。'); + } + const vendor = vendorInfo.find(v => v.name === vendorKey); + return vendor?.description || t('该供应商提供多种AI模型,适用于不同的应用场景。'); + }; + + // 为全部供应商创建特殊的头像组合 + const renderAllVendorsAvatar = () => { + // 重新排列数组,让当前偏移量的头像在第一位 + const rotatedVendors = vendorInfo.length > 3 ? [ + ...vendorInfo.slice(currentOffset), + ...vendorInfo.slice(0, currentOffset) + ] : vendorInfo; + + // 如果没有供应商,显示占位符 + if (vendorInfo.length === 0) { + return ( +
+ + AI + +
+ ); + } + + return ( +
+ ( + + {`+${restNumber}`} + + )} + > + {rotatedVendors.map((vendor) => ( + + {vendor.icon ? + getLobeHubIcon(vendor.icon, 20) : + (vendor.name === 'unknown' ? '?' : vendor.name.charAt(0).toUpperCase()) + } + + ))} + +
+ ); + }; + + // 为具体供应商渲染单个图标 + const renderVendorAvatar = (vendor) => ( +
+ {vendor.icon ? + getLobeHubIcon(vendor.icon, 40) : + + {vendor.name === 'unknown' ? '?' : vendor.name.charAt(0).toUpperCase()} + + } +
+ ); + + // 如果是全部供应商 + if (filterVendor === 'all') { + return ( +
+ +
+ {/* 全部供应商的头像组合 */} +
+ {renderAllVendorsAvatar()} +
+ + {/* 供应商信息 */} +
+
+

{t('全部供应商')}

+ + {t('共 {{count}} 个模型', { count: currentModelCount })} + +
+ + {getVendorDescription('all')} + +
+
+
+
+ ); + } + + // 具体供应商 + const currentVendor = vendorInfo.find(v => v.name === filterVendor); + if (!currentVendor) { + return null; + } + + const vendorDisplayName = currentVendor.name === 'unknown' ? t('未知供应商') : currentVendor.name; + + return ( +
+ +
+ {/* 供应商图标 */} +
+ {renderVendorAvatar(currentVendor)} +
+ + {/* 供应商信息 */} +
+
+

{vendorDisplayName}

+ + {t('共 {{count}} 个模型', { count: currentModelCount })} + +
+ + {currentVendor.description || getVendorDescription(currentVendor.name)} + +
+
+
+
+ ); +}; + +export default PricingVendorIntro; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/layout/header/PricingCategoryIntroSkeleton.jsx b/web/src/components/table/model-pricing/layout/header/PricingVendorIntroSkeleton.jsx similarity index 85% rename from web/src/components/table/model-pricing/layout/header/PricingCategoryIntroSkeleton.jsx rename to web/src/components/table/model-pricing/layout/header/PricingVendorIntroSkeleton.jsx index 8ae719df..1a0a759a 100644 --- a/web/src/components/table/model-pricing/layout/header/PricingCategoryIntroSkeleton.jsx +++ b/web/src/components/table/model-pricing/layout/header/PricingVendorIntroSkeleton.jsx @@ -20,26 +20,26 @@ For commercial licensing, please contact support@quantumnous.com import React from 'react'; import { Card, Skeleton } from '@douyinfe/semi-ui'; -const PricingCategoryIntroSkeleton = ({ - isAllModels = false +const PricingVendorIntroSkeleton = ({ + isAllVendors = false }) => { const placeholder = (
- {/* 分类图标骨架 */} + {/* 供应商图标骨架 */}
- {isAllModels ? ( + {isAllVendors ? (
- {Array.from({ length: 5 }).map((_, index) => ( + {Array.from({ length: 4 }).map((_, index) => ( ))} @@ -49,7 +49,7 @@ const PricingCategoryIntroSkeleton = ({ )}
- {/* 分类信息骨架 */} + {/* 供应商信息骨架 */}
@@ -72,4 +72,4 @@ const PricingCategoryIntroSkeleton = ({ ); }; -export default PricingCategoryIntroSkeleton; \ No newline at end of file +export default PricingVendorIntroSkeleton; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/layout/header/PricingCategoryIntroWithSkeleton.jsx b/web/src/components/table/model-pricing/layout/header/PricingVendorIntroWithSkeleton.jsx similarity index 65% rename from web/src/components/table/model-pricing/layout/header/PricingCategoryIntroWithSkeleton.jsx rename to web/src/components/table/model-pricing/layout/header/PricingVendorIntroWithSkeleton.jsx index fbb7113a..dc7cba93 100644 --- a/web/src/components/table/model-pricing/layout/header/PricingCategoryIntroWithSkeleton.jsx +++ b/web/src/components/table/model-pricing/layout/header/PricingVendorIntroWithSkeleton.jsx @@ -18,37 +18,35 @@ For commercial licensing, please contact support@quantumnous.com */ import React from 'react'; -import PricingCategoryIntro from './PricingCategoryIntro'; -import PricingCategoryIntroSkeleton from './PricingCategoryIntroSkeleton'; +import PricingVendorIntro from './PricingVendorIntro'; +import PricingVendorIntroSkeleton from './PricingVendorIntroSkeleton'; import { useMinimumLoadingTime } from '../../../../../hooks/common/useMinimumLoadingTime'; -const PricingCategoryIntroWithSkeleton = ({ +const PricingVendorIntroWithSkeleton = ({ loading = false, - activeKey, - modelCategories, - categoryCounts, - availableCategories, + filterVendor, + models, + allModels, t }) => { const showSkeleton = useMinimumLoadingTime(loading); if (showSkeleton) { return ( - ); } return ( - ); }; -export default PricingCategoryIntroWithSkeleton; \ No newline at end of file +export default PricingVendorIntroWithSkeleton; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/modal/ModelDetailSideSheet.jsx b/web/src/components/table/model-pricing/modal/ModelDetailSideSheet.jsx index 6723e2f7..372401c0 100644 --- a/web/src/components/table/model-pricing/modal/ModelDetailSideSheet.jsx +++ b/web/src/components/table/model-pricing/modal/ModelDetailSideSheet.jsx @@ -46,6 +46,7 @@ const ModelDetailSideSheet = ({ displayPrice, showRatio, usableGroup, + vendorsMap, t, }) => { const isMobile = useIsMobile(); @@ -53,7 +54,7 @@ const ModelDetailSideSheet = ({ return ( } + title={} bodyStyle={{ padding: '0', display: 'flex', @@ -80,7 +81,7 @@ const ModelDetailSideSheet = ({ )} {modelData && ( <> - + resetPricingFilters({ handleChange: sidebarProps.handleChange, - setActiveKey: sidebarProps.setActiveKey, - availableCategories: sidebarProps.availableCategories, setShowWithRecharge: sidebarProps.setShowWithRecharge, setCurrency: sidebarProps.setCurrency, setShowRatio: sidebarProps.setShowRatio, @@ -41,6 +39,7 @@ const PricingFilterModal = ({ setFilterGroup: sidebarProps.setFilterGroup, setFilterQuotaType: sidebarProps.setFilterQuotaType, setFilterEndpointType: sidebarProps.setFilterEndpointType, + setFilterVendor: sidebarProps.setFilterVendor, 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 e9f3178e..94ab3c04 100644 --- a/web/src/components/table/model-pricing/modal/components/FilterModalContent.jsx +++ b/web/src/components/table/model-pricing/modal/components/FilterModalContent.jsx @@ -19,10 +19,10 @@ For commercial licensing, please contact support@quantumnous.com import React from 'react'; import PricingDisplaySettings from '../../filter/PricingDisplaySettings'; -import PricingCategories from '../../filter/PricingCategories'; import PricingGroups from '../../filter/PricingGroups'; import PricingQuotaTypes from '../../filter/PricingQuotaTypes'; import PricingEndpointTypes from '../../filter/PricingEndpointTypes'; +import PricingVendors from '../../filter/PricingVendors'; import { usePricingFilterCounts } from '../../../../../hooks/model-pricing/usePricingFilterCounts'; const FilterModalContent = ({ sidebarProps, t }) => { @@ -43,6 +43,8 @@ const FilterModalContent = ({ sidebarProps, t }) => { setFilterQuotaType, filterEndpointType, setFilterEndpointType, + filterVendor, + setFilterVendor, tokenUnit, setTokenUnit, loading, @@ -52,15 +54,14 @@ const FilterModalContent = ({ sidebarProps, t }) => { const { quotaTypeModels, endpointTypeModels, - dynamicCategoryCounts, + vendorModels, groupCountModels, } = usePricingFilterCounts({ models: categoryProps.models, - modelCategories: categoryProps.modelCategories, - activeKey: categoryProps.activeKey, filterGroup, filterQuotaType, filterEndpointType, + filterVendor, searchValue: sidebarProps.searchValue, }); @@ -81,10 +82,11 @@ const FilterModalContent = ({ sidebarProps, t }) => { t={t} /> - diff --git a/web/src/components/table/model-pricing/modal/components/ModelBasicInfo.jsx b/web/src/components/table/model-pricing/modal/components/ModelBasicInfo.jsx index 662b5616..d33d2766 100644 --- a/web/src/components/table/model-pricing/modal/components/ModelBasicInfo.jsx +++ b/web/src/components/table/model-pricing/modal/components/ModelBasicInfo.jsx @@ -18,20 +18,43 @@ For commercial licensing, please contact support@quantumnous.com */ import React from 'react'; -import { Card, Avatar, Typography } from '@douyinfe/semi-ui'; +import { Card, Avatar, Typography, Tag, Space } from '@douyinfe/semi-ui'; import { IconInfoCircle } from '@douyinfe/semi-icons'; +import { stringToColor } from '../../../../../helpers'; const { Text } = Typography; -const ModelBasicInfo = ({ modelData, t }) => { - // 获取模型描述 +const ModelBasicInfo = ({ modelData, vendorsMap = {}, t }) => { + // 获取模型描述(使用后端真实数据) const getModelDescription = () => { if (!modelData) return t('暂无模型描述'); - // 这里可以根据模型名称返回不同的描述 - if (modelData.model_name?.includes('gpt-4o-image')) { - return t('逆向plus账号的可绘图的gpt-4o模型,由于OpenAI限制绘图数量,并非每次都能绘图成功,由于是逆向模型,只要请求成功,即使绘图失败也会扣费,请谨慎使用。推荐使用正式版的 gpt-image-1模型。'); + + // 优先使用后端提供的描述 + if (modelData.description) { + return modelData.description; } - return modelData.description || t('暂无模型描述'); + + // 如果没有描述但有供应商描述,显示供应商信息 + if (modelData.vendor_description) { + return t('供应商信息:') + modelData.vendor_description; + } + + return t('暂无模型描述'); + }; + + // 获取模型标签 + const getModelTags = () => { + const tags = []; + + if (modelData?.tags) { + const customTags = modelData.tags.split(',').filter(tag => tag.trim()); + customTags.forEach(tag => { + const tagText = tag.trim(); + tags.push({ text: tagText, color: stringToColor(tagText) }); + }); + } + + return tags; }; return ( @@ -46,7 +69,24 @@ const ModelBasicInfo = ({ modelData, t }) => {
-

{getModelDescription()}

+

{getModelDescription()}

+ {getModelTags().length > 0 && ( +
+ {t('模型标签')} + + {getModelTags().map((tag, index) => ( + + {tag.text} + + ))} + +
+ )}
); diff --git a/web/src/components/table/model-pricing/modal/components/ModelHeader.jsx b/web/src/components/table/model-pricing/modal/components/ModelHeader.jsx index 23ae179c..63475819 100644 --- a/web/src/components/table/model-pricing/modal/components/ModelHeader.jsx +++ b/web/src/components/table/model-pricing/modal/components/ModelHeader.jsx @@ -18,8 +18,8 @@ For commercial licensing, please contact support@quantumnous.com */ import React from 'react'; -import { Tag, Typography, Toast, Avatar } from '@douyinfe/semi-ui'; -import { getModelCategories } from '../../../../../helpers'; +import { Typography, Toast, Avatar } from '@douyinfe/semi-ui'; +import { getLobeHubIcon } from '../../../../../helpers'; const { Paragraph } = Typography; @@ -28,52 +28,22 @@ const CARD_STYLES = { icon: "w-8 h-8 flex items-center justify-center", }; -const ModelHeader = ({ modelData, t }) => { - // 获取模型图标 - const getModelIcon = (modelName) => { - // 如果没有模型名称,直接返回默认头像 - if (!modelName) { - return ( -
- - AI - -
- ); - } - - const categories = getModelCategories(t); - let icon = null; - - // 遍历分类,找到匹配的模型图标 - for (const [key, category] of Object.entries(categories)) { - if (key !== 'all' && category.filter({ model_name: modelName })) { - icon = category.icon; - break; - } - } - - // 如果找到了匹配的图标,返回包装后的图标 - if (icon) { +const ModelHeader = ({ modelData, vendorsMap = {}, t }) => { + // 获取模型图标(使用供应商图标) + const getModelIcon = () => { + // 优先使用供应商图标 + if (modelData?.vendor_icon) { return (
- {React.cloneElement(icon, { size: 32 })} + {getLobeHubIcon(modelData.vendor_icon, 32)}
); } - const avatarText = modelName?.slice(0, 2).toUpperCase() || 'AI'; + // 如果没有供应商图标,使用模型名称的前两个字符 + const avatarText = modelData?.model_name?.slice(0, 2).toUpperCase() || 'AI'; return (
{ ); }; - // 获取模型标签 - const getModelTags = () => { - const tags = [ - { text: t('文本对话'), color: 'green' }, - { text: t('图片生成'), color: 'blue' }, - { text: t('图像分析'), color: 'cyan' } - ]; - - return tags; - }; - return (
- {getModelIcon(modelData?.model_name)} + {getModelIcon()}
Toast.success({ content: t('已复制模型名称') }) @@ -116,18 +75,6 @@ const ModelHeader = ({ modelData, t }) => { > {modelData?.model_name || t('未知模型')} -
- {getModelTags().map((tag, index) => ( - - {tag.text} - - ))} -
); diff --git a/web/src/components/table/model-pricing/view/card/PricingCardView.jsx b/web/src/components/table/model-pricing/view/card/PricingCardView.jsx index 9d0fbf48..35b84e2e 100644 --- a/web/src/components/table/model-pricing/view/card/PricingCardView.jsx +++ b/web/src/components/table/model-pricing/view/card/PricingCardView.jsx @@ -21,9 +21,10 @@ import React from 'react'; import { Card, Tag, Tooltip, Checkbox, Empty, Pagination, Button, Avatar } from '@douyinfe/semi-ui'; import { IconHelpCircle, IconCopy } from '@douyinfe/semi-icons'; import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations'; -import { stringToColor, getModelCategories, calculateModelPrice, formatPriceInfo } from '../../../../../helpers'; +import { stringToColor, calculateModelPrice, formatPriceInfo, getLobeHubIcon } from '../../../../../helpers'; import PricingCardSkeleton from './PricingCardSkeleton'; import { useMinimumLoadingTime } from '../../../../../hooks/common/useMinimumLoadingTime'; +import { renderLimitedItems } from '../../../../common/ui/RenderUtils'; const CARD_STYLES = { container: "w-12 h-12 rounded-2xl flex items-center justify-center relative shadow-md", @@ -52,16 +53,11 @@ const PricingCardView = ({ t, selectedRowKeys = [], setSelectedRowKeys, - activeKey, - availableCategories, openModelDetail, }) => { const showSkeleton = useMinimumLoadingTime(loading); - const startIndex = (currentPage - 1) * pageSize; - const endIndex = startIndex + pageSize; - const paginatedModels = filteredModels.slice(startIndex, endIndex); - + const paginatedModels = filteredModels.slice(startIndex, startIndex + pageSize); const getModelKey = (model) => model.key ?? model.model_name ?? model.id; const handleCheckboxChange = (model, checked) => { @@ -75,30 +71,28 @@ const PricingCardView = ({ }; // 获取模型图标 - const getModelIcon = (modelName) => { - const categories = getModelCategories(t); - let icon = null; - - // 遍历分类,找到匹配的模型图标 - for (const [key, category] of Object.entries(categories)) { - if (key !== 'all' && category.filter({ model_name: modelName })) { - icon = category.icon; - break; - } + const getModelIcon = (model) => { + if (!model || !model.model_name) { + return ( +
+ ? +
+ ); } - - // 如果找到了匹配的图标,返回包装后的图标 - if (icon) { + // 优先使用供应商图标 + if (model.vendor_icon) { return (
- {React.cloneElement(icon, { size: 32 })} + {getLobeHubIcon(model.vendor_icon, 32)}
); } - const avatarText = modelName.slice(0, 2).toUpperCase(); + // 如果没有供应商图标,使用模型名称生成头像 + + const avatarText = model.model_name.slice(0, 2).toUpperCase(); return (
{ - return t('高性能AI模型,适用于各种文本生成和理解任务。'); + const getModelDescription = (record) => { + return record.description || ''; }; // 渲染价格信息 @@ -137,47 +131,41 @@ const PricingCardView = ({ // 渲染标签 const renderTags = (record) => { - const tags = []; + const allTags = []; // 计费类型标签 const billingType = record.quota_type === 1 ? 'teal' : 'violet'; const billingText = record.quota_type === 1 ? t('按次计费') : t('按量计费'); - tags.push( - - {billingText} - - ); - - // 热门模型标签 - if (record.model_name.includes('gpt')) { - tags.push( - - {t('热')} + allTags.push({ + key: "billing", + element: ( + + {billingText} - ); - } + ) + }); - // 端点类型标签 - if (record.supported_endpoint_types?.length > 0) { - record.supported_endpoint_types.slice(0, 2).forEach((endpoint, index) => { - tags.push( - - {endpoint} - - ); + // 自定义标签 + if (record.tags) { + const tagArr = record.tags.split(',').filter(Boolean); + tagArr.forEach((tg, idx) => { + allTags.push({ + key: `custom-${idx}`, + element: ( + + {tg} + + ) + }); }); } - // 上下文长度标签 - const contextMatch = record.model_name.match(/(\d+)k/i); - const contextSize = contextMatch ? contextMatch[1] + 'K' : '4K'; - tags.push( - - {contextSize} - - ); - - return tags; + // 使用 renderLimitedItems 渲染标签 + return renderLimitedItems({ + items: allTags, + renderItem: (item, idx) => React.cloneElement(item.element, { key: item.key }), + maxDisplay: 3 + }); }; // 显示骨架屏 @@ -212,15 +200,14 @@ const PricingCardView = ({ return ( openModelDetail && openModelDetail(model)} > {/* 头部:图标 + 模型名称 + 操作按钮 */}
- {getModelIcon(model.model_name)} + {getModelIcon(model)}

{model.model_name} @@ -262,12 +249,12 @@ const PricingCardView = ({ className="text-xs line-clamp-2 leading-relaxed" style={{ color: 'var(--semi-color-text-2)' }} > - {getModelDescription(model.model_name)} + {getModelDescription(model)}

{/* 标签区域 */} -
+
{renderTags(model)}
diff --git a/web/src/components/table/model-pricing/view/table/PricingTableColumns.js b/web/src/components/table/model-pricing/view/table/PricingTableColumns.js index 7ff77a57..e38cde13 100644 --- a/web/src/components/table/model-pricing/view/table/PricingTableColumns.js +++ b/web/src/components/table/model-pricing/view/table/PricingTableColumns.js @@ -20,7 +20,8 @@ For commercial licensing, please contact support@quantumnous.com import React from 'react'; import { Tag, Space, Tooltip } from '@douyinfe/semi-ui'; import { IconHelpCircle } from '@douyinfe/semi-icons'; -import { renderModelTag, stringToColor, calculateModelPrice } from '../../../../../helpers'; +import { renderModelTag, stringToColor, calculateModelPrice, getLobeHubIcon } from '../../../../../helpers'; +import { renderLimitedItems, renderDescription } from '../../../../common/ui/RenderUtils'; function renderQuotaType(type, t) { switch (type) { @@ -41,6 +42,31 @@ function renderQuotaType(type, t) { } } +// Render vendor name +const renderVendor = (vendorName, vendorIcon, t) => { + if (!vendorName) return '-'; + return ( + + {vendorName} + + ); +}; + +// Render tags list using RenderUtils +const renderTags = (text) => { + if (!text) return '-'; + const tagsArr = text.split(',').filter(tag => tag.trim()); + return renderLimitedItems({ + items: tagsArr, + renderItem: (tag, idx) => ( + + {tag.trim()} + + ), + maxDisplay: 3 + }); +}; + function renderSupportedEndpoints(endpoints) { if (!endpoints || endpoints.length === 0) { return null; @@ -104,7 +130,25 @@ export const getPricingTableColumns = ({ sorter: (a, b) => a.quota_type - b.quota_type, }; - const baseColumns = [modelNameColumn, quotaColumn]; + const descriptionColumn = { + title: t('描述'), + dataIndex: 'description', + render: (text) => renderDescription(text, 200), + }; + + const tagsColumn = { + title: t('标签'), + dataIndex: 'tags', + render: renderTags, + }; + + const vendorColumn = { + title: t('供应商'), + dataIndex: 'vendor_name', + render: (text, record) => renderVendor(text, record.vendor_icon, t), + }; + + const baseColumns = [modelNameColumn, vendorColumn, descriptionColumn, tagsColumn, quotaColumn]; const ratioColumn = { title: () => ( diff --git a/web/src/components/table/models/ModelsColumnDefs.js b/web/src/components/table/models/ModelsColumnDefs.js index c02201c4..48841e60 100644 --- a/web/src/components/table/models/ModelsColumnDefs.js +++ b/web/src/components/table/models/ModelsColumnDefs.js @@ -30,7 +30,7 @@ import { getLobeHubIcon, stringToColor } from '../../../helpers'; -import { renderLimitedItems, renderDescription } from './ui/RenderUtils.jsx'; +import { renderLimitedItems, renderDescription } from '../../common/ui/RenderUtils'; const { Text } = Typography; diff --git a/web/src/components/table/models/modals/PrefillGroupManagement.jsx b/web/src/components/table/models/modals/PrefillGroupManagement.jsx index 569fcdcd..1ce51b9e 100644 --- a/web/src/components/table/models/modals/PrefillGroupManagement.jsx +++ b/web/src/components/table/models/modals/PrefillGroupManagement.jsx @@ -41,9 +41,9 @@ import { import { API, showError, showSuccess, stringToColor } from '../../../../helpers'; import { useTranslation } from 'react-i18next'; import { useIsMobile } from '../../../../hooks/common/useIsMobile'; -import CardTable from '../../../common/ui/CardTable.js'; -import EditPrefillGroupModal from './EditPrefillGroupModal.jsx'; -import { renderLimitedItems, renderDescription } from '../ui/RenderUtils.jsx'; +import CardTable from '../../../common/ui/CardTable'; +import EditPrefillGroupModal from './EditPrefillGroupModal'; +import { renderLimitedItems, renderDescription } from '../../../common/ui/RenderUtils'; const { Text, Title } = Typography; diff --git a/web/src/helpers/utils.js b/web/src/helpers/utils.js index 27dd7ab9..c226bdd9 100644 --- a/web/src/helpers/utils.js +++ b/web/src/helpers/utils.js @@ -698,14 +698,13 @@ const DEFAULT_PRICING_FILTERS = { filterGroup: 'all', filterQuotaType: 'all', filterEndpointType: 'all', + filterVendor: 'all', currentPage: 1, }; // 重置模型定价筛选条件 export const resetPricingFilters = ({ handleChange, - setActiveKey, - availableCategories, setShowWithRecharge, setCurrency, setShowRatio, @@ -713,11 +712,11 @@ export const resetPricingFilters = ({ setFilterGroup, setFilterQuotaType, setFilterEndpointType, + setFilterVendor, setCurrentPage, setTokenUnit, }) => { handleChange?.(DEFAULT_PRICING_FILTERS.search); - availableCategories?.length > 0 && setActiveKey?.(availableCategories[0]); setShowWithRecharge?.(DEFAULT_PRICING_FILTERS.showWithRecharge); setCurrency?.(DEFAULT_PRICING_FILTERS.currency); setShowRatio?.(DEFAULT_PRICING_FILTERS.showRatio); @@ -726,5 +725,6 @@ export const resetPricingFilters = ({ setFilterGroup?.(DEFAULT_PRICING_FILTERS.filterGroup); setFilterQuotaType?.(DEFAULT_PRICING_FILTERS.filterQuotaType); setFilterEndpointType?.(DEFAULT_PRICING_FILTERS.filterEndpointType); + setFilterVendor?.(DEFAULT_PRICING_FILTERS.filterVendor); setCurrentPage?.(DEFAULT_PRICING_FILTERS.currentPage); }; diff --git a/web/src/hooks/model-pricing/useModelPricingData.js b/web/src/hooks/model-pricing/useModelPricingData.js index 98a8e566..1a8fb719 100644 --- a/web/src/hooks/model-pricing/useModelPricingData.js +++ b/web/src/hooks/model-pricing/useModelPricingData.js @@ -19,7 +19,7 @@ For commercial licensing, please contact support@quantumnous.com import { useState, useEffect, useContext, useRef, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { API, copy, showError, showInfo, showSuccess, getModelCategories } from '../../helpers'; +import { API, copy, showError, showInfo, showSuccess } from '../../helpers'; import { Modal } from '@douyinfe/semi-ui'; import { UserContext } from '../../context/User/index.js'; import { StatusContext } from '../../context/Status/index.js'; @@ -34,16 +34,17 @@ export const useModelPricingData = () => { const [selectedGroup, setSelectedGroup] = useState('default'); const [showModelDetail, setShowModelDetail] = useState(false); const [selectedModel, setSelectedModel] = useState(null); - const [filterGroup, setFilterGroup] = useState('all'); // 用于 Table 的可用分组筛选,“all” 表示不过滤 + const [filterGroup, setFilterGroup] = useState('all'); // 用于 Table 的可用分组筛选,"all" 表示不过滤 const [filterQuotaType, setFilterQuotaType] = useState('all'); // 计费类型筛选: 'all' | 0 | 1 - const [activeKey, setActiveKey] = useState('all'); const [filterEndpointType, setFilterEndpointType] = useState('all'); // 端点类型筛选: 'all' | string + const [filterVendor, setFilterVendor] = useState('all'); // 供应商筛选: 'all' | 'unknown' | string const [pageSize, setPageSize] = useState(10); const [currentPage, setCurrentPage] = useState(1); const [currency, setCurrency] = useState('USD'); const [showWithRecharge, setShowWithRecharge] = useState(false); const [tokenUnit, setTokenUnit] = useState('M'); const [models, setModels] = useState([]); + const [vendorsMap, setVendorsMap] = useState({}); const [loading, setLoading] = useState(true); const [groupRatio, setGroupRatio] = useState({}); const [usableGroup, setUsableGroup] = useState({}); @@ -55,37 +56,9 @@ export const useModelPricingData = () => { const priceRate = useMemo(() => statusState?.status?.price ?? 1, [statusState]); const usdExchangeRate = useMemo(() => statusState?.status?.usd_exchange_rate ?? priceRate, [statusState, priceRate]); - const modelCategories = getModelCategories(t); - - const categoryCounts = useMemo(() => { - const counts = {}; - if (models.length > 0) { - counts['all'] = models.length; - Object.entries(modelCategories).forEach(([key, category]) => { - if (key !== 'all') { - counts[key] = models.filter(model => category.filter(model)).length; - } - }); - } - return counts; - }, [models, modelCategories]); - - const availableCategories = useMemo(() => { - if (!models.length) return ['all']; - return Object.entries(modelCategories).filter(([key, category]) => { - if (key === 'all') return true; - return models.some(model => category.filter(model)); - }).map(([key]) => key); - }, [models]); - const filteredModels = useMemo(() => { let result = models; - // 分类筛选 - if (activeKey !== 'all') { - result = result.filter(model => modelCategories[activeKey].filter(model)); - } - // 分组筛选 if (filterGroup !== 'all') { result = result.filter(model => model.enable_groups.includes(filterGroup)); @@ -104,16 +77,28 @@ export const useModelPricingData = () => { ); } + // 供应商筛选 + if (filterVendor !== 'all') { + if (filterVendor === 'unknown') { + result = result.filter(model => !model.vendor_name); + } else { + result = result.filter(model => model.vendor_name === filterVendor); + } + } + // 搜索筛选 if (searchValue.length > 0) { const searchTerm = searchValue.toLowerCase(); result = result.filter(model => - model.model_name.toLowerCase().includes(searchTerm) + (model.model_name && model.model_name.toLowerCase().includes(searchTerm)) || + (model.description && model.description.toLowerCase().includes(searchTerm)) || + (model.tags && model.tags.toLowerCase().includes(searchTerm)) || + (model.vendor_name && model.vendor_name.toLowerCase().includes(searchTerm)) ); } return result; - }, [activeKey, models, searchValue, filterGroup, filterQuotaType, filterEndpointType]); + }, [models, searchValue, filterGroup, filterQuotaType, filterEndpointType, filterVendor]); const rowSelection = useMemo( () => ({ @@ -137,10 +122,18 @@ export const useModelPricingData = () => { return `$${priceInUSD.toFixed(3)}`; }; - const setModelsFormat = (models, groupRatio) => { + const setModelsFormat = (models, groupRatio, vendorMap) => { for (let i = 0; i < models.length; i++) { - models[i].key = models[i].model_name; - models[i].group_ratio = groupRatio[models[i].model_name]; + const m = models[i]; + m.key = m.model_name; + m.group_ratio = groupRatio[m.model_name]; + + if (m.vendor_id && vendorMap[m.vendor_id]) { + const vendor = vendorMap[m.vendor_id]; + m.vendor_name = vendor.name; + m.vendor_icon = vendor.icon; + m.vendor_description = vendor.description; + } } models.sort((a, b) => { return a.quota_type - b.quota_type; @@ -166,12 +159,20 @@ export const useModelPricingData = () => { setLoading(true); let url = '/api/pricing'; const res = await API.get(url); - const { success, message, data, group_ratio, usable_group } = res.data; + const { success, message, data, vendors, group_ratio, usable_group } = res.data; if (success) { setGroupRatio(group_ratio); setUsableGroup(usable_group); setSelectedGroup(userState.user ? userState.user.group : 'default'); - setModelsFormat(data, group_ratio); + // 构建供应商 Map 方便查找 + const vendorMap = {}; + if (Array.isArray(vendors)) { + vendors.forEach(v => { + vendorMap[v.id] = v; + }); + } + setVendorsMap(vendorMap); + setModelsFormat(data, group_ratio, vendorMap); } else { showError(message); } @@ -238,7 +239,7 @@ export const useModelPricingData = () => { // 当筛选条件变化时重置到第一页 useEffect(() => { setCurrentPage(1); - }, [activeKey, filterGroup, filterQuotaType, filterEndpointType, searchValue]); + }, [filterGroup, filterQuotaType, filterEndpointType, filterVendor, searchValue]); return { // 状态 @@ -262,8 +263,8 @@ export const useModelPricingData = () => { setFilterQuotaType, filterEndpointType, setFilterEndpointType, - activeKey, - setActiveKey, + filterVendor, + setFilterVendor, pageSize, setPageSize, currentPage, @@ -282,12 +283,12 @@ export const useModelPricingData = () => { // 计算属性 priceRate, usdExchangeRate, - modelCategories, - categoryCounts, - availableCategories, filteredModels, rowSelection, + // 供应商 + vendorsMap, + // 用户和状态 userState, statusState, diff --git a/web/src/hooks/model-pricing/usePricingFilterCounts.js b/web/src/hooks/model-pricing/usePricingFilterCounts.js index e23111f3..cd993bd5 100644 --- a/web/src/hooks/model-pricing/usePricingFilterCounts.js +++ b/web/src/hooks/model-pricing/usePricingFilterCounts.js @@ -24,61 +24,18 @@ import { useMemo } from 'react'; export const usePricingFilterCounts = ({ models = [], - modelCategories = {}, - activeKey = 'all', filterGroup = 'all', filterQuotaType = 'all', filterEndpointType = 'all', + filterVendor = '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 allModels = models; // 针对计费类型按钮计数 const quotaTypeModels = useMemo(() => { - let result = modelsAfterCategory; + let result = allModels; if (filterGroup !== 'all') { result = result.filter(m => m.enable_groups && m.enable_groups.includes(filterGroup)); } @@ -87,24 +44,38 @@ export const usePricingFilterCounts = ({ 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); + } + } return result; - }, [modelsAfterCategory, filterGroup, filterEndpointType]); + }, [allModels, filterGroup, filterEndpointType, filterVendor]); // 针对端点类型按钮计数 const endpointTypeModels = useMemo(() => { - let result = modelsAfterCategory; + 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 (filterVendor !== 'all') { + if (filterVendor === 'unknown') { + result = result.filter(m => !m.vendor_name); + } else { + result = result.filter(m => m.vendor_name === filterVendor); + } + } return result; - }, [modelsAfterCategory, filterGroup, filterQuotaType]); + }, [allModels, filterGroup, filterQuotaType, filterVendor]); // === 可用令牌分组计数模型(排除 group 过滤,保留其余过滤) === const groupCountModels = useMemo(() => { - let result = modelsAfterCategory; // 已包含分类筛选 + let result = allModels; // 不应用 filterGroup 本身 if (filterQuotaType !== 'all') { @@ -115,17 +86,46 @@ export const usePricingFilterCounts = ({ 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) { const term = searchValue.toLowerCase(); - result = result.filter(m => m.model_name.toLowerCase().includes(term)); + 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)) + ); } return result; - }, [modelsAfterCategory, filterQuotaType, filterEndpointType, searchValue]); + }, [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 { quotaTypeModels, endpointTypeModels, - dynamicCategoryCounts, + vendorModels, groupCountModels, }; }; \ No newline at end of file