Commit 91c9b8d0 authored by erio's avatar erio
Browse files

feat(channel): 渠道管理系统 — 多模式定价 + 统一计费解析

Cherry-picked from release/custom-0.1.106: a9117600
parent b384570d
...@@ -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
......
<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 -->
<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"
/>
</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="'∞'"
/>
</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"
/>
</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"
/>
</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"
/>
</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"
/>
</div>
</template>
<!-- Per-request / Image mode: tier label + 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')
}}
</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">{{ 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"
/>
</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="'∞'"
/>
</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"
/>
</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 { 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: []
}>()
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 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="flex-1">
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">
{{ t('admin.channels.form.models', 'Models (comma separated, supports *)') }}
</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>
</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') }}
</label>
<Select
:modelValue="entry.billing_mode"
@update:modelValue="emit('update', { ...entry, billing_mode: $event as BillingMode, intervals: [] })"
:options="billingModeOptions"
class="mt-1"
/>
</div>
<button
type="button"
@click="emit('remove')"
class="mt-5 rounded p-1 text-gray-400 hover:text-red-500"
>
<Icon name="trash" size="sm" />
</button>
</div>
<!-- Token mode: flat prices + intervals -->
<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">
<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')"
/>
</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')"
/>
</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')"
/>
</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')"
/>
</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', 'Context Intervals (optional)') }}
</label>
<button type="button" @click="addInterval" class="text-xs text-primary-600 hover:text-primary-700">
+ {{ t('admin.channels.form.addInterval', 'Add Interval') }}
</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: tiers -->
<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') }}
</label>
<button type="button" @click="addInterval" class="text-xs text-primary-600 hover:text-primary-700">
+ {{ t('admin.channels.form.addTier', 'Add Tier') }}
</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', 'No tiers. Add one to configure per-request pricing.') }}
</div>
</div>
<!-- Image mode: tiers -->
<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') }}
</label>
<button type="button" @click="addImageTier" class="text-xs text-primary-600 hover:text-primary-700">
+ {{ t('admin.channels.form.addTier', 'Add Tier') }}
</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>
<!-- 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')"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { 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 type { PricingFormEntry, IntervalFormEntry } from './types'
import type { BillingMode } from '@/api/admin/channels'
const { t } = useI18n()
const props = defineProps<{
entry: PricingFormEntry
}>()
const emit = defineEmits<{
update: [entry: PricingFormEntry]
remove: []
}>()
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') }
])
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']
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,
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 })
}
</script>
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 {
modelsInput: 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[]
}
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
}
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,
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: 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),
per_request_price: toNullableNumber(iv.per_request_price),
sort_order: iv.sort_order
}))
}
...@@ -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 },
......
...@@ -278,6 +278,16 @@ const routes: RouteRecordRaw[] = [ ...@@ -278,6 +278,16 @@ 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'
}
},
{ {
path: '/admin/subscriptions', path: '/admin/subscriptions',
name: 'AdminSubscriptions', name: 'AdminSubscriptions',
......
<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="{ value }">
<span
:class="[
'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium',
value === 'active'
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
]"
>
{{ value === 'active' ? t('admin.channels.statusActive', 'Active') : t('admin.channels.statusDisabled', 'Disabled') }}
</span>
</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"
>
<form id="channel-form" @submit.prevent="handleSubmit" class="space-y-5">
<!-- Name -->
<div>
<label class="input-label">{{ t('admin.channels.form.name', 'Name') }}</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>
<!-- Group Association -->
<div>
<label class="input-label">{{ t('admin.channels.form.groups', 'Associated Groups') }}</label>
<div
class="max-h-48 overflow-auto rounded-lg border border-gray-200 bg-white p-2 dark:border-dark-600 dark:bg-dark-800"
>
<div v-if="groupsLoading" class="py-4 text-center text-sm text-gray-500">
{{ t('common.loading', 'Loading...') }}
</div>
<div v-else-if="allGroups.length === 0" class="py-4 text-center text-sm text-gray-500">
{{ t('admin.channels.form.noGroupsAvailable', 'No groups available') }}
</div>
<label
v-for="group in allGroups"
:key="group.id"
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 hover:bg-gray-50 dark:hover:bg-dark-700"
:class="{ 'opacity-50': isGroupInOtherChannel(group.id) }"
>
<input
type="checkbox"
:checked="form.group_ids.includes(group.id)"
:disabled="isGroupInOtherChannel(group.id)"
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
@change="toggleGroup(group.id)"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">{{ group.name }}</span>
<span
v-if="isGroupInOtherChannel(group.id)"
class="ml-auto text-xs text-gray-400"
>
{{ getGroupInOtherChannelLabel(group.id) }}
</span>
<span
v-if="group.platform"
class="ml-auto text-xs text-gray-400 dark:text-gray-500"
>
{{ group.platform }}
</span>
</label>
</div>
</div>
<!-- Model Pricing -->
<div>
<div class="mb-2 flex items-center justify-between">
<label class="input-label mb-0">{{ t('admin.channels.form.modelPricing', 'Model Pricing') }}</label>
<button type="button" @click="addPricingEntry" class="btn btn-secondary btn-sm">
<Icon name="plus" size="sm" class="mr-1" />
{{ t('common.add', 'Add') }}
</button>
</div>
<div
v-if="form.model_pricing.length === 0"
class="rounded-lg border border-dashed border-gray-300 p-4 text-center text-sm text-gray-500 dark:border-dark-500 dark:text-gray-400"
>
{{ t('admin.channels.form.noPricingRules', 'No pricing rules yet. Click "Add" to create one.') }}
</div>
<div v-else class="space-y-3">
<PricingEntryCard
v-for="(entry, idx) in form.model_pricing"
:key="idx"
:entry="entry"
@update="updatePricingEntry(idx, $event)"
@remove="removePricingEntry(idx)"
/>
</div>
</div>
</form>
<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 { toNullableNumber, 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'
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 PricingEntryCard from '@/components/admin/channel/PricingEntryCard.vue'
import { getPersistedPageSize } from '@/composables/usePersistedPageSize'
const { t } = useI18n()
const appStore = useAppStore()
// ── 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') }
])
// ── 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)
// Groups
const allGroups = ref<AdminGroup[]>([])
const groupsLoading = ref(false)
// Form data
const form = reactive({
name: '',
description: '',
status: 'active',
group_ids: [] as number[],
model_pricing: [] as PricingFormEntry[]
})
let abortController: AbortController | null = null
// ── Helpers ──
function formatDate(value: string): string {
if (!value) return '-'
return new Date(value).toLocaleDateString()
}
// ── Group helpers ──
const groupToChannelMap = computed(() => {
const map = new Map<number, Channel>()
for (const ch of channels.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): 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 toggleGroup(groupId: number) {
const idx = form.group_ids.indexOf(groupId)
if (idx >= 0) {
form.group_ids.splice(idx, 1)
} else {
form.group_ids.push(groupId)
}
}
// ── Pricing helpers ──
function addPricingEntry() {
form.model_pricing.push({
modelsInput: '',
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: []
})
}
function updatePricingEntry(idx: number, updated: PricingFormEntry) {
form.model_pricing[idx] = updated
}
function removePricingEntry(idx: number) {
form.model_pricing.splice(idx, 1)
}
function formPricingToAPI(): ChannelModelPricing[] {
return form.model_pricing
.filter(e => e.modelsInput.trim())
.map(e => ({
models: e.modelsInput.split(',').map(m => m.trim()).filter(Boolean),
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),
intervals: formIntervalsToAPI(e.intervals || [])
}))
}
function apiPricingToForm(pricing: ChannelModelPricing[]): PricingFormEntry[] {
return pricing.map(p => ({
modelsInput: p.models.join(', '),
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,
intervals: apiIntervalsToForm(p.intervals || [])
}))
}
// ── 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
}
}
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.group_ids = []
form.model_pricing = []
}
function openCreateDialog() {
editingChannel.value = null
resetForm()
loadGroups()
showDialog.value = true
}
function openEditDialog(channel: Channel) {
editingChannel.value = channel
form.name = channel.name
form.description = channel.description || ''
form.status = channel.status
form.group_ids = [...(channel.group_ids || [])]
form.model_pricing = apiPricingToForm(channel.model_pricing || [])
loadGroups()
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
}
submitting.value = true
try {
if (editingChannel.value) {
const req: UpdateChannelRequest = {
name: form.name.trim(),
description: form.description.trim() || undefined,
status: form.status,
group_ids: form.group_ids,
model_pricing: formPricingToAPI()
}
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: form.group_ids,
model_pricing: formPricingToAPI()
}
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
}
}
// ── 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()
})
onUnmounted(() => {
clearTimeout(searchTimeout)
abortController?.abort()
})
</script>
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