feat(channel): 模型标签输入 + $/MTok 价格单位 + 左开右闭区间 + i18n
- 模型输入改为标签列表(输入回车添加,支持粘贴批量导入) - 价格显示单位改为 $/MTok(每百万 token),提交时自动转换 - Token 模式增加图片输出价格字段(适配 Gemini 图片模型按 token 计费) - 区间边界改为左开右闭 (min, max],右边界包含 - 默认价格作为未命中区间时的回退价格 - 添加完整中英文 i18n 翻译
This commit is contained in:
@@ -1,125 +1,66 @@
|
||||
<template>
|
||||
<div class="flex items-start gap-2 rounded border border-gray-200 bg-white p-2 dark:border-dark-500 dark:bg-dark-700">
|
||||
<!-- Token mode: context range + prices -->
|
||||
<!-- Token mode: context range + prices ($/MTok) -->
|
||||
<template v-if="mode === 'token'">
|
||||
<div class="w-20">
|
||||
<label class="text-xs text-gray-400">{{ t('admin.channels.form.minTokens', 'Min (K)') }}</label>
|
||||
<input
|
||||
:value="interval.min_tokens"
|
||||
@input="emitField('min_tokens', toInt(($event.target as HTMLInputElement).value))"
|
||||
type="number"
|
||||
min="0"
|
||||
class="input mt-0.5 text-xs"
|
||||
/>
|
||||
<label class="text-xs text-gray-400">Min</label>
|
||||
<input :value="interval.min_tokens" @input="emitField('min_tokens', toInt(($event.target as HTMLInputElement).value))"
|
||||
type="number" min="0" class="input mt-0.5 text-xs" />
|
||||
</div>
|
||||
<div class="w-20">
|
||||
<label class="text-xs text-gray-400">{{ t('admin.channels.form.maxTokens', 'Max (K)') }}</label>
|
||||
<input
|
||||
:value="interval.max_tokens ?? ''"
|
||||
@input="emitField('max_tokens', toIntOrNull(($event.target as HTMLInputElement).value))"
|
||||
type="number"
|
||||
min="0"
|
||||
class="input mt-0.5 text-xs"
|
||||
:placeholder="'∞'"
|
||||
/>
|
||||
<label class="text-xs text-gray-400">Max <span class="text-gray-300">(含)</span></label>
|
||||
<input :value="interval.max_tokens ?? ''" @input="emitField('max_tokens', toIntOrNull(($event.target as HTMLInputElement).value))"
|
||||
type="number" min="0" class="input mt-0.5 text-xs" :placeholder="'∞'" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<label class="text-xs text-gray-400">{{ t('admin.channels.form.inputPrice', 'Input') }}</label>
|
||||
<input
|
||||
:value="interval.input_price"
|
||||
@input="emitField('input_price', ($event.target as HTMLInputElement).value)"
|
||||
type="number"
|
||||
step="any" min="0"
|
||||
class="input mt-0.5 text-xs"
|
||||
/>
|
||||
<label class="text-xs text-gray-400">{{ t('admin.channels.form.inputPrice', '输入') }} <span class="text-gray-300">$/M</span></label>
|
||||
<input :value="interval.input_price" @input="emitField('input_price', ($event.target as HTMLInputElement).value)"
|
||||
type="number" step="any" min="0" class="input mt-0.5 text-xs" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<label class="text-xs text-gray-400">{{ t('admin.channels.form.outputPrice', 'Output') }}</label>
|
||||
<input
|
||||
:value="interval.output_price"
|
||||
@input="emitField('output_price', ($event.target as HTMLInputElement).value)"
|
||||
type="number"
|
||||
step="any" min="0"
|
||||
class="input mt-0.5 text-xs"
|
||||
/>
|
||||
<label class="text-xs text-gray-400">{{ t('admin.channels.form.outputPrice', '输出') }} <span class="text-gray-300">$/M</span></label>
|
||||
<input :value="interval.output_price" @input="emitField('output_price', ($event.target as HTMLInputElement).value)"
|
||||
type="number" step="any" min="0" class="input mt-0.5 text-xs" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<label class="text-xs text-gray-400">{{ t('admin.channels.form.cacheWritePrice', 'Cache W') }}</label>
|
||||
<input
|
||||
:value="interval.cache_write_price"
|
||||
@input="emitField('cache_write_price', ($event.target as HTMLInputElement).value)"
|
||||
type="number"
|
||||
step="any" min="0"
|
||||
class="input mt-0.5 text-xs"
|
||||
/>
|
||||
<label class="text-xs text-gray-400">{{ t('admin.channels.form.cacheWritePrice', '缓存W') }} <span class="text-gray-300">$/M</span></label>
|
||||
<input :value="interval.cache_write_price" @input="emitField('cache_write_price', ($event.target as HTMLInputElement).value)"
|
||||
type="number" step="any" min="0" class="input mt-0.5 text-xs" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<label class="text-xs text-gray-400">{{ t('admin.channels.form.cacheReadPrice', 'Cache R') }}</label>
|
||||
<input
|
||||
:value="interval.cache_read_price"
|
||||
@input="emitField('cache_read_price', ($event.target as HTMLInputElement).value)"
|
||||
type="number"
|
||||
step="any" min="0"
|
||||
class="input mt-0.5 text-xs"
|
||||
/>
|
||||
<label class="text-xs text-gray-400">{{ t('admin.channels.form.cacheReadPrice', '缓存R') }} <span class="text-gray-300">$/M</span></label>
|
||||
<input :value="interval.cache_read_price" @input="emitField('cache_read_price', ($event.target as HTMLInputElement).value)"
|
||||
type="number" step="any" min="0" class="input mt-0.5 text-xs" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Per-request / Image mode: tier label + price -->
|
||||
<!-- Per-request / Image mode: tier label + context range + price -->
|
||||
<template v-else>
|
||||
<div class="w-24">
|
||||
<label class="text-xs text-gray-400">
|
||||
{{ mode === 'image'
|
||||
? t('admin.channels.form.resolution', 'Resolution')
|
||||
: t('admin.channels.form.tierLabel', 'Tier')
|
||||
}}
|
||||
{{ mode === 'image' ? t('admin.channels.form.resolution', '分辨率') : t('admin.channels.form.tierLabel', '层级') }}
|
||||
</label>
|
||||
<input
|
||||
:value="interval.tier_label"
|
||||
@input="emitField('tier_label', ($event.target as HTMLInputElement).value)"
|
||||
type="text"
|
||||
class="input mt-0.5 text-xs"
|
||||
:placeholder="mode === 'image' ? '1K / 2K / 4K' : ''"
|
||||
/>
|
||||
<input :value="interval.tier_label" @input="emitField('tier_label', ($event.target as HTMLInputElement).value)"
|
||||
type="text" class="input mt-0.5 text-xs" :placeholder="mode === 'image' ? '1K / 2K / 4K' : ''" />
|
||||
</div>
|
||||
<div class="w-20">
|
||||
<label class="text-xs text-gray-400">{{ t('admin.channels.form.minTokens', 'Min') }}</label>
|
||||
<input
|
||||
:value="interval.min_tokens"
|
||||
@input="emitField('min_tokens', toInt(($event.target as HTMLInputElement).value))"
|
||||
type="number"
|
||||
min="0"
|
||||
class="input mt-0.5 text-xs"
|
||||
/>
|
||||
<label class="text-xs text-gray-400">Min</label>
|
||||
<input :value="interval.min_tokens" @input="emitField('min_tokens', toInt(($event.target as HTMLInputElement).value))"
|
||||
type="number" min="0" class="input mt-0.5 text-xs" />
|
||||
</div>
|
||||
<div class="w-20">
|
||||
<label class="text-xs text-gray-400">{{ t('admin.channels.form.maxTokens', 'Max') }}</label>
|
||||
<input
|
||||
:value="interval.max_tokens ?? ''"
|
||||
@input="emitField('max_tokens', toIntOrNull(($event.target as HTMLInputElement).value))"
|
||||
type="number"
|
||||
min="0"
|
||||
class="input mt-0.5 text-xs"
|
||||
:placeholder="'∞'"
|
||||
/>
|
||||
<label class="text-xs text-gray-400">Max <span class="text-gray-300">(含)</span></label>
|
||||
<input :value="interval.max_tokens ?? ''" @input="emitField('max_tokens', toIntOrNull(($event.target as HTMLInputElement).value))"
|
||||
type="number" min="0" class="input mt-0.5 text-xs" :placeholder="'∞'" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<label class="text-xs text-gray-400">{{ t('admin.channels.form.perRequestPrice', 'Price') }}</label>
|
||||
<input
|
||||
:value="interval.per_request_price"
|
||||
@input="emitField('per_request_price', ($event.target as HTMLInputElement).value)"
|
||||
type="number"
|
||||
step="any" min="0"
|
||||
class="input mt-0.5 text-xs"
|
||||
/>
|
||||
<label class="text-xs text-gray-400">{{ t('admin.channels.form.perRequestPrice', '单次价格') }} <span class="text-gray-300">$</span></label>
|
||||
<input :value="interval.per_request_price" @input="emitField('per_request_price', ($event.target as HTMLInputElement).value)"
|
||||
type="number" step="any" min="0" class="input mt-0.5 text-xs" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click="emit('remove')"
|
||||
class="mt-4 rounded p-0.5 text-gray-400 hover:text-red-500"
|
||||
>
|
||||
<button type="button" @click="emit('remove')" class="mt-4 rounded p-0.5 text-gray-400 hover:text-red-500">
|
||||
<Icon name="x" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
86
frontend/src/components/admin/channel/ModelTagInput.vue
Normal file
86
frontend/src/components/admin/channel/ModelTagInput.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Tags display -->
|
||||
<div class="flex flex-wrap gap-1.5 rounded-lg border border-gray-200 bg-white p-2 dark:border-dark-600 dark:bg-dark-800 min-h-[2.5rem]">
|
||||
<span
|
||||
v-for="(model, idx) in models"
|
||||
:key="idx"
|
||||
class="inline-flex items-center gap-1 rounded-md bg-primary-50 px-2 py-0.5 text-sm text-primary-700 dark:bg-primary-900/30 dark:text-primary-300"
|
||||
>
|
||||
{{ model }}
|
||||
<button
|
||||
type="button"
|
||||
@click="removeModel(idx)"
|
||||
class="ml-0.5 rounded-full p-0.5 hover:bg-primary-200 dark:hover:bg-primary-800"
|
||||
>
|
||||
<Icon name="x" size="xs" />
|
||||
</button>
|
||||
</span>
|
||||
<input
|
||||
ref="inputRef"
|
||||
v-model="inputValue"
|
||||
type="text"
|
||||
class="flex-1 min-w-[120px] border-none bg-transparent text-sm outline-none placeholder:text-gray-400 dark:text-white"
|
||||
:placeholder="models.length === 0 ? placeholder : ''"
|
||||
@keydown.enter.prevent="addModel"
|
||||
@keydown.tab.prevent="addModel"
|
||||
@keydown.delete="handleBackspace"
|
||||
@paste="handlePaste"
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-400">
|
||||
{{ t('admin.channels.form.modelInputHint', 'Press Enter to add. Supports wildcard *.') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
models: string[]
|
||||
placeholder?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:models': [models: string[]]
|
||||
}>()
|
||||
|
||||
const inputValue = ref('')
|
||||
const inputRef = ref<HTMLInputElement>()
|
||||
|
||||
function addModel() {
|
||||
const val = inputValue.value.trim()
|
||||
if (!val) return
|
||||
if (!props.models.includes(val)) {
|
||||
emit('update:models', [...props.models, val])
|
||||
}
|
||||
inputValue.value = ''
|
||||
}
|
||||
|
||||
function removeModel(idx: number) {
|
||||
const newModels = [...props.models]
|
||||
newModels.splice(idx, 1)
|
||||
emit('update:models', newModels)
|
||||
}
|
||||
|
||||
function handleBackspace() {
|
||||
if (inputValue.value === '' && props.models.length > 0) {
|
||||
removeModel(props.models.length - 1)
|
||||
}
|
||||
}
|
||||
|
||||
function handlePaste(e: ClipboardEvent) {
|
||||
e.preventDefault()
|
||||
const text = e.clipboardData?.getData('text') || ''
|
||||
const items = text.split(/[,\n;]+/).map(s => s.trim()).filter(Boolean)
|
||||
if (items.length === 0) return
|
||||
const unique = [...new Set([...props.models, ...items])]
|
||||
emit('update:models', unique)
|
||||
inputValue.value = ''
|
||||
}
|
||||
</script>
|
||||
@@ -1,22 +1,21 @@
|
||||
<template>
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-dark-600 dark:bg-dark-800">
|
||||
<!-- Header: Models + Billing Mode + Remove -->
|
||||
<div class="mb-2 flex items-start gap-2">
|
||||
<div class="mb-3 flex items-start gap-2">
|
||||
<div class="flex-1">
|
||||
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.channels.form.models', 'Models (comma separated, supports *)') }}
|
||||
{{ t('admin.channels.form.models', '模型列表') }}
|
||||
</label>
|
||||
<textarea
|
||||
:value="entry.modelsInput"
|
||||
@input="emit('update', { ...entry, modelsInput: ($event.target as HTMLTextAreaElement).value })"
|
||||
rows="2"
|
||||
class="input mt-1 text-sm"
|
||||
:placeholder="t('admin.channels.form.modelsPlaceholder', 'claude-sonnet-4-20250514, claude-opus-4-20250514, *')"
|
||||
></textarea>
|
||||
<ModelTagInput
|
||||
:models="entry.models"
|
||||
@update:models="emit('update', { ...entry, models: $event })"
|
||||
:placeholder="t('admin.channels.form.modelsPlaceholder', '输入模型名后按回车添加,支持通配符 *')"
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-40">
|
||||
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.channels.form.billingMode', 'Billing Mode') }}
|
||||
{{ t('admin.channels.form.billingMode', '计费模式') }}
|
||||
</label>
|
||||
<Select
|
||||
:modelValue="entry.billing_mode"
|
||||
@@ -34,61 +33,38 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Token mode: flat prices + intervals -->
|
||||
<!-- Token mode -->
|
||||
<div v-if="entry.billing_mode === 'token'">
|
||||
<!-- Flat prices (used when no intervals) -->
|
||||
<div class="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
<!-- Default prices (fallback when no interval matches) -->
|
||||
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.channels.form.defaultPrices', '默认价格(未命中区间时使用)') }}
|
||||
<span class="ml-1 font-normal text-gray-400">$/MTok</span>
|
||||
</label>
|
||||
<div class="mt-1 grid grid-cols-2 gap-2 sm:grid-cols-5">
|
||||
<div>
|
||||
<label class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.channels.form.inputPrice', 'Input Price') }}
|
||||
</label>
|
||||
<input
|
||||
:value="entry.input_price"
|
||||
@input="emitField('input_price', ($event.target as HTMLInputElement).value)"
|
||||
type="number"
|
||||
step="any" min="0"
|
||||
class="input mt-1 text-sm"
|
||||
:placeholder="t('admin.channels.form.pricePlaceholder', 'Default')"
|
||||
/>
|
||||
<label class="text-xs text-gray-400">{{ t('admin.channels.form.inputPrice', '输入') }}</label>
|
||||
<input :value="entry.input_price" @input="emitField('input_price', ($event.target as HTMLInputElement).value)"
|
||||
type="number" step="any" min="0" class="input mt-0.5 text-sm" :placeholder="t('admin.channels.form.pricePlaceholder', '默认')" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.channels.form.outputPrice', 'Output Price') }}
|
||||
</label>
|
||||
<input
|
||||
:value="entry.output_price"
|
||||
@input="emitField('output_price', ($event.target as HTMLInputElement).value)"
|
||||
type="number"
|
||||
step="any" min="0"
|
||||
class="input mt-1 text-sm"
|
||||
:placeholder="t('admin.channels.form.pricePlaceholder', 'Default')"
|
||||
/>
|
||||
<label class="text-xs text-gray-400">{{ t('admin.channels.form.outputPrice', '输出') }}</label>
|
||||
<input :value="entry.output_price" @input="emitField('output_price', ($event.target as HTMLInputElement).value)"
|
||||
type="number" step="any" min="0" class="input mt-0.5 text-sm" :placeholder="t('admin.channels.form.pricePlaceholder', '默认')" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.channels.form.cacheWritePrice', 'Cache Write') }}
|
||||
</label>
|
||||
<input
|
||||
:value="entry.cache_write_price"
|
||||
@input="emitField('cache_write_price', ($event.target as HTMLInputElement).value)"
|
||||
type="number"
|
||||
step="any" min="0"
|
||||
class="input mt-1 text-sm"
|
||||
:placeholder="t('admin.channels.form.pricePlaceholder', 'Default')"
|
||||
/>
|
||||
<label class="text-xs text-gray-400">{{ t('admin.channels.form.cacheWritePrice', '缓存写入') }}</label>
|
||||
<input :value="entry.cache_write_price" @input="emitField('cache_write_price', ($event.target as HTMLInputElement).value)"
|
||||
type="number" step="any" min="0" class="input mt-0.5 text-sm" :placeholder="t('admin.channels.form.pricePlaceholder', '默认')" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.channels.form.cacheReadPrice', 'Cache Read') }}
|
||||
</label>
|
||||
<input
|
||||
:value="entry.cache_read_price"
|
||||
@input="emitField('cache_read_price', ($event.target as HTMLInputElement).value)"
|
||||
type="number"
|
||||
step="any" min="0"
|
||||
class="input mt-1 text-sm"
|
||||
:placeholder="t('admin.channels.form.pricePlaceholder', 'Default')"
|
||||
/>
|
||||
<label class="text-xs text-gray-400">{{ t('admin.channels.form.cacheReadPrice', '缓存读取') }}</label>
|
||||
<input :value="entry.cache_read_price" @input="emitField('cache_read_price', ($event.target as HTMLInputElement).value)"
|
||||
type="number" step="any" min="0" class="input mt-0.5 text-sm" :placeholder="t('admin.channels.form.pricePlaceholder', '默认')" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-400">{{ t('admin.channels.form.imageTokenPrice', '图片输出') }}</label>
|
||||
<input :value="entry.image_output_price" @input="emitField('image_output_price', ($event.target as HTMLInputElement).value)"
|
||||
type="number" step="any" min="0" class="input mt-0.5 text-sm" :placeholder="t('admin.channels.form.pricePlaceholder', '默认')" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -96,10 +72,11 @@
|
||||
<div class="mt-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.channels.form.intervals', 'Context Intervals (optional)') }}
|
||||
{{ t('admin.channels.form.intervals', '上下文区间定价(可选)') }}
|
||||
<span class="ml-1 font-normal text-gray-400">(min, max]</span>
|
||||
</label>
|
||||
<button type="button" @click="addInterval" class="text-xs text-primary-600 hover:text-primary-700">
|
||||
+ {{ t('admin.channels.form.addInterval', 'Add Interval') }}
|
||||
+ {{ t('admin.channels.form.addInterval', '添加区间') }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="entry.intervals && entry.intervals.length > 0" class="mt-2 space-y-2">
|
||||
@@ -115,14 +92,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Per-request mode: tiers -->
|
||||
<!-- Per-request mode -->
|
||||
<div v-else-if="entry.billing_mode === 'per_request'">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.channels.form.requestTiers', 'Request Tiers') }}
|
||||
{{ t('admin.channels.form.requestTiers', '按次计费层级') }}
|
||||
</label>
|
||||
<button type="button" @click="addInterval" class="text-xs text-primary-600 hover:text-primary-700">
|
||||
+ {{ t('admin.channels.form.addTier', 'Add Tier') }}
|
||||
+ {{ t('admin.channels.form.addTier', '添加层级') }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="entry.intervals && entry.intervals.length > 0" class="mt-2 space-y-2">
|
||||
@@ -136,18 +113,18 @@
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="mt-2 rounded border border-dashed border-gray-300 p-3 text-center text-xs text-gray-400 dark:border-dark-500">
|
||||
{{ t('admin.channels.form.noTiersYet', 'No tiers. Add one to configure per-request pricing.') }}
|
||||
{{ t('admin.channels.form.noTiersYet', '暂无层级,点击添加配置按次计费价格') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Image mode: tiers -->
|
||||
<!-- Image mode (legacy per-request) -->
|
||||
<div v-else-if="entry.billing_mode === 'image'">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.channels.form.imageTiers', 'Image Tiers') }}
|
||||
{{ t('admin.channels.form.imageTiers', '图片计费层级(按次)') }}
|
||||
</label>
|
||||
<button type="button" @click="addImageTier" class="text-xs text-primary-600 hover:text-primary-700">
|
||||
+ {{ t('admin.channels.form.addTier', 'Add Tier') }}
|
||||
+ {{ t('admin.channels.form.addTier', '添加层级') }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="entry.intervals && entry.intervals.length > 0" class="mt-2 space-y-2">
|
||||
@@ -161,20 +138,11 @@
|
||||
/>
|
||||
</div>
|
||||
<div v-else>
|
||||
<!-- Legacy image_output_price fallback -->
|
||||
<div class="mt-2 grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
<div>
|
||||
<label class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.channels.form.imageOutputPrice', 'Image Output Price') }}
|
||||
</label>
|
||||
<input
|
||||
:value="entry.image_output_price"
|
||||
@input="emitField('image_output_price', ($event.target as HTMLInputElement).value)"
|
||||
type="number"
|
||||
step="any" min="0"
|
||||
class="input mt-1 text-sm"
|
||||
:placeholder="t('admin.channels.form.pricePlaceholder', 'Default')"
|
||||
/>
|
||||
<label class="text-xs text-gray-400">{{ t('admin.channels.form.imageOutputPrice', '图片输出价格') }}</label>
|
||||
<input :value="entry.image_output_price" @input="emitField('image_output_price', ($event.target as HTMLInputElement).value)"
|
||||
type="number" step="any" min="0" class="input mt-0.5 text-sm" :placeholder="t('admin.channels.form.pricePlaceholder', '默认')" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -188,6 +156,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import IntervalRow from './IntervalRow.vue'
|
||||
import ModelTagInput from './ModelTagInput.vue'
|
||||
import type { PricingFormEntry, IntervalFormEntry } from './types'
|
||||
import type { BillingMode } from '@/api/admin/channels'
|
||||
|
||||
@@ -203,9 +172,9 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const billingModeOptions = computed(() => [
|
||||
{ value: 'token', label: t('admin.channels.billingMode.token', 'Token') },
|
||||
{ value: 'per_request', label: t('admin.channels.billingMode.perRequest', 'Per Request') },
|
||||
{ value: 'image', label: t('admin.channels.billingMode.image', 'Image') }
|
||||
{ value: 'token', label: 'Token' },
|
||||
{ value: 'per_request', label: t('admin.channels.billingMode.perRequest', '按次') },
|
||||
{ value: 'image', label: t('admin.channels.billingMode.image', '图片(按次)') }
|
||||
])
|
||||
|
||||
function emitField(field: keyof PricingFormEntry, value: string) {
|
||||
@@ -215,14 +184,9 @@ function emitField(field: keyof PricingFormEntry, value: string) {
|
||||
function addInterval() {
|
||||
const intervals = [...(props.entry.intervals || [])]
|
||||
intervals.push({
|
||||
min_tokens: 0,
|
||||
max_tokens: null,
|
||||
tier_label: '',
|
||||
input_price: null,
|
||||
output_price: null,
|
||||
cache_write_price: null,
|
||||
cache_read_price: null,
|
||||
per_request_price: null,
|
||||
min_tokens: 0, max_tokens: null, tier_label: '',
|
||||
input_price: null, output_price: null, cache_write_price: null,
|
||||
cache_read_price: null, per_request_price: null,
|
||||
sort_order: intervals.length
|
||||
})
|
||||
emit('update', { ...props.entry, intervals })
|
||||
@@ -231,16 +195,10 @@ function addInterval() {
|
||||
function addImageTier() {
|
||||
const intervals = [...(props.entry.intervals || [])]
|
||||
const labels = ['1K', '2K', '4K', 'HD']
|
||||
const nextLabel = labels[intervals.length] || ''
|
||||
intervals.push({
|
||||
min_tokens: 0,
|
||||
max_tokens: null,
|
||||
tier_label: nextLabel,
|
||||
input_price: null,
|
||||
output_price: null,
|
||||
cache_write_price: null,
|
||||
cache_read_price: null,
|
||||
per_request_price: null,
|
||||
min_tokens: 0, max_tokens: null, tier_label: labels[intervals.length] || '',
|
||||
input_price: null, output_price: null, cache_write_price: null,
|
||||
cache_read_price: null, per_request_price: null,
|
||||
sort_order: intervals.length
|
||||
})
|
||||
emit('update', { ...props.entry, intervals })
|
||||
|
||||
@@ -13,32 +13,46 @@ export interface IntervalFormEntry {
|
||||
}
|
||||
|
||||
export interface PricingFormEntry {
|
||||
modelsInput: string
|
||||
models: string[]
|
||||
billing_mode: BillingMode
|
||||
input_price: number | string | null
|
||||
output_price: number | string | null
|
||||
cache_write_price: number | string | null
|
||||
cache_read_price: number | string | null
|
||||
per_request_price: number | string | null
|
||||
image_output_price: number | string | null
|
||||
intervals: IntervalFormEntry[]
|
||||
}
|
||||
|
||||
// 价格转换:后端存 per-token,前端显示 per-MTok ($/1M tokens)
|
||||
const MTOK = 1_000_000
|
||||
|
||||
export function toNullableNumber(val: number | string | null | undefined): number | null {
|
||||
if (val === null || val === undefined || val === '') return null
|
||||
const num = Number(val)
|
||||
return isNaN(num) ? null : num
|
||||
}
|
||||
|
||||
/** 前端显示值($/MTok) → 后端存储值(per-token) */
|
||||
export function mTokToPerToken(val: number | string | null | undefined): number | null {
|
||||
const num = toNullableNumber(val)
|
||||
return num === null ? null : num / MTOK
|
||||
}
|
||||
|
||||
/** 后端存储值(per-token) → 前端显示值($/MTok) */
|
||||
export function perTokenToMTok(val: number | null | undefined): number | null {
|
||||
if (val === null || val === undefined) return null
|
||||
return val * MTOK
|
||||
}
|
||||
|
||||
export function apiIntervalsToForm(intervals: PricingInterval[]): IntervalFormEntry[] {
|
||||
return (intervals || []).map(iv => ({
|
||||
min_tokens: iv.min_tokens,
|
||||
max_tokens: iv.max_tokens,
|
||||
tier_label: iv.tier_label || '',
|
||||
input_price: iv.input_price,
|
||||
output_price: iv.output_price,
|
||||
cache_write_price: iv.cache_write_price,
|
||||
cache_read_price: iv.cache_read_price,
|
||||
input_price: perTokenToMTok(iv.input_price),
|
||||
output_price: perTokenToMTok(iv.output_price),
|
||||
cache_write_price: perTokenToMTok(iv.cache_write_price),
|
||||
cache_read_price: perTokenToMTok(iv.cache_read_price),
|
||||
per_request_price: iv.per_request_price,
|
||||
sort_order: iv.sort_order
|
||||
}))
|
||||
@@ -49,10 +63,10 @@ export function formIntervalsToAPI(intervals: IntervalFormEntry[]): PricingInter
|
||||
min_tokens: iv.min_tokens,
|
||||
max_tokens: iv.max_tokens,
|
||||
tier_label: iv.tier_label,
|
||||
input_price: toNullableNumber(iv.input_price),
|
||||
output_price: toNullableNumber(iv.output_price),
|
||||
cache_write_price: toNullableNumber(iv.cache_write_price),
|
||||
cache_read_price: toNullableNumber(iv.cache_read_price),
|
||||
input_price: mTokToPerToken(iv.input_price),
|
||||
output_price: mTokToPerToken(iv.output_price),
|
||||
cache_write_price: mTokToPerToken(iv.cache_write_price),
|
||||
cache_read_price: mTokToPerToken(iv.cache_read_price),
|
||||
per_request_price: toNullableNumber(iv.per_request_price),
|
||||
sort_order: iv.sort_order
|
||||
}))
|
||||
|
||||
Reference in New Issue
Block a user