模型广场:增加渠道分时段计价展示

- 阶梯表查询附带分时倍率时段:时段取自计费解析到的渠道定价,
  每个时段的倍率由计费的 resolvedChannelTimeMultiplier 在时段内取值,
  分组价卡覆盖或配置非法时自然不出现;倍率为 1 的时段不列
- 广场模型条目新增 time_pricing(时区 + 时段 + 倍率)
- 前端把分时时段展开为独立行:模型名旁标注时段,价格按时段倍率折算,
  倍率列显示生效倍率;时区与计算口径放在提示中
This commit is contained in:
feeeei
2026-08-24 10:50:52 +08:00
parent ecce0769c0
commit 83d4eb6a43
11 changed files with 390 additions and 26 deletions
+17 -1
View File
@@ -27,14 +27,30 @@ export interface PlazaOfficialPricing {
*/
export type PlazaLongContextBasis = 'whole_request' | 'marginal'
/** 分时倍率时段:配置时区当天 [start_time, end_time) 内整单实付乘 multiplier。 */
export interface PlazaTimePricingPeriod {
start_time: string
end_time: string
multiplier: number
}
/** 计费会生效的分时倍率(仅倍率 ≠ 1 的时段,已按开始时间升序)。 */
export interface PlazaTimePricing {
/** IANA 时区名,如 Asia/Shanghai。 */
timezone: string
periods: PlazaTimePricingPeriod[]
}
export interface PlazaModel {
name: string
platform: string
/** 实收口径的展示定价:多档时 intervals 为各档绝对单价(已由计费服务折算)。 */
/** 实收口径的展示定价:多档时 intervals 为各档绝对单价(已由计费服务折算);均为标准时段价。 */
pricing: UserSupportedModelPricing | null
official_pricing: PlazaOfficialPricing | null
/** 仅多档模型返回。 */
long_context_basis?: PlazaLongContextBasis
/** 仅配置了分时倍率的模型返回。 */
time_pricing?: PlazaTimePricing
}
export interface ModelPlazaGroup {
@@ -2,14 +2,14 @@
<div class="plaza-pricing-table overflow-x-auto" :style="accentStyle">
<table class="w-full min-w-[1000px] table-fixed border-collapse text-sm tabular-nums">
<colgroup>
<col class="w-[20%]" />
<col class="w-[25%]" />
<col class="w-[11%]" />
<col class="w-[9%]" />
<col class="w-[15%]" />
<col class="w-[14%]" />
<col class="w-[11%]" />
<col class="w-[9%]" />
<col class="w-[15%]" />
<col class="w-[10%]" />
<col class="w-[8%]" />
<col class="w-[14%]" />
<col class="w-[8%]" />
</colgroup>
<thead>
<tr
@@ -58,14 +58,22 @@
</thead>
<tbody>
<tr
v-for="m in sortedModels"
:key="`${m.platform}:${m.name}`"
v-for="{ model: m, period, key } in rows"
:key="key"
class="border-b border-gray-100 transition-colors last:border-b-0 hover:bg-gray-50/70 dark:border-dark-800 dark:hover:bg-dark-800/50"
>
<!-- 模型名 + 非 token 计费模式徽章 -->
<!-- 模型名 + 非 token 计费模式徽章;分时时段行额外标注时段 -->
<td class="border-r border-gray-100 py-2.5 pl-5 pr-4 align-middle dark:border-dark-700/60">
<div class="flex flex-wrap items-center gap-1.5">
<span class="font-medium text-gray-900 dark:text-white">{{ m.name }}</span>
<!-- 时段徽章紧跟模型名,其余徽章排在后面,空间不足时先换行的是它们 -->
<span
v-if="period"
class="inline-flex items-center whitespace-nowrap rounded-md bg-gray-100 px-1 py-0.5 font-mono text-[10px] font-medium text-gray-500 dark:bg-dark-700/70 dark:text-dark-300"
:title="t('modelPlaza.table.timePricingRowHint', { timezone: m.time_pricing?.timezone })"
>
{{ formatTimeWindow(period) }}
</span>
<span
v-if="platform && m.platform !== platform"
:class="[
@@ -101,10 +109,10 @@
class="whitespace-nowrap text-xs leading-5"
>
<span class="mr-1 font-sans font-normal text-gray-400 dark:text-dark-500" :title="tierHint(m)">{{ tierLabel(iv) }}</span>
{{ paidPerMillion(iv.input_price) }}
{{ paidPerMillion(iv.input_price, period) }}
</div>
</template>
<template v-else>{{ paidPerMillion(m.pricing?.input_price) }}</template>
<template v-else>{{ paidPerMillion(m.pricing?.input_price, period) }}</template>
</td>
<td class="pz-cell px-3 py-2.5 align-middle font-mono font-semibold text-gray-900 dark:text-gray-50">
<template v-if="tokenIntervals(m).length">
@@ -114,10 +122,10 @@
class="whitespace-nowrap text-xs leading-5"
:title="tierHint(m)"
>
{{ paidPerMillion(iv.output_price) }}
{{ paidPerMillion(iv.output_price, period) }}
</div>
</template>
<template v-else>{{ paidPerMillion(m.pricing?.output_price) }}</template>
<template v-else>{{ paidPerMillion(m.pricing?.output_price, period) }}</template>
</td>
<td class="pz-cell px-3 py-2.5 align-middle">
<template v-if="hasTierCachePricing(tokenIntervals(m))">
@@ -129,9 +137,9 @@
>
<template v-if="iv.cache_write_price != null || iv.cache_read_price != null">
<span class="font-sans font-normal text-gray-400 dark:text-dark-500">{{ t('modelPlaza.table.cacheWriteShort') }}</span>
{{ paidPerMillion(iv.cache_write_price) }}
{{ paidPerMillion(iv.cache_write_price, period) }}
<span class="ml-1 font-sans font-normal text-gray-400 dark:text-dark-500">{{ t('modelPlaza.table.cacheReadShort') }}</span>
{{ paidPerMillion(iv.cache_read_price) }}
{{ paidPerMillion(iv.cache_read_price, period) }}
</template>
<span v-else class="text-gray-400 dark:text-dark-500">-</span>
</div>
@@ -142,11 +150,11 @@
>
<div>
<span class="mr-1 font-sans font-normal text-gray-400 dark:text-dark-500">{{ t('modelPlaza.table.cacheWrite') }}</span>
{{ paidPerMillion(m.pricing?.cache_write_price) }}
{{ paidPerMillion(m.pricing?.cache_write_price, period) }}
</div>
<div>
<span class="mr-1 font-sans font-normal text-gray-400 dark:text-dark-500">{{ t('modelPlaza.table.cacheRead') }}</span>
{{ paidPerMillion(m.pricing?.cache_read_price) }}
{{ paidPerMillion(m.pricing?.cache_read_price, period) }}
</div>
</div>
<span v-else class="text-gray-400 dark:text-dark-500">-</span>
@@ -246,12 +254,18 @@
<span v-else class="text-gray-400 dark:text-dark-500">-</span>
</td>
<!-- 折扣倍率(生图独立倍率行展示独立倍率;专属倍率划线展示原倍率) -->
<!-- 折扣倍率(分时时段行展示 生效倍率×时段倍率;生图独立倍率行展示独立倍率;专属倍率划线展示原倍率) -->
<td
class="border-l border-gray-100 py-2.5 pl-3 pr-5 text-right align-middle font-mono text-xs dark:border-dark-700/60"
>
<span
v-if="usesIndependentImageRate(m)"
v-if="period"
class="font-bold text-primary-600 dark:text-primary-400"
:title="t('modelPlaza.table.timePricingRateHint', { rate: effectiveRate, multiplier: period.multiplier })"
>{{ periodRate(period) }}x</span
>
<span
v-else-if="usesIndependentImageRate(m)"
class="font-bold text-gray-700 dark:text-gray-300"
>{{ requestRate(m) }}x</span
>
@@ -277,7 +291,7 @@ import {
BILLING_MODE_IMAGE,
type BillingMode
} from '@/constants/channel'
import type { PlazaModel } from '@/api/modelPlaza'
import type { PlazaModel, PlazaTimePricingPeriod } from '@/api/modelPlaza'
import type { UserPricingInterval } from '@/api/channels'
const props = defineProps<{
@@ -338,10 +352,35 @@ function billingModeLabel(m: PlazaModel): string {
/** 价格统一保底 2 位小数,更长的有效小数原样保留。 */
const MIN_DECIMALS = 2
/** 实付价 = 渠道单价 × 生效倍率,按 $/1M token 展示。 */
function paidPerMillion(value: number | null | undefined): string {
/** 表格行:每个模型一行标准价;配置了分时倍率的模型再按时段各加一行。 */
interface PlazaRow {
model: PlazaModel
period: PlazaTimePricingPeriod | null
key: string
}
const rows = computed<PlazaRow[]>(() =>
sortedModels.value.flatMap((m) => {
const base: PlazaRow = { model: m, period: null, key: `${m.platform}:${m.name}` }
const periodRows = timePeriods(m).map<PlazaRow>((p, idx) => ({
model: m,
period: p,
key: `${m.platform}:${m.name}:${idx}`
}))
return [base, ...periodRows]
})
)
/** 时段行的生效倍率 = 生效倍率 × 时段倍率(去掉浮点噪声)。 */
function periodRate(period: PlazaTimePricingPeriod): number {
return Math.round(effectiveRate.value * period.multiplier * 1000) / 1000
}
/** 实付价 = 渠道单价 × 生效倍率(时段行再乘时段倍率),按 $/1M token 展示。 */
function paidPerMillion(value: number | null | undefined, period: PlazaTimePricingPeriod | null = null): string {
if (value == null) return '-'
return formatScaled(value * effectiveRate.value, PER_MILLION, MIN_DECIMALS)
const rate = period ? periodRate(period) : effectiveRate.value
return formatScaled(value * rate, PER_MILLION, MIN_DECIMALS)
}
/** 图片计费模型且分组开启生图独立倍率:实付倍率取独立倍率,与计费口径一致。 */
@@ -381,6 +420,17 @@ function hasOfficialCache(o: NonNullable<PlazaModel['official_pricing']>): boole
return o.cache_write_price != null || o.cache_read_price != null || o.cache_write_1h_price != null
}
/** 分时倍率时段(后端只给出倍率 ≠ 1 的时段,已升序)。 */
function timePeriods(m: PlazaModel): PlazaTimePricingPeriod[] {
return m.time_pricing?.periods ?? []
}
/** “00:30–08:30”;整分钟的 HH:mm:ss 省略秒。 */
function formatTimeWindow(p: PlazaTimePricingPeriod): string {
const clock = (v: string) => v.replace(/^(\d{2}:\d{2}):00$/, '$1')
return `${clock(p.start_time)}–${clock(p.end_time)}`
}
/** 上下文档位按下限升序展示(后端已升序,此处兜底)。 */
function sortByContext(intervals: UserPricingInterval[]): UserPricingInterval[] {
return [...intervals].sort((a, b) => a.min_tokens - b.min_tokens)
@@ -533,3 +533,58 @@ describe('PlazaModelPricingTable 长上下文阶梯', () => {
expect(cells[6].text()).toContain('(1h')
})
})
describe('PlazaModelPricingTable 分时计价', () => {
function timePricedModel() {
return tokenModel({
name: 'deepseek-chat',
platform: 'deepseek',
time_pricing: {
timezone: 'Asia/Shanghai',
periods: [
{ start_time: '00:30', end_time: '08:30:00', multiplier: 0.5 },
{ start_time: '18:00', end_time: '22:00', multiplier: 1.2 }
]
}
})
}
it('有分时倍率的模型展开为标准行 + 每时段一行,时段行价格按倍率折算且倍率列显示生效倍率', () => {
const wrapper = mountTable([timePricedModel()], 0.8)
const trs = wrapper.findAll('tbody tr')
expect(trs).toHaveLength(3)
// 标准行:输入 3 × 0.8
const baseCells = trs[0].findAll('td')
expect(baseCells[0].text()).toBe('deepseek-chat')
expect(baseCells[1].text()).toContain('$2.40')
expect(baseCells[7].text()).toContain('0.8x')
// 夜间时段行:输入 3 × 0.8 × 0.5,倍率 0.4x,标注时段不含时区
const nightCells = trs[1].findAll('td')
expect(nightCells[0].text()).toContain('deepseek-chat')
expect(nightCells[0].text()).toContain('00:30–08:30')
expect(nightCells[0].text()).not.toContain('Asia/Shanghai')
// 时区只放在 tooltip 里(i18n mock 不做插值,这里只断言挂了说明)
expect(nightCells[0].find('[title="modelPlaza.table.timePricingRowHint"]').exists()).toBe(true)
expect(nightCells[1].text()).toContain('$1.20')
expect(nightCells[2].text()).toContain('$6.00')
expect(nightCells[3].text()).toContain('$1.50')
expect(nightCells[7].text()).toContain('0.4x')
// 晚高峰行:3 × 0.8 × 1.2 = 2.88,倍率 0.96x
const peakCells = trs[2].findAll('td')
expect(peakCells[0].text()).toContain('18:00–22:00')
expect(peakCells[1].text()).toContain('$2.88')
expect(peakCells[7].text()).toContain('0.96x')
// 官方列不受时段影响
expect(nightCells[4].text()).toContain('$3.00')
})
it('无分时倍率时只有一行,不渲染时段标注', () => {
const wrapper = mountTable([tokenModel()], 1)
expect(wrapper.findAll('tbody tr')).toHaveLength(1)
expect(wrapper.find('[title*="modelPlaza.table.timePricingRowHint"]').exists()).toBe(false)
})
})
@@ -604,6 +604,8 @@ export default {
tierHint: 'The whole request is billed at the tier matching its total context (input + cache write + cache read)',
tierHintMarginal: 'Only the portion above the threshold is billed at this tier; output is unaffected',
marginalBadge: 'excess-only tiers',
timePricingRowHint: 'Requests made within this period ({timezone} time) are billed at the prices in this row',
timePricingRateHint: 'Effective rate {rate} × period multiplier {multiplier}',
paidPrice: 'Your Price (Discounted)',
officialPrice: 'Official Price',
rate: 'Rate',
@@ -609,6 +609,8 @@ export default {
tierHint: '按单次请求的总上下文(输入 + 缓存写入 + 缓存读取)所在档位对整单计价',
tierHintMarginal: '仅超过阈值的部分按该档计价,输出不加价',
marginalBadge: '超出部分计价',
timePricingRowHint: '按 {timezone} 时间,在该时段内发起的请求按本行价格计费',
timePricingRateHint: '生效倍率 {rate} × 时段倍率 {multiplier}',
paidPrice: '实付价格(折后)',
officialPrice: '官方价格',
rate: '折扣倍率',