mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-03 00:02:28 +08:00
Initial commit
This commit is contained in:
531
frontend/src/features/models/components/TieredPricingEditor.vue
Normal file
531
frontend/src/features/models/components/TieredPricingEditor.vue
Normal file
@@ -0,0 +1,531 @@
|
||||
<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>
|
||||
</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', showCache1h ? 'grid-cols-5' : 'grid-cols-4']">
|
||||
<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">
|
||||
<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">
|
||||
{{ 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(2))
|
||||
}
|
||||
|
||||
function getAutoCacheRead(index: number): number {
|
||||
const inputPrice = localTiers.value[index]?.input_price_per_1m || 0
|
||||
return parseFloat((inputPrice * 0.1).toFixed(2))
|
||||
}
|
||||
|
||||
function getAutoCache1h(index: number): number {
|
||||
const inputPrice = localTiers.value[index]?.input_price_per_1m || 0
|
||||
return parseFloat((inputPrice * 2).toFixed(2))
|
||||
}
|
||||
|
||||
function getCacheCreationPlaceholder(index: number): string {
|
||||
const auto = getAutoCacheCreation(index)
|
||||
return auto > 0 ? auto.toFixed(2) : '自动'
|
||||
}
|
||||
|
||||
function getCacheReadPlaceholder(index: number): string {
|
||||
const auto = getAutoCacheRead(index)
|
||||
return auto > 0 ? auto.toFixed(2) : '自动'
|
||||
}
|
||||
|
||||
function getCache1hPlaceholder(index: number): string {
|
||||
const auto = getAutoCache1h(index)
|
||||
return auto > 0 ? auto.toFixed(2) : '自动'
|
||||
}
|
||||
|
||||
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 ''
|
||||
}
|
||||
|
||||
// 同步到父组件(包含自动计算的缓存价格)
|
||||
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
|
||||
} 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
|
||||
})
|
||||
|
||||
emit('update:modelValue', { tiers })
|
||||
}
|
||||
|
||||
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 }> = {}
|
||||
localTiers.value.forEach((_, i) => {
|
||||
newManuallySet[i] = cacheManuallySet[i] || { creation: false, read: false }
|
||||
})
|
||||
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>
|
||||
Reference in New Issue
Block a user