Files
Aether/frontend/src/features/models/components/TieredPricingEditor.vue

579 lines
18 KiB
Vue
Raw Normal View History

2025-12-10 20:52:44 +08:00
<template>
<div class="space-y-3">
<!-- 阶梯列表 -->
<div
v-for="(tier, index) in localTiers"
:key="index"
class="p-3 border rounded-lg bg-muted/20 space-y-3"
>
<!-- 阶梯头部 -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-2 text-sm">
<span class="text-muted-foreground">{{ getTierStartLabel(index) }}</span>
<span class="text-muted-foreground">-</span>
<template v-if="index < localTiers.length - 1">
<template v-if="customInputMode[index]">
<Input
v-model="customInputValue[index]"
type="number"
min="1"
class="h-7 w-20 text-sm"
placeholder="K"
@keyup.enter="confirmCustomInput(index)"
@blur="confirmCustomInput(index)"
/>
<span class="text-xs text-muted-foreground">K</span>
</template>
<select
v-else
:value="getSelectValue(index)"
class="h-7 px-2 text-sm border rounded bg-background"
@change="(e) => handleThresholdChange(index, parseInt((e.target as HTMLSelectElement).value))"
>
<option
v-for="opt in getAvailableThresholds(index)"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</option>
</select>
</template>
<span
v-else
class="font-medium"
>无上限</span>
2025-12-10 20:52:44 +08:00
</div>
<Button
v-if="localTiers.length > 1"
variant="ghost"
size="sm"
class="h-7 w-7 p-0"
@click="removeTier(index)"
>
<X class="w-4 h-4 text-muted-foreground hover:text-destructive" />
</Button>
</div>
<!-- 价格输入 -->
<div
class="grid gap-3"
:class="[showCache1h ? 'grid-cols-5' : 'grid-cols-4']"
>
2025-12-10 20:52:44 +08:00
<div class="space-y-1">
<Label class="text-xs">输入 ($/M)</Label>
<Input
:model-value="tier.input_price_per_1m"
type="number"
step="0.01"
min="0"
class="h-8"
placeholder="0"
@update:model-value="(v) => updateInputPrice(index, parseFloatInput(v))"
/>
</div>
<div class="space-y-1">
<Label class="text-xs">输出 ($/M)</Label>
<Input
:model-value="tier.output_price_per_1m"
type="number"
step="0.01"
min="0"
class="h-8"
placeholder="0"
@update:model-value="(v) => updateOutputPrice(index, parseFloatInput(v))"
/>
</div>
<div class="space-y-1">
<Label class="text-xs text-muted-foreground">缓存创建</Label>
<Input
:model-value="getCacheCreationDisplay(index)"
type="number"
step="0.01"
min="0"
class="h-8"
:placeholder="getCacheCreationPlaceholder(index)"
@update:model-value="(v) => updateCacheCreation(index, v)"
/>
</div>
<div class="space-y-1">
<Label class="text-xs text-muted-foreground">缓存读取</Label>
<Input
:model-value="getCacheReadDisplay(index)"
type="number"
step="0.01"
min="0"
class="h-8"
:placeholder="getCacheReadPlaceholder(index)"
@update:model-value="(v) => updateCacheRead(index, v)"
/>
</div>
<div
v-if="showCache1h"
class="space-y-1"
>
2025-12-10 20:52:44 +08:00
<Label class="text-xs text-muted-foreground">1h 缓存创建</Label>
<Input
:model-value="getCache1hDisplay(index)"
type="number"
step="0.01"
min="0"
class="h-8"
:placeholder="getCache1hPlaceholder(index)"
@update:model-value="(v) => updateCache1h(index, v)"
/>
</div>
</div>
</div>
<!-- 添加阶梯按钮 -->
<Button
variant="outline"
size="sm"
class="w-full"
@click="addTier"
>
<Plus class="w-4 h-4 mr-2" />
添加价格阶梯
</Button>
<!-- 验证提示 -->
<p
v-if="validationError"
class="text-xs text-destructive"
>
2025-12-10 20:52:44 +08:00
{{ validationError }}
</p>
</div>
</template>
<script setup lang="ts">
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'
const props = defineProps<{
modelValue?: TieredPricingConfig | null
showCache1h?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: TieredPricingConfig | null]
}>()
// 本地状态
const localTiers = ref<PricingTier[]>([])
// 跟踪每个阶梯的缓存价格是否被手动设置
const cacheManuallySet = reactive<Record<number, { creation: boolean; read: boolean; cache1h: boolean }>>({})
// 预设的阶梯上限选项
const THRESHOLD_OPTIONS = [
{ value: 64000, label: '64K' },
{ value: 128000, label: '128K' },
{ value: 200000, label: '200K' },
{ value: 500000, label: '500K' },
{ value: 1000000, label: '1M' },
{ value: -1, label: '自定义...' }, // 特殊值表示自定义输入
]
// 跟踪哪些阶梯正在使用自定义输入
const customInputMode = reactive<Record<number, boolean>>({})
const customInputValue = reactive<Record<number, string>>({})
// 初始化
watch(
() => props.modelValue,
(newValue) => {
if (newValue?.tiers) {
localTiers.value = newValue.tiers.map(t => ({ ...t }))
// 如果已有缓存价格,标记为手动设置
newValue.tiers.forEach((t, i) => {
const has1hCache = t.cache_ttl_pricing?.some(c => c.ttl_minutes === 60) ?? false
cacheManuallySet[i] = {
creation: t.cache_creation_price_per_1m != null,
read: t.cache_read_price_per_1m != null,
cache1h: has1hCache,
}
})
} else {
localTiers.value = [{
up_to: null,
input_price_per_1m: 0,
output_price_per_1m: 0,
}]
cacheManuallySet[0] = { creation: false, read: false, cache1h: false }
}
},
{ immediate: true }
)
// 监听 showCache1h 变化
watch(
() => props.showCache1h,
(newValue, oldValue) => {
if (oldValue === true && newValue === false) {
// 取消勾选时,清除本地的 1h 缓存数据和手动设置标记
localTiers.value.forEach((tier, i) => {
tier.cache_ttl_pricing = undefined
if (cacheManuallySet[i]) {
cacheManuallySet[i].cache1h = false
}
})
syncToParent()
} else if (oldValue === false && newValue === true) {
// 勾选时,同步自动计算的价格到父组件
syncToParent()
}
}
)
// 验证错误
const validationError = computed(() => {
if (localTiers.value.length === 0) {
return '至少需要一个价格阶梯'
}
if (localTiers.value[localTiers.value.length - 1].up_to !== null) {
return '最后一个阶梯必须是无上限的'
}
let prevUpTo = 0
for (let i = 0; i < localTiers.value.length - 1; i++) {
const tier = localTiers.value[i]
if (tier.up_to === null || tier.up_to <= prevUpTo) {
return `阶梯 ${i + 1} 的上限必须大于前一个阶梯`
}
prevUpTo = tier.up_to
}
return null
})
// 获取阶梯起始标签
function getTierStartLabel(index: number): string {
if (index === 0) return '0'
const prevTier = localTiers.value[index - 1]
if (prevTier.up_to === null) return '0'
return formatTokens(prevTier.up_to)
}
// 获取可用的阈值选项
function getAvailableThresholds(index: number) {
const usedThresholds = new Set<number>()
localTiers.value.forEach((t, i) => {
if (i !== index && t.up_to !== null) {
usedThresholds.add(t.up_to)
}
})
const minValue = index > 0 ? (localTiers.value[index - 1].up_to || 0) : 0
const currentValue = localTiers.value[index].up_to
// 过滤可用的预设选项
const options = THRESHOLD_OPTIONS.filter(opt =>
(opt.value === -1) || // "自定义..."始终显示
(!usedThresholds.has(opt.value) && opt.value > minValue)
)
// 如果当前值是自定义的(不在预设中),添加到选项列表
if (currentValue !== null && !THRESHOLD_OPTIONS.some(opt => opt.value === currentValue)) {
options.unshift({ value: currentValue, label: formatTokens(currentValue) })
}
return options
}
// 格式化 token 数量
function formatTokens(tokens: number): string {
if (tokens >= 1000000) {
return `${(tokens / 1000000).toFixed(tokens % 1000000 === 0 ? 0 : 1)}M`
}
if (tokens >= 1000) {
return `${(tokens / 1000).toFixed(0)}K`
}
return tokens.toString()
}
// 缓存价格自动计算
function getAutoCacheCreation(index: number): number {
const inputPrice = localTiers.value[index]?.input_price_per_1m || 0
return parseFloat((inputPrice * 1.25).toFixed(4))
2025-12-10 20:52:44 +08:00
}
function getAutoCacheRead(index: number): number {
const inputPrice = localTiers.value[index]?.input_price_per_1m || 0
return parseFloat((inputPrice * 0.1).toFixed(4))
2025-12-10 20:52:44 +08:00
}
function getAutoCache1h(index: number): number {
const inputPrice = localTiers.value[index]?.input_price_per_1m || 0
return parseFloat((inputPrice * 2).toFixed(4))
2025-12-10 20:52:44 +08:00
}
function getCacheCreationPlaceholder(index: number): string {
const auto = getAutoCacheCreation(index)
return auto > 0 ? String(auto) : '自动'
2025-12-10 20:52:44 +08:00
}
function getCacheReadPlaceholder(index: number): string {
const auto = getAutoCacheRead(index)
return auto > 0 ? String(auto) : '自动'
2025-12-10 20:52:44 +08:00
}
function getCache1hPlaceholder(index: number): string {
const auto = getAutoCache1h(index)
return auto > 0 ? String(auto) : '自动'
2025-12-10 20:52:44 +08:00
}
function getCacheCreationDisplay(index: number): string | number {
const tier = localTiers.value[index]
if (cacheManuallySet[index]?.creation && tier?.cache_creation_price_per_1m != null) {
// 修复浮点数精度问题
return parseFloat(tier.cache_creation_price_per_1m.toFixed(4))
}
return ''
}
function getCacheReadDisplay(index: number): string | number {
const tier = localTiers.value[index]
if (cacheManuallySet[index]?.read && tier?.cache_read_price_per_1m != null) {
// 修复浮点数精度问题
return parseFloat(tier.cache_read_price_per_1m.toFixed(4))
}
return ''
}
function getCache1hDisplay(index: number): string | number {
const tier = localTiers.value[index]
// 只有手动设置过才显示值
if (cacheManuallySet[index]?.cache1h) {
const ttl1h = tier?.cache_ttl_pricing?.find(t => t.ttl_minutes === 60)
if (ttl1h) {
// 修复浮点数精度问题
return parseFloat(ttl1h.cache_creation_price_per_1m.toFixed(4))
}
}
return ''
}
// 同步到父组件(只同步用户实际输入的值,不自动填充)
2025-12-10 20:52:44 +08:00
function syncToParent() {
if (validationError.value) return
const tiers: PricingTier[] = localTiers.value.map((t, i) => {
const tier: PricingTier = {
up_to: t.up_to,
input_price_per_1m: t.input_price_per_1m,
output_price_per_1m: t.output_price_per_1m,
}
// 缓存创建价格:只有手动设置才同步
if (cacheManuallySet[i]?.creation && t.cache_creation_price_per_1m != null) {
tier.cache_creation_price_per_1m = t.cache_creation_price_per_1m
}
// 缓存读取价格:只有手动设置才同步
if (cacheManuallySet[i]?.read && t.cache_read_price_per_1m != null) {
tier.cache_read_price_per_1m = t.cache_read_price_per_1m
}
// 缓存 TTL 价格1h 缓存)- 只有启用 1h 缓存能力且手动设置时才处理
if (props.showCache1h && cacheManuallySet[i]?.cache1h && t.cache_ttl_pricing?.length) {
tier.cache_ttl_pricing = t.cache_ttl_pricing
}
return tier
})
emit('update:modelValue', { tiers })
}
// 获取最终提交的数据(包含自动计算的缓存价格)
function getFinalTiers(): PricingTier[] {
return localTiers.value.map((t, i) => {
const tier: PricingTier = {
up_to: t.up_to,
input_price_per_1m: t.input_price_per_1m,
output_price_per_1m: t.output_price_per_1m,
}
2025-12-10 20:52:44 +08:00
// 缓存创建价格:手动设置则用设置值,否则自动计算
if (cacheManuallySet[i]?.creation && t.cache_creation_price_per_1m != null) {
tier.cache_creation_price_per_1m = t.cache_creation_price_per_1m
} else if (t.input_price_per_1m > 0) {
tier.cache_creation_price_per_1m = getAutoCacheCreation(i)
}
// 缓存读取价格:手动设置则用设置值,否则自动计算
if (cacheManuallySet[i]?.read && t.cache_read_price_per_1m != null) {
tier.cache_read_price_per_1m = t.cache_read_price_per_1m
} else if (t.input_price_per_1m > 0) {
tier.cache_read_price_per_1m = getAutoCacheRead(i)
}
// 缓存 TTL 价格1h 缓存)- 只有启用 1h 缓存能力时才处理
if (props.showCache1h) {
if (cacheManuallySet[i]?.cache1h && t.cache_ttl_pricing?.length) {
tier.cache_ttl_pricing = t.cache_ttl_pricing
} else if (t.input_price_per_1m > 0) {
tier.cache_ttl_pricing = [{ ttl_minutes: 60, cache_creation_price_per_1m: getAutoCache1h(i) }]
}
}
return tier
})
}
// 暴露给父组件调用
defineExpose({
getFinalTiers,
})
2025-12-10 20:52:44 +08:00
function parseFloatInput(value: string | number): number {
const num = typeof value === 'string' ? parseFloat(value) : value
return isNaN(num) ? 0 : num
}
// 更新输入价格(会触发缓存价格自动更新)
function updateInputPrice(index: number, value: number) {
localTiers.value[index].input_price_per_1m = value
syncToParent()
}
function updateOutputPrice(index: number, value: number) {
localTiers.value[index].output_price_per_1m = value
syncToParent()
}
// 获取下拉框当前选中值
function getSelectValue(index: number): number {
const upTo = localTiers.value[index].up_to
if (upTo === null) return -1
return upTo // 直接返回当前值,让下拉框显示对应选项
}
// 处理下拉框选择变化
function handleThresholdChange(index: number, value: number) {
if (value === -1) {
// 选择了"自定义...",进入自定义输入模式
customInputMode[index] = true
customInputValue[index] = ''
} else {
localTiers.value[index].up_to = value
syncToParent()
}
}
// 确认自定义输入
function confirmCustomInput(index: number) {
const inputK = parseInt(customInputValue[index])
if (inputK > 0) {
localTiers.value[index].up_to = inputK * 1000
syncToParent()
}
customInputMode[index] = false
}
function updateCacheCreation(index: number, value: string | number) {
const numValue = parseFloatInput(value)
if (numValue > 0) {
cacheManuallySet[index] = { ...cacheManuallySet[index], creation: true }
localTiers.value[index].cache_creation_price_per_1m = numValue
} else {
// 清空时恢复自动计算
cacheManuallySet[index] = { ...cacheManuallySet[index], creation: false }
localTiers.value[index].cache_creation_price_per_1m = undefined
}
syncToParent()
}
function updateCacheRead(index: number, value: string | number) {
const numValue = parseFloatInput(value)
if (numValue > 0) {
cacheManuallySet[index] = { ...cacheManuallySet[index], read: true }
localTiers.value[index].cache_read_price_per_1m = numValue
} else {
// 清空时恢复自动计算
cacheManuallySet[index] = { ...cacheManuallySet[index], read: false }
localTiers.value[index].cache_read_price_per_1m = undefined
}
syncToParent()
}
function updateCache1h(index: number, value: string | number) {
const numValue = parseFloatInput(value)
const tier = localTiers.value[index]
if (numValue > 0) {
// 手动设置 1 小时缓存创建价格
cacheManuallySet[index] = { ...cacheManuallySet[index], cache1h: true }
tier.cache_ttl_pricing = [{ ttl_minutes: 60, cache_creation_price_per_1m: numValue }]
} else {
// 清空时恢复自动计算
cacheManuallySet[index] = { ...cacheManuallySet[index], cache1h: false }
tier.cache_ttl_pricing = undefined
}
syncToParent()
}
// 阶梯操作
function addTier() {
if (localTiers.value.length === 0) {
localTiers.value = [{
up_to: null,
input_price_per_1m: 0,
output_price_per_1m: 0,
}]
cacheManuallySet[0] = { creation: false, read: false, cache1h: false }
} else {
// 把当前最后一个阶梯(无上限)改为有上限
const lastTier = localTiers.value[localTiers.value.length - 1]
const secondLastTier = localTiers.value[localTiers.value.length - 2]
const minValue = secondLastTier?.up_to || 0
const availableThresholds = THRESHOLD_OPTIONS.filter(opt => opt.value > minValue)
const newUpTo = availableThresholds[0]?.value || minValue + 200000
// 给当前最后一个阶梯设置上限
lastTier.up_to = newUpTo
// 添加新的无上限阶梯
const newIndex = localTiers.value.length
const newTier: PricingTier = {
up_to: null,
input_price_per_1m: 0,
output_price_per_1m: 0,
}
localTiers.value.push(newTier)
cacheManuallySet[newIndex] = { creation: false, read: false, cache1h: false }
}
syncToParent()
}
function removeTier(index: number) {
if (localTiers.value.length <= 1) return
localTiers.value.splice(index, 1)
delete cacheManuallySet[index]
// 重新整理 cacheManuallySet 的索引
const newManuallySet: Record<
number,
{ creation: boolean; read: boolean; cache1h: boolean }
> = {}
2025-12-10 20:52:44 +08:00
localTiers.value.forEach((_, i) => {
newManuallySet[i] =
cacheManuallySet[i] || { creation: false, read: false, cache1h: false }
2025-12-10 20:52:44 +08:00
})
Object.keys(cacheManuallySet).forEach(k => delete cacheManuallySet[Number(k)])
Object.assign(cacheManuallySet, newManuallySet)
if (localTiers.value.length > 0) {
localTiers.value[localTiers.value.length - 1].up_to = null
}
syncToParent()
}
</script>