- 新增 API 端点获取模型在各渠道的定价信息,支持全局价格回退 - 在模型详情侧边栏添加渠道价格卡片,显示所有支持该模型的渠道 - 优化 HorizontalFilterRow 组件,改用 flex-wrap 自动换行布局 - 隐藏数量为 0 的模型类型筛选项 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>feat/alipay-payment
| @@ -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")) | |||
| @@ -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 | |||
| } | |||
| @@ -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) | |||
| @@ -17,13 +17,12 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. | |||
| 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 ( | |||
| <div className='mb-6'> | |||
| <Skeleton.Title active style={{ width: 60, marginBottom: 8 }} /> | |||
| <div className='flex gap-2'> | |||
| <div className='flex gap-2 flex-wrap'> | |||
| {[1, 2, 3, 4, 5].map((i) => ( | |||
| <Skeleton.Button | |||
| key={i} | |||
| active | |||
| style={{ width: 80, height: 32 }} | |||
| style={{ width: 100, height: 32 }} | |||
| /> | |||
| ))} | |||
| </div> | |||
| @@ -74,64 +62,38 @@ const HorizontalFilterRow = ({ | |||
| <div className='text-sm font-medium text-semi-color-text-1 mb-3'> | |||
| {title} | |||
| </div> | |||
| <div className='flex items-center gap-2'> | |||
| <Button | |||
| type='tertiary' | |||
| theme='borderless' | |||
| icon={<IconChevronLeft />} | |||
| onClick={() => handleScroll('left')} | |||
| className='flex-shrink-0' | |||
| aria-label={t('向左')} | |||
| /> | |||
| <div | |||
| ref={scrollRef} | |||
| className='flex-1 min-w-0 flex gap-2 overflow-x-auto overflow-y-hidden scroll-smooth scrollbar-hide' | |||
| style={{ | |||
| scrollbarWidth: 'none', | |||
| msOverflowStyle: 'none', | |||
| WebkitOverflowScrolling: 'touch', | |||
| }} | |||
| > | |||
| {items.map((item) => { | |||
| const isDisabled = | |||
| item.disabled || | |||
| (typeof item.tagCount === 'number' && item.tagCount === 0); | |||
| const isActive = activeValue === item.value; | |||
| <div className='flex flex-wrap gap-2'> | |||
| {items.map((item) => { | |||
| const isDisabled = | |||
| item.disabled || | |||
| (typeof item.tagCount === 'number' && item.tagCount === 0); | |||
| const isActive = activeValue === item.value; | |||
| return ( | |||
| <Button | |||
| key={item.value} | |||
| onClick={() => !isDisabled && onChange(item.value)} | |||
| theme={isActive ? 'solid' : 'borderless'} | |||
| type={isActive ? 'primary' : 'tertiary'} | |||
| disabled={isDisabled} | |||
| className='flex-shrink-0 !rounded-full' | |||
| > | |||
| <span className='flex items-center gap-2'> | |||
| {item.icon} | |||
| <span>{item.label}</span> | |||
| {item.tagCount !== undefined && ( | |||
| <Tag | |||
| color={isActive ? 'white' : 'grey'} | |||
| shape='circle' | |||
| size='small' | |||
| > | |||
| {item.tagCount} | |||
| </Tag> | |||
| )} | |||
| </span> | |||
| </Button> | |||
| ); | |||
| })} | |||
| </div> | |||
| <Button | |||
| type='tertiary' | |||
| theme='borderless' | |||
| icon={<IconChevronRight />} | |||
| onClick={() => handleScroll('right')} | |||
| className='flex-shrink-0' | |||
| aria-label={t('向右')} | |||
| /> | |||
| return ( | |||
| <Button | |||
| key={item.value} | |||
| onClick={() => !isDisabled && onChange(item.value)} | |||
| theme={isActive ? 'solid' : 'borderless'} | |||
| type={isActive ? 'primary' : 'tertiary'} | |||
| disabled={isDisabled} | |||
| className='!rounded-full' | |||
| > | |||
| <span className='flex items-center gap-2'> | |||
| {item.icon} | |||
| <span>{item.label}</span> | |||
| {item.tagCount !== undefined && ( | |||
| <Tag | |||
| color={isActive ? 'white' : 'grey'} | |||
| shape='circle' | |||
| size='small' | |||
| > | |||
| {item.tagCount} | |||
| </Tag> | |||
| )} | |||
| </span> | |||
| </Button> | |||
| ); | |||
| })} | |||
| </div> | |||
| </div> | |||
| ); | |||
| @@ -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} | |||
| /> | |||
| <ChannelPricingCard | |||
| modelName={modelData?.model_name} | |||
| currency={currency} | |||
| tokenUnit={tokenUnit} | |||
| displayPrice={displayPrice} | |||
| t={t} | |||
| /> | |||
| <ModelCodeSnippet modelData={modelData} t={t} /> | |||
| </> | |||
| )} | |||
| @@ -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 <https://www.gnu.org/licenses/>. | |||
| 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 ( | |||
| <Card className='!rounded-2xl shadow-sm border-0 mb-6'> | |||
| <div className='flex justify-center items-center py-8'> | |||
| <Spin size='large' /> | |||
| </div> | |||
| </Card> | |||
| ); | |||
| } | |||
| 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) => ( | |||
| <div className='flex items-center gap-2'> | |||
| <Tag color='cyan' size='small' shape='circle'> | |||
| {text} | |||
| </Tag> | |||
| <span className='text-xs text-gray-500'> | |||
| {getChannelTypeName(record.channelType)} | |||
| </span> | |||
| {record.hasCustomPricing && ( | |||
| <Tooltip content={t('已设置自定义定价')}> | |||
| <IconEditStroked size='small' style={{ color: 'var(--semi-color-primary)' }} /> | |||
| </Tooltip> | |||
| )} | |||
| </div> | |||
| ), | |||
| }, | |||
| { | |||
| title: t('计费类型'), | |||
| dataIndex: 'quotaType', | |||
| render: (quotaType) => { | |||
| const text = quotaType === 0 ? t('按量计费') : t('按次计费'); | |||
| const color = quotaType === 0 ? 'violet' : 'teal'; | |||
| return ( | |||
| <Tag color={color} size='small' shape='circle'> | |||
| {text} | |||
| </Tag> | |||
| ); | |||
| }, | |||
| }, | |||
| ]; | |||
| // 只有存在按量计费的渠道时才显示输入/输出价格列 | |||
| const tokenBasedColumns = hasTokenBased | |||
| ? [ | |||
| { | |||
| title: t('输入价格'), | |||
| dataIndex: 'modelRatio', | |||
| render: (ratio, record) => { | |||
| if (record.quotaType !== 0) return '-'; | |||
| // 输入价格 = model_ratio * 2 (渠道定价不乘分组倍率) | |||
| const inputPrice = ratio * 2; | |||
| return ( | |||
| <> | |||
| <div className='font-semibold text-orange-600'> | |||
| {formatPrice(inputPrice)} | |||
| </div> | |||
| <div className='text-xs text-gray-500'> | |||
| / {tokenUnit === 'K' ? '1K' : '1M'} tokens | |||
| </div> | |||
| </> | |||
| ); | |||
| }, | |||
| }, | |||
| { | |||
| title: t('输出价格'), | |||
| dataIndex: 'completionRatio', | |||
| render: (completionRatio, record) => { | |||
| if (record.quotaType !== 0) return '-'; | |||
| // 输出价格 = model_ratio * completion_ratio * 2 | |||
| const outputPrice = record.modelRatio * completionRatio * 2; | |||
| return ( | |||
| <> | |||
| <div className='font-semibold text-orange-600'> | |||
| {formatPrice(outputPrice)} | |||
| </div> | |||
| <div className='text-xs text-gray-500'> | |||
| / {tokenUnit === 'K' ? '1K' : '1M'} tokens | |||
| </div> | |||
| </> | |||
| ); | |||
| }, | |||
| }, | |||
| ] | |||
| : []; | |||
| // 只有存在按次计费的渠道时才显示固定价格列 | |||
| const callBasedColumn = hasCallBased | |||
| ? [ | |||
| { | |||
| title: t('固定价格'), | |||
| dataIndex: 'modelPrice', | |||
| render: (price, record) => { | |||
| if (record.quotaType !== 1) return '-'; | |||
| return ( | |||
| <> | |||
| <div className='font-semibold text-orange-600'> | |||
| {displayPrice(price)} | |||
| </div> | |||
| <div className='text-xs text-gray-500'>/ {t('次')}</div> | |||
| </> | |||
| ); | |||
| }, | |||
| }, | |||
| ] | |||
| : []; | |||
| const columns = [...baseColumns, ...tokenBasedColumns, ...callBasedColumn]; | |||
| return ( | |||
| <Card className='!rounded-2xl shadow-sm border-0 mb-6'> | |||
| <div className='flex items-center mb-4'> | |||
| <Avatar size='small' color='cyan' className='mr-2 shadow-md'> | |||
| <IconServer size={16} /> | |||
| </Avatar> | |||
| <div> | |||
| <Text className='text-lg font-medium'>{t('渠道价格')}</Text> | |||
| <div className='text-xs text-gray-600'> | |||
| {t('所有支持该模型的渠道价格(自定义定价已标记)')} | |||
| </div> | |||
| </div> | |||
| </div> | |||
| <Table | |||
| dataSource={tableData} | |||
| columns={columns} | |||
| pagination={false} | |||
| size='small' | |||
| bordered={false} | |||
| className='!rounded-lg' | |||
| /> | |||
| </Card> | |||
| ); | |||
| }; | |||
| export default ChannelPricingCard; | |||
| @@ -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; | |||