From 3a075047bb7caca5b24637eeab788b07cc7d302e Mon Sep 17 00:00:00 2001 From: qian Date: Thu, 20 Aug 2026 01:25:51 +0800 Subject: [PATCH] =?UTF-8?q?improve(ui):=E4=BC=98=E5=8C=96=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E5=B9=BF=E5=9C=BA=E6=98=BE=E7=A4=BA=E5=88=86=E6=97=B6?= =?UTF-8?q?=E4=BB=B7=E6=A0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/tenant/v1/marketplace.go | 27 +- internal/logic/tenant/marketplace.go | 76 +++- web/tenant/src/api/marketplace.ts | 20 ++ .../src/views/marketplace/MarketplacePage.vue | 338 +++++++++++++++--- 4 files changed, 393 insertions(+), 68 deletions(-) diff --git a/api/tenant/v1/marketplace.go b/api/tenant/v1/marketplace.go index e557bb9..a03dd74 100644 --- a/api/tenant/v1/marketplace.go +++ b/api/tenant/v1/marketplace.go @@ -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 { diff --git a/internal/logic/tenant/marketplace.go b/internal/logic/tenant/marketplace.go index 145dd64..5052c0c 100644 --- a/internal/logic/tenant/marketplace.go +++ b/internal/logic/tenant/marketplace.go @@ -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 diff --git a/web/tenant/src/api/marketplace.ts b/web/tenant/src/api/marketplace.ts index 69ae282..ad00d3d 100644 --- a/web/tenant/src/api/marketplace.ts +++ b/web/tenant/src/api/marketplace.ts @@ -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 } diff --git a/web/tenant/src/views/marketplace/MarketplacePage.vue b/web/tenant/src/views/marketplace/MarketplacePage.vue index 38da627..0c7d68b 100644 --- a/web/tenant/src/views/marketplace/MarketplacePage.vue +++ b/web/tenant/src/views/marketplace/MarketplacePage.vue @@ -140,6 +140,7 @@
{{ getProvider(model) }}

{{ model.model_name || model.model_id }}

+ {{ model.discount_label }} @@ -164,8 +165,28 @@
+
+
+ {{ tp.name }}{{ timeWindow(tp) }} + {{ timePriceText(model, tp) }} +
+
+

+ + {{ model.price_change_note }} +

+ + {{ selectedModel.discount_label }} {{ getCategoryMeta(selectedModel.category).label }} @@ -263,7 +285,51 @@

{{ selectedModel.description || '暂无模型描述。' }}

- +
+ +
+ + +
+
+
分时段价格命中时段按乘数计费,未命中按默认价
+
+ {{ tp.name }}{{ timeWindow(tp) }} + {{ timePriceText(selectedModel, tp) }} +
+
+

+ + {{ selectedModel.price_change_note }} +

+
@@ -278,12 +344,7 @@
-
- -
- {{ tag }} -
-
+ @@ -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 { 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; }