improve(ui):优化模型广场显示分时价格

This commit is contained in:
qian committed 2026-08-20 01:25:51 +08:00
1 parent d19d3074b7
commit 3a075047bb
4 files changed
+393 -68

No files matched your search

+17 -10
View File
@@ -20,16 +20,23 @@ type MarketplaceListRes struct {
}
type MarketplaceModelItem struct {
ModelId string `json:"model_id"` // 模型标识
ModelName string `json:"model_name"` // 模型显示名称
Category string `json:"category"` // 分类(chat/embedding/image等)
Description string `json:"description"` // 描述
MaxContextTokens int `json:"max_context_tokens"` // 最大上下文 tokens
MaxOutputTokens int `json:"max_output_tokens"` // 最大输出 tokens
InputPrice float64 `json:"input_price"` // 输入价格(每百万 token,USD)
OutputPrice float64 `json:"output_price"` // 输出价格(每百万 token,USD)
Tags []string `json:"tags"` // 标签
Capabilities g.Map `json:"capabilities"` // 能力标签
ModelId string `json:"model_id"` // 模型标识
ModelName string `json:"model_name"` // 模型显示名称
Category string `json:"category"` // 分类(chat/embedding/image等)
Description string `json:"description"` // 描述
MaxContextTokens int `json:"max_context_tokens"` // 最大上下文 tokens
MaxOutputTokens int `json:"max_output_tokens"` // 最大输出 tokens
BillingMode string `json:"billing_mode"` // 计费模式:token/per_request/tiered
InputPrice float64 `json:"input_price"` // 输入价格(每百万 token,USD;tiered=首档)
OutputPrice float64 `json:"output_price"` // 输出价格(每百万 token,USD;tiered=首档)
PerRequestPrice float64 `json:"per_request_price"` // 按次单价(USD/次,仅 per_request)
CacheReadPrice float64 `json:"cache_read_price"` // 缓存读取价格(每百万 token,USD)
CacheCreationPrice float64 `json:"cache_creation_price"` // 缓存创建价格(每百万 token,USD)
DiscountLabel string `json:"discount_label"` // 折扣标签(营销展示,空=不展示)
PriceChangeNote string `json:"price_change_note"` // 价格调整说明(对外提示,空=不展示)
TimePrices []TimePriceItem `json:"time_prices"` // 时段价目(平台基础价 × 时段乘数,无配置=空)
Tags []string `json:"tags"` // 标签
Capabilities g.Map `json:"capabilities"` // 能力标签
}
type MarketplaceDetailReq struct {
+61 -15
View File
@@ -2,6 +2,7 @@ package tenant
import (
"context"
"encoding/json"
"fmt"
"github.com/gogf/gf/v2/frame/g"
@@ -13,6 +14,22 @@ import (
"github.com/qianfree/team-api/internal/model/entity"
)
// marketplacePricingRow 定价锚点行(min_tokens=0):全部计费模式的基础价载体
// (token 默认价 / tiered 首档价 / per_request 单价),另带时段定价与对外展示字段。
// 显式列名扫描进独立结构体,与 do/entity 生成物解耦(新展示列需迁移落库 + gf gen dao 后才进生成物)。
type marketplacePricingRow struct {
ModelID int64 `json:"model_id"`
BillingMode string `json:"billing_mode"`
InputPrice float64 `json:"input_price"`
OutputPrice float64 `json:"output_price"`
PerRequestPrice *float64 `json:"per_request_price"`
CacheReadPrice float64 `json:"cache_read_price"`
CacheCreationPrice float64 `json:"cache_creation_price"`
TimeSegments string `json:"time_segments"`
DiscountLabel *string `json:"discount_label"`
PriceChangeNote *string `json:"price_change_note"`
}
// GetModelList 获取模型广场列表(从默认模型分组加载)
func (s *sTenant) GetModelList(ctx context.Context, req *v1.MarketplaceListReq) (*v1.MarketplaceListRes, error) {
// 1. 查找默认模型分组
@@ -84,19 +101,19 @@ func (s *sTenant) GetModelList(ctx context.Context, req *v1.MarketplaceListReq)
modelIds[i] = model.Id
}
var pricings []*entity.MdlPricing
var pricings []marketplacePricingRow
err = dao.MdlPricing.Ctx(ctx).
WhereIn("model_id", modelIds).
Where("billing_mode", "token"). // 只取 token 计费模式的基础价格
Where("min_tokens", 0). // 锚点行承载全部计费模式的基础价与展示字段
Scan(&pricings)
if err != nil {
return nil, err
}
// 构建价格映射(model_id -> pricing)
priceMap := make(map[int64]*entity.MdlPricing)
for _, pricing := range pricings {
priceMap[pricing.ModelId] = pricing
priceMap := make(map[int64]*marketplacePricingRow, len(pricings))
for i := range pricings {
priceMap[pricings[i].ModelID] = &pricings[i]
}
// 6. 转换为响应格式
@@ -145,11 +162,11 @@ func (s *sTenant) GetModelDetail(ctx context.Context, req *v1.MarketplaceDetailR
return nil, fmt.Errorf("模型不存在或未公开")
}
// 3. 查询价格
var pricing *entity.MdlPricing
// 3. 查询价格(锚点行:全部计费模式的基础价 + 时段/展示字段)
var pricing *marketplacePricingRow
err = dao.MdlPricing.Ctx(ctx).
Where("model_id", model.Id).
Where("billing_mode", "token").
Where("min_tokens", 0).
Scan(&pricing)
if err != nil {
return nil, err
@@ -164,7 +181,7 @@ func (s *sTenant) GetModelDetail(ctx context.Context, req *v1.MarketplaceDetailR
}
// convertToMarketplaceItem 转换模型实体为响应格式
func (s *sTenant) convertToMarketplaceItem(ctx context.Context, model *entity.MdlModels, pricing *entity.MdlPricing) v1.MarketplaceModelItem {
func (s *sTenant) convertToMarketplaceItem(ctx context.Context, model *entity.MdlModels, pricing *marketplacePricingRow) v1.MarketplaceModelItem {
item := v1.MarketplaceModelItem{
ModelId: model.ModelId,
ModelName: model.ModelName,
@@ -189,12 +206,41 @@ func (s *sTenant) convertToMarketplaceItem(ctx context.Context, model *entity.Md
}
}
// 价格(数据库已存储每百万 token 的美元价格,直接返回)
if pricing != nil {
// input_price/output_price 数据库字段存储的就是每 1M token 价格(USD)
// 无需转换,直接使用
item.InputPrice = billing.InexactFloat64(pricing.InputPrice)
item.OutputPrice = billing.InexactFloat64(pricing.OutputPrice)
if pricing == nil {
return item
}
billingMode := pricing.BillingMode
if billingMode == "" {
billingMode = "token"
}
item.BillingMode = billingMode
// 锚点行价即对外展示价(数据库存的就是每 1M token 美元价,直接返回):
// token=默认价,tiered=首档价(前端标「起」),per_request=按次单价
item.InputPrice = pricing.InputPrice
item.OutputPrice = pricing.OutputPrice
item.CacheReadPrice = pricing.CacheReadPrice
item.CacheCreationPrice = pricing.CacheCreationPrice
if pricing.PerRequestPrice != nil {
item.PerRequestPrice = *pricing.PerRequestPrice
}
if pricing.DiscountLabel != nil {
item.DiscountLabel = *pricing.DiscountLabel
}
if pricing.PriceChangeNote != nil {
item.PriceChangeNote = *pricing.PriceChangeNote
}
// 时段价目:平台基础价 × 时段乘数换算(tiered 用锚点首档价换算,与 buildTiers 首档口径一致)
if pricing.TimeSegments != "" && pricing.TimeSegments != "null" {
var segments []billing.TimeSegment
if err := json.Unmarshal([]byte(pricing.TimeSegments), &segments); err == nil && len(segments) > 0 {
var tiers []v1.PricingTierItem
if billingMode == "tiered" {
tiers = append(tiers, v1.PricingTierItem{InputPrice: pricing.InputPrice, OutputPrice: pricing.OutputPrice})
}
item.TimePrices = buildTimePrices(segments, billingMode, &pricing.InputPrice, &pricing.OutputPrice, pricing.PerRequestPrice, tiers)
}
}
return item
+20
View File
@@ -1,5 +1,18 @@
import request from '@/utils/request'
export interface TimePriceItem {
name: string
days?: number[] | null
start_time?: string
end_time?: string
valid_from?: string
valid_to?: string
multiplier: number
input_price?: number | null
output_price?: number | null
per_request_price?: number | null
}
export interface MarketplaceModel {
model_id: string
model_name: string
@@ -7,8 +20,15 @@ export interface MarketplaceModel {
description: string
max_context_tokens: number
max_output_tokens: number
billing_mode?: string
input_price: number
output_price: number
per_request_price?: number
cache_read_price?: number
cache_creation_price?: number
discount_label?: string | null
price_change_note?: string | null
time_prices?: TimePriceItem[] | null
tags: string[]
capabilities: Record<string, any>
}
@@ -140,6 +140,7 @@
<div class="model-provider">{{ getProvider(model) }}</div>
<div class="model-name-line">
<h3>{{ model.model_name || model.model_id }}</h3>
<span v-if="model.discount_label" class="promo-badge">{{ model.discount_label }}</span>
<button type="button" aria-label="复制模型 ID" title="复制模型 ID" @click="copyModelId(model.model_id)">
<Icon name="copy" size="xs" />
</button>
@@ -164,8 +165,28 @@
</div>
</div>
<div v-if="model.time_prices?.length" class="time-price-list">
<div v-for="(tp, idx) in model.time_prices" :key="idx" class="time-price-row">
<span>{{ tp.name }}<small>{{ timeWindow(tp) }}</small></span>
<strong>{{ timePriceText(model, tp) }}</strong>
</div>
</div>
<p v-if="model.price_change_note" class="price-change-note">
<Icon name="infoCircle" size="xs" />
{{ model.price_change_note }}
</p>
<div class="model-card-footer">
<div class="price-pair">
<template v-if="model.billing_mode === 'per_request'">
<div class="price-pair">
<div>
<span>调用单价</span>
<strong>{{ formatPrice(model.per_request_price) }}</strong>
</div>
<small>/ 次</small>
</div>
</template>
<div v-else class="price-pair">
<div>
<span>输入</span>
<strong>{{ formatPrice(model.input_price) }}</strong>
@@ -175,7 +196,7 @@
<span>输出</span>
<strong>{{ formatPrice(model.output_price) }}</strong>
</div>
<small>/ 1M tokens</small>
<small>{{ model.billing_mode === 'tiered' ? '/ 1M tokens 起' : '/ 1M tokens' }}</small>
</div>
<button type="button" class="detail-button" @click="openDetail(model)">
查看详情
@@ -249,6 +270,7 @@
</button>
</div>
</div>
<span v-if="selectedModel.discount_label" class="promo-badge large">{{ selectedModel.discount_label }}</span>
<span class="detail-category">{{ getCategoryMeta(selectedModel.category).label }}</span>
</div>
@@ -263,7 +285,51 @@
<span class="detail-section-label">模型简介</span>
<p class="detail-description">{{ selectedModel.description || '暂无模型描述。' }}</p>
</section>
<section class="detail-section">
<span class="detail-section-label">计费信息<em v-if="selectedModel.billing_mode === 'tiered'" class="price-tiered-hint">阶梯计费 · 以下为首档起价</em></span>
<div class="price-tile-grid">
<template v-if="selectedModel.billing_mode === 'per_request'">
<div class="price-tile accent">
<span>单次调用</span>
<strong>{{ formatPrice(selectedModel.per_request_price) }}</strong>
<small>USD / 次</small>
</div>
</template>
<template v-else>
<div class="price-tile accent">
<span>输入</span>
<strong>{{ formatPrice(selectedModel.input_price) }}</strong>
<small>USD / 1M tokens</small>
</div>
<div class="price-tile">
<span>输出</span>
<strong>{{ formatPrice(selectedModel.output_price) }}</strong>
<small>USD / 1M tokens</small>
</div>
<div v-if="selectedModel.cache_read_price" class="price-tile">
<span>缓存读取</span>
<strong>{{ formatPrice(selectedModel.cache_read_price) }}</strong>
<small>USD / 1M tokens</small>
</div>
<div v-if="selectedModel.cache_creation_price" class="price-tile">
<span>缓存创建</span>
<strong>{{ formatPrice(selectedModel.cache_creation_price) }}</strong>
<small>USD / 1M tokens</small>
</div>
</template>
</div>
<div v-if="selectedModel.time_prices?.length" class="detail-time-prices">
<div class="detail-time-head">分时段价格<small>命中时段按乘数计费,未命中按默认价</small></div>
<div v-for="(tp, idx) in selectedModel.time_prices" :key="idx" class="detail-time-row">
<span>{{ tp.name }}<small>{{ timeWindow(tp) }}</small></span>
<strong>{{ timePriceText(selectedModel, tp) }}</strong>
</div>
</div>
<p v-if="selectedModel.price_change_note" class="price-change-note">
<Icon name="infoCircle" size="xs" />
{{ selectedModel.price_change_note }}
</p>
</section>
<section class="detail-section">
<span class="detail-section-label">能力支持</span>
<div class="detail-capabilities">
@@ -278,12 +344,7 @@
</div>
</section>
<section v-if="selectedModel.tags?.length" class="detail-section">
<span class="detail-section-label">模型标签</span>
<div class="detail-tags">
<span v-for="tag in selectedModel.tags" :key="tag">{{ tag }}</span>
</div>
</section>
</div>
<aside class="detail-side-column">
@@ -301,19 +362,18 @@
<span><Icon name="grid" size="sm" /> 模型类别</span>
<strong>{{ getCategoryMeta(selectedModel.category).label }}</strong>
</div>
<div class="detail-spec-row">
<span><Icon name="creditCard" size="sm" /> 计费模式</span>
<strong>{{ billingModeLabel(selectedModel.billing_mode) }}</strong>
</div>
</div>
<div v-if="selectedModel.tags?.length" class="detail-spec-card">
<div class="detail-spec-title">模型标签</div>
<div class="detail-tags">
<span v-for="tag in selectedModel.tags" :key="tag">{{ tag }}</span>
</div>
</div>
<div class="detail-price-card">
<div class="detail-spec-title">Token 计费</div>
<div class="detail-price-row">
<div><span>输入价格</span><small>每百万 tokens</small></div>
<strong>{{ formatPrice(selectedModel.input_price) }}</strong>
</div>
<div class="detail-price-row">
<div><span>输出价格</span><small>每百万 tokens</small></div>
<strong>{{ formatPrice(selectedModel.output_price) }}</strong>
</div>
</div>
</aside>
</div>
@@ -333,7 +393,7 @@
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { NModal, NPagination } from 'naive-ui'
import Icon from '@/components/common/Icon.vue'
import { getMarketplaceModelDetail, getMarketplaceModels, type MarketplaceModel } from '@/api/marketplace'
import { getMarketplaceModelDetail, getMarketplaceModels, type MarketplaceModel, type TimePriceItem } from '@/api/marketplace'
import { usePublicSettings } from '@/composables/usePublicSettings'
import { useSeo } from '@/composables/useSeo'
import { useTenantAuthStore } from '@/stores/tenant-auth'
@@ -469,6 +529,36 @@ function formatPrice(price?: number): string {
return `$${Number(value.toFixed(6))}`
}
function billingModeLabel(mode?: string): string {
if (mode === 'per_request') return '按次计费'
if (mode === 'tiered') return '阶梯计费'
return '按量计费'
}
// ── 时段价目展示辅助(价格为后端换算好的展示价,前端直接渲染) ──
function daysLabel(days?: number[] | null): string {
if (!days || days.length === 0) return '每天'
const sorted = [...days].sort((a, b) => a - b)
if (sorted.join(',') === '1,2,3,4,5') return '工作日'
if (sorted.join(',') === '6,7') return '周末'
const names = ['', '周一', '周二', '周三', '周四', '周五', '周六', '周日']
return sorted.map((d) => names[d] || String(d)).join('、')
}
function timeWindow(tp: TimePriceItem): string {
const parts: string[] = [daysLabel(tp.days)]
if (tp.start_time && tp.end_time) parts.push(`${tp.start_time}~${tp.end_time}`)
else parts.push('全天')
if (tp.valid_from) parts.push(tp.valid_to ? `${tp.valid_from}~${tp.valid_to}` : `${tp.valid_from} 起`)
return parts.join(' · ')
}
function timePriceText(model: MarketplaceModel, tp: TimePriceItem): string {
if (model.billing_mode === 'per_request') return `${formatPrice(tp.per_request_price ?? undefined)} /次`
return `输入 ${formatPrice(tp.input_price ?? undefined)} · 输出 ${formatPrice(tp.output_price ?? undefined)}${model.billing_mode === 'tiered' ? ' 起' : ''}`
}
async function loadModels(): Promise<void> {
const requestId = ++listRequestId
loading.value = true
@@ -1244,6 +1334,186 @@ useSeo({
font-weight: 700;
}
.promo-badge {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
background: linear-gradient(135deg, #f43f5e, #fb923c);
color: #fff;
font-size: 9px;
font-weight: 700;
letter-spacing: 0.02em;
box-shadow: 0 3px 10px rgba(244, 63, 94, 0.25);
}
.promo-badge.large {
margin-left: auto;
padding: 4px 11px;
font-size: 11px;
}
.price-change-note {
display: flex;
align-items: flex-start;
gap: 6px;
margin-top: 10px;
padding: 7px 9px;
border: 1px solid rgba(180, 83, 9, 0.16);
border-radius: 9px;
background: rgba(180, 83, 9, 0.06);
color: #b45309;
font-size: 10px;
line-height: 1.5;
}
.time-price-list {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 11px;
padding-top: 9px;
border-top: 1px dashed #e2e8f0;
}
.time-price-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
color: #64706c;
font-size: 9px;
}
.time-price-row span {
display: inline-flex;
align-items: baseline;
gap: 5px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.time-price-row small {
flex: 0 0 auto;
color: #98a29f;
font-size: 8px;
}
.time-price-row strong {
flex: 0 0 auto;
color: #10231f;
font-size: 9px;
font-weight: 700;
}
.detail-time-prices {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 12px;
padding-top: 10px;
border-top: 1px dashed rgba(15, 118, 110, 0.22);
}
.detail-time-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: #586660;
font-size: 11px;
}
.detail-time-row span {
display: inline-flex;
align-items: baseline;
gap: 6px;
min-width: 0;
}
.detail-time-row small {
color: #98a29f;
font-size: 9px;
}
.detail-time-row strong {
flex: 0 0 auto;
color: #10231f;
font-size: 11px;
font-weight: 700;
}
.price-tile-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
gap: 10px;
}
.price-tile {
display: flex;
flex-direction: column;
gap: 3px;
padding: 13px 15px;
border: 1px solid #e0e8e5;
border-radius: 13px;
background: #fafcfb;
}
.price-tile span {
color: #64706c;
font-size: 10px;
font-weight: 600;
}
.price-tile strong {
color: var(--ink);
font-size: 17px;
font-weight: 800;
letter-spacing: -0.01em;
}
.price-tile small {
color: #98a29f;
font-size: 9px;
}
.price-tile.accent {
border-color: color-mix(in srgb, var(--model-accent) 26%, #dfe7e4);
background: var(--model-soft);
}
.price-tile.accent strong {
color: var(--model-accent);
}
.price-tiered-hint {
margin-left: 7px;
color: #98a29f;
font-size: 9px;
font-style: normal;
letter-spacing: 0.02em;
text-transform: none;
}
.detail-time-head {
display: flex;
align-items: baseline;
gap: 8px;
margin-bottom: 8px;
color: #0f766e;
font-size: 10px;
font-weight: 700;
}
.detail-time-head small {
color: #98a29f;
font-size: 9px;
font-weight: 400;
}
.model-card-footer {
justify-content: space-between;
gap: 9px;
@@ -1727,19 +1997,12 @@ useSeo({
}
.detail-spec-card,
.detail-price-card {
padding: 18px;
border: 1px solid #dfe7e4;
border-radius: 16px;
background: #fafcfb;
}
.detail-spec-row,
.detail-price-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 15px;
flex-direction: column;
align-items: flex-start;
gap: 4px;
padding: 11px 0;
border-bottom: 1px solid #e7ecea;
}
@@ -1763,14 +2026,8 @@ useSeo({
}
.detail-spec-row strong {
font-size: 12px;
font-size: 13px;
font-weight: 700;
text-align: right;
}
.detail-price-card {
border-color: color-mix(in srgb, var(--model-accent) 20%, #dfe7e4);
background: var(--model-soft);
}
.detail-price-row > div {
@@ -2479,11 +2736,6 @@ useSeo({
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.94);
}
.detail-price-card {
border-color: rgba(153, 246, 228, 0.55);
background: rgba(240, 253, 250, 0.82);
}
.detail-price-row strong {
color: #0d9488;
}