feat(channel): 渠道管理系统 — 多模式定价 + 统一计费解析
Cherry-picked from release/custom-0.1.106: a9117600
This commit is contained in:
628
frontend/src/views/admin/ChannelsView.vue
Normal file
628
frontend/src/views/admin/ChannelsView.vue
Normal file
@@ -0,0 +1,628 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user