Przeglądaj źródła

feat: 添加模型详情页渠道价格展示 & 优化筛选行布局

- 新增 API 端点获取模型在各渠道的定价信息,支持全局价格回退
- 在模型详情侧边栏添加渠道价格卡片,显示所有支持该模型的渠道
- 优化 HorizontalFilterRow 组件,改用 flex-wrap 自动换行布局
- 隐藏数量为 0 的模型类型筛选项

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
feat/alipay-payment
fengsilin 1 miesiąc temu
rodzic
commit
62737d3f8c
7 zmienionych plików z 389 dodań i 74 usunięć
  1. +20
    -0
      controller/channel_pricing.go
  2. +61
    -0
      model/channel_pricing.go
  3. +1
    -0
      router/api-router.go
  4. +35
    -73
      web/src/components/common/ui/HorizontalFilterRow.jsx
  5. +8
    -0
      web/src/components/table/model-pricing/modal/ModelDetailSideSheet.jsx
  6. +262
    -0
      web/src/components/table/model-pricing/modal/components/ChannelPricingCard.jsx
  7. +2
    -1
      web/src/pages/Home/HomePricingFilters.jsx

+ 20
- 0
controller/channel_pricing.go Wyświetl plik

@@ -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"))


+ 61
- 0
model/channel_pricing.go Wyświetl plik

@@ -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
}

+ 1
- 0
router/api-router.go Wyświetl plik

@@ -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)


+ 35
- 73
web/src/components/common/ui/HorizontalFilterRow.jsx Wyświetl plik

@@ -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>
);


+ 8
- 0
web/src/components/table/model-pricing/modal/ModelDetailSideSheet.jsx Wyświetl plik

@@ -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} />
</>
)}


+ 262
- 0
web/src/components/table/model-pricing/modal/components/ChannelPricingCard.jsx Wyświetl plik

@@ -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;

+ 2
- 1
web/src/pages/Home/HomePricingFilters.jsx Wyświetl plik

@@ -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;


Ładowanie…
Anuluj
Zapisz