- 新增 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"` | 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 获取渠道定价(带标签详情) | // GetChannelPricingWithTags 获取渠道定价(带标签详情) | ||||
| func GetChannelPricingWithTags(c *gin.Context) { | func GetChannelPricingWithTags(c *gin.Context) { | ||||
| page, _ := strconv.Atoi(c.DefaultQuery("p", "1")) | page, _ := strconv.Atoi(c.DefaultQuery("p", "1")) | ||||
| @@ -6,6 +6,7 @@ import ( | |||||
| "time" | "time" | ||||
| "github.com/QuantumNous/new-api/common" | "github.com/QuantumNous/new-api/common" | ||||
| "github.com/QuantumNous/new-api/setting/ratio_setting" | |||||
| "gorm.io/gorm" | "gorm.io/gorm" | ||||
| "gorm.io/gorm/clause" | "gorm.io/gorm/clause" | ||||
| ) | ) | ||||
| @@ -205,3 +206,63 @@ func InvalidateChannelPricingCache() { | |||||
| channelPricingCache = make(map[string]*ChannelPricing) | channelPricingCache = make(map[string]*ChannelPricing) | ||||
| channelPricingCacheTime = time.Time{} // 重置为零值 | 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("/midjourney", controller.GetMidjourney) | ||||
| apiRouter.GET("/home_page_content", controller.GetHomePageContent) | apiRouter.GET("/home_page_content", controller.GetHomePageContent) | ||||
| apiRouter.GET("/pricing", middleware.TryUserAuth(), controller.GetPricing) | 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("/verification", middleware.EmailVerificationRateLimit(), middleware.TurnstileCheck(), controller.SendEmailVerification) | ||||
| apiRouter.GET("/reset_password", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.SendPasswordResetEmail) | apiRouter.GET("/reset_password", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.SendPasswordResetEmail) | ||||
| apiRouter.POST("/user/reset", middleware.CriticalRateLimit(), controller.ResetPassword) | 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 | 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 { Button, Tag, Skeleton } from '@douyinfe/semi-ui'; | ||||
| import { IconChevronLeft, IconChevronRight } from '@douyinfe/semi-icons'; | |||||
| import { useMinimumLoadingTime } from '../../../hooks/common/useMinimumLoadingTime'; | import { useMinimumLoadingTime } from '../../../hooks/common/useMinimumLoadingTime'; | ||||
| /** | /** | ||||
| * 横向筛选项行,通过左右箭头翻页 | |||||
| * 横向筛选项行,自动换行 | |||||
| * @param {string} title 标题 | * @param {string} title 标题 | ||||
| * @param {Array<{value:any,label:string,icon?:React.ReactNode,tagCount?:number}>} items 选项列表 | * @param {Array<{value:any,label:string,icon?:React.ReactNode,tagCount?:number}>} items 选项列表 | ||||
| * @param {*} activeValue 当前选中值 | * @param {*} activeValue 当前选中值 | ||||
| @@ -39,29 +38,18 @@ const HorizontalFilterRow = ({ | |||||
| loading = false, | loading = false, | ||||
| t = (v) => v, | t = (v) => v, | ||||
| }) => { | }) => { | ||||
| const scrollRef = useRef(null); | |||||
| const showSkeleton = useMinimumLoadingTime(loading); | 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) { | if (showSkeleton) { | ||||
| return ( | return ( | ||||
| <div className='mb-6'> | <div className='mb-6'> | ||||
| <Skeleton.Title active style={{ width: 60, marginBottom: 8 }} /> | <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) => ( | {[1, 2, 3, 4, 5].map((i) => ( | ||||
| <Skeleton.Button | <Skeleton.Button | ||||
| key={i} | key={i} | ||||
| active | active | ||||
| style={{ width: 80, height: 32 }} | |||||
| style={{ width: 100, height: 32 }} | |||||
| /> | /> | ||||
| ))} | ))} | ||||
| </div> | </div> | ||||
| @@ -74,64 +62,38 @@ const HorizontalFilterRow = ({ | |||||
| <div className='text-sm font-medium text-semi-color-text-1 mb-3'> | <div className='text-sm font-medium text-semi-color-text-1 mb-3'> | ||||
| {title} | {title} | ||||
| </div> | </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> | ||||
| </div> | </div> | ||||
| ); | ); | ||||
| @@ -26,6 +26,7 @@ import ModelHeader from './components/ModelHeader'; | |||||
| import ModelBasicInfo from './components/ModelBasicInfo'; | import ModelBasicInfo from './components/ModelBasicInfo'; | ||||
| import ModelEndpoints from './components/ModelEndpoints'; | import ModelEndpoints from './components/ModelEndpoints'; | ||||
| import ModelPricingTable from './components/ModelPricingTable'; | import ModelPricingTable from './components/ModelPricingTable'; | ||||
| import ChannelPricingCard from './components/ChannelPricingCard'; | |||||
| import ModelCodeSnippet from './components/ModelCodeSnippet'; | import ModelCodeSnippet from './components/ModelCodeSnippet'; | ||||
| const { Text } = Typography; | const { Text } = Typography; | ||||
| @@ -100,6 +101,13 @@ const ModelDetailSideSheet = ({ | |||||
| autoGroups={autoGroups} | autoGroups={autoGroups} | ||||
| t={t} | t={t} | ||||
| /> | /> | ||||
| <ChannelPricingCard | |||||
| modelName={modelData?.model_name} | |||||
| currency={currency} | |||||
| tokenUnit={tokenUnit} | |||||
| displayPrice={displayPrice} | |||||
| t={t} | |||||
| /> | |||||
| <ModelCodeSnippet modelData={modelData} 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 }) => { | MODEL_TYPE_LIST.forEach(({ value, labelKey }) => { | ||||
| const count = getCount(value); | const count = getCount(value); | ||||
| // 数量为0时不显示 | |||||
| if (count === 0) return; | |||||
| items.push({ | items.push({ | ||||
| value: String(value), | value: String(value), | ||||
| label: t(labelKey), | label: t(labelKey), | ||||
| tagCount: count, | tagCount: count, | ||||
| disabled: count === 0, | |||||
| }); | }); | ||||
| }); | }); | ||||
| return items; | return items; | ||||