feat(billing): add image output range pricing support

This commit is contained in:
ZheFox
2026-05-18 11:52:01 +08:00
parent a3094fda53
commit a10c02ef63
10 changed files with 871 additions and 55 deletions
@@ -948,9 +948,16 @@ async function handleSubmit() {
function tieredPricingHasImageOutputPricing(pricing: TieredPricingConfig | null | undefined): boolean {
if (!pricing) return false
if (toFinitePrice(pricing.image_output_price_default) !== null) return true
return Object.values(pricing.image_output_prices || {}).some((prices) => {
if (Object.values(pricing.image_output_prices || {}).some((prices) => {
if (!prices || typeof prices !== 'object') return false
return Object.values(prices).some((price) => toFinitePrice(price) !== null)
})) return true
return (pricing.image_output_price_ranges || []).some((range) => {
if (!range || typeof range !== 'object') return false
const prices = range.prices && typeof range.prices === 'object'
? range.prices
: range as Record<string, unknown>
return Object.values(prices).some((price) => toFinitePrice(price) !== null)
})
}
@@ -161,6 +161,13 @@
>
矩阵
</Badge>
<Badge
v-if="imagePriceRangeEntries.length > 0"
variant="outline"
class="text-[10px] h-5 px-1.5"
>
区间
</Badge>
</div>
<span
v-if="imageOutputDefaultPrice !== null"
@@ -206,6 +213,45 @@
</TableBody>
</Table>
</div>
<div
v-if="imagePriceRangeEntries.length > 0"
class="border rounded-lg overflow-hidden"
>
<Table>
<TableHeader>
<TableRow class="bg-muted/30">
<TableHead class="text-xs h-9">
上限像素
</TableHead>
<TableHead
v-for="quality in IMAGE_OUTPUT_QUALITIES"
:key="quality"
class="text-xs h-9 text-right"
>
{{ quality }}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="entry in imagePriceRangeEntries"
:key="entry.key"
class="text-xs"
>
<TableCell class="py-2 font-mono">
{{ formatPixelLimit(entry.upToPixels) }}
</TableCell>
<TableCell
v-for="quality in IMAGE_OUTPUT_QUALITIES"
:key="`${entry.key}-${quality}`"
class="py-2 text-right font-mono"
>
{{ formatImagePrice(entry.prices[quality]) }}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
<!-- 单阶梯(固定价格)展示 -->
@@ -640,8 +686,26 @@ const imagePricingEntries = computed(() => {
})).filter(entry => Object.values(entry.prices).some(price => price !== null))
})
const imagePriceRangeEntries = computed(() => {
const ranges = props.model?.default_tiered_pricing?.image_output_price_ranges
if (!Array.isArray(ranges)) return []
return ranges.map((range, index) => {
const object = range && typeof range === 'object' ? range as Record<string, unknown> : {}
const rawPrices = object.prices && typeof object.prices === 'object'
? object.prices
: object
return {
key: `${object.up_to_pixels ?? 'unbounded'}-${index}`,
upToPixels: toFiniteNumber(object.up_to_pixels),
prices: normalizeImageQualityPrices(rawPrices),
}
}).filter(entry => Object.values(entry.prices).some(price => price !== null))
})
const hasImagePricing = computed(() =>
imageOutputDefaultPrice.value !== null || imagePricingEntries.value.length > 0,
imageOutputDefaultPrice.value !== null
|| imagePricingEntries.value.length > 0
|| imagePriceRangeEntries.value.length > 0,
)
function normalizeImageQualityPrices(value: unknown): Record<typeof IMAGE_OUTPUT_QUALITIES[number], number | null> {
@@ -665,6 +729,20 @@ function formatImageSize(value: string): string {
return value.replace(/\s*[xX×]\s*/g, ' x ')
}
function formatPixelLimit(value: number | null): string {
return value === null ? '无上限' : `<= ${formatPixels(value)}`
}
function formatPixels(value: number): string {
if (value >= 1_000_000) {
return `${(value / 1_000_000).toFixed(value % 1_000_000 === 0 ? 0 : 2)}M px`
}
if (value >= 1_000) {
return `${(value / 1_000).toFixed(0)}K px`
}
return `${value} px`
}
const detailTab = ref('basic')
// 处理背景点击
@@ -142,7 +142,7 @@
class="rounded-lg border bg-muted/10 p-3 space-y-3"
>
<div class="flex flex-wrap items-end justify-between gap-3">
<Label class="text-xs font-medium">图像输出矩阵 ($/张)</Label>
<Label class="text-xs font-medium">图像输出计费 ($/张)</Label>
<div class="flex items-center gap-2">
<Label class="text-xs text-muted-foreground">默认价</Label>
<Input
@@ -158,6 +158,10 @@
</div>
<div class="space-y-2">
<div class="flex items-center justify-between gap-2">
<Label class="text-xs text-muted-foreground">精确分辨率覆盖</Label>
<span class="text-[11px] text-muted-foreground">优先匹配 size + quality</span>
</div>
<div class="grid grid-cols-[minmax(120px,1.1fr)_repeat(3,minmax(0,1fr))_32px] gap-2 text-xs text-muted-foreground">
<span>分辨率</span>
<span>low</span>
@@ -208,6 +212,64 @@
添加分辨率
</Button>
</div>
<div class="space-y-2 border-t pt-3">
<div class="flex items-center justify-between gap-2">
<Label class="text-xs text-muted-foreground">像素区间</Label>
<span class="text-[11px] text-muted-foreground">矩阵未命中时按宽×高落档</span>
</div>
<div class="grid grid-cols-[minmax(120px,1.1fr)_repeat(3,minmax(0,1fr))_32px] gap-2 text-xs text-muted-foreground">
<span>上限像素</span>
<span>low</span>
<span>medium</span>
<span>high</span>
<span />
</div>
<div
v-for="row in imageOutputPriceRangeRows"
:key="row.id"
class="grid grid-cols-[minmax(120px,1.1fr)_repeat(3,minmax(0,1fr))_32px] gap-2 items-center"
>
<Input
:model-value="row.upToPixels"
type="number"
min="1"
class="h-8 font-mono text-xs"
placeholder="空=无上限"
@update:model-value="(v) => updateImageOutputRangeLimit(row.id, v)"
/>
<Input
v-for="quality in IMAGE_OUTPUT_QUALITIES"
:key="`${row.id}-${quality}`"
:model-value="getImageOutputRangePrice(row, quality)"
type="number"
step="0.001"
min="0"
class="h-8"
placeholder="0"
@update:model-value="(v) => updateImageOutputRangePrice(row.id, quality, v)"
/>
<Button
type="button"
variant="ghost"
size="sm"
class="h-8 w-8 p-0"
@click="removeImageOutputRangeRow(row.id)"
>
<X class="w-4 h-4 text-muted-foreground hover:text-destructive" />
</Button>
</div>
<Button
type="button"
variant="outline"
size="sm"
class="w-full"
@click="addImageOutputRangeRow"
>
<Plus class="w-4 h-4 mr-2" />
添加像素区间
</Button>
</div>
</div>
<!-- 验证提示 -->
@@ -224,7 +286,7 @@
import { ref, computed, watch, reactive } from 'vue'
import { Plus, X } from 'lucide-vue-next'
import { Button, Input, Label } from '@/components/ui'
import type { TieredPricingConfig, PricingTier } from '@/api/endpoints/types'
import type { TieredPricingConfig, PricingTier, ImageOutputPriceRange } from '@/api/endpoints/types'
type ImageOutputQuality = 'low' | 'medium' | 'high'
type ImageOutputPriceRow = {
@@ -232,8 +294,14 @@ type ImageOutputPriceRow = {
size: string
prices: Partial<Record<ImageOutputQuality, number>>
}
type ImageOutputPriceRangeRow = {
id: string
upToPixels: string
prices: Partial<Record<ImageOutputQuality, number>>
}
const DEFAULT_IMAGE_OUTPUT_SIZES = ['1024x1024', '1536x1024', '1024x1536']
const DEFAULT_IMAGE_OUTPUT_PIXEL_LIMITS = [1_048_576, 1_572_864, 2_097_152]
const IMAGE_OUTPUT_QUALITIES: ImageOutputQuality[] = ['low', 'medium', 'high']
const props = defineProps<{
@@ -249,9 +317,11 @@ const emit = defineEmits<{
// 本地状态
const localTiers = ref<PricingTier[]>([])
const imageOutputPriceRows = ref<ImageOutputPriceRow[]>([])
const imageOutputPriceRangeRows = ref<ImageOutputPriceRangeRow[]>([])
const imageOutputPriceDefault = ref<string>('')
const lastEmittedPricingJson = ref<string>('')
let imageOutputPriceRowId = 0
let imageOutputPriceRangeRowId = 0
// 跟踪每个阶梯的缓存价格是否被手动设置
const cacheManuallySet = reactive<Record<number, { creation: boolean; read: boolean; cache1h: boolean }>>({})
@@ -280,6 +350,7 @@ watch(
if (newValue?.tiers) {
localTiers.value = newValue.tiers.map(t => ({ ...t }))
imageOutputPriceRows.value = createImageOutputPriceRows(newValue.image_output_prices)
imageOutputPriceRangeRows.value = createImageOutputPriceRangeRows(newValue.image_output_price_ranges)
imageOutputPriceDefault.value = newValue.image_output_price_default != null
? String(newValue.image_output_price_default)
: ''
@@ -299,6 +370,7 @@ watch(
output_price_per_1m: 0,
}]
imageOutputPriceRows.value = createImageOutputPriceRows(null)
imageOutputPriceRangeRows.value = createImageOutputPriceRangeRows(null)
imageOutputPriceDefault.value = ''
cacheManuallySet[0] = { creation: false, read: false, cache1h: false }
}
@@ -524,6 +596,10 @@ function buildPricingConfig(tiers: PricingTier[]): TieredPricingConfig {
if (Object.keys(matrix).length > 0) {
config.image_output_prices = matrix
}
const ranges = normalizedImageOutputPriceRanges()
if (ranges.length > 0) {
config.image_output_price_ranges = ranges
}
const defaultPrice = parseOptionalFloat(imageOutputPriceDefault.value)
if (defaultPrice != null) {
config.image_output_price_default = defaultPrice
@@ -551,6 +627,31 @@ function createImageOutputPriceRows(value: TieredPricingConfig['image_output_pri
return DEFAULT_IMAGE_OUTPUT_SIZES.map(size => createImageOutputPriceRow(size))
}
function createImageOutputPriceRangeRows(value: TieredPricingConfig['image_output_price_ranges']): ImageOutputPriceRangeRow[] {
const rows: ImageOutputPriceRangeRow[] = []
if (!Array.isArray(value)) {
return rows
}
for (const range of value) {
if (!range || typeof range !== 'object') continue
const rowPrices: Partial<Record<ImageOutputQuality, number>> = {}
const rawPrices = 'prices' in range && range.prices && typeof range.prices === 'object'
? range.prices as Record<string, unknown>
: range as Record<string, unknown>
for (const quality of IMAGE_OUTPUT_QUALITIES) {
const price = rawPrices[quality]
if (typeof price === 'number' && Number.isFinite(price)) {
rowPrices[quality] = price
}
}
const upToPixels = 'up_to_pixels' in range && range.up_to_pixels != null
? String(range.up_to_pixels)
: ''
rows.push(createImageOutputPriceRangeRow(upToPixels, rowPrices))
}
return rows
}
function createImageOutputPriceRow(
size = '',
prices: Partial<Record<ImageOutputQuality, number>> = {},
@@ -563,6 +664,18 @@ function createImageOutputPriceRow(
}
}
function createImageOutputPriceRangeRow(
upToPixels = '',
prices: Partial<Record<ImageOutputQuality, number>> = {},
): ImageOutputPriceRangeRow {
imageOutputPriceRangeRowId += 1
return {
id: `image-output-range-${imageOutputPriceRangeRowId}`,
upToPixels,
prices: { ...prices },
}
}
function normalizedImageOutputPrices(): Record<string, Record<string, number>> {
const out: Record<string, Record<string, number>> = {}
for (const row of imageOutputPriceRows.value) {
@@ -578,12 +691,42 @@ function normalizedImageOutputPrices(): Record<string, Record<string, number>> {
return out
}
function normalizedImageOutputPriceRanges(): ImageOutputPriceRange[] {
const ranges: ImageOutputPriceRange[] = []
for (const row of imageOutputPriceRangeRows.value) {
const prices: Partial<Record<ImageOutputQuality, number>> = {}
for (const quality of IMAGE_OUTPUT_QUALITIES) {
const price = row.prices[quality]
if (price != null && Number.isFinite(price)) {
prices[quality] = price
}
}
if (Object.keys(prices).length === 0) continue
ranges.push({
up_to_pixels: parseOptionalInteger(row.upToPixels),
prices,
})
}
return ranges.sort((a, b) => {
if (a.up_to_pixels == null && b.up_to_pixels == null) return 0
if (a.up_to_pixels == null) return 1
if (b.up_to_pixels == null) return -1
return a.up_to_pixels - b.up_to_pixels
})
}
function parseOptionalFloat(value: string | number): number | null {
if (value === '' || value === null || value === undefined) return null
const number = typeof value === 'string' ? parseFloat(value) : value
return Number.isFinite(number) ? number : null
}
function parseOptionalInteger(value: string | number): number | null {
if (value === '' || value === null || value === undefined) return null
const number = typeof value === 'string' ? parseInt(value, 10) : value
return Number.isFinite(number) && number > 0 ? Math.trunc(number) : null
}
function normalizeImageOutputSize(size: string): string {
return String(size || '').trim().replace(/\s*[xX×]\s*/g, 'x')
}
@@ -592,6 +735,10 @@ function getImageOutputPrice(row: ImageOutputPriceRow, quality: ImageOutputQuali
return row.prices[quality] ?? ''
}
function getImageOutputRangePrice(row: ImageOutputPriceRangeRow, quality: ImageOutputQuality): string | number {
return row.prices[quality] ?? ''
}
function updateImageOutputSize(rowId: string, value: string | number) {
const row = imageOutputPriceRows.value.find(item => item.id === rowId)
if (!row) return
@@ -625,6 +772,39 @@ function removeImageOutputSizeRow(rowId: string) {
syncToParent()
}
function updateImageOutputRangeLimit(rowId: string, value: string | number) {
const row = imageOutputPriceRangeRows.value.find(item => item.id === rowId)
if (!row) return
row.upToPixels = String(value ?? '')
imageOutputPriceRangeRows.value = [...imageOutputPriceRangeRows.value]
syncToParent()
}
function updateImageOutputRangePrice(rowId: string, quality: ImageOutputQuality, value: string | number) {
const row = imageOutputPriceRangeRows.value.find(item => item.id === rowId)
if (!row) return
const price = parseOptionalFloat(value)
if (price == null) {
delete row.prices[quality]
} else {
row.prices[quality] = price
}
imageOutputPriceRangeRows.value = [...imageOutputPriceRangeRows.value]
syncToParent()
}
function addImageOutputRangeRow() {
const usedLimits = new Set(imageOutputPriceRangeRows.value.map(row => parseOptionalInteger(row.upToPixels)).filter((value): value is number => value !== null))
const suggestedLimit = DEFAULT_IMAGE_OUTPUT_PIXEL_LIMITS.find(limit => !usedLimits.has(limit))
imageOutputPriceRangeRows.value = [...imageOutputPriceRangeRows.value, createImageOutputPriceRangeRow(suggestedLimit ? String(suggestedLimit) : '')]
syncToParent()
}
function removeImageOutputRangeRow(rowId: string) {
imageOutputPriceRangeRows.value = imageOutputPriceRangeRows.value.filter(row => row.id !== rowId)
syncToParent()
}
function updateImageOutputPriceDefault(value: string | number) {
imageOutputPriceDefault.value = String(value ?? '')
syncToParent()