Unverified Commit bf455811 authored by Wesley Liddick's avatar Wesley Liddick Committed by GitHub
Browse files

Merge pull request #1455 from touwaeriol/feat/channel-management

feat(channel): add channel management with multi-mode pricing and billing integration
parents b384570d e88b2890
...@@ -167,6 +167,13 @@ export interface UserBreakdownParams { ...@@ -167,6 +167,13 @@ export interface UserBreakdownParams {
endpoint?: string endpoint?: string
endpoint_type?: 'inbound' | 'upstream' | 'path' endpoint_type?: 'inbound' | 'upstream' | 'path'
limit?: number limit?: number
// Additional filter conditions
user_id?: number
api_key_id?: number
account_id?: number
request_type?: number
stream?: boolean
billing_type?: number | null
} }
export interface UserBreakdownResponse { export interface UserBreakdownResponse {
......
...@@ -25,6 +25,7 @@ import apiKeysAPI from './apiKeys' ...@@ -25,6 +25,7 @@ import apiKeysAPI from './apiKeys'
import scheduledTestsAPI from './scheduledTests' import scheduledTestsAPI from './scheduledTests'
import backupAPI from './backup' import backupAPI from './backup'
import tlsFingerprintProfileAPI from './tlsFingerprintProfile' import tlsFingerprintProfileAPI from './tlsFingerprintProfile'
import channelsAPI from './channels'
/** /**
* Unified admin API object for convenient access * Unified admin API object for convenient access
...@@ -51,7 +52,8 @@ export const adminAPI = { ...@@ -51,7 +52,8 @@ export const adminAPI = {
apiKeys: apiKeysAPI, apiKeys: apiKeysAPI,
scheduledTests: scheduledTestsAPI, scheduledTests: scheduledTestsAPI,
backup: backupAPI, backup: backupAPI,
tlsFingerprintProfiles: tlsFingerprintProfileAPI tlsFingerprintProfiles: tlsFingerprintProfileAPI,
channels: channelsAPI
} }
export { export {
...@@ -76,7 +78,8 @@ export { ...@@ -76,7 +78,8 @@ export {
apiKeysAPI, apiKeysAPI,
scheduledTestsAPI, scheduledTestsAPI,
backupAPI, backupAPI,
tlsFingerprintProfileAPI tlsFingerprintProfileAPI,
channelsAPI
} }
export default adminAPI export default adminAPI
......
...@@ -80,6 +80,7 @@ export interface CreateUsageCleanupTaskRequest { ...@@ -80,6 +80,7 @@ export interface CreateUsageCleanupTaskRequest {
export interface AdminUsageQueryParams extends UsageQueryParams { export interface AdminUsageQueryParams extends UsageQueryParams {
user_id?: number user_id?: number
exact_total?: boolean exact_total?: boolean
billing_mode?: string
} }
// ==================== API Functions ==================== // ==================== API Functions ====================
......
<template>
<div class="flex items-start gap-2 rounded border p-2"
:class="isEmpty ? 'border-red-400 bg-red-50 dark:border-red-500 dark:bg-red-950/20' : 'border-gray-200 bg-white dark:border-dark-500 dark:bg-dark-700'">
<!-- Token mode: context range + prices ($/MTok) -->
<template v-if="mode === 'token'">
<div class="w-20">
<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">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', '输入') }} <span v-if="isEmpty" class="text-red-500">*</span> <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', '输出') }} <span v-if="isEmpty" class="text-red-500">*</span> <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', '缓存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', '缓存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 + context range + price -->
<template v-else>
<div class="w-24">
<label class="text-xs text-gray-400">
{{ 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' : ''" />
</div>
<div class="w-20">
<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">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', '单次价格') }} <span v-if="isEmpty" class="text-red-500">*</span> <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">
<Icon name="x" size="sm" />
</button>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Icon from '@/components/icons/Icon.vue'
import type { IntervalFormEntry } from './types'
import type { BillingMode } from '@/api/admin/channels'
const { t } = useI18n()
const props = defineProps<{
interval: IntervalFormEntry
mode: BillingMode
}>()
const emit = defineEmits<{
update: [interval: IntervalFormEntry]
remove: []
}>()
// 检测所有价格字段是否都为空
const isEmpty = computed(() => {
const iv = props.interval
return (iv.input_price == null || iv.input_price === '') &&
(iv.output_price == null || iv.output_price === '') &&
(iv.cache_write_price == null || iv.cache_write_price === '') &&
(iv.cache_read_price == null || iv.cache_read_price === '') &&
(iv.per_request_price == null || iv.per_request_price === '')
})
function emitField(field: keyof IntervalFormEntry, value: string | number | null) {
emit('update', { ...props.interval, [field]: value === '' ? null : value })
}
function toInt(val: string): number {
const n = parseInt(val, 10)
return isNaN(n) ? 0 : n
}
function toIntOrNull(val: string): number | null {
if (val === '') return null
const n = parseInt(val, 10)
return isNaN(n) ? null : n
}
</script>
<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 px-2 py-0.5 text-sm"
:class="getPlatformTagClass(props.platform || '')"
>
{{ 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 paste for batch import.') }}
</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Icon from '@/components/icons/Icon.vue'
import { getPlatformTagClass } from './types'
const { t } = useI18n()
const props = defineProps<{
models: string[]
placeholder?: string
platform?: 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">
<!-- Collapsed summary header (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-1 flex-wrap items-center gap-1">
<span
v-for="(m, i) in entry.models.slice(0, 3)"
:key="i"
class="inline-flex shrink-0 rounded px-1.5 py-0.5 text-xs"
:class="getPlatformTagClass(props.platform || '')"
>
{{ 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>
<!-- 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.stop="emit('remove')"
class="flex-shrink-0 rounded p-1 text-gray-400 hover:text-red-500"
>
<Icon name="trash" size="sm" />
</button>
</div>
<!-- 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-500 dark:text-gray-400">
{{ t('admin.channels.form.models', '模型列表') }} <span class="text-red-500">*</span>
</label>
<ModelTagInput
:models="entry.models"
:platform="props.platform"
@update:models="onModelsUpdate($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>
<!-- 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>
<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)"
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>
</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"
:interval="iv"
:mode="entry.billing_mode"
@update="updateInterval(idx, $event)"
@remove="removeInterval(idx)"
/>
</div>
</div>
</div>
<!-- Per-request mode -->
<div v-else-if="entry.billing_mode === 'per_request'">
<!-- Default per-request price -->
<label class="mt-3 block text-xs font-medium text-gray-500 dark:text-gray-400">
{{ t('admin.channels.form.defaultPerRequestPrice', '默认单次价格(未命中层级时使用)') }}
<span class="ml-1 font-normal text-gray-400">$</span>
</label>
<div class="mt-1 w-48">
<input :value="entry.per_request_price" @input="emitField('per_request_price', ($event.target as HTMLInputElement).value)"
type="number" step="any" min="0" class="input text-sm" :placeholder="t('admin.channels.form.pricePlaceholder', '默认')" />
</div>
<!-- Tiers -->
<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.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.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 -->
<div v-else-if="entry.billing_mode === 'image'">
<!-- Default image price (per-request, same as per_request mode) -->
<label class="mt-3 block text-xs font-medium text-gray-500 dark:text-gray-400">
{{ t('admin.channels.form.defaultImagePrice', '默认图片价格(未命中层级时使用)') }}
<span class="ml-1 font-normal text-gray-400">$</span>
</label>
<div class="mt-1 w-48">
<input :value="entry.per_request_price" @input="emitField('per_request_price', ($event.target as HTMLInputElement).value)"
type="number" step="any" min="0" class="input text-sm" :placeholder="t('admin.channels.form.pricePlaceholder', '默认')" />
</div>
<!-- Image tiers -->
<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>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
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 { perTokenToMTok, getPlatformTagClass } from './types'
import type { BillingMode } from '@/api/admin/channels'
import channelsAPI from '@/api/admin/channels'
const { t } = useI18n()
const props = defineProps<{
entry: PricingFormEntry
platform?: string
}>()
const emit = defineEmits<{
update: [entry: PricingFormEntry]
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 })
}
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,
sort_order: intervals.length
})
emit('update', { ...props.entry, intervals })
}
function addImageTier() {
const intervals = [...(props.entry.intervals || [])]
const labels = ['1K', '2K', '4K', 'HD']
intervals.push({
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 })
}
function updateInterval(idx: number, updated: IntervalFormEntry) {
const intervals = [...(props.entry.intervals || [])]
intervals[idx] = updated
emit('update', { ...props.entry, intervals })
}
function removeInterval(idx: number) {
const intervals = [...(props.entry.intervals || [])]
intervals.splice(idx, 1)
emit('update', { ...props.entry, intervals })
}
async function onModelsUpdate(newModels: string[]) {
const oldModels = props.entry.models
emit('update', { ...props.entry, models: newModels })
// 只在新增模型且当前无价格时自动填充
const addedModels = newModels.filter(m => !oldModels.includes(m))
if (addedModels.length === 0) return
// 检查是否所有价格字段都为空
const e = props.entry
const hasPrice = e.input_price != null || e.output_price != null ||
e.cache_write_price != null || e.cache_read_price != null
if (hasPrice) return
// 查询第一个新增模型的默认价格
try {
const result = await channelsAPI.getModelDefaultPricing(addedModels[0])
if (result.found) {
emit('update', {
...props.entry,
models: newModels,
input_price: perTokenToMTok(result.input_price ?? null),
output_price: perTokenToMTok(result.output_price ?? null),
cache_write_price: perTokenToMTok(result.cache_write_price ?? null),
cache_read_price: perTokenToMTok(result.cache_read_price ?? null),
image_output_price: perTokenToMTok(result.image_output_price ?? null),
})
}
} catch {
// 查询失败不影响用户操作
}
}
</script>
<style scoped>
.collapsible-content {
display: grid;
grid-template-rows: 1fr;
transition: grid-template-rows 0.25s ease;
}
.collapsible-content--collapsed {
grid-template-rows: 0fr;
}
.collapsible-inner {
overflow: hidden;
}
</style>
import type { BillingMode, PricingInterval } from '@/api/admin/channels'
export interface IntervalFormEntry {
min_tokens: number
max_tokens: number | null
tier_label: string
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
sort_order: number
}
export interface PricingFormEntry {
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
image_output_price: number | string | null
per_request_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 : parseFloat((num / MTOK).toPrecision(10))
}
/** 后端存储值(per-token) → 前端显示值($/MTok) */
export function perTokenToMTok(val: number | null | undefined): number | null {
if (val === null || val === undefined) return null
// toPrecision(10) 消除 IEEE 754 浮点乘法精度误差,如 5e-8 * 1e6 = 0.04999...96 → 0.05
return parseFloat((val * MTOK).toPrecision(10))
}
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: 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
}))
}
export function formIntervalsToAPI(intervals: IntervalFormEntry[]): PricingInterval[] {
return (intervals || []).map(iv => ({
min_tokens: iv.min_tokens,
max_tokens: iv.max_tokens,
tier_label: iv.tier_label,
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
}))
}
// ── 模型模式冲突检测 ──────────────────────────────────────
interface ModelPattern {
pattern: string
prefix: string // lowercase, 通配符去掉尾部 *
wildcard: boolean
}
function toModelPattern(model: string): ModelPattern {
const lower = model.toLowerCase()
const wildcard = lower.endsWith('*')
return {
pattern: model,
prefix: wildcard ? lower.slice(0, -1) : lower,
wildcard,
}
}
function patternsConflict(a: ModelPattern, b: ModelPattern): boolean {
if (!a.wildcard && !b.wildcard) return a.prefix === b.prefix
if (a.wildcard && !b.wildcard) return b.prefix.startsWith(a.prefix)
if (!a.wildcard && b.wildcard) return a.prefix.startsWith(b.prefix)
// 双通配符:任一前缀是另一前缀的前缀即冲突
return a.prefix.startsWith(b.prefix) || b.prefix.startsWith(a.prefix)
}
/** 检测模型模式列表中的冲突,返回冲突的两个模式名;无冲突返回 null */
export function findModelConflict(models: string[]): [string, string] | null {
const patterns = models.map(toModelPattern)
for (let i = 0; i < patterns.length; i++) {
for (let j = i + 1; j < patterns.length; j++) {
if (patternsConflict(patterns[i], patterns[j])) {
return [patterns[i].pattern, patterns[j].pattern]
}
}
}
return null
}
// ── 区间校验 ──────────────────────────────────────────────
/** 校验区间列表的合法性,返回错误消息;通过则返回 null */
export function validateIntervals(intervals: IntervalFormEntry[]): string | null {
if (!intervals || intervals.length === 0) return null
// 按 min_tokens 排序(不修改原数组)
const sorted = [...intervals].sort((a, b) => a.min_tokens - b.min_tokens)
for (let i = 0; i < sorted.length; i++) {
const err = validateSingleInterval(sorted[i], i)
if (err) return err
}
return checkIntervalOverlap(sorted)
}
function validateSingleInterval(iv: IntervalFormEntry, idx: number): string | null {
if (iv.min_tokens < 0) {
return `区间 #${idx + 1}: 最小 token 数 (${iv.min_tokens}) 不能为负数`
}
if (iv.max_tokens != null) {
if (iv.max_tokens <= 0) {
return `区间 #${idx + 1}: 最大 token 数 (${iv.max_tokens}) 必须大于 0`
}
if (iv.max_tokens <= iv.min_tokens) {
return `区间 #${idx + 1}: 最大 token 数 (${iv.max_tokens}) 必须大于最小 token 数 (${iv.min_tokens})`
}
}
return validateIntervalPrices(iv, idx)
}
function validateIntervalPrices(iv: IntervalFormEntry, idx: number): string | null {
const prices: [string, number | string | null][] = [
['输入价格', iv.input_price],
['输出价格', iv.output_price],
['缓存写入价格', iv.cache_write_price],
['缓存读取价格', iv.cache_read_price],
['单次价格', iv.per_request_price],
]
for (const [name, val] of prices) {
if (val != null && val !== '' && Number(val) < 0) {
return `区间 #${idx + 1}: ${name}不能为负数`
}
}
return null
}
function checkIntervalOverlap(sorted: IntervalFormEntry[]): string | null {
for (let i = 0; i < sorted.length; i++) {
// 无上限区间必须是最后一个
if (sorted[i].max_tokens == null && i < sorted.length - 1) {
return `区间 #${i + 1}: 无上限区间(最大 token 数为空)只能是最后一个`
}
if (i === 0) continue
const prev = sorted[i - 1]
// (min, max] 语义:前一个区间上界 > 当前区间下界则重叠
if (prev.max_tokens == null || prev.max_tokens > sorted[i].min_tokens) {
const prevMax = prev.max_tokens == null ? '' : String(prev.max_tokens)
return `区间 #${i} 和 #${i + 1} 重叠:前一个区间上界 (${prevMax}) 大于当前区间下界 (${sorted[i].min_tokens})`
}
}
return null
}
/** 平台对应的模型 tag 样式(背景+文字) */
export function getPlatformTagClass(platform: string): string {
switch (platform) {
case 'anthropic': return 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
case 'openai': return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
case 'gemini': return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
case 'antigravity': return 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
case 'sora': return 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-400'
default: return 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400'
}
}
...@@ -133,6 +133,12 @@ ...@@ -133,6 +133,12 @@
<Select v-model="filters.billing_type" :options="billingTypeOptions" @change="emitChange" /> <Select v-model="filters.billing_type" :options="billingTypeOptions" @change="emitChange" />
</div> </div>
<!-- Billing Mode Filter -->
<div class="w-full sm:w-auto sm:min-w-[200px]">
<label class="input-label">{{ t('admin.usage.billingMode') }}</label>
<Select v-model="filters.billing_mode" :options="billingModeOptions" @change="emitChange" />
</div>
<!-- Group Filter --> <!-- Group Filter -->
<div class="w-full sm:w-auto sm:min-w-[200px]"> <div class="w-full sm:w-auto sm:min-w-[200px]">
<label class="input-label">{{ t('admin.usage.group') }}</label> <label class="input-label">{{ t('admin.usage.group') }}</label>
...@@ -232,6 +238,13 @@ const billingTypeOptions = ref<SelectOption[]>([ ...@@ -232,6 +238,13 @@ const billingTypeOptions = ref<SelectOption[]>([
{ value: 1, label: t('admin.usage.billingTypeSubscription') } { value: 1, label: t('admin.usage.billingTypeSubscription') }
]) ])
const billingModeOptions = ref<SelectOption[]>([
{ value: null, label: t('admin.usage.allBillingModes') },
{ value: 'token', label: t('admin.usage.billingModeToken') },
{ value: 'per_request', label: t('admin.usage.billingModePerRequest') },
{ value: 'image', label: t('admin.usage.billingModeImage') }
])
const emitChange = () => emit('change') const emitChange = () => emit('change')
const debounceUserSearch = () => { const debounceUserSearch = () => {
......
...@@ -26,7 +26,15 @@ ...@@ -26,7 +26,15 @@
</template> </template>
<template #cell-model="{ row }"> <template #cell-model="{ row }">
<div v-if="row.upstream_model && row.upstream_model !== row.model" class="space-y-0.5 text-xs"> <div v-if="row.model_mapping_chain && row.model_mapping_chain.includes('→')" class="space-y-0.5 text-xs">
<div v-for="(step, i) in row.model_mapping_chain.split('→')" :key="i"
class="break-all"
:class="i === 0 ? 'font-medium text-gray-900 dark:text-white' : 'text-gray-500 dark:text-gray-400'"
:style="i > 0 ? `padding-left: ${i * 0.75}rem` : ''">
<span v-if="i > 0" class="mr-0.5"></span>{{ step }}
</div>
</div>
<div v-else-if="row.upstream_model && row.upstream_model !== row.model" class="space-y-0.5 text-xs">
<div class="break-all font-medium text-gray-900 dark:text-white"> <div class="break-all font-medium text-gray-900 dark:text-white">
{{ row.model }} {{ row.model }}
</div> </div>
...@@ -69,9 +77,15 @@ ...@@ -69,9 +77,15 @@
</span> </span>
</template> </template>
<template #cell-billing_mode="{ row }">
<span class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium" :class="getBillingModeBadgeClass(row.billing_mode)">
{{ getBillingModeLabel(row.billing_mode) }}
</span>
</template>
<template #cell-tokens="{ row }"> <template #cell-tokens="{ row }">
<!-- 图片生成请求 --> <!-- 图片生成请求(仅按次计费时显示图片格式) -->
<div v-if="row.image_count > 0" class="flex items-center gap-1.5"> <div v-if="row.image_count > 0 && row.billing_mode === 'image'" class="flex items-center gap-1.5">
<svg class="h-4 w-4 text-indigo-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="h-4 w-4 text-indigo-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg> </svg>
...@@ -281,11 +295,11 @@ ...@@ -281,11 +295,11 @@
</div> </div>
<div class="flex items-center justify-between gap-6"> <div class="flex items-center justify-between gap-6">
<span class="text-gray-400">{{ t('usage.rate') }}</span> <span class="text-gray-400">{{ t('usage.rate') }}</span>
<span class="font-semibold text-blue-400">{{ (tooltipData?.rate_multiplier || 1).toFixed(2) }}x</span> <span class="font-semibold text-blue-400">{{ formatMultiplier(tooltipData?.rate_multiplier || 1) }}x</span>
</div> </div>
<div class="flex items-center justify-between gap-6"> <div class="flex items-center justify-between gap-6">
<span class="text-gray-400">{{ t('usage.accountMultiplier') }}</span> <span class="text-gray-400">{{ t('usage.accountMultiplier') }}</span>
<span class="font-semibold text-blue-400">{{ (tooltipData?.account_rate_multiplier ?? 1).toFixed(2) }}x</span> <span class="font-semibold text-blue-400">{{ formatMultiplier(tooltipData?.account_rate_multiplier ?? 1) }}x</span>
</div> </div>
<div class="flex items-center justify-between gap-6"> <div class="flex items-center justify-between gap-6">
<span class="text-gray-400">{{ t('usage.original') }}</span> <span class="text-gray-400">{{ t('usage.original') }}</span>
...@@ -312,6 +326,7 @@ ...@@ -312,6 +326,7 @@
import { ref } from 'vue' import { ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { formatDateTime, formatReasoningEffort } from '@/utils/format' import { formatDateTime, formatReasoningEffort } from '@/utils/format'
import { formatCacheTokens, formatMultiplier } from '@/utils/formatters'
import { formatTokenPricePerMillion } from '@/utils/usagePricing' import { formatTokenPricePerMillion } from '@/utils/usagePricing'
import { getUsageServiceTierLabel } from '@/utils/usageServiceTier' import { getUsageServiceTierLabel } from '@/utils/usageServiceTier'
import { resolveUsageRequestType } from '@/utils/usageRequestType' import { resolveUsageRequestType } from '@/utils/usageRequestType'
...@@ -350,12 +365,19 @@ const getRequestTypeBadgeClass = (row: AdminUsageLog): string => { ...@@ -350,12 +365,19 @@ const getRequestTypeBadgeClass = (row: AdminUsageLog): string => {
return 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200' return 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200'
} }
const formatCacheTokens = (tokens: number): string => { const getBillingModeLabel = (mode: string | null | undefined): string => {
if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M` if (mode === 'per_request') return t('admin.usage.billingModePerRequest')
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K` if (mode === 'image') return t('admin.usage.billingModeImage')
return tokens.toString() return t('admin.usage.billingModeToken')
}
const getBillingModeBadgeClass = (mode: string | null | undefined): string => {
if (mode === 'per_request') return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
if (mode === 'image') return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200'
} }
const formatUserAgent = (ua: string): string => { const formatUserAgent = (ua: string): string => {
return ua return ua
} }
......
...@@ -161,6 +161,7 @@ const props = withDefaults( ...@@ -161,6 +161,7 @@ const props = withDefaults(
showSourceToggle?: boolean showSourceToggle?: boolean
startDate?: string startDate?: string
endDate?: string endDate?: string
filters?: Record<string, any>
}>(), }>(),
{ {
upstreamEndpointStats: () => [], upstreamEndpointStats: () => [],
...@@ -193,6 +194,7 @@ const toggleBreakdown = async (endpoint: string) => { ...@@ -193,6 +194,7 @@ const toggleBreakdown = async (endpoint: string) => {
breakdownItems.value = [] breakdownItems.value = []
try { try {
const res = await getUserBreakdown({ const res = await getUserBreakdown({
...props.filters,
start_date: props.startDate, start_date: props.startDate,
end_date: props.endDate, end_date: props.endDate,
endpoint, endpoint,
......
...@@ -125,6 +125,7 @@ const props = withDefaults(defineProps<{ ...@@ -125,6 +125,7 @@ const props = withDefaults(defineProps<{
showMetricToggle?: boolean showMetricToggle?: boolean
startDate?: string startDate?: string
endDate?: string endDate?: string
filters?: Record<string, any>
}>(), { }>(), {
loading: false, loading: false,
metric: 'tokens', metric: 'tokens',
...@@ -150,6 +151,7 @@ const toggleBreakdown = async (type: string, id: number | string) => { ...@@ -150,6 +151,7 @@ const toggleBreakdown = async (type: string, id: number | string) => {
breakdownItems.value = [] breakdownItems.value = []
try { try {
const res = await getUserBreakdown({ const res = await getUserBreakdown({
...props.filters,
start_date: props.startDate, start_date: props.startDate,
end_date: props.endDate, end_date: props.endDate,
group_id: Number(id), group_id: Number(id),
......
...@@ -270,6 +270,7 @@ const props = withDefaults(defineProps<{ ...@@ -270,6 +270,7 @@ const props = withDefaults(defineProps<{
rankingError?: boolean rankingError?: boolean
startDate?: string startDate?: string
endDate?: string endDate?: string
filters?: Record<string, any>
}>(), { }>(), {
upstreamModelStats: () => [], upstreamModelStats: () => [],
mappingModelStats: () => [], mappingModelStats: () => [],
...@@ -302,6 +303,7 @@ const toggleBreakdown = async (type: string, id: string) => { ...@@ -302,6 +303,7 @@ const toggleBreakdown = async (type: string, id: string) => {
breakdownItems.value = [] breakdownItems.value = []
try { try {
const res = await getUserBreakdown({ const res = await getUserBreakdown({
...props.filters,
start_date: props.startDate, start_date: props.startDate,
end_date: props.endDate, end_date: props.endDate,
model: id, model: id,
......
...@@ -287,6 +287,21 @@ const FolderIcon = { ...@@ -287,6 +287,21 @@ const FolderIcon = {
) )
} }
const ChannelIcon = {
render: () =>
h(
'svg',
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
[
h('path', {
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
d: 'M6.429 9.75L2.25 12l4.179 2.25m0-4.5l5.571 3 5.571-3m-11.142 0L2.25 7.5 12 2.25l9.75 5.25-4.179 2.25m0 0l4.179 2.25L12 17.25 2.25 12m15.321-2.25l4.179 2.25L12 17.25l-9.75-5.25'
})
]
)
}
const CreditCardIcon = { const CreditCardIcon = {
render: () => render: () =>
h( h(
...@@ -568,6 +583,7 @@ const adminNavItems = computed((): NavItem[] => { ...@@ -568,6 +583,7 @@ const adminNavItems = computed((): NavItem[] => {
: []), : []),
{ path: '/admin/users', label: t('nav.users'), icon: UsersIcon, hideInSimpleMode: true }, { path: '/admin/users', label: t('nav.users'), icon: UsersIcon, hideInSimpleMode: true },
{ path: '/admin/groups', label: t('nav.groups'), icon: FolderIcon, hideInSimpleMode: true }, { path: '/admin/groups', label: t('nav.groups'), icon: FolderIcon, hideInSimpleMode: true },
{ path: '/admin/channels', label: t('nav.channels', '渠道管理'), icon: ChannelIcon, hideInSimpleMode: true },
{ path: '/admin/subscriptions', label: t('nav.subscriptions'), icon: CreditCardIcon, hideInSimpleMode: true }, { path: '/admin/subscriptions', label: t('nav.subscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
{ path: '/admin/accounts', label: t('nav.accounts'), icon: GlobeIcon }, { path: '/admin/accounts', label: t('nav.accounts'), icon: GlobeIcon },
{ path: '/admin/announcements', label: t('nav.announcements'), icon: BellIcon }, { path: '/admin/announcements', label: t('nav.announcements'), icon: BellIcon },
......
...@@ -335,6 +335,7 @@ export default { ...@@ -335,6 +335,7 @@ export default {
profile: 'Profile', profile: 'Profile',
users: 'Users', users: 'Users',
groups: 'Groups', groups: 'Groups',
channels: 'Channels',
subscriptions: 'Subscriptions', subscriptions: 'Subscriptions',
accounts: 'Accounts', accounts: 'Accounts',
proxies: 'Proxies', proxies: 'Proxies',
...@@ -1719,6 +1720,107 @@ export default { ...@@ -1719,6 +1720,107 @@ 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',
duplicateModels: 'Model "{0}" appears in multiple pricing entries',
modelConflict: "Model patterns '{model1}' and '{model2}' conflict: overlapping match range",
mappingConflict: "Mapping source patterns '{model1}' and '{model2}' conflict: overlapping match range",
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 full model name and press Enter',
modelInputHint: 'Press Enter to add, supports paste for batch import.',
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',
perRequestPriceRequired: 'Per-request price or billing tiers required for per-request/image billing mode',
tierLabel: 'Tier',
resolution: 'Resolution',
modelMapping: 'Model Mapping',
modelMappingHint: 'Map request model names to actual model names. Runs before account-level mapping.',
noMappingRules: 'No mapping rules. Click "Add" to create one.',
mappingSource: 'Source model',
mappingTarget: 'Target model',
billingModelSource: 'Billing Model',
billingModelSourceChannelMapped: 'Bill by channel-mapped model',
billingModelSourceRequested: 'Bill by requested model',
billingModelSourceUpstream: 'Bill by final upstream model',
billingModelSourceHint: 'Controls which model name is used for pricing lookup',
selectedCount: '{count} selected',
searchGroups: 'Search groups...',
noGroupsMatch: 'No groups match your search',
restrictModels: 'Restrict Models',
restrictModelsHint: 'When enabled, only models in the pricing list are allowed. Others will be rejected.',
defaultPerRequestPrice: 'Default per-request price (fallback when no tier matches)',
defaultImagePrice: 'Default image price (fallback when no tier matches)',
platformConfig: 'Platform Configuration',
basicSettings: 'Basic Settings',
addPlatform: 'Add Platform',
noPlatforms: 'Click "Add Platform" to start configuring the channel',
mappingCount: 'mappings',
pricingEntry: 'Pricing Entry',
noModels: 'No models added'
}
},
// Subscriptions // Subscriptions
subscriptions: { subscriptions: {
title: 'Subscription Management', title: 'Subscription Management',
...@@ -3258,6 +3360,11 @@ export default { ...@@ -3258,6 +3360,11 @@ export default {
allBillingTypes: 'All Billing Types', allBillingTypes: 'All Billing Types',
billingTypeBalance: 'Balance', billingTypeBalance: 'Balance',
billingTypeSubscription: 'Subscription', billingTypeSubscription: 'Subscription',
billingMode: 'Billing Mode',
billingModeToken: 'Token',
billingModePerRequest: 'Per Request',
billingModeImage: 'Image',
allBillingModes: 'All Billing Modes',
ipAddress: 'IP', ipAddress: 'IP',
clickToViewBalance: 'Click to view balance history', clickToViewBalance: 'Click to view balance history',
failedToLoadUser: 'Failed to load user info', failedToLoadUser: 'Failed to load user info',
......
...@@ -335,6 +335,7 @@ export default { ...@@ -335,6 +335,7 @@ export default {
profile: '个人资料', profile: '个人资料',
users: '用户管理', users: '用户管理',
groups: '分组管理', groups: '分组管理',
channels: '渠道管理',
subscriptions: '订阅管理', subscriptions: '订阅管理',
accounts: '账号管理', accounts: '账号管理',
proxies: 'IP管理', proxies: 'IP管理',
...@@ -1799,6 +1800,107 @@ export default { ...@@ -1799,6 +1800,107 @@ 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: '请输入渠道名称',
duplicateModels: '模型「{0}」在多个定价条目中重复',
modelConflict: "模型模式 '{model1}' 和 '{model2}' 冲突:匹配范围重叠",
mappingConflict: "模型映射源 '{model1}' 和 '{model2}' 冲突:匹配范围重叠",
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: '单次价格',
perRequestPriceRequired: '按次/图片计费模式必须设置默认价格或至少一个计费层级',
tierLabel: '层级',
resolution: '分辨率',
modelMapping: '模型映射',
modelMappingHint: '将请求中的模型名映射为实际模型名。在账号级别映射之前执行。',
noMappingRules: '暂无映射规则,点击"添加"创建',
mappingSource: '源模型',
mappingTarget: '目标模型',
billingModelSource: '计费基准',
billingModelSourceChannelMapped: '以渠道映射后的模型计费',
billingModelSourceRequested: '以请求模型计费',
billingModelSourceUpstream: '以最终模型计费',
billingModelSourceHint: '控制使用哪个模型名称进行定价查找',
selectedCount: '已选 {count} 个',
searchGroups: '搜索分组...',
noGroupsMatch: '没有匹配的分组',
restrictModels: '限制模型',
restrictModelsHint: '开启后,仅允许模型定价列表中的模型。不在列表中的模型请求将被拒绝。',
defaultPerRequestPrice: '默认单次价格(未命中层级时使用)',
defaultImagePrice: '默认图片价格(未命中层级时使用)',
platformConfig: '平台配置',
basicSettings: '基础设置',
addPlatform: '添加平台',
noPlatforms: '点击"添加平台"开始配置渠道',
mappingCount: '条映射',
pricingEntry: '定价配置',
noModels: '未添加模型'
}
},
// Subscriptions Management // Subscriptions Management
subscriptions: { subscriptions: {
title: '订阅管理', title: '订阅管理',
...@@ -3417,6 +3519,11 @@ export default { ...@@ -3417,6 +3519,11 @@ export default {
allBillingTypes: '全部计费类型', allBillingTypes: '全部计费类型',
billingTypeBalance: '钱包余额', billingTypeBalance: '钱包余额',
billingTypeSubscription: '订阅套餐', billingTypeSubscription: '订阅套餐',
billingMode: '计费模式',
billingModeToken: '按量',
billingModePerRequest: '按次',
billingModeImage: '按次(图片)',
allBillingModes: '全部计费模式',
ipAddress: 'IP', ipAddress: 'IP',
clickToViewBalance: '点击查看充值记录', clickToViewBalance: '点击查看充值记录',
failedToLoadUser: '加载用户信息失败', failedToLoadUser: '加载用户信息失败',
......
...@@ -278,6 +278,18 @@ const routes: RouteRecordRaw[] = [ ...@@ -278,6 +278,18 @@ const routes: RouteRecordRaw[] = [
descriptionKey: 'admin.groups.description' descriptionKey: 'admin.groups.description'
} }
}, },
{
path: '/admin/channels',
name: 'AdminChannels',
component: () => import('@/views/admin/ChannelsView.vue'),
meta: {
requiresAuth: true,
requiresAdmin: true,
title: 'Channel Management',
titleKey: 'admin.channels.title',
descriptionKey: 'admin.channels.description'
}
},
{ {
path: '/admin/subscriptions', path: '/admin/subscriptions',
name: 'AdminSubscriptions', name: 'AdminSubscriptions',
......
...@@ -1036,6 +1036,9 @@ export interface UsageLog { ...@@ -1036,6 +1036,9 @@ export interface UsageLog {
// Cache TTL Override // Cache TTL Override
cache_ttl_overridden: boolean cache_ttl_overridden: boolean
// 计费模式
billing_mode?: string | null
created_at: string created_at: string
user?: User user?: User
...@@ -1051,6 +1054,7 @@ export interface UsageLogAccountSummary { ...@@ -1051,6 +1054,7 @@ export interface UsageLogAccountSummary {
export interface AdminUsageLog extends UsageLog { export interface AdminUsageLog extends UsageLog {
upstream_model?: string | null upstream_model?: string | null
model_mapping_chain?: string | null
// 账号计费倍率(仅管理员可见) // 账号计费倍率(仅管理员可见)
account_rate_multiplier?: number | null account_rate_multiplier?: number | null
......
/**
* 格式化缓存 token 数量(1K/1M 缩写)
*/
export function formatCacheTokens(tokens: number): string {
if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M`
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K`
return tokens.toLocaleString()
}
/**
* 自适应精度格式化倍率(确保小数值如 0.001 不被截断)
*/
export function formatMultiplier(val: number): string {
if (val >= 0.01) return val.toFixed(2)
if (val >= 0.001) return val.toFixed(3)
if (val >= 0.0001) return val.toFixed(4)
return val.toPrecision(2)
}
<template>
<AppLayout>
<TablePageLayout>
<template #filters>
<div class="flex flex-col justify-between gap-4 lg:flex-row lg:items-start">
<!-- Left: Search + Filters -->
<div class="flex flex-1 flex-wrap items-center gap-3">
<div class="relative w-full sm:w-64">
<Icon
name="search"
size="md"
class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500"
/>
<input
v-model="searchQuery"
type="text"
:placeholder="t('admin.channels.searchChannels', 'Search channels...')"
class="input pl-10"
@input="handleSearch"
/>
</div>
<Select
v-model="filters.status"
:options="statusFilterOptions"
:placeholder="t('admin.channels.allStatus', 'All Status')"
class="w-40"
@change="loadChannels"
/>
</div>
<!-- Right: Actions -->
<div class="flex w-full flex-shrink-0 flex-wrap items-center justify-end gap-3 lg:w-auto">
<button
@click="loadChannels"
:disabled="loading"
class="btn btn-secondary"
:title="t('common.refresh', 'Refresh')"
>
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
</button>
<button @click="openCreateDialog" class="btn btn-primary">
<Icon name="plus" size="md" class="mr-2" />
{{ t('admin.channels.createChannel', 'Create Channel') }}
</button>
</div>
</div>
</template>
<template #table>
<DataTable :columns="columns" :data="channels" :loading="loading">
<template #cell-name="{ value }">
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
</template>
<template #cell-description="{ value }">
<span class="text-sm text-gray-600 dark:text-gray-400">{{ value || '-' }}</span>
</template>
<template #cell-status="{ row }">
<Toggle
:modelValue="row.status === 'active'"
@update:modelValue="toggleChannelStatus(row)"
/>
</template>
<template #cell-group_count="{ row }">
<span
class="inline-flex items-center rounded bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-800 dark:bg-dark-600 dark:text-gray-300"
>
{{ (row.group_ids || []).length }}
{{ t('admin.channels.groupsUnit', 'groups') }}
</span>
</template>
<template #cell-pricing_count="{ row }">
<span
class="inline-flex items-center rounded bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-800 dark:bg-dark-600 dark:text-gray-300"
>
{{ (row.model_pricing || []).length }}
{{ t('admin.channels.pricingUnit', 'pricing rules') }}
</span>
</template>
<template #cell-created_at="{ value }">
<span class="text-sm text-gray-600 dark:text-gray-400">
{{ formatDate(value) }}
</span>
</template>
<template #cell-actions="{ row }">
<div class="flex items-center gap-1">
<button
@click="openEditDialog(row)"
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400"
>
<Icon name="edit" size="sm" />
<span class="text-xs">{{ t('common.edit', 'Edit') }}</span>
</button>
<button
@click="handleDelete(row)"
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
>
<Icon name="trash" size="sm" />
<span class="text-xs">{{ t('common.delete', 'Delete') }}</span>
</button>
</div>
</template>
<template #empty>
<EmptyState
:title="t('admin.channels.noChannelsYet', 'No Channels Yet')"
:description="t('admin.channels.createFirstChannel', 'Create your first channel to manage model pricing')"
:action-text="t('admin.channels.createChannel', 'Create Channel')"
@action="openCreateDialog"
/>
</template>
</DataTable>
</template>
<template #pagination>
<Pagination
v-if="pagination.total > 0"
:page="pagination.page"
:total="pagination.total"
:page-size="pagination.page_size"
@update:page="handlePageChange"
@update:pageSize="handlePageSizeChange"
/>
</template>
</TablePageLayout>
<!-- Create/Edit Dialog -->
<BaseDialog
:show="showDialog"
:title="editingChannel ? t('admin.channels.editChannel', 'Edit Channel') : t('admin.channels.createChannel', 'Create Channel')"
width="extra-wide"
@close="closeDialog"
>
<div class="channel-dialog-body">
<!-- Tab Bar -->
<div class="flex items-center border-b border-gray-200 dark:border-dark-700 flex-shrink-0 -mx-4 sm:-mx-6 px-4 sm:px-6 -mt-3 sm:-mt-4">
<!-- Basic Settings Tab -->
<button
type="button"
@click="activeTab = 'basic'"
class="channel-tab"
:class="activeTab === 'basic' ? 'channel-tab-active' : 'channel-tab-inactive'"
>
{{ t('admin.channels.form.basicSettings', '基础设置') }}
</button>
<!-- Platform Tabs (only enabled) -->
<button
v-for="section in form.platforms.filter(s => s.enabled)"
:key="section.platform"
type="button"
@click="activeTab = section.platform"
class="channel-tab group"
:class="activeTab === section.platform ? 'channel-tab-active' : 'channel-tab-inactive'"
>
<PlatformIcon :platform="section.platform" size="xs" :class="getPlatformTextColor(section.platform)" />
<span :class="getPlatformTextColor(section.platform)">{{ t('admin.groups.platforms.' + section.platform, section.platform) }}</span>
</button>
</div>
<!-- Tab Content -->
<form id="channel-form" @submit.prevent="handleSubmit" class="flex-1 overflow-y-auto pt-4">
<!-- Basic Settings Tab -->
<div v-show="activeTab === 'basic'" class="space-y-5">
<!-- Name -->
<div>
<label class="input-label">{{ t('admin.channels.form.name', 'Name') }} <span class="text-red-500">*</span></label>
<input
v-model="form.name"
type="text"
required
class="input"
:placeholder="t('admin.channels.form.namePlaceholder', 'Enter channel name')"
/>
</div>
<!-- Description -->
<div>
<label class="input-label">{{ t('admin.channels.form.description', 'Description') }}</label>
<textarea
v-model="form.description"
rows="2"
class="input"
:placeholder="t('admin.channels.form.descriptionPlaceholder', 'Optional description')"
></textarea>
</div>
<!-- Status (edit only) -->
<div v-if="editingChannel">
<label class="input-label">{{ t('admin.channels.form.status', 'Status') }}</label>
<Select v-model="form.status" :options="statusEditOptions" />
</div>
<!-- Model Restriction -->
<div>
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
v-model="form.restrict_models"
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
<span class="input-label mb-0">{{ t('admin.channels.form.restrictModels', 'Restrict Models') }}</span>
</label>
<p class="mt-1 ml-6 text-xs text-gray-400">
{{ t('admin.channels.form.restrictModelsHint', 'When enabled, only models in the pricing list are allowed. Others will be rejected.') }}
</p>
</div>
<!-- Billing Basis -->
<div>
<label class="input-label">{{ t('admin.channels.form.billingModelSource', 'Billing Basis') }}</label>
<Select v-model="form.billing_model_source" :options="billingModelSourceOptions" />
<p class="mt-1 text-xs text-gray-400">
{{ t('admin.channels.form.billingModelSourceHint', 'Controls which model name is used for pricing lookup') }}
</p>
</div>
<!-- Platform Management -->
<div class="space-y-3">
<label class="input-label mb-0">{{ t('admin.channels.form.platformConfig', '平台配置') }}</label>
<div class="flex flex-wrap gap-2">
<label
v-for="p in platformOrder"
:key="p"
class="inline-flex cursor-pointer items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors"
:class="activePlatforms.includes(p)
? 'bg-primary-50 border-primary-300 dark:bg-primary-900/20 dark:border-primary-700'
: 'border-gray-200 hover:bg-gray-50 dark:border-dark-600 dark:hover:bg-dark-700'"
>
<input
type="checkbox"
:checked="activePlatforms.includes(p)"
class="h-3.5 w-3.5 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
@change="togglePlatform(p)"
/>
<PlatformIcon :platform="p" size="xs" :class="getPlatformTextColor(p)" />
<span :class="getPlatformTextColor(p)">{{ t('admin.groups.platforms.' + p, p) }}</span>
</label>
</div>
</div>
</div>
<!-- Platform Tab Content -->
<div
v-for="(section, sIdx) in form.platforms"
:key="'tab-' + section.platform"
v-show="section.enabled && activeTab === section.platform"
class="space-y-4"
>
<!-- Groups -->
<div>
<label class="input-label text-xs">
{{ t('admin.channels.form.groups', 'Associated Groups') }} <span class="text-red-500">*</span>
<span v-if="section.group_ids.length > 0" class="ml-1 font-normal text-gray-400">
({{ t('admin.channels.form.selectedCount', { count: section.group_ids.length }, `已选 ${section.group_ids.length} 个`) }})
</span>
</label>
<div class="max-h-40 overflow-auto rounded-lg border border-gray-200 bg-gray-50 p-2 dark:border-dark-600 dark:bg-dark-900">
<div v-if="groupsLoading" class="py-2 text-center text-xs text-gray-500">
{{ t('common.loading', 'Loading...') }}
</div>
<div v-else-if="getGroupsForPlatform(section.platform).length === 0" class="py-2 text-center text-xs text-gray-500">
{{ t('admin.channels.form.noGroupsAvailable', 'No groups available') }}
</div>
<div v-else class="flex flex-wrap gap-1">
<label
v-for="group in getGroupsForPlatform(section.platform)"
:key="group.id"
class="inline-flex cursor-pointer items-center gap-1.5 rounded-md border border-gray-200 px-2 py-1 text-xs transition-colors hover:bg-gray-50 dark:border-dark-600 dark:hover:bg-dark-700"
:class="[
section.group_ids.includes(group.id) ? 'bg-primary-50 border-primary-300 dark:bg-primary-900/20 dark:border-primary-700' : '',
isGroupInOtherChannel(group.id, section.platform) ? 'opacity-40' : ''
]"
>
<input
type="checkbox"
:checked="section.group_ids.includes(group.id)"
:disabled="isGroupInOtherChannel(group.id, section.platform)"
class="h-3 w-3 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
@change="toggleGroupInSection(sIdx, group.id)"
/>
<span :class="['font-medium', getPlatformTextColor(group.platform)]">{{ group.name }}</span>
<span
:class="['rounded-full px-1 py-0 text-[10px]', getRateBadgeClass(group.platform)]"
>{{ group.rate_multiplier }}x</span>
<span class="text-[10px] text-gray-400">{{ group.account_count || 0 }}</span>
<span
v-if="isGroupInOtherChannel(group.id, section.platform)"
class="text-[10px] text-gray-400"
>{{ getGroupInOtherChannelLabel(group.id) }}</span>
</label>
</div>
</div>
</div>
<!-- Model Mapping -->
<div>
<div class="mb-1 flex items-center justify-between">
<label class="input-label text-xs mb-0">{{ t('admin.channels.form.modelMapping', 'Model Mapping') }}</label>
<button type="button" @click="addMappingEntry(sIdx)" class="text-xs text-primary-600 hover:text-primary-700">
+ {{ t('common.add', 'Add') }}
</button>
</div>
<div
v-if="Object.keys(section.model_mapping).length === 0"
class="rounded border border-dashed border-gray-300 p-2 text-center text-xs text-gray-400 dark:border-dark-500"
>
{{ t('admin.channels.form.noMappingRules', 'No mapping rules. Click "Add" to create one.') }}
</div>
<div v-else class="space-y-1">
<div
v-for="(_, srcModel) in section.model_mapping"
:key="srcModel"
class="flex items-center gap-2"
>
<input
:value="srcModel"
type="text"
class="input flex-1 text-xs"
:class="getPlatformTextColor(section.platform)"
:placeholder="t('admin.channels.form.mappingSource', 'Source model')"
@change="renameMappingKey(sIdx, srcModel, ($event.target as HTMLInputElement).value)"
/>
<span class="text-gray-400 text-xs"></span>
<input
:value="section.model_mapping[srcModel]"
type="text"
class="input flex-1 text-xs"
:class="getPlatformTextColor(section.platform)"
:placeholder="t('admin.channels.form.mappingTarget', 'Target model')"
@input="section.model_mapping[srcModel] = ($event.target as HTMLInputElement).value"
/>
<button
type="button"
@click="removeMappingEntry(sIdx, srcModel)"
class="rounded p-0.5 text-gray-400 hover:text-red-500"
>
<Icon name="trash" size="sm" />
</button>
</div>
</div>
</div>
<!-- Model Pricing -->
<div>
<div class="mb-1 flex items-center justify-between">
<label class="input-label text-xs mb-0">{{ t('admin.channels.form.modelPricing', 'Model Pricing') }}</label>
<button type="button" @click="addPricingEntry(sIdx)" class="text-xs text-primary-600 hover:text-primary-700">
+ {{ t('common.add', 'Add') }}
</button>
</div>
<div
v-if="section.model_pricing.length === 0"
class="rounded border border-dashed border-gray-300 p-2 text-center text-xs text-gray-400 dark:border-dark-500"
>
{{ t('admin.channels.form.noPricingRules', 'No pricing rules yet. Click "Add" to create one.') }}
</div>
<div v-else class="space-y-2">
<PricingEntryCard
v-for="(entry, idx) in section.model_pricing"
:key="idx"
:entry="entry"
:platform="section.platform"
@update="updatePricingEntry(sIdx, idx, $event)"
@remove="removePricingEntry(sIdx, idx)"
/>
</div>
</div>
</div>
</form>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<button @click="closeDialog" type="button" class="btn btn-secondary">
{{ t('common.cancel', 'Cancel') }}
</button>
<button
type="submit"
form="channel-form"
:disabled="submitting"
class="btn btn-primary"
>
{{ submitting
? t('common.submitting', 'Submitting...')
: editingChannel
? t('common.update', 'Update')
: t('common.create', 'Create')
}}
</button>
</div>
</template>
</BaseDialog>
<!-- Delete Confirmation -->
<ConfirmDialog
:show="showDeleteDialog"
:title="t('admin.channels.deleteChannel', 'Delete Channel')"
:message="deleteConfirmMessage"
:confirm-text="t('common.delete', 'Delete')"
:cancel-text="t('common.cancel', 'Cancel')"
:danger="true"
@confirm="confirmDelete"
@cancel="showDeleteDialog = false"
/>
</AppLayout>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
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 { mTokToPerToken, perTokenToMTok, apiIntervalsToForm, formIntervalsToAPI, findModelConflict, validateIntervals } from '@/components/admin/channel/types'
import type { AdminGroup, GroupPlatform } from '@/types'
import type { Column } from '@/components/common/types'
import AppLayout from '@/components/layout/AppLayout.vue'
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
import DataTable from '@/components/common/DataTable.vue'
import Pagination from '@/components/common/Pagination.vue'
import BaseDialog from '@/components/common/BaseDialog.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import Select from '@/components/common/Select.vue'
import Icon from '@/components/icons/Icon.vue'
import PlatformIcon from '@/components/common/PlatformIcon.vue'
import Toggle from '@/components/common/Toggle.vue'
import PricingEntryCard from '@/components/admin/channel/PricingEntryCard.vue'
import { getPersistedPageSize } from '@/composables/usePersistedPageSize'
const { t } = useI18n()
const appStore = useAppStore()
// ── Platform Section type ──
interface PlatformSection {
platform: GroupPlatform
enabled: boolean
collapsed: boolean
group_ids: number[]
model_mapping: Record<string, string>
model_pricing: PricingFormEntry[]
}
// ── Table columns ──
const columns = computed<Column[]>(() => [
{ key: 'name', label: t('admin.channels.columns.name', 'Name'), sortable: true },
{ key: 'description', label: t('admin.channels.columns.description', 'Description'), sortable: false },
{ key: 'status', label: t('admin.channels.columns.status', 'Status'), sortable: true },
{ key: 'group_count', label: t('admin.channels.columns.groups', 'Groups'), sortable: false },
{ key: 'pricing_count', label: t('admin.channels.columns.pricing', 'Pricing'), sortable: false },
{ key: 'created_at', label: t('admin.channels.columns.createdAt', 'Created'), sortable: true },
{ key: 'actions', label: t('admin.channels.columns.actions', 'Actions'), sortable: false }
])
const statusFilterOptions = computed(() => [
{ value: '', label: t('admin.channels.allStatus', 'All Status') },
{ value: 'active', label: t('admin.channels.statusActive', 'Active') },
{ value: 'disabled', label: t('admin.channels.statusDisabled', 'Disabled') }
])
const statusEditOptions = computed(() => [
{ value: 'active', label: t('admin.channels.statusActive', 'Active') },
{ value: 'disabled', label: t('admin.channels.statusDisabled', 'Disabled') }
])
const billingModelSourceOptions = computed(() => [
{ value: 'channel_mapped', label: t('admin.channels.form.billingModelSourceChannelMapped', 'Bill by channel-mapped model') },
{ value: 'requested', label: t('admin.channels.form.billingModelSourceRequested', 'Bill by requested model') },
{ value: 'upstream', label: t('admin.channels.form.billingModelSourceUpstream', 'Bill by final upstream model') }
])
// ── State ──
const channels = ref<Channel[]>([])
const loading = ref(false)
const searchQuery = ref('')
const filters = reactive({ status: '' })
const pagination = reactive({
page: 1,
page_size: getPersistedPageSize(),
total: 0
})
// Dialog state
const showDialog = ref(false)
const editingChannel = ref<Channel | null>(null)
const submitting = ref(false)
const showDeleteDialog = ref(false)
const deletingChannel = ref<Channel | null>(null)
const activeTab = ref<string>('basic')
// Groups
const allGroups = ref<AdminGroup[]>([])
const groupsLoading = ref(false)
// All channels for group-conflict detection (independent of current page)
const allChannelsForConflict = ref<Channel[]>([])
// Form data
const form = reactive({
name: '',
description: '',
status: 'active',
restrict_models: false,
billing_model_source: 'channel_mapped' as string,
platforms: [] as PlatformSection[]
})
let abortController: AbortController | null = null
// ── Platform config ──
const platformOrder: GroupPlatform[] = ['anthropic', 'openai', 'gemini', 'antigravity']
function getPlatformTextColor(platform: string): string {
switch (platform) {
case 'anthropic': return 'text-orange-600 dark:text-orange-400'
case 'openai': return 'text-emerald-600 dark:text-emerald-400'
case 'gemini': return 'text-blue-600 dark:text-blue-400'
case 'antigravity': return 'text-purple-600 dark:text-purple-400'
case 'sora': return 'text-rose-600 dark:text-rose-400'
default: return 'text-gray-600 dark:text-gray-400'
}
}
function getRateBadgeClass(platform: string): string {
switch (platform) {
case 'anthropic': return 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
case 'openai': return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
case 'gemini': return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
case 'antigravity': return 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
case 'sora': return 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-400'
default: return 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400'
}
}
// ── Helpers ──
function formatDate(value: string): string {
if (!value) return '-'
return new Date(value).toLocaleDateString()
}
// ── Platform section helpers ──
const activePlatforms = computed(() => form.platforms.filter(s => s.enabled).map(s => s.platform))
function addPlatformSection(platform: GroupPlatform) {
form.platforms.push({
platform,
enabled: true,
collapsed: false,
group_ids: [],
model_mapping: {},
model_pricing: []
})
}
function togglePlatform(platform: GroupPlatform) {
const section = form.platforms.find(s => s.platform === platform)
if (section) {
section.enabled = !section.enabled
if (!section.enabled && activeTab.value === platform) {
activeTab.value = 'basic'
}
} else {
addPlatformSection(platform)
}
}
function getGroupsForPlatform(platform: GroupPlatform): AdminGroup[] {
return allGroups.value.filter(g => g.platform === platform)
}
// ── Group helpers ──
const groupToChannelMap = computed(() => {
const map = new Map<number, Channel>()
for (const ch of allChannelsForConflict.value) {
if (editingChannel.value && ch.id === editingChannel.value.id) continue
for (const gid of ch.group_ids || []) {
map.set(gid, ch)
}
}
return map
})
function isGroupInOtherChannel(groupId: number, _platform: string): boolean {
return groupToChannelMap.value.has(groupId)
}
function getGroupChannelName(groupId: number): string {
return groupToChannelMap.value.get(groupId)?.name || ''
}
function getGroupInOtherChannelLabel(groupId: number): string {
const name = getGroupChannelName(groupId)
return t('admin.channels.form.inOtherChannel', { name }, `In "${name}"`)
}
const deleteConfirmMessage = computed(() => {
const name = deletingChannel.value?.name || ''
return t(
'admin.channels.deleteConfirm',
{ name },
`Are you sure you want to delete channel "${name}"? This action cannot be undone.`
)
})
function toggleGroupInSection(sectionIdx: number, groupId: number) {
const section = form.platforms[sectionIdx]
const idx = section.group_ids.indexOf(groupId)
if (idx >= 0) {
section.group_ids.splice(idx, 1)
} else {
section.group_ids.push(groupId)
}
}
// ── Pricing helpers ──
function addPricingEntry(sectionIdx: number) {
form.platforms[sectionIdx].model_pricing.push({
models: [],
billing_mode: 'token',
input_price: null,
output_price: null,
cache_write_price: null,
cache_read_price: null,
image_output_price: null,
per_request_price: null,
intervals: []
})
}
function updatePricingEntry(sectionIdx: number, idx: number, updated: PricingFormEntry) {
form.platforms[sectionIdx].model_pricing.splice(idx, 1, updated)
}
function removePricingEntry(sectionIdx: number, idx: number) {
form.platforms[sectionIdx].model_pricing.splice(idx, 1)
}
// ── Model Mapping helpers ──
function addMappingEntry(sectionIdx: number) {
const mapping = form.platforms[sectionIdx].model_mapping
let key = ''
let i = 1
while (key === '' || key in mapping) {
key = `model-${i}`
i++
}
mapping[key] = ''
}
function removeMappingEntry(sectionIdx: number, key: string) {
delete form.platforms[sectionIdx].model_mapping[key]
}
function renameMappingKey(sectionIdx: number, oldKey: string, newKey: string) {
newKey = newKey.trim()
if (!newKey || newKey === oldKey) return
const mapping = form.platforms[sectionIdx].model_mapping
if (newKey in mapping) return
const value = mapping[oldKey]
delete mapping[oldKey]
mapping[newKey] = value
}
// ── Form ↔ API conversion ──
function formToAPI(): { group_ids: number[], model_pricing: ChannelModelPricing[], model_mapping: Record<string, Record<string, string>> } {
const group_ids: number[] = []
const model_pricing: ChannelModelPricing[] = []
const model_mapping: Record<string, Record<string, string>> = {}
for (const section of form.platforms) {
if (!section.enabled) continue
group_ids.push(...section.group_ids)
// Model mapping per platform
if (Object.keys(section.model_mapping).length > 0) {
model_mapping[section.platform] = { ...section.model_mapping }
}
// Model pricing with platform tag
for (const entry of section.model_pricing) {
if (entry.models.length === 0) continue
model_pricing.push({
platform: section.platform,
models: entry.models,
billing_mode: entry.billing_mode,
input_price: mTokToPerToken(entry.input_price),
output_price: mTokToPerToken(entry.output_price),
cache_write_price: mTokToPerToken(entry.cache_write_price),
cache_read_price: mTokToPerToken(entry.cache_read_price),
image_output_price: mTokToPerToken(entry.image_output_price),
per_request_price: entry.per_request_price != null && entry.per_request_price !== '' ? Number(entry.per_request_price) : null,
intervals: formIntervalsToAPI(entry.intervals || [])
})
}
}
return { group_ids, model_pricing, model_mapping }
}
function apiToForm(channel: Channel): PlatformSection[] {
// Build a map: groupID → platform
const groupPlatformMap = new Map<number, GroupPlatform>()
for (const g of allGroups.value) {
groupPlatformMap.set(g.id, g.platform)
}
// Determine which platforms are active (from groups + pricing + mapping)
const activePlatforms = new Set<GroupPlatform>()
for (const gid of channel.group_ids || []) {
const p = groupPlatformMap.get(gid)
if (p) activePlatforms.add(p)
}
for (const p of channel.model_pricing || []) {
if (p.platform) activePlatforms.add(p.platform as GroupPlatform)
}
for (const p of Object.keys(channel.model_mapping || {})) {
if (platformOrder.includes(p as GroupPlatform)) activePlatforms.add(p as GroupPlatform)
}
// Build sections in platform order
const sections: PlatformSection[] = []
for (const platform of platformOrder) {
if (!activePlatforms.has(platform)) continue
const groupIds = (channel.group_ids || []).filter(gid => groupPlatformMap.get(gid) === platform)
const mapping = (channel.model_mapping || {})[platform] || {}
const pricing = (channel.model_pricing || [])
.filter(p => (p.platform || 'anthropic') === platform)
.map(p => ({
models: p.models || [],
billing_mode: p.billing_mode,
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),
per_request_price: p.per_request_price,
intervals: apiIntervalsToForm(p.intervals || [])
} as PricingFormEntry))
sections.push({
platform,
enabled: true,
collapsed: false,
group_ids: groupIds,
model_mapping: { ...mapping },
model_pricing: pricing
})
}
return sections
}
// ── Load data ──
async function loadChannels() {
if (abortController) abortController.abort()
const ctrl = new AbortController()
abortController = ctrl
loading.value = true
try {
const response = await adminAPI.channels.list(pagination.page, pagination.page_size, {
status: filters.status || undefined,
search: searchQuery.value || undefined
}, { signal: ctrl.signal })
if (ctrl.signal.aborted || abortController !== ctrl) return
channels.value = response.items || []
pagination.total = response.total
} catch (error: any) {
if (error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return
appStore.showError(t('admin.channels.loadError', 'Failed to load channels'))
console.error('Error loading channels:', error)
} finally {
if (abortController === ctrl) {
loading.value = false
abortController = null
}
}
}
async function loadGroups() {
groupsLoading.value = true
try {
allGroups.value = await adminAPI.groups.getAll()
} catch (error) {
console.error('Error loading groups:', error)
} finally {
groupsLoading.value = false
}
}
async function loadAllChannelsForConflict() {
try {
const response = await adminAPI.channels.list(1, 1000)
allChannelsForConflict.value = response.items || []
} catch (error) {
// Fallback to current page data
allChannelsForConflict.value = channels.value
}
}
let searchTimeout: ReturnType<typeof setTimeout>
function handleSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
pagination.page = 1
loadChannels()
}, 300)
}
function handlePageChange(page: number) {
pagination.page = page
loadChannels()
}
function handlePageSizeChange(pageSize: number) {
pagination.page_size = pageSize
pagination.page = 1
loadChannels()
}
// ── Dialog ──
function resetForm() {
form.name = ''
form.description = ''
form.status = 'active'
form.restrict_models = false
form.billing_model_source = 'channel_mapped'
form.platforms = []
activeTab.value = 'basic'
}
async function openCreateDialog() {
editingChannel.value = null
resetForm()
await Promise.all([loadGroups(), loadAllChannelsForConflict()])
showDialog.value = true
}
async function openEditDialog(channel: Channel) {
editingChannel.value = channel
form.name = channel.name
form.description = channel.description || ''
form.status = channel.status
form.restrict_models = channel.restrict_models || false
form.billing_model_source = channel.billing_model_source || 'channel_mapped'
// Must load groups first so apiToForm can map groupID → platform
await Promise.all([loadGroups(), loadAllChannelsForConflict()])
form.platforms = apiToForm(channel)
showDialog.value = true
}
function closeDialog() {
showDialog.value = false
editingChannel.value = null
resetForm()
}
async function handleSubmit() {
if (submitting.value) return
if (!form.name.trim()) {
appStore.showError(t('admin.channels.nameRequired', 'Please enter a channel name'))
return
}
// Check for pricing entries with empty models (would be silently skipped)
for (const section of form.platforms.filter(s => s.enabled)) {
if (section.group_ids.length === 0) {
const platformLabel = t('admin.groups.platforms.' + section.platform, section.platform)
appStore.showError(t('admin.channels.noGroupsSelected', { platform: platformLabel }, `${platformLabel} 平台未选择分组,请至少选择一个分组或禁用该平台`))
activeTab.value = section.platform
return
}
for (const entry of section.model_pricing) {
if (entry.models.length === 0) {
const platformLabel = t('admin.groups.platforms.' + section.platform, section.platform)
appStore.showError(t('admin.channels.emptyModelsInPricing', { platform: platformLabel }, `${platformLabel} 平台下有定价条目未添加模型,请添加模型或删除该条目`))
activeTab.value = section.platform
return
}
}
}
// Check model pattern conflicts per platform (duplicate / wildcard overlap)
for (const section of form.platforms.filter(s => s.enabled)) {
// Collect all pricing models for this platform
const allModels: string[] = []
for (const entry of section.model_pricing) {
allModels.push(...entry.models)
}
const pricingConflict = findModelConflict(allModels)
if (pricingConflict) {
appStore.showError(
t('admin.channels.modelConflict',
{ model1: pricingConflict[0], model2: pricingConflict[1] },
`模型模式 '${pricingConflict[0]}' 和 '${pricingConflict[1]}' 冲突:匹配范围重叠`)
)
activeTab.value = section.platform
return
}
// Check model mapping source pattern conflicts
const mappingKeys = Object.keys(section.model_mapping)
if (mappingKeys.length > 0) {
const mappingConflict = findModelConflict(mappingKeys)
if (mappingConflict) {
appStore.showError(
t('admin.channels.mappingConflict',
{ model1: mappingConflict[0], model2: mappingConflict[1] },
`模型映射源 '${mappingConflict[0]}' 和 '${mappingConflict[1]}' 冲突:匹配范围重叠`)
)
activeTab.value = section.platform
return
}
}
}
// 校验 per_request/image 模式必须有价格 (只校验启用的平台)
for (const section of form.platforms.filter(s => s.enabled)) {
for (const entry of section.model_pricing) {
if (entry.models.length === 0) continue
if ((entry.billing_mode === 'per_request' || entry.billing_mode === 'image') &&
(entry.per_request_price == null || entry.per_request_price === '') &&
(!entry.intervals || entry.intervals.length === 0)) {
appStore.showError(t('admin.channels.form.perRequestPriceRequired', '按次/图片计费模式必须设置默认价格或至少一个计费层级'))
return
}
}
}
// 校验区间合法性(范围、重叠等)
for (const section of form.platforms.filter(s => s.enabled)) {
for (const entry of section.model_pricing) {
if (!entry.intervals || entry.intervals.length === 0) continue
const intervalErr = validateIntervals(entry.intervals)
if (intervalErr) {
const platformLabel = t('admin.groups.platforms.' + section.platform, section.platform)
const modelLabel = entry.models.join(', ') || '未命名'
appStore.showError(`${platformLabel} - ${modelLabel}: ${intervalErr}`)
activeTab.value = section.platform
return
}
}
}
const { group_ids, model_pricing, model_mapping } = formToAPI()
submitting.value = true
try {
if (editingChannel.value) {
const req: UpdateChannelRequest = {
name: form.name.trim(),
description: form.description.trim() || undefined,
status: form.status,
group_ids,
model_pricing,
model_mapping: Object.keys(model_mapping).length > 0 ? model_mapping : {},
billing_model_source: form.billing_model_source,
restrict_models: form.restrict_models
}
await adminAPI.channels.update(editingChannel.value.id, req)
appStore.showSuccess(t('admin.channels.updateSuccess', 'Channel updated'))
} else {
const req: CreateChannelRequest = {
name: form.name.trim(),
description: form.description.trim() || undefined,
group_ids,
model_pricing,
model_mapping: Object.keys(model_mapping).length > 0 ? model_mapping : {},
billing_model_source: form.billing_model_source,
restrict_models: form.restrict_models
}
await adminAPI.channels.create(req)
appStore.showSuccess(t('admin.channels.createSuccess', 'Channel created'))
}
closeDialog()
loadChannels()
} catch (error: any) {
const msg = error.response?.data?.detail || (editingChannel.value
? t('admin.channels.updateError', 'Failed to update channel')
: t('admin.channels.createError', 'Failed to create channel'))
appStore.showError(msg)
console.error('Error saving channel:', error)
} finally {
submitting.value = false
}
}
// ── Toggle status ──
async function toggleChannelStatus(channel: Channel) {
const newStatus = channel.status === 'active' ? 'disabled' : 'active'
try {
await adminAPI.channels.update(channel.id, { status: newStatus })
if (filters.status && filters.status !== newStatus) {
// Item no longer matches the active filter — reload list
await loadChannels()
} else {
channel.status = newStatus
}
} catch (error) {
appStore.showError(t('admin.channels.updateError', 'Failed to update channel'))
console.error('Error toggling channel status:', error)
}
}
// ── Delete ──
function handleDelete(channel: Channel) {
deletingChannel.value = channel
showDeleteDialog.value = true
}
async function confirmDelete() {
if (!deletingChannel.value) return
try {
await adminAPI.channels.remove(deletingChannel.value.id)
appStore.showSuccess(t('admin.channels.deleteSuccess', 'Channel deleted'))
showDeleteDialog.value = false
deletingChannel.value = null
loadChannels()
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.channels.deleteError', 'Failed to delete channel'))
console.error('Error deleting channel:', error)
}
}
// ── Lifecycle ──
onMounted(() => {
loadChannels()
loadGroups()
})
onUnmounted(() => {
clearTimeout(searchTimeout)
abortController?.abort()
})
</script>
<style scoped>
.channel-dialog-body {
display: flex;
flex-direction: column;
height: 70vh;
min-height: 400px;
}
.channel-tab {
@apply flex items-center gap-1.5 px-3 py-2.5 text-sm font-medium border-b-2 transition-colors whitespace-nowrap;
}
.channel-tab-active {
@apply border-primary-600 text-primary-600 dark:border-primary-400 dark:text-primary-400;
}
.channel-tab-inactive {
@apply border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300;
}
</style>
...@@ -34,6 +34,7 @@ ...@@ -34,6 +34,7 @@
:show-metric-toggle="true" :show-metric-toggle="true"
:start-date="startDate" :start-date="startDate"
:end-date="endDate" :end-date="endDate"
:filters="breakdownFilters"
/> />
<GroupDistributionChart <GroupDistributionChart
v-model:metric="groupDistributionMetric" v-model:metric="groupDistributionMetric"
...@@ -42,6 +43,7 @@ ...@@ -42,6 +43,7 @@
:show-metric-toggle="true" :show-metric-toggle="true"
:start-date="startDate" :start-date="startDate"
:end-date="endDate" :end-date="endDate"
:filters="breakdownFilters"
/> />
</div> </div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2"> <div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
...@@ -57,6 +59,7 @@ ...@@ -57,6 +59,7 @@
:title="t('usage.endpointDistribution')" :title="t('usage.endpointDistribution')"
:start-date="startDate" :start-date="startDate"
:end-date="endDate" :end-date="endDate"
:filters="breakdownFilters"
/> />
<TokenUsageTrend :trend-data="trendData" :loading="chartsLoading" /> <TokenUsageTrend :trend-data="trendData" :loading="chartsLoading" />
</div> </div>
...@@ -169,6 +172,17 @@ const cleanupDialogVisible = ref(false) ...@@ -169,6 +172,17 @@ const cleanupDialogVisible = ref(false)
const showBalanceHistoryModal = ref(false) const showBalanceHistoryModal = ref(false)
const balanceHistoryUser = ref<AdminUser | null>(null) const balanceHistoryUser = ref<AdminUser | null>(null)
const breakdownFilters = computed(() => {
const f: Record<string, any> = {}
if (filters.value.user_id) f.user_id = filters.value.user_id
if (filters.value.api_key_id) f.api_key_id = filters.value.api_key_id
if (filters.value.account_id) f.account_id = filters.value.account_id
if (filters.value.group_id) f.group_id = filters.value.group_id
if (filters.value.request_type != null) f.request_type = filters.value.request_type
if (filters.value.billing_type != null) f.billing_type = filters.value.billing_type
return f
})
const handleUserClick = async (userId: number) => { const handleUserClick = async (userId: number) => {
try { try {
const user = await adminAPI.users.getById(userId) const user = await adminAPI.users.getById(userId)
...@@ -392,7 +406,7 @@ const resetFilters = () => { ...@@ -392,7 +406,7 @@ const resetFilters = () => {
const range = getLast24HoursRangeDates() const range = getLast24HoursRangeDates()
startDate.value = range.start startDate.value = range.start
endDate.value = range.end endDate.value = range.end
filters.value = { start_date: startDate.value, end_date: endDate.value, request_type: undefined, billing_type: null } filters.value = { start_date: startDate.value, end_date: endDate.value, request_type: undefined, billing_type: null, billing_mode: undefined }
granularity.value = getGranularityForRange(startDate.value, endDate.value) granularity.value = getGranularityForRange(startDate.value, endDate.value)
applyFilters() applyFilters()
} }
...@@ -440,7 +454,7 @@ const exportToExcel = async () => { ...@@ -440,7 +454,7 @@ const exportToExcel = async () => {
log.input_tokens, log.output_tokens, log.cache_read_tokens, log.cache_creation_tokens, log.input_tokens, log.output_tokens, log.cache_read_tokens, log.cache_creation_tokens,
log.input_cost?.toFixed(6) || '0.000000', log.output_cost?.toFixed(6) || '0.000000', log.input_cost?.toFixed(6) || '0.000000', log.output_cost?.toFixed(6) || '0.000000',
log.cache_read_cost?.toFixed(6) || '0.000000', log.cache_creation_cost?.toFixed(6) || '0.000000', log.cache_read_cost?.toFixed(6) || '0.000000', log.cache_creation_cost?.toFixed(6) || '0.000000',
log.rate_multiplier?.toFixed(2) || '1.00', (log.account_rate_multiplier ?? 1).toFixed(2), log.rate_multiplier?.toPrecision(4) || '1.00', (log.account_rate_multiplier ?? 1).toPrecision(4),
log.total_cost?.toFixed(6) || '0.000000', log.actual_cost?.toFixed(6) || '0.000000', log.total_cost?.toFixed(6) || '0.000000', log.actual_cost?.toFixed(6) || '0.000000',
(log.total_cost * (log.account_rate_multiplier ?? 1)).toFixed(6), log.first_token_ms ?? '', log.duration_ms, (log.total_cost * (log.account_rate_multiplier ?? 1)).toFixed(6), log.first_token_ms ?? '', log.duration_ms,
log.request_id || '', log.user_agent || '', log.ip_address || '' log.request_id || '', log.user_agent || '', log.ip_address || ''
...@@ -477,6 +491,7 @@ const allColumns = computed(() => [ ...@@ -477,6 +491,7 @@ const allColumns = computed(() => [
{ key: 'endpoint', label: t('usage.endpoint'), sortable: false }, { key: 'endpoint', label: t('usage.endpoint'), sortable: false },
{ key: 'group', label: t('admin.usage.group'), sortable: false }, { key: 'group', label: t('admin.usage.group'), sortable: false },
{ key: 'stream', label: t('usage.type'), sortable: false }, { key: 'stream', label: t('usage.type'), sortable: false },
{ key: 'billing_mode', label: t('admin.usage.billingMode'), sortable: false },
{ key: 'tokens', label: t('usage.tokens'), sortable: false }, { key: 'tokens', label: t('usage.tokens'), sortable: false },
{ key: 'cost', label: t('usage.cost'), sortable: false }, { key: 'cost', label: t('usage.cost'), sortable: false },
{ key: 'first_token', label: t('usage.firstToken'), sortable: false }, { key: 'first_token', label: t('usage.firstToken'), sortable: false },
......
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