From e1f10fec9511f7f530703939d9f17cb5efff91fb Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 3 Mar 2026 15:12:13 +0800 Subject: [PATCH] add localiazed --- model/ability.go | 3 +- model/model_meta.go | 12 ++ model/pricing.go | 4 +- .../common/ui/HorizontalFilterRow.jsx | 140 +++++++++++++ .../view/card/PricingCardView.jsx | 13 +- .../model-pricing/useModelPricingData.jsx | 11 + .../model-pricing/usePricingFilterCounts.js | 26 +++ web/src/pages/Home/HomePricingFilters.jsx | 197 ++++++++++++++++++ web/src/pages/Home/index.jsx | 87 +------- web/vite.config.js | 5 + 10 files changed, 410 insertions(+), 88 deletions(-) create mode 100644 web/src/components/common/ui/HorizontalFilterRow.jsx create mode 100644 web/src/pages/Home/HomePricingFilters.jsx diff --git a/model/ability.go b/model/ability.go index 1d7c53f..3f26aa5 100644 --- a/model/ability.go +++ b/model/ability.go @@ -33,7 +33,8 @@ func GetAllEnableAbilityWithChannels() ([]AbilityWithChannel, error) { err := DB.Table("abilities"). Select("abilities.*, channels.type as channel_type"). Joins("left join channels on abilities.channel_id = channels.id"). - Where("abilities.enabled = ?", true). + Joins("left join models on abilities.model = models.model_name"). + Where("abilities.enabled = ? and models.model_name is NOT null", true). Scan(&abilities).Error return abilities, err } diff --git a/model/model_meta.go b/model/model_meta.go index 860b960..4f76898 100644 --- a/model/model_meta.go +++ b/model/model_meta.go @@ -15,6 +15,17 @@ const ( NameRuleSuffix ) +const ( + ModelTypeChat = 1 + iota + ModelTypeImage + ModelTypeAudio + ModelTypeVideo + ModelTypeEmbedding + ModelTypeRerank + ModelTypeVision + ModelTypeOther +) + type BoundChannel struct { Name string `json:"name"` Type int `json:"type"` @@ -26,6 +37,7 @@ type Model struct { Description string `json:"description,omitempty" gorm:"type:text"` Icon string `json:"icon,omitempty" gorm:"type:varchar(128)"` Tags string `json:"tags,omitempty" gorm:"type:varchar(255)"` + Type int `json:"type" gorm:"default:0"` VendorID int `json:"vendor_id,omitempty" gorm:"index"` Endpoints string `json:"endpoints,omitempty" gorm:"type:text"` Status int `json:"status" gorm:"default:1"` diff --git a/model/pricing.go b/model/pricing.go index cb687d0..6dfe69b 100644 --- a/model/pricing.go +++ b/model/pricing.go @@ -28,6 +28,7 @@ type Pricing struct { EnableGroup []string `json:"enable_groups"` SupportedEndpointTypes []constant.EndpointType `json:"supported_endpoint_types"` PricingVersion string `json:"pricing_version,omitempty"` + Type int `json:"type"` } type PricingVendor struct { @@ -279,13 +280,14 @@ func updatePricing() { // 补充模型元数据(描述、标签、供应商、状态) if meta, ok := metaMap[model]; ok { // 若模型被禁用(status!=1),则直接跳过,不返回给前端 - if meta.Status != 1 { + if meta.Status != 1 || meta.Type == 0 { continue } pricing.Description = meta.Description pricing.Icon = meta.Icon pricing.Tags = meta.Tags pricing.VendorID = meta.VendorID + pricing.Type = meta.Type } modelPrice, findPrice := ratio_setting.GetModelPrice(model, false) if findPrice { diff --git a/web/src/components/common/ui/HorizontalFilterRow.jsx b/web/src/components/common/ui/HorizontalFilterRow.jsx new file mode 100644 index 0000000..b98cce6 --- /dev/null +++ b/web/src/components/common/ui/HorizontalFilterRow.jsx @@ -0,0 +1,140 @@ +/* +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, { useRef } from 'react'; +import { Button, Tag, Skeleton } from '@douyinfe/semi-ui'; +import { IconChevronLeft, IconChevronRight } from '@douyinfe/semi-icons'; +import { useMinimumLoadingTime } from '../../../hooks/common/useMinimumLoadingTime'; + +/** + * 横向筛选项行,通过左右箭头翻页 + * @param {string} title 标题 + * @param {Array<{value:any,label:string,icon?:React.ReactNode,tagCount?:number}>} items 选项列表 + * @param {*} activeValue 当前选中值 + * @param {(value:any)=>void} onChange 选择回调 + * @param {boolean} loading 加载中 + * @param {Function} t i18n + */ +const HorizontalFilterRow = ({ + title, + items = [], + activeValue, + onChange, + loading = false, + t = (v) => v, +}) => { + const scrollRef = useRef(null); + const showSkeleton = useMinimumLoadingTime(loading); + + const handleScroll = (dir) => { + const el = scrollRef.current; + if (!el) return; + const step = el.clientWidth * 0.8; + el.scrollBy({ + left: dir === 'left' ? -step : step, + behavior: 'smooth', + }); + }; + + if (showSkeleton) { + return ( +
+ +
+ {[1, 2, 3, 4, 5].map((i) => ( + + ))} +
+
+ ); + } + + return ( +
+
+ {title} +
+
+ + ); + })} +
+
+ + ); +}; + +export default HorizontalFilterRow; 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 6d53286..849ea97 100644 --- a/web/src/components/table/model-pricing/view/card/PricingCardView.jsx +++ b/web/src/components/table/model-pricing/view/card/PricingCardView.jsx @@ -74,13 +74,14 @@ const PricingCardView = ({ selectedRowKeys = [], setSelectedRowKeys, openModelDetail, + gridColsClass = 'grid-cols-1 xl:grid-cols-2 2xl:grid-cols-3', + showPagination = true, }) => { const showSkeleton = useMinimumLoadingTime(loading); const startIndex = (currentPage - 1) * pageSize; - const paginatedModels = filteredModels.slice( - startIndex, - startIndex + pageSize, - ); + const paginatedModels = showPagination + ? filteredModels.slice(startIndex, startIndex + pageSize) + : filteredModels; const getModelKey = (model) => model.key ?? model.model_name ?? model.id; const isMobile = useIsMobile(); @@ -234,7 +235,7 @@ const PricingCardView = ({ return (
-
+
{paginatedModels.map((model, index) => { const modelKey = getModelKey(model); const isSelected = selectedRowKeys.includes(modelKey); @@ -357,7 +358,7 @@ const PricingCardView = ({
{/* 分页 */} - {filteredModels.length > 0 && ( + {showPagination && filteredModels.length > 0 && (
{ const [filterEndpointType, setFilterEndpointType] = useState('all'); // 端点类型筛选: 'all' | string const [filterVendor, setFilterVendor] = useState('all'); // 供应商筛选: 'all' | 'unknown' | string const [filterTag, setFilterTag] = useState('all'); // 模型标签筛选: 'all' | string + const [filterType, setFilterType] = useState('all'); // 模型类型筛选: 'all' | number (model_meta.ModelType) const [pageSize, setPageSize] = useState(20); const [currentPage, setCurrentPage] = useState(1); const [currency, setCurrency] = useState('USD'); @@ -135,6 +136,12 @@ export const useModelPricingData = () => { }); } + // 模型类型筛选(pricing.Type / model.Type) + if (filterType !== 'all') { + const typeNum = Number(filterType); + result = result.filter((model) => model.type === typeNum); + } + // 搜索筛选 if (searchValue.length > 0) { const searchTerm = searchValue.toLowerCase(); @@ -159,6 +166,7 @@ export const useModelPricingData = () => { filterEndpointType, filterVendor, filterTag, + filterType, ]); const rowSelection = useMemo( @@ -321,6 +329,7 @@ export const useModelPricingData = () => { filterEndpointType, filterVendor, filterTag, + filterType, searchValue, ]); @@ -350,6 +359,8 @@ export const useModelPricingData = () => { setFilterVendor, filterTag, setFilterTag, + filterType, + setFilterType, pageSize, setPageSize, currentPage, diff --git a/web/src/hooks/model-pricing/usePricingFilterCounts.js b/web/src/hooks/model-pricing/usePricingFilterCounts.js index 3bb3d85..dd422d2 100644 --- a/web/src/hooks/model-pricing/usePricingFilterCounts.js +++ b/web/src/hooks/model-pricing/usePricingFilterCounts.js @@ -37,6 +37,7 @@ export const usePricingFilterCounts = ({ filterEndpointType = 'all', filterVendor = 'all', filterTag = 'all', + filterType = 'all', searchValue = '', }) => { // 均使用同一份模型列表,避免创建新引用 @@ -84,6 +85,12 @@ export const usePricingFilterCounts = ({ if (!tagsArr.includes(filterTag.toLowerCase())) return false; } + // 模型类型(pricing.Type / model.Type) + if (!ignore.includes('type') && filterType !== 'all') { + const typeNum = Number(filterType); + if (model.type !== typeNum) return false; + } + // 搜索 if (!ignore.includes('search') && searchValue) { const term = searchValue.toLowerCase(); @@ -112,6 +119,7 @@ export const usePricingFilterCounts = ({ filterEndpointType, filterVendor, filterTag, + filterType, searchValue, ], ); @@ -124,6 +132,7 @@ export const usePricingFilterCounts = ({ filterQuotaType, filterVendor, filterTag, + filterType, searchValue, ], ); @@ -136,6 +145,7 @@ export const usePricingFilterCounts = ({ filterQuotaType, filterEndpointType, filterTag, + filterType, searchValue, ], ); @@ -148,6 +158,20 @@ export const usePricingFilterCounts = ({ filterQuotaType, filterEndpointType, filterVendor, + filterType, + searchValue, + ], + ); + + const typeModels = useMemo( + () => allModels.filter((m) => matchesFilters(m, ['type'])), + [ + allModels, + filterGroup, + filterQuotaType, + filterEndpointType, + filterVendor, + filterTag, searchValue, ], ); @@ -160,6 +184,7 @@ export const usePricingFilterCounts = ({ filterEndpointType, filterVendor, filterTag, + filterType, searchValue, ], ); @@ -170,5 +195,6 @@ export const usePricingFilterCounts = ({ vendorModels, groupCountModels, tagModels, + typeModels, }; }; diff --git a/web/src/pages/Home/HomePricingFilters.jsx b/web/src/pages/Home/HomePricingFilters.jsx new file mode 100644 index 0000000..7726a0d --- /dev/null +++ b/web/src/pages/Home/HomePricingFilters.jsx @@ -0,0 +1,197 @@ +/* +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, { useMemo, useState } from 'react'; +import { ImagePreview } from '@douyinfe/semi-ui'; +import HorizontalFilterRow from '../../components/common/ui/HorizontalFilterRow'; +import PricingCardView from '../../components/table/model-pricing/view/card/PricingCardView'; +import ModelDetailSideSheet from '../../components/table/model-pricing/modal/ModelDetailSideSheet'; +import { useModelPricingData } from '../../hooks/model-pricing/useModelPricingData'; +import { usePricingFilterCounts } from '../../hooks/model-pricing/usePricingFilterCounts'; +import { getLobeHubIcon } from '../../helpers'; + +// 与 model_meta.go ModelType 常量一致:1=Chat, 2=Image, 3=Audio, 4=Video, 5=Embedding, 6=Rerank, 7=Vision, 8=Other +const MODEL_TYPE_LIST = [ + { value: 1, labelKey: '对话' }, + { value: 2, labelKey: '图像' }, + { value: 3, labelKey: '音频' }, + { value: 4, labelKey: '视频' }, + { value: 5, labelKey: '嵌入' }, + { value: 6, labelKey: '重排序' }, + { value: 7, labelKey: '视觉' }, + { value: 8, labelKey: '其他' }, +]; + +const HomePricingFilters = ({ t }) => { + const [showRatio, setShowRatio] = useState(false); + const pricingData = useModelPricingData(); + const { + vendorModels, + typeModels, + } = usePricingFilterCounts({ + models: pricingData.models, + filterGroup: pricingData.filterGroup, + filterQuotaType: pricingData.filterQuotaType, + filterEndpointType: pricingData.filterEndpointType, + filterVendor: pricingData.filterVendor, + filterTag: pricingData.filterTag, + filterType: pricingData.filterType, + searchValue: pricingData.searchValue, + }); + + const vendorItems = useMemo(() => { + const models = pricingData.models; + const vendorSet = new Set(); + const vendorIcons = new Map(); + let hasUnknown = false; + (models || []).forEach((m) => { + if (m.vendor_name) { + vendorSet.add(m.vendor_name); + if (m.vendor_icon && !vendorIcons.has(m.vendor_name)) { + vendorIcons.set(m.vendor_name, m.vendor_icon); + } + } else { + hasUnknown = true; + } + }); + const vendors = Array.from(vendorSet).sort(); + const getCount = (v) => { + if (v === 'all') return vendorModels.length; + if (v === 'unknown') return vendorModels.filter((m) => !m.vendor_name).length; + return vendorModels.filter((m) => m.vendor_name === v).length; + }; + const items = [ + { value: 'all', label: t('全部供应商'), tagCount: getCount('all'), disabled: (models || []).length === 0 }, + ]; + vendors.forEach((v) => { + const count = getCount(v); + items.push({ + value: v, + label: v, + icon: vendorIcons.get(v) ? getLobeHubIcon(vendorIcons.get(v), 16) : null, + tagCount: count, + disabled: count === 0, + }); + }); + if (hasUnknown) { + items.push({ + value: 'unknown', + label: t('未知供应商'), + tagCount: getCount('unknown'), + disabled: getCount('unknown') === 0, + }); + } + return items; + }, [pricingData.models, vendorModels, t]); + + const typeItems = useMemo(() => { + const models = pricingData.models; + const getCount = (typeVal) => { + if (typeVal === 'all') return typeModels.length; + const n = Number(typeVal); + return typeModels.filter((m) => m.type === n).length; + }; + const items = [ + { value: 'all', label: t('全部类型'), tagCount: getCount('all'), disabled: (models || []).length === 0 }, + ]; + MODEL_TYPE_LIST.forEach(({ value, labelKey }) => { + const count = getCount(value); + items.push({ + value: String(value), + label: t(labelKey), + tagCount: count, + disabled: count === 0, + }); + }); + return items; + }, [pricingData.models, typeModels, t]); + + return ( +
+
+ + +
+ +
+ +
+ + pricingData.setIsModalOpenurl(visible)} + /> + + +
+ ); +}; + +export default HomePricingFilters; diff --git a/web/src/pages/Home/index.jsx b/web/src/pages/Home/index.jsx index c153c1b..e1a238c 100644 --- a/web/src/pages/Home/index.jsx +++ b/web/src/pages/Home/index.jsx @@ -40,6 +40,7 @@ import { } from '@douyinfe/semi-icons'; import { Link } from 'react-router-dom'; import NoticeModal from '../../components/layout/NoticeModal'; +import HomePricingFilters from './HomePricingFilters'; import { Moonshot, OpenAI, @@ -162,9 +163,9 @@ const Home = () => { {/* 背景模糊晕染球 */}
-
+
{/* 居中内容区 */} -
+

{ )}

- {/* 框架兼容性图标 */} -
-
- - {t('支持众多的大模型供应商')} - -
-
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- - 30+ - -
-
-
+
+ {/* 模型广场筛选项与列表 */} +
+
diff --git a/web/vite.config.js b/web/vite.config.js index 73e4621..65828e0 100644 --- a/web/vite.config.js +++ b/web/vite.config.js @@ -29,6 +29,11 @@ export default defineConfig({ resolve: { alias: { '@': path.resolve(__dirname, './src'), + // 绕过 semi-ui exports 限制,dist/css/semi.css 不在 package.json exports 中 + '@douyinfe/semi-ui/dist/css/semi.css': path.resolve( + __dirname, + 'node_modules/@douyinfe/semi-ui/dist/css/semi.css', + ), }, }, plugins: [