feat: enhance model billing aggregation & UI display for unknown quota type

Summary
-------
1. **Backend**
   • `controller/model_meta.go`
     – For prefix/suffix/contains rules, aggregate endpoints, bound channels, enable groups, and quota types across all matched models.
     – When mixed billing types are detected, return `quota_type = -1` (unknown) instead of defaulting to volume-based.

2. **Frontend**
   • `web/src/helpers/utils.js`
     – `calculateModelPrice` now handles `quota_type = -1`, returning placeholder `'-'`.

   • `web/src/components/table/model-pricing/view/card/PricingCardView.jsx`
     – Billing tag logic updated: displays “按次计费” (times), “按量计费” (volume), or `'-'` for unknown.

   • `web/src/components/table/model-pricing/view/table/PricingTableColumns.js`
     – `renderQuotaType` shows “未知” for unknown billing type.

   • `web/src/components/table/models/ModelsColumnDefs.js`
     – Unified `renderQuotaType` to return `'-'` when type is unknown.

   • `web/src/components/table/model-pricing/modal/components/ModelPricingTable.jsx`
     – Group price table honors unknown billing type; pricing columns show `'-'` and neutral tag color.

3. **Utilities**
   • Added safe fallback colours/tags for unknown billing type across affected components.

Impact
------
• Ensures correct data aggregation for non-exact model matches.
• Prevents UI from implying volume billing when actual type is ambiguous.
• Provides consistent placeholder display (`'-'` or “未知”) across cards, tables and modals.

No breaking API changes; frontend gracefully handles legacy values.
This commit is contained in:
t0ng7u
2025-08-10 21:09:49 +08:00
parent d1d945eaa0
commit 94bd44d0f2
5 changed files with 140 additions and 23 deletions

View File

@@ -3,8 +3,10 @@ package controller
import ( import (
"encoding/json" "encoding/json"
"strconv" "strconv"
"strings"
"one-api/common" "one-api/common"
"one-api/constant"
"one-api/model" "one-api/model"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -162,17 +164,105 @@ func DeleteModelMeta(c *gin.Context) {
// 辅助函数:填充 Endpoints 和 BoundChannels 和 EnableGroups // 辅助函数:填充 Endpoints 和 BoundChannels 和 EnableGroups
func fillModelExtra(m *model.Model) { func fillModelExtra(m *model.Model) {
if m.Endpoints == "" { // 若为精确匹配,保持原有逻辑
eps := model.GetModelSupportEndpointTypes(m.ModelName) if m.NameRule == model.NameRuleExact {
if m.Endpoints == "" {
eps := model.GetModelSupportEndpointTypes(m.ModelName)
if b, err := json.Marshal(eps); err == nil {
m.Endpoints = string(b)
}
}
if channels, err := model.GetBoundChannels(m.ModelName); err == nil {
m.BoundChannels = channels
}
m.EnableGroups = model.GetModelEnableGroups(m.ModelName)
m.QuotaType = model.GetModelQuotaType(m.ModelName)
return
}
// 非精确匹配:计算并集
pricings := model.GetPricing()
// 端点去重集合
endpointSet := make(map[constant.EndpointType]struct{})
// 已绑定渠道去重集合
channelSet := make(map[string]model.BoundChannel)
// 分组去重集合
groupSet := make(map[string]struct{})
// 计费类型(若有任意模型为 1则返回 1
quotaTypeSet := make(map[int]struct{})
for _, p := range pricings {
var matched bool
switch m.NameRule {
case model.NameRulePrefix:
matched = strings.HasPrefix(p.ModelName, m.ModelName)
case model.NameRuleSuffix:
matched = strings.HasSuffix(p.ModelName, m.ModelName)
case model.NameRuleContains:
matched = strings.Contains(p.ModelName, m.ModelName)
}
if !matched {
continue
}
// 收集端点
for _, et := range p.SupportedEndpointTypes {
endpointSet[et] = struct{}{}
}
// 收集分组
for _, g := range p.EnableGroup {
groupSet[g] = struct{}{}
}
// 收集计费类型
quotaTypeSet[p.QuotaType] = struct{}{}
// 收集渠道
if channels, err := model.GetBoundChannels(p.ModelName); err == nil {
for _, ch := range channels {
key := ch.Name + "_" + strconv.Itoa(ch.Type)
channelSet[key] = ch
}
}
}
// 序列化端点
if len(endpointSet) > 0 && m.Endpoints == "" {
eps := make([]constant.EndpointType, 0, len(endpointSet))
for et := range endpointSet {
eps = append(eps, et)
}
if b, err := json.Marshal(eps); err == nil { if b, err := json.Marshal(eps); err == nil {
m.Endpoints = string(b) m.Endpoints = string(b)
} }
} }
if channels, err := model.GetBoundChannels(m.ModelName); err == nil {
// 序列化渠道
if len(channelSet) > 0 {
channels := make([]model.BoundChannel, 0, len(channelSet))
for _, ch := range channelSet {
channels = append(channels, ch)
}
m.BoundChannels = channels m.BoundChannels = channels
} }
// 填充启用分组
m.EnableGroups = model.GetModelEnableGroups(m.ModelName) // 序列化分组
// 填充计费类型 if len(groupSet) > 0 {
m.QuotaType = model.GetModelQuotaType(m.ModelName) groups := make([]string, 0, len(groupSet))
for g := range groupSet {
groups = append(groups, g)
}
m.EnableGroups = groups
}
// 确定计费类型:仅当所有匹配模型计费类型一致时才返回该类型,否则返回 -1 表示未知/不确定
if len(quotaTypeSet) == 1 {
for k := range quotaTypeSet {
m.QuotaType = k
}
} else {
m.QuotaType = -1
}
} }

View File

@@ -63,7 +63,7 @@ const ModelPricingTable = ({
key: group, key: group,
group: group, group: group,
ratio: groupRatioValue, ratio: groupRatioValue,
billingType: modelData?.quota_type === 0 ? t('按量计费') : t('按次计费'), billingType: modelData?.quota_type === 0 ? t('按量计费') : (modelData?.quota_type === 1 ? t('按次计费') : '-'),
inputPrice: modelData?.quota_type === 0 ? priceData.inputPrice : '-', inputPrice: modelData?.quota_type === 0 ? priceData.inputPrice : '-',
outputPrice: modelData?.quota_type === 0 ? (priceData.completionPrice || priceData.outputPrice) : '-', outputPrice: modelData?.quota_type === 0 ? (priceData.completionPrice || priceData.outputPrice) : '-',
fixedPrice: modelData?.quota_type === 1 ? priceData.price : '-', fixedPrice: modelData?.quota_type === 1 ? priceData.price : '-',
@@ -100,11 +100,16 @@ const ModelPricingTable = ({
columns.push({ columns.push({
title: t('计费类型'), title: t('计费类型'),
dataIndex: 'billingType', dataIndex: 'billingType',
render: (text) => ( render: (text) => {
<Tag color={text === t('按量计费') ? 'violet' : 'teal'} size="small" shape="circle"> let color = 'white';
{text} if (text === t('按量计费')) color = 'violet';
</Tag> else if (text === t('按次计费')) color = 'teal';
), return (
<Tag color={color} size="small" shape="circle">
{text || '-'}
</Tag>
);
},
}); });
// 根据计费类型添加价格列 // 根据计费类型添加价格列

View File

@@ -144,13 +144,24 @@ const PricingCardView = ({
// 渲染标签 // 渲染标签
const renderTags = (record) => { const renderTags = (record) => {
// 计费类型标签(左边) // 计费类型标签(左边)
const billingType = record.quota_type === 1 ? 'teal' : 'violet'; let billingTag = (
const billingText = record.quota_type === 1 ? t('按次计费') : t('按量计费'); <Tag key="billing" shape='circle' color='white' size='small'>
const billingTag = ( -
<Tag key="billing" shape='circle' color={billingType} size='small'>
{billingText}
</Tag> </Tag>
); );
if (record.quota_type === 1) {
billingTag = (
<Tag key="billing" shape='circle' color='teal' size='small'>
{t('按次计费')}
</Tag>
);
} else if (record.quota_type === 0) {
billingTag = (
<Tag key="billing" shape='circle' color='violet' size='small'>
{t('按量计费')}
</Tag>
);
}
// 自定义标签(右边) // 自定义标签(右边)
const customTags = []; const customTags = [];

View File

@@ -137,7 +137,8 @@ const renderQuotaType = (qt, t) => {
</Tag> </Tag>
); );
} }
return qt ?? '-'; // 未知
return '-';
}; };
// Render bound channels // Render bound channels

View File

@@ -632,12 +632,22 @@ export const calculateModelPrice = ({
}; };
} }
// 按次计费 if (record.quota_type === 1) {
const priceUSD = parseFloat(record.model_price) * usedGroupRatio; // 按次计费
const displayVal = displayPrice(priceUSD); const priceUSD = parseFloat(record.model_price) * usedGroupRatio;
const displayVal = displayPrice(priceUSD);
return {
price: displayVal,
isPerToken: false,
usedGroup,
usedGroupRatio,
};
}
// 未知计费类型,返回占位信息
return { return {
price: displayVal, price: '-',
isPerToken: false, isPerToken: false,
usedGroup, usedGroup,
usedGroupRatio, usedGroupRatio,