"backend/internal/vscode:/vscode.git/clone" did not exist on "a9398d210b41a45d85485304e0d10addbd9659d8"
Commit dca0054e authored by erio's avatar erio
Browse files

feat(channel): 模型标签输入 + $/MTok 价格单位 + 左开右闭区间 + i18n

- 模型输入改为标签列表(输入回车添加,支持粘贴批量导入)
- 价格显示单位改为 $/MTok(每百万 token),提交时自动转换
- Token 模式增加图片输出价格字段(适配 Gemini 图片模型按 token 计费)
- 区间边界改为左开右闭 (min, max],右边界包含
- 默认价格作为未命中区间时的回退价格
- 添加完整中英文 i18n 翻译
parent 983fe589
......@@ -110,11 +110,12 @@ func (c *Channel) GetModelPricing(model string) *ChannelModelPricing {
}
// FindMatchingInterval 在区间列表中查找匹配 totalTokens 的区间。
// 通用辅助函数,供 GetIntervalForContext、ModelPricingResolver 等复用。
// 区间为左开右闭 (min, max]:min 不含,max 包含。
// 第一个区间 min=0 时,0 token 不匹配任何区间(回退到默认价格)。
func FindMatchingInterval(intervals []PricingInterval, totalTokens int) *PricingInterval {
for i := range intervals {
iv := &intervals[i]
if totalTokens >= iv.MinTokens && (iv.MaxTokens == nil || totalTokens < *iv.MaxTokens) {
if totalTokens > iv.MinTokens && (iv.MaxTokens == nil || totalTokens <= *iv.MaxTokens) {
return iv
}
}
......
......@@ -87,10 +87,13 @@ func TestGetIntervalForContext(t *testing.T) {
wantNil bool
}{
{"first interval", 50000, channelTestPtrFloat64(1e-6), false},
{"boundary: at min of second", 128000, channelTestPtrFloat64(2e-6), false},
{"boundary: at max of first (exclusive)", 128000, channelTestPtrFloat64(2e-6), false},
// (min, max] — 128000 在第一个区间的 max,包含,所以匹配第一个
{"boundary: max of first (inclusive)", 128000, channelTestPtrFloat64(1e-6), false},
// 128001 > 128000,匹配第二个区间
{"boundary: just above first max", 128001, channelTestPtrFloat64(2e-6), false},
{"unbounded interval", 500000, channelTestPtrFloat64(2e-6), false},
{"zero tokens", 0, channelTestPtrFloat64(1e-6), false},
// (0, max] — 0 不匹配任何区间(左开)
{"zero tokens: no match", 0, nil, true},
}
for _, tt := range tests {
......@@ -112,8 +115,10 @@ func TestGetIntervalForContext_NoMatch(t *testing.T) {
{MinTokens: 10000, MaxTokens: channelTestPtrInt(50000)},
},
}
require.Nil(t, p.GetIntervalForContext(5000))
require.Nil(t, p.GetIntervalForContext(50000))
require.Nil(t, p.GetIntervalForContext(5000)) // 5000 <= 10000, not > min
require.Nil(t, p.GetIntervalForContext(10000)) // 10000 not > 10000 (left-open)
require.NotNil(t, p.GetIntervalForContext(50000)) // 50000 <= 50000 (right-closed)
require.Nil(t, p.GetIntervalForContext(50001)) // 50001 > 50000
}
func TestGetIntervalForContext_Empty(t *testing.T) {
......
<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>
......
<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>
<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
}))
......
......@@ -335,6 +335,7 @@ export default {
profile: 'Profile',
users: 'Users',
groups: 'Groups',
channels: 'Channels',
subscriptions: 'Subscriptions',
accounts: 'Accounts',
proxies: 'Proxies',
......@@ -1719,6 +1720,79 @@ export default {
}
},
// Channel Management
channels: {
title: 'Channel Management',
description: 'Manage channels and custom model pricing',
searchChannels: 'Search channels...',
createChannel: 'Create Channel',
editChannel: 'Edit Channel',
deleteChannel: 'Delete Channel',
statusActive: 'Active',
statusDisabled: 'Disabled',
allStatus: 'All Status',
groupsUnit: 'groups',
pricingUnit: 'pricing rules',
noChannelsYet: 'No Channels Yet',
createFirstChannel: 'Create your first channel to manage model pricing',
loadError: 'Failed to load channels',
createSuccess: 'Channel created',
updateSuccess: 'Channel updated',
deleteSuccess: 'Channel deleted',
createError: 'Failed to create channel',
updateError: 'Failed to update channel',
deleteError: 'Failed to delete channel',
nameRequired: 'Please enter a channel name',
deleteConfirm: 'Are you sure you want to delete channel "{name}"? This cannot be undone.',
columns: {
name: 'Name',
description: 'Description',
status: 'Status',
groups: 'Groups',
pricing: 'Pricing',
createdAt: 'Created',
actions: 'Actions'
},
billingMode: {
token: 'Token',
perRequest: 'Per Request',
image: 'Image (Per Request)'
},
form: {
name: 'Name',
namePlaceholder: 'Enter channel name',
description: 'Description',
descriptionPlaceholder: 'Optional description',
status: 'Status',
groups: 'Associated Groups',
noGroupsAvailable: 'No groups available',
inOtherChannel: 'In "{name}"',
modelPricing: 'Model Pricing',
models: 'Models',
modelsPlaceholder: 'Type model name and press Enter. Supports wildcard *',
modelInputHint: 'Press Enter to add. Supports paste and wildcard *.',
billingMode: 'Billing Mode',
defaultPrices: 'Default prices (fallback when no interval matches)',
inputPrice: 'Input',
outputPrice: 'Output',
cacheWritePrice: 'Cache Write',
cacheReadPrice: 'Cache Read',
imageTokenPrice: 'Image Output',
imageOutputPrice: 'Image Output Price',
pricePlaceholder: 'Default',
intervals: 'Context Intervals (optional)',
addInterval: 'Add Interval',
requestTiers: 'Request Tiers',
imageTiers: 'Image Tiers (Per Request)',
addTier: 'Add Tier',
noTiersYet: 'No tiers yet. Click add to configure per-request pricing.',
noPricingRules: 'No pricing rules yet. Click "Add" to create one.',
perRequestPrice: 'Price per Request',
tierLabel: 'Tier',
resolution: 'Resolution'
}
},
// Subscriptions
subscriptions: {
title: 'Subscription Management',
......
......@@ -335,6 +335,7 @@ export default {
profile: '个人资料',
users: '用户管理',
groups: '分组管理',
channels: '渠道管理',
subscriptions: '订阅管理',
accounts: '账号管理',
proxies: 'IP管理',
......@@ -1799,6 +1800,79 @@ export default {
}
},
// Channel Management
channels: {
title: '渠道管理',
description: '管理渠道和自定义模型定价',
searchChannels: '搜索渠道...',
createChannel: '创建渠道',
editChannel: '编辑渠道',
deleteChannel: '删除渠道',
statusActive: '启用',
statusDisabled: '停用',
allStatus: '全部状态',
groupsUnit: '个分组',
pricingUnit: '条定价',
noChannelsYet: '暂无渠道',
createFirstChannel: '创建第一个渠道来管理模型定价',
loadError: '加载渠道列表失败',
createSuccess: '渠道创建成功',
updateSuccess: '渠道更新成功',
deleteSuccess: '渠道删除成功',
createError: '创建渠道失败',
updateError: '更新渠道失败',
deleteError: '删除渠道失败',
nameRequired: '请输入渠道名称',
deleteConfirm: '确定要删除渠道「{name}」吗?此操作不可撤销。',
columns: {
name: '名称',
description: '描述',
status: '状态',
groups: '分组',
pricing: '定价',
createdAt: '创建时间',
actions: '操作'
},
billingMode: {
token: 'Token',
perRequest: '按次',
image: '图片(按次)'
},
form: {
name: '名称',
namePlaceholder: '输入渠道名称',
description: '描述',
descriptionPlaceholder: '可选描述',
status: '状态',
groups: '关联分组',
noGroupsAvailable: '暂无可用分组',
inOtherChannel: '已属于「{name}」',
modelPricing: '模型定价',
models: '模型列表',
modelsPlaceholder: '输入模型名后按回车添加,支持通配符 *',
modelInputHint: '按回车添加,支持粘贴批量导入,支持通配符 *',
billingMode: '计费模式',
defaultPrices: '默认价格(未命中区间时使用)',
inputPrice: '输入',
outputPrice: '输出',
cacheWritePrice: '缓存写入',
cacheReadPrice: '缓存读取',
imageTokenPrice: '图片输出',
imageOutputPrice: '图片输出价格',
pricePlaceholder: '默认',
intervals: '上下文区间定价(可选)',
addInterval: '添加区间',
requestTiers: '按次计费层级',
imageTiers: '图片计费层级(按次)',
addTier: '添加层级',
noTiersYet: '暂无层级,点击添加配置按次计费价格',
noPricingRules: '暂无定价规则,点击"添加"创建',
perRequestPrice: '单次价格',
tierLabel: '层级',
resolution: '分辨率'
}
},
// Subscriptions Management
subscriptions: {
title: '订阅管理',
......
......@@ -287,7 +287,7 @@ import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
import type { Channel, ChannelModelPricing, CreateChannelRequest, UpdateChannelRequest } from '@/api/admin/channels'
import type { PricingFormEntry } from '@/components/admin/channel/types'
import { toNullableNumber, apiIntervalsToForm, formIntervalsToAPI } from '@/components/admin/channel/types'
import { mTokToPerToken, perTokenToMTok, apiIntervalsToForm, formIntervalsToAPI } from '@/components/admin/channel/types'
import type { AdminGroup } from '@/types'
import type { Column } from '@/components/common/types'
import AppLayout from '@/components/layout/AppLayout.vue'
......@@ -412,13 +412,12 @@ function toggleGroup(groupId: number) {
// ── Pricing helpers ──
function addPricingEntry() {
form.model_pricing.push({
modelsInput: '',
models: [],
billing_mode: 'token',
input_price: null,
output_price: null,
cache_write_price: null,
cache_read_price: null,
per_request_price: null,
image_output_price: null,
intervals: []
})
......@@ -434,29 +433,28 @@ function removePricingEntry(idx: number) {
function formPricingToAPI(): ChannelModelPricing[] {
return form.model_pricing
.filter(e => e.modelsInput.trim())
.filter(e => e.models.length > 0)
.map(e => ({
models: e.modelsInput.split(',').map(m => m.trim()).filter(Boolean),
models: e.models,
billing_mode: e.billing_mode,
input_price: toNullableNumber(e.input_price),
output_price: toNullableNumber(e.output_price),
cache_write_price: toNullableNumber(e.cache_write_price),
cache_read_price: toNullableNumber(e.cache_read_price),
image_output_price: toNullableNumber(e.image_output_price),
input_price: mTokToPerToken(e.input_price),
output_price: mTokToPerToken(e.output_price),
cache_write_price: mTokToPerToken(e.cache_write_price),
cache_read_price: mTokToPerToken(e.cache_read_price),
image_output_price: mTokToPerToken(e.image_output_price),
intervals: formIntervalsToAPI(e.intervals || [])
}))
}
function apiPricingToForm(pricing: ChannelModelPricing[]): PricingFormEntry[] {
return pricing.map(p => ({
modelsInput: p.models.join(', '),
models: p.models || [],
billing_mode: p.billing_mode,
input_price: p.input_price,
output_price: p.output_price,
cache_write_price: p.cache_write_price,
cache_read_price: p.cache_read_price,
per_request_price: null,
image_output_price: p.image_output_price,
input_price: perTokenToMTok(p.input_price),
output_price: perTokenToMTok(p.output_price),
cache_write_price: perTokenToMTok(p.cache_write_price),
cache_read_price: perTokenToMTok(p.cache_read_price),
image_output_price: perTokenToMTok(p.image_output_price),
intervals: apiIntervalsToForm(p.intervals || [])
}))
}
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment