diff --git a/controller/channel_pricing.go b/controller/channel_pricing.go index 16bcf0e..f904205 100644 --- a/controller/channel_pricing.go +++ b/controller/channel_pricing.go @@ -258,6 +258,26 @@ type ChannelPricingWithTags struct { Tags []*model.PricingTag `json:"tags"` } +// GetChannelPricingByModelWithChannelInfo 获取指定模型的渠道定价(带渠道信息,所有用户可访问) +func GetChannelPricingByModelWithChannelInfo(c *gin.Context) { + // 使用通配符路由时,参数包含前导斜杠,需要去除 + modelName := c.Param("name") + if modelName == "" { + common.ApiErrorMsg(c, "model name is required") + return + } + // 去除前导斜杠(路由是 /channel-pricing/model/*name,name 会是 "/deepseek-ai/xxx") + modelName = strings.TrimPrefix(modelName, "/") + + list, err := model.GetChannelPricingByModelWithChannelInfo(modelName) + if err != nil { + common.ApiError(c, err) + return + } + + common.ApiSuccess(c, list) +} + // GetChannelPricingWithTags 获取渠道定价(带标签详情) func GetChannelPricingWithTags(c *gin.Context) { page, _ := strconv.Atoi(c.DefaultQuery("p", "1")) diff --git a/model/channel_pricing.go b/model/channel_pricing.go index e1f82eb..a7004f8 100644 --- a/model/channel_pricing.go +++ b/model/channel_pricing.go @@ -6,6 +6,7 @@ import ( "time" "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/setting/ratio_setting" "gorm.io/gorm" "gorm.io/gorm/clause" ) @@ -205,3 +206,63 @@ func InvalidateChannelPricingCache() { channelPricingCache = make(map[string]*ChannelPricing) channelPricingCacheTime = time.Time{} // 重置为零值 } + +// ChannelPricingWithChannel 带渠道信息的定价响应 +type ChannelPricingWithChannel struct { + Id int `json:"id"` + ChannelId int `json:"channel_id"` + ChannelName string `json:"channel_name"` + ChannelType int `json:"channel_type"` + QuotaType int `json:"quota_type"` // 0=按量, 1=按次 + ModelRatio float64 `json:"model_ratio"` + CompletionRatio float64 `json:"completion_ratio"` + ModelPrice float64 `json:"model_price"` + HasCustomPricing bool `json:"has_custom_pricing"` // 是否有自定义定价 +} + +// GetChannelPricingByModelWithChannelInfo 获取指定模型的渠道定价(带渠道信息) +// 返回所有支持该模型的渠道,对于没有渠道定价的渠道使用全局默认价格 +func GetChannelPricingByModelWithChannelInfo(modelName string) ([]*ChannelPricingWithChannel, error) { + var results []*ChannelPricingWithChannel + + // 获取全局默认价格 + globalModelRatio, hasRatio, _ := ratio_setting.GetModelRatio(modelName) + globalModelPrice, hasPrice := ratio_setting.GetModelPrice(modelName, false) + globalCompletionRatio := ratio_setting.GetCompletionRatio(modelName) + + // 确定默认计费类型 + var defaultQuotaType int + if hasPrice { + defaultQuotaType = QuotaTypeByCall + } else { + defaultQuotaType = QuotaTypeByTokens + } + + // 如果没有全局价格,设置默认值 + if !hasRatio { + globalModelRatio = 0 + } + if !hasPrice { + globalModelPrice = 0 + } + + // 查询所有支持该模型的渠道,左连接渠道定价表 + err := DB.Table("abilities"). + Select(`abilities.channel_id, channels.name as channel_name, channels.type as channel_type, + COALESCE(channel_pricings.quota_type, ?) as quota_type, + COALESCE(channel_pricings.model_ratio, ?) as model_ratio, + COALESCE(channel_pricings.completion_ratio, ?) as completion_ratio, + COALESCE(channel_pricings.model_price, ?) as model_price, + channel_pricings.id as id, + (channel_pricings.id IS NOT NULL) as has_custom_pricing`, + defaultQuotaType, globalModelRatio, globalCompletionRatio, globalModelPrice). + Joins("LEFT JOIN channels ON abilities.channel_id = channels.id"). + Joins("LEFT JOIN channel_pricings ON abilities.channel_id = channel_pricings.channel_id AND channel_pricings.model_name = ? AND channel_pricings.deleted_at IS NULL", modelName). + Where("abilities.model = ?", modelName). + Where("abilities.enabled = ?", true). + Where("channels.status = ?", 1). // 只显示启用的渠道 + Group("abilities.channel_id"). // 去重(同一渠道可能有多个分组) + Scan(&results).Error + + return results, err +} diff --git a/router/api-router.go b/router/api-router.go index b5b6e8c..278c070 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -30,6 +30,7 @@ func SetApiRouter(router *gin.Engine) { //apiRouter.GET("/midjourney", controller.GetMidjourney) apiRouter.GET("/home_page_content", controller.GetHomePageContent) apiRouter.GET("/pricing", middleware.TryUserAuth(), controller.GetPricing) + apiRouter.GET("/channel-pricing/model/*name", middleware.TryUserAuth(), controller.GetChannelPricingByModelWithChannelInfo) apiRouter.GET("/verification", middleware.EmailVerificationRateLimit(), middleware.TurnstileCheck(), controller.SendEmailVerification) apiRouter.GET("/reset_password", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.SendPasswordResetEmail) apiRouter.POST("/user/reset", middleware.CriticalRateLimit(), controller.ResetPassword) diff --git a/web/src/components/common/ui/HorizontalFilterRow.jsx b/web/src/components/common/ui/HorizontalFilterRow.jsx index b98cce6..376d9b4 100644 --- a/web/src/components/common/ui/HorizontalFilterRow.jsx +++ b/web/src/components/common/ui/HorizontalFilterRow.jsx @@ -17,13 +17,12 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import React, { useRef } from 'react'; +import React 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 当前选中值 @@ -39,29 +38,18 @@ const HorizontalFilterRow = ({ 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) => ( ))}
@@ -74,64 +62,38 @@ const HorizontalFilterRow = ({
{title}
-
- - ); - })} -
- + ); + })}
); diff --git a/web/src/components/table/model-pricing/modal/ModelDetailSideSheet.jsx b/web/src/components/table/model-pricing/modal/ModelDetailSideSheet.jsx index c23e9f8..9266d55 100644 --- a/web/src/components/table/model-pricing/modal/ModelDetailSideSheet.jsx +++ b/web/src/components/table/model-pricing/modal/ModelDetailSideSheet.jsx @@ -26,6 +26,7 @@ import ModelHeader from './components/ModelHeader'; import ModelBasicInfo from './components/ModelBasicInfo'; import ModelEndpoints from './components/ModelEndpoints'; import ModelPricingTable from './components/ModelPricingTable'; +import ChannelPricingCard from './components/ChannelPricingCard'; import ModelCodeSnippet from './components/ModelCodeSnippet'; const { Text } = Typography; @@ -100,6 +101,13 @@ const ModelDetailSideSheet = ({ autoGroups={autoGroups} t={t} /> + )} diff --git a/web/src/components/table/model-pricing/modal/components/ChannelPricingCard.jsx b/web/src/components/table/model-pricing/modal/components/ChannelPricingCard.jsx new file mode 100644 index 0000000..3d50060 --- /dev/null +++ b/web/src/components/table/model-pricing/modal/components/ChannelPricingCard.jsx @@ -0,0 +1,262 @@ +/* +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, Avatar, Typography, Table, Tag, Spin, Tooltip } from '@douyinfe/semi-ui'; +import { IconServer, IconEditStroked } from '@douyinfe/semi-icons'; +import { API } from '../../../../../helpers'; +import { CHANNEL_OPTIONS } from '../../../../../constants'; + +const { Text } = Typography; + +// 渠道类型映射表 - 移到组件外部避免重复计算 +const channelTypeMap = (() => { + const map = {}; + CHANNEL_OPTIONS.forEach((opt) => { + map[opt.value] = opt.label; + }); + return map; +})(); + +const getChannelTypeName = (type) => { + return channelTypeMap[type] || `类型 ${type}`; +}; + +const ChannelPricingCard = ({ + modelName, + currency, + tokenUnit, + displayPrice, + t, +}) => { + const [loading, setLoading] = useState(false); + const [channelPricingData, setChannelPricingData] = useState([]); + + useEffect(() => { + const fetchChannelPricing = async () => { + if (!modelName) return; + + setLoading(true); + try { + const res = await API.get(`/api/channel-pricing/model/${encodeURIComponent(modelName)}`); + const { success, data } = res.data; + if (success && Array.isArray(data)) { + setChannelPricingData(data); + } else { + setChannelPricingData([]); + } + } catch (error) { + // 静默处理错误,不显示给用户 + setChannelPricingData([]); + } finally { + setLoading(false); + } + }; + + fetchChannelPricing(); + }, [modelName]); + + // 获取货币符号 + const currencySymbol = useMemo(() => { + if (currency === 'CNY') return '¥'; + if (currency === 'CUSTOM') { + try { + const statusStr = localStorage.getItem('status'); + if (statusStr) { + const s = JSON.parse(statusStr); + return s?.custom_currency_symbol || '¤'; + } + } catch (e) { + // ignore + } + return '¤'; + } + return '$'; + }, [currency]); + + // 格式化价格显示 + const formatPrice = (price) => { + if (price === 0) return '-'; + const displayVal = displayPrice(price); + const unitDivisor = tokenUnit === 'K' ? 1000 : 1; + const numPrice = parseFloat(displayVal.replace(/[^0-9.]/g, '')) / unitDivisor; + return `${currencySymbol}${numPrice.toFixed(4)}`; + }; + + // 空状态或加载中 + if (loading) { + return ( + +
+ +
+
+ ); + } + + if (channelPricingData.length === 0) { + return null; // 无渠道时不显示 + } + + // 准备表格数据 + const tableData = channelPricingData.map((item, index) => ({ + key: item.channel_id || index, + channelName: item.channel_name || `渠道 ${item.channel_id}`, + channelType: item.channel_type, + quotaType: item.quota_type, + modelRatio: item.model_ratio, + completionRatio: item.completion_ratio, + modelPrice: item.model_price, + hasCustomPricing: item.has_custom_pricing, + })); + + // 判断是否存在按次计费的渠道 + const hasCallBased = tableData.some((item) => item.quotaType === 1); + // 判断是否存在按量计费的渠道 + const hasTokenBased = tableData.some((item) => item.quotaType === 0); + + // 定义基础列 + const baseColumns = [ + { + title: t('渠道'), + dataIndex: 'channelName', + render: (text, record) => ( +
+ + {text} + + + {getChannelTypeName(record.channelType)} + + {record.hasCustomPricing && ( + + + + )} +
+ ), + }, + { + title: t('计费类型'), + dataIndex: 'quotaType', + render: (quotaType) => { + const text = quotaType === 0 ? t('按量计费') : t('按次计费'); + const color = quotaType === 0 ? 'violet' : 'teal'; + return ( + + {text} + + ); + }, + }, + ]; + + // 只有存在按量计费的渠道时才显示输入/输出价格列 + const tokenBasedColumns = hasTokenBased + ? [ + { + title: t('输入价格'), + dataIndex: 'modelRatio', + render: (ratio, record) => { + if (record.quotaType !== 0) return '-'; + // 输入价格 = model_ratio * 2 (渠道定价不乘分组倍率) + const inputPrice = ratio * 2; + return ( + <> +
+ {formatPrice(inputPrice)} +
+
+ / {tokenUnit === 'K' ? '1K' : '1M'} tokens +
+ + ); + }, + }, + { + title: t('输出价格'), + dataIndex: 'completionRatio', + render: (completionRatio, record) => { + if (record.quotaType !== 0) return '-'; + // 输出价格 = model_ratio * completion_ratio * 2 + const outputPrice = record.modelRatio * completionRatio * 2; + return ( + <> +
+ {formatPrice(outputPrice)} +
+
+ / {tokenUnit === 'K' ? '1K' : '1M'} tokens +
+ + ); + }, + }, + ] + : []; + + // 只有存在按次计费的渠道时才显示固定价格列 + const callBasedColumn = hasCallBased + ? [ + { + title: t('固定价格'), + dataIndex: 'modelPrice', + render: (price, record) => { + if (record.quotaType !== 1) return '-'; + return ( + <> +
+ {displayPrice(price)} +
+
/ {t('次')}
+ + ); + }, + }, + ] + : []; + + const columns = [...baseColumns, ...tokenBasedColumns, ...callBasedColumn]; + + return ( + +
+ + + +
+ {t('渠道价格')} +
+ {t('所有支持该模型的渠道价格(自定义定价已标记)')} +
+
+
+ + + ); +}; + +export default ChannelPricingCard; diff --git a/web/src/pages/Home/HomePricingFilters.jsx b/web/src/pages/Home/HomePricingFilters.jsx index cfb443a..b0b954b 100644 --- a/web/src/pages/Home/HomePricingFilters.jsx +++ b/web/src/pages/Home/HomePricingFilters.jsx @@ -117,11 +117,12 @@ const HomePricingFilters = ({ t }) => { ]; MODEL_TYPE_LIST.forEach(({ value, labelKey }) => { const count = getCount(value); + // 数量为0时不显示 + if (count === 0) return; items.push({ value: String(value), label: t(labelKey), tagCount: count, - disabled: count === 0, }); }); return items;