@@ -1,148 +1,209 @@
< 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 Mo de + Remove -- >
< 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' , '模型列表' ) } }
< / label >
< ModelTagInput
:models = "entry.models "
@ update : models = "emit('update', { ...entry, models: $event }) "
: placeholder = "t('admin.channels.form.modelsPlaceholder', '输入模型名后按回车添加,支持通配符 *') "
class = "mt-1"
/ >
<!-- Collapsed summary hea der ( clickable ) -- >
< div
class = "flex cursor-pointer select-none items-center gap-2"
@click ="collapsed = !collapsed"
>
< Icon
: name = "collapsed ? 'chevronRight' : 'chevronDown'"
size = "sm "
:stroke-width = "2 "
class = "flex-shrink-0 text-gray-400 transition-transform duration-200 "
/ >
<!-- Summary : model tags + billing badge -- >
< div v-if = "collapsed" class="flex min-w-0 flex-1 items-center gap-2 overflow-hidden" >
< ! - - Compact model tags ( show first 3 ) - - >
< div class = "flex min-w-0 flex-shrink items-center gap-1 overflow-hidden" >
< span
v-for = "(m, i) in entry.models.slice(0, 3)"
:key = "i"
class = "inline-flex max-w-[120px] truncate rounded bg-gray-200 px-1.5 py-0.5 text-xs text-gray-700 dark:bg-dark-600 dark:text-gray-300"
>
{ { m } }
< / span >
< span
v-if = "entry.models.length > 3"
class = "whitespace-nowrap text-xs text-gray-400"
>
+ { { entry . models . length - 3 } }
< / span >
< span
v-if = "entry.models.length === 0"
class = "text-xs italic text-gray-400"
>
{ { t ( 'admin.channels.form.noModels' , '未添加模型' ) } }
< / span >
< / div >
<!-- Billing mode badge -- >
< span
class = "flex-shrink-0 rounded-full bg-primary-100 px-2 py-0.5 text-xs font-medium text-primary-700 dark:bg-primary-900/30 dark:text-primary-300"
>
{ { billingModeLabel } }
< / span >
< / div >
< div class = "w-40" >
< label class = "text-xs font-medium text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.channels.form.billingMode' , '计费模式' ) } }
< / label >
< Select
:modelValue = "entry.billing_mode"
@ update : modelValue = "emit('update', { ...entry, billing_mode: $event as BillingMode, intervals: [] })"
:options = "billingModeOptions"
class = "mt-1"
/ >
<!-- Expanded : show the label "Pricing Entry" or similar -- >
< div v-else class = "flex-1 text-xs font-medium text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.channels.form.pricingEntry' , '定价配置' ) } }
< / div >
<!-- Remove button ( always visible , stop propagation ) -- >
< button
type = "button"
@click ="emit('remove')"
class = "mt-5 rounded p-1 text-gray-400 hover:text-red-500"
@click.stop ="emit('remove')"
class = "flex-shrink-0 rounded p-1 text-gray-400 hover:text-red-500"
>
< Icon name = "trash" size = "sm" / >
< / button >
< / div >
<!-- Token mode -- >
< div v-if = "entry.billing_mode === 'token'" >
< ! - - 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-4 00" > { { 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', '默认')" / >
<!-- Expandable content with transition -- >
< div
class = "collapsible-content"
: class = "{ 'collapsible-content--collapsed': collapsed }"
>
< div class = "collapsible-inner" >
<!-- Header : Models + Billing Mode -- >
< div class = "mt-3 flex items-start gap-2 " >
< div class = "flex-1" >
< label class = "text-xs font-medium text-gray-5 00 dark:text-gray-400 " >
{ { t ( 'admin.channels.form.models' , '模型列表' ) } }
< / label >
< 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' , '计费模式' ) } }
< / label >
< Select
:modelValue = "entry.billing_mode"
@ update : modelValue = "emit('update', { ...entry, billing_mode: $event as BillingMode, intervals: [] })"
:options = "billingModeOptions"
class = "mt-1"
/ >
< / div >
< / div >
< div >
< 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-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-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 >
<!-- Token intervals -- >
< 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' , '上下文区间定价(可选 ) ' ) } }
< span class = "ml-1 font-normal text-gray-400" > ( min , max ] < / span >
<!-- Token mode -- >
< div v-if = "entry.billing_mode === 'token'" >
< ! - - Default prices ( fallback when no interval matches ) - - >
< label class = "mt-3 block 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 >
< button type = "button" @click ="addInterval" class = "text-xs text-primary-600 hover:text-primary-700 ">
+ { { t ( 'admin.channels.form.addInterval' , '添加区间' ) } }
< / button >
< / div >
< div v-if = "entry.intervals && entry.intervals.length > 0" class="mt-2 space-y-2" >
< IntervalRow
v-for = "(iv, idx) in entry.intervals"
:key = "idx"
:inter val = "iv "
:mod e= "entry.billing_mode"
@update ="updateInterval(idx, $event)"
@remove ="removeInterval(idx)"
/ >
< / div >
< / div >
< / div >
< div class = "mt-1 grid grid-cols-2 gap-2 sm:grid-cols-5 ">
< div >
< 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-400" > { { t ( 'admin.channels.form.outputPrice' , '输出' ) } } < / label >
< input :value = "entry.output_price" @input ="emitField('output_price', ($event.target as HTMLInputElement).value) "
typ e = "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.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-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 >
<!-- 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' , '按次计费层级 ' ) } }
< / label >
< button type = "button" @click ="addInterval" class = "text-xs text-primary-600 hover:text-primary-700" >
+ { { t ( 'admin.channels.form.addTier' , '添加层级' ) } }
< / button >
< / div >
< div v-if = "entry.intervals && entry.intervals.length > 0" class="mt-2 space-y-2" >
< IntervalRow
v-for = "(iv, idx) in entry.i ntervals"
:key = "idx "
:interval = "iv "
:mode = "entry.billing_mode "
@update ="updateInterval(idx, $event) "
@remove ="remov eInterval(idx)"
/ >
< / 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' , '暂无层级,点击添加配置按次计费价格' ) } }
< / div >
< / div >
<!-- Token intervals -- >
< 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' , '上下文区间定价(可选) ' ) } }
< 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' , '添加区间' ) } }
< / button >
< / div >
< div v-if = "entry.intervals && entry.intervals.length > 0" class="mt-2 space-y-2" >
< I ntervalRow
v-for = "(iv, idx) in entry.intervals "
:key = "idx "
:interval = "iv "
:mode = "entry.billing_mode "
@update ="updat eInterval(idx, $event )"
@remove ="removeInterval(idx)"
/ >
< / div >
< / div >
< / div >
<!-- Image mode ( legacy p er- 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.image Tiers' , '图片 计费层级(按次) ' ) } }
< / label >
< button type = "button" @click ="addImageTier " class = "text-xs text-primary-600 hover:text-primary-700" >
+ { { t ( 'admin.channels.form.addTier' , '添加层级' ) } }
< / button >
< / div >
< div v-if = "entry.intervals && entry.intervals.length > 0" class="mt-2 space-y-2" >
< IntervalRow
v-for = "(iv, idx) in entry.intervals"
:key = "idx"
:interval = "iv"
:mode = "entry.billing_mode"
@update ="updateInterval(idx, $event)"
@remove ="removeInterval(idx)"
/ >
< / div >
< div v-else >
< div class = "mt-2 grid grid-cols-2 gap-2 sm:grid-cols-4" >
< div >
< 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', '默认')" / >
<!-- P er- request mode -- >
< div v-else-if = "entry.billing_mode === 'per_request '" >
< div class = "mt-3 flex items-center justify-between" >
< label class = "text-xs font-medium text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.channels.form.request Tiers' , '按次 计费层级' ) } }
< / label >
< button type = "button" @click ="addInterval " class = "text-xs text-primary-600 hover:text-primary-700" >
+ { { t ( 'admin.channels.form.addTier' , '添加层级' ) } }
< / button >
< / div >
< div v-if = "entry.intervals && entry.intervals.length > 0" class="mt-2 space-y-2" >
< IntervalRow
v-for = "(iv, idx) in entry.intervals"
:key = "idx"
:interval = "iv"
:mode = "entry.billing_mode"
@update ="updateInterval(idx, $event)"
@remove ="removeInterval(idx)"
/ >
< / 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' , '暂无层级,点击添加配置按次计费价格' ) } }
< / div >
< / div >
<!-- Image mode ( legacy per - request ) -- >
< div v-else-if = "entry.billing_mode === 'image'" >
< div class = "mt-3 flex items-center justify-between" >
< label class = "text-xs font-medium text-gray-500 dark:text-gray-400" >
{ { 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' , '添加层级' ) } }
< / button >
< / div >
< div v-if = "entry.intervals && entry.intervals.length > 0" class="mt-2 space-y-2" >
< IntervalRow
v-for = "(iv, idx) in entry.intervals"
:key = "idx"
:interval = "iv"
:mode = "entry.billing_mode"
@update ="updateInterval(idx, $event)"
@remove ="removeInterval(idx)"
/ >
< / div >
< div v-else >
< div class = "mt-2 grid grid-cols-2 gap-2 sm:grid-cols-4" >
< div >
< 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 >
< / div >
< / div >
@@ -151,7 +212,7 @@
< / template >
< script setup lang = "ts" >
import { computed } from 'vue'
import { ref , computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Select from '@/components/common/Select.vue'
import Icon from '@/components/icons/Icon.vue'
@@ -171,12 +232,20 @@ const emit = defineEmits<{
remove : [ ]
} > ( )
// Collapse state: entries with existing models default to collapsed
const collapsed = ref ( props . entry . models . length > 0 )
const billingModeOptions = computed ( ( ) => [
{ value : 'token' , label : 'Token' } ,
{ value : 'per_request' , label : t ( 'admin.channels.billingMode.perRequest' , '按次' ) } ,
{ value : 'image' , label : t ( 'admin.channels.billingMode.image' , '图片(按次)' ) }
] )
const billingModeLabel = computed ( ( ) => {
const opt = billingModeOptions . value . find ( o => o . value === props . entry . billing _mode )
return opt ? opt . label : props . entry . billing _mode
} )
function emitField ( field : keyof PricingFormEntry , value : string ) {
emit ( 'update' , { ... props . entry , [ field ] : value === '' ? null : value } )
}
@@ -216,3 +285,19 @@ function removeInterval(idx: number) {
emit ( 'update' , { ... props . entry , intervals } )
}
< / script >
< style scoped >
. collapsible - content {
display : grid ;
grid - template - rows : 1 fr ;
transition : grid - template - rows 0.25 s ease ;
}
. collapsible - content -- collapsed {
grid - template - rows : 0 fr ;
}
. collapsible - inner {
overflow : hidden ;
}
< / style >