2026-04-04 11:00:55 +08:00
< 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 >
2026-04-09 18:14:28 +08:00
< DataTable
: columns = "columns"
: data = "channels"
: loading = "loading"
: server - side - sort = "true"
default - sort - key = "created_at"
default - sort - order = "desc"
@ sort = "handleSort"
>
2026-04-04 11:00:55 +08:00
< 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 >
2026-03-31 21:21:03 +08:00
< template # cell -status = " { row } " >
< Toggle
: modelValue = "row.status === 'active'"
@ update : modelValue = "toggleChannelStatus(row)"
/ >
2026-04-04 11:00:55 +08:00
< / 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"
>
2026-03-30 19:06:03 +08:00
< 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 >
2026-03-31 01:34:16 +08:00
<!-- Platform Tabs ( only enabled ) -- >
2026-03-30 19:06:03 +08:00
< button
2026-03-31 01:34:16 +08:00
v - for = "section in form.platforms.filter(s => s.enabled)"
2026-03-30 19:06:03 +08:00
: 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 >
2026-04-04 11:00:55 +08:00
< / div >
2026-03-30 19:06:03 +08:00
<!-- 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 >
2026-04-04 11:00:55 +08:00
2026-03-30 19:06:03 +08:00
<!-- 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 >
2026-04-04 11:00:55 +08:00
2026-03-30 19:06:03 +08:00
<!-- 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 >
2026-03-30 13:26:05 +08:00
2026-03-30 19:06:03 +08:00
<!-- 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" >
2026-03-30 21:04:48 +08:00
< 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' "
2026-03-30 15:04:30 +08:00
>
2026-03-30 21:04:48 +08:00
< 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 >
2026-03-30 15:04:30 +08:00
< / div >
< / div >
2026-03-30 13:26:05 +08:00
< / div >
2026-03-30 15:04:30 +08:00
2026-03-30 19:06:03 +08:00
<!-- Platform Tab Content -- >
2026-03-30 15:04:30 +08:00
< div
v - for = "(section, sIdx) in form.platforms"
2026-03-30 19:06:03 +08:00
: key = "'tab-' + section.platform"
2026-03-31 01:34:16 +08:00
v - show = "section.enabled && activeTab === section.platform"
2026-03-30 19:06:03 +08:00
class = "space-y-4"
2026-03-30 15:04:30 +08:00
>
2026-03-30 19:06:03 +08:00
<!-- Groups -- >
< div >
< label class = "input-label text-xs" >
2026-03-31 19:22:48 +08:00
{ { t ( 'admin.channels.form.groups' , 'Associated Groups' ) } } < span class = "text-red-500" > * < / span >
2026-03-30 19:06:03 +08:00
< 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 } 个 ` ) } } )
2026-03-30 15:04:30 +08:00
< / span >
2026-03-30 19:06:03 +08:00
< / 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...' ) } }
2026-03-30 15:04:30 +08:00
< / div >
2026-03-30 19:06:03 +08:00
< 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' ) } }
2026-03-30 15:04:30 +08:00
< / div >
2026-03-30 19:06:03 +08:00
< 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' : ''
] "
2026-03-30 15:04:30 +08:00
>
< input
2026-03-30 19:06:03 +08:00
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)"
2026-03-30 15:04:30 +08:00
/ >
2026-03-30 19:06:03 +08:00
< 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 >
2026-03-30 15:04:30 +08:00
< / div >
< / div >
2026-03-30 19:06:03 +08:00
< / div >
2026-03-30 13:40:29 +08:00
2026-04-12 15:59:45 +08:00
<!-- Web Search Emulation ( Anthropic only , hidden when global disabled ) -- >
< div v-if = "section.platform === 'anthropic' && webSearchGlobalEnabled" class="border-t border-gray-200 pt-3 dark:border-dark-600" >
< div class = "flex items-center justify-between" >
< div >
< label class = "text-xs font-medium text-orange-600 dark:text-orange-400" >
{ { t ( 'admin.channels.form.webSearchEmulation' ) } }
< / label >
< p class = "mt-0.5 text-[11px] text-amber-500 dark:text-amber-400" >
{ { t ( 'admin.channels.form.webSearchEmulationHint' ) } }
< / p >
< / div >
< Toggle v-model = "section.web_search_emulation" / >
< / div >
< / div >
2026-03-30 19:06:03 +08:00
<!-- 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" >
2026-03-30 15:04:30 +08:00
< div
2026-03-30 19:06:03 +08:00
v - for = "(_, srcModel) in section.model_mapping"
: key = "srcModel"
class = "flex items-center gap-2"
2026-03-30 15:04:30 +08:00
>
2026-03-30 19:06:03 +08:00
< input
: value = "srcModel"
type = "text"
class = "input flex-1 text-xs"
2026-03-31 21:36:45 +08:00
: class = "getPlatformTextColor(section.platform)"
2026-03-30 19:06:03 +08:00
: 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"
2026-03-31 21:36:45 +08:00
: class = "getPlatformTextColor(section.platform)"
2026-03-30 19:06:03 +08:00
: placeholder = "t('admin.channels.form.mappingTarget', 'Target model')"
@ input = "section.model_mapping[srcModel] = ($event.target as HTMLInputElement).value"
2026-03-30 15:04:30 +08:00
/ >
2026-03-30 19:06:03 +08:00
< 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 >
2026-03-30 15:04:30 +08:00
< / div >
< / div >
< / div >
2026-03-30 19:06:03 +08:00
<!-- 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"
2026-03-31 21:27:32 +08:00
: platform = "section.platform"
2026-03-30 19:06:03 +08:00
@ update = "updatePricingEntry(sIdx, idx, $event)"
@ remove = "removePricingEntry(sIdx, idx)"
/ >
< / div >
< / div >
2026-03-30 13:40:29 +08:00
< / div >
2026-04-11 23:39:49 +08:00
<!-- Account Stats Pricing ( always visible , not tied to platform tabs ) -- >
< div class = "mt-6 border-t border-gray-200 pt-4 dark:border-dark-700" >
<!-- Toggle -- >
< div class = "flex items-center justify-between mb-3" >
< div >
< label class = "text-sm font-medium text-gray-700 dark:text-gray-300" >
{ { t ( 'admin.channels.form.applyPricingToAccountStats' , 'Apply Pricing to Account Stats' ) } }
< / label >
< p class = "mt-0.5 text-xs text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.channels.form.applyPricingToAccountStatsDesc' , 'When enabled, account statistics cost will use channel model pricing. Account rate multiplier still applies.' ) } }
< / p >
< / div >
< Toggle
: modelValue = "form.apply_pricing_to_account_stats"
@ update : modelValue = "form.apply_pricing_to_account_stats = $event"
/ >
< / div >
<!-- Custom rules ( only when toggle is on ) -- >
< div v-if = "form.apply_pricing_to_account_stats" class="mt-4 space-y-4" >
< div class = "flex items-center justify-between" >
< h4 class = "text-sm font-medium text-gray-700 dark:text-gray-300" >
{ { t ( 'admin.channels.form.accountStatsPricingRules' , 'Custom Account Stats Pricing Rules' ) } }
< / h4 >
< button
type = "button"
@ click = "addAccountStatsRule()"
class = "rounded-lg border border-primary-300 px-3 py-1 text-xs font-medium text-primary-600 hover:bg-primary-50 dark:border-primary-600 dark:text-primary-400 dark:hover:bg-primary-900/20"
>
+ { { t ( 'admin.channels.form.addRule' , 'Add Rule' ) } }
< / button >
< / div >
< p
v - if = "form.account_stats_pricing_rules.length === 0"
class = "text-xs italic text-gray-400 dark:text-gray-500"
>
{ { t ( 'admin.channels.form.noRulesConfigured' , 'No custom rules configured. Channel model pricing above will be used.' ) } }
< / p >
<!-- Rule cards -- >
< div
v - for = "(rule, ruleIndex) in form.account_stats_pricing_rules"
: key = "ruleIndex"
class = "space-y-3 rounded-lg border border-gray-200 p-4 dark:border-dark-600"
>
< div class = "flex items-center justify-between" >
< input
v - model = "rule.name"
: placeholder = "t('admin.channels.form.ruleName', 'Rule name (optional)')"
class = "bg-transparent text-sm font-medium text-gray-700 placeholder-gray-400 outline-none dark:text-gray-300"
/ >
< button
type = "button"
@ click = "removeAccountStatsRule(ruleIndex)"
class = "text-xs text-red-500 hover:text-red-700"
>
{ { t ( 'common.delete' , 'Delete' ) } }
< / button >
< / div >
<!-- Group selection ( multi - select from channel ' s groups ) -- >
< div >
< label class = "text-xs text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.channels.form.ruleGroups' , 'Groups' ) } }
< / label >
< div class = "mt-1 flex flex-wrap gap-1" >
< label
v - for = "gid in allFormGroupIds"
: key = "gid"
class = "inline-flex cursor-pointer items-center gap-1 rounded-md border px-2 py-1 text-xs transition-colors"
: class = " rule . group _ids . includes ( gid )
? 'border-primary-300 bg-primary-50 dark:border-primary-700 dark:bg-primary-900/20'
: 'border-gray-200 hover:bg-gray-50 dark:border-dark-600 dark:hover:bg-dark-700' "
>
< input
type = "checkbox"
: checked = "rule.group_ids.includes(gid)"
class = "h-3 w-3 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
@ change = "rule.group_ids.includes(gid) ? rule.group_ids.splice(rule.group_ids.indexOf(gid), 1) : rule.group_ids.push(gid)"
/ >
< span > { { getGroupNameById ( gid ) } } < / span >
< / label >
< / div >
< p v-if = "allFormGroupIds.length === 0" class="mt-1 text-xs text-gray-400" >
{ { t ( 'admin.channels.form.noGroupsInChannel' , 'No groups selected in platform tabs above' ) } }
< / p >
< / div >
<!-- Account IDs input -- >
< div >
< label class = "text-xs text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.channels.form.ruleAccounts' , 'Account IDs' ) } }
< / label >
< input
: value = "rule.account_ids.join(', ')"
@ change = "rule.account_ids = parseAccountIdsInput(($event.target as HTMLInputElement).value)"
: placeholder = "t('admin.channels.form.ruleAccountsPlaceholder', 'Enter account IDs, comma-separated')"
class = "input mt-1 text-sm"
/ >
< / div >
<!-- Model Pricing entries -- >
< div >
< div class = "mb-1 flex items-center justify-between" >
< label class = "text-xs text-gray-500 dark:text-gray-400" >
{ { t ( 'admin.channels.form.ruleModelPricing' , 'Model Pricing' ) } }
< / label >
< button
type = "button"
@ click = "addRulePricingEntry(ruleIndex)"
class = "text-xs text-primary-600 hover:text-primary-700"
>
+ { { t ( 'common.add' , 'Add' ) } }
< / button >
< / div >
< div
v - if = "rule.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, pIdx) in rule.pricing"
: key = "pIdx"
: entry = "entry"
platform = ""
@ update = "rule.pricing.splice(pIdx, 1, $event)"
@ remove = "removeRulePricingEntry(ruleIndex, pIdx)"
/ >
< / div >
< / div >
< / div >
< / div >
< / div >
2026-03-30 19:06:03 +08:00
< / form >
< / div >
2026-04-04 11:00:55 +08:00
< 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'
2026-04-12 15:59:45 +08:00
import { extractApiErrorMessage } from '@/utils/apiError'
2026-04-04 11:00:55 +08:00
import { adminAPI } from '@/api/admin'
2026-04-11 23:39:49 +08:00
import type { Channel , ChannelModelPricing , CreateChannelRequest , UpdateChannelRequest , AccountStatsPricingRule } from '@/api/admin/channels'
2026-04-04 11:00:55 +08:00
import type { PricingFormEntry } from '@/components/admin/channel/types'
2026-04-02 20:28:04 +08:00
import { mTokToPerToken , perTokenToMTok , apiIntervalsToForm , formIntervalsToAPI , findModelConflict , validateIntervals } from '@/components/admin/channel/types'
2026-03-30 13:26:05 +08:00
import type { AdminGroup , GroupPlatform } from '@/types'
2026-04-04 11:00:55 +08:00
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'
2026-03-30 02:36:04 +08:00
import PlatformIcon from '@/components/common/PlatformIcon.vue'
2026-03-31 21:21:03 +08:00
import Toggle from '@/components/common/Toggle.vue'
2026-04-04 11:00:55 +08:00
import PricingEntryCard from '@/components/admin/channel/PricingEntryCard.vue'
import { getPersistedPageSize } from '@/composables/usePersistedPageSize'
const { t } = useI18n ( )
const appStore = useAppStore ( )
2026-04-12 15:59:45 +08:00
// Web Search global enabled state (loaded once on mount)
const webSearchGlobalEnabled = ref ( false )
async function loadWebSearchGlobalState ( ) {
try {
const cfg = await adminAPI . settings . getWebSearchEmulationConfig ( )
webSearchGlobalEnabled . value = cfg ? . enabled === true && ( cfg ? . providers ? . length ? ? 0 ) > 0
} catch ( err : unknown ) {
console . warn ( 'Failed to load web search global state:' , err )
webSearchGlobalEnabled . value = false
}
}
2026-03-30 15:04:30 +08:00
// ── Platform Section type ──
interface PlatformSection {
platform : GroupPlatform
2026-03-31 01:34:16 +08:00
enabled : boolean
2026-03-30 15:04:30 +08:00
collapsed : boolean
group _ids : number [ ]
model _mapping : Record < string , string >
model _pricing : PricingFormEntry [ ]
2026-04-12 15:59:45 +08:00
web _search _emulation : boolean
2026-03-30 15:04:30 +08:00
}
2026-04-04 11:00:55 +08:00
// ── 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' ) }
] )
2026-03-30 13:26:05 +08:00
const billingModelSourceOptions = computed ( ( ) => [
2026-04-01 15:08:57 +08:00
{ value : 'channel_mapped' , label : t ( 'admin.channels.form.billingModelSourceChannelMapped' , 'Bill by channel-mapped model' ) } ,
2026-03-30 13:26:05 +08:00
{ 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' ) }
] )
2026-04-04 11:00:55 +08:00
// ── 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
} )
2026-04-09 18:14:28 +08:00
const sortState = reactive ( {
sort _by : 'created_at' ,
sort _order : 'desc' as 'asc' | 'desc'
} )
2026-04-04 11:00:55 +08:00
// 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 )
2026-03-30 19:06:03 +08:00
const activeTab = ref < string > ( 'basic' )
2026-04-04 11:00:55 +08:00
// Groups
const allGroups = ref < AdminGroup [ ] > ( [ ] )
const groupsLoading = ref ( false )
2026-04-02 23:47:37 +08:00
// All channels for group-conflict detection (independent of current page)
const allChannelsForConflict = ref < Channel [ ] > ( [ ] )
2026-04-04 11:00:55 +08:00
// Form data
const form = reactive ( {
name : '' ,
description : '' ,
status : 'active' ,
2026-03-30 13:26:05 +08:00
restrict _models : false ,
2026-04-01 15:08:57 +08:00
billing _model _source : 'channel_mapped' as string ,
2026-04-11 23:39:49 +08:00
platforms : [ ] as PlatformSection [ ] ,
apply _pricing _to _account _stats : false ,
account _stats _pricing _rules : [ ] as Array < {
name : string
group _ids : number [ ]
account _ids : number [ ]
pricing : PricingFormEntry [ ]
} >
2026-04-04 11:00:55 +08:00
} )
let abortController : AbortController | null = null
2026-03-30 15:04:30 +08:00
// ── Platform config ──
2026-03-30 21:37:27 +08:00
const platformOrder : GroupPlatform [ ] = [ 'anthropic' , 'openai' , 'gemini' , 'antigravity' ]
2026-03-30 13:26:05 +08:00
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'
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'
default : return 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400'
}
}
2026-03-30 15:04:30 +08:00
// ── Helpers ──
function formatDate ( value : string ) : string {
if ( ! value ) return '-'
return new Date ( value ) . toLocaleDateString ( )
}
2026-03-30 13:26:05 +08:00
2026-03-30 15:04:30 +08:00
// ── Platform section helpers ──
2026-03-31 01:34:16 +08:00
const activePlatforms = computed ( ( ) => form . platforms . filter ( s => s . enabled ) . map ( s => s . platform ) )
2026-03-30 02:36:04 +08:00
2026-03-30 15:04:30 +08:00
function addPlatformSection ( platform : GroupPlatform ) {
form . platforms . push ( {
platform ,
2026-03-31 01:34:16 +08:00
enabled : true ,
2026-03-30 15:04:30 +08:00
collapsed : false ,
group _ids : [ ] ,
model _mapping : { } ,
2026-04-12 15:59:45 +08:00
model _pricing : [ ] ,
web _search _emulation : false ,
2026-03-30 15:04:30 +08:00
} )
2026-03-30 21:04:48 +08:00
}
function togglePlatform ( platform : GroupPlatform ) {
2026-03-31 01:34:16 +08:00
const section = form . platforms . find ( s => s . platform === platform )
if ( section ) {
section . enabled = ! section . enabled
if ( ! section . enabled && activeTab . value === platform ) {
activeTab . value = 'basic'
}
2026-03-30 21:04:48 +08:00
} else {
addPlatformSection ( platform )
}
2026-03-30 15:04:30 +08:00
}
function getGroupsForPlatform ( platform : GroupPlatform ) : AdminGroup [ ] {
return allGroups . value . filter ( g => g . platform === platform )
}
// ── Group helpers ──
2026-04-04 11:00:55 +08:00
const groupToChannelMap = computed ( ( ) => {
const map = new Map < number , Channel > ( )
2026-04-02 23:47:37 +08:00
for ( const ch of allChannelsForConflict . value ) {
2026-04-04 11:00:55 +08:00
if ( editingChannel . value && ch . id === editingChannel . value . id ) continue
for ( const gid of ch . group _ids || [ ] ) {
map . set ( gid , ch )
}
}
return map
} )
2026-03-30 15:04:30 +08:00
function isGroupInOtherChannel ( groupId : number , _platform : string ) : boolean {
2026-04-04 11:00:55 +08:00
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. `
)
} )
2026-03-30 15:04:30 +08:00
function toggleGroupInSection ( sectionIdx : number , groupId : number ) {
const section = form . platforms [ sectionIdx ]
const idx = section . group _ids . indexOf ( groupId )
2026-04-04 11:00:55 +08:00
if ( idx >= 0 ) {
2026-03-30 15:04:30 +08:00
section . group _ids . splice ( idx , 1 )
2026-04-04 11:00:55 +08:00
} else {
2026-03-30 15:04:30 +08:00
section . group _ids . push ( groupId )
2026-04-04 11:00:55 +08:00
}
}
// ── Pricing helpers ──
2026-03-30 15:04:30 +08:00
function addPricingEntry ( sectionIdx : number ) {
form . platforms [ sectionIdx ] . model _pricing . push ( {
2026-03-30 02:24:54 +08:00
models : [ ] ,
2026-04-04 11:00:55 +08:00
billing _mode : 'token' ,
input _price : null ,
output _price : null ,
cache _write _price : null ,
cache _read _price : null ,
image _output _price : null ,
2026-03-30 13:26:05 +08:00
per _request _price : null ,
2026-04-04 11:00:55 +08:00
intervals : [ ]
} )
}
2026-03-30 15:04:30 +08:00
function updatePricingEntry ( sectionIdx : number , idx : number , updated : PricingFormEntry ) {
2026-03-30 21:47:06 +08:00
form . platforms [ sectionIdx ] . model _pricing . splice ( idx , 1 , updated )
2026-04-04 11:00:55 +08:00
}
2026-03-30 15:04:30 +08:00
function removePricingEntry ( sectionIdx : number , idx : number ) {
form . platforms [ sectionIdx ] . model _pricing . splice ( idx , 1 )
2026-04-04 11:00:55 +08:00
}
2026-03-30 13:26:05 +08:00
// ── Model Mapping helpers ──
2026-03-30 15:04:30 +08:00
function addMappingEntry ( sectionIdx : number ) {
const mapping = form . platforms [ sectionIdx ] . model _mapping
2026-03-30 13:26:05 +08:00
let key = ''
let i = 1
2026-03-30 15:04:30 +08:00
while ( key === '' || key in mapping ) {
2026-03-30 13:26:05 +08:00
key = ` model- ${ i } `
i ++
}
2026-03-30 15:04:30 +08:00
mapping [ key ] = ''
2026-03-30 13:26:05 +08:00
}
2026-03-30 15:04:30 +08:00
function removeMappingEntry ( sectionIdx : number , key : string ) {
delete form . platforms [ sectionIdx ] . model _mapping [ key ]
2026-03-30 13:26:05 +08:00
}
2026-03-30 15:04:30 +08:00
function renameMappingKey ( sectionIdx : number , oldKey : string , newKey : string ) {
2026-03-30 13:26:05 +08:00
newKey = newKey . trim ( )
if ( ! newKey || newKey === oldKey ) return
2026-03-30 15:04:30 +08:00
const mapping = form . platforms [ sectionIdx ] . model _mapping
if ( newKey in mapping ) return
const value = mapping [ oldKey ]
delete mapping [ oldKey ]
mapping [ newKey ] = value
}
2026-04-11 23:39:49 +08:00
// ── Account Stats Pricing helpers ──
function addAccountStatsRule ( ) {
form . account _stats _pricing _rules . push ( {
name : '' ,
group _ids : [ ] ,
account _ids : [ ] ,
pricing : [ ]
} )
}
function addRulePricingEntry ( ruleIndex : number ) {
form . account _stats _pricing _rules [ ruleIndex ] . 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 removeAccountStatsRule ( ruleIndex : number ) {
form . account _stats _pricing _rules . splice ( ruleIndex , 1 )
}
function removeRulePricingEntry ( ruleIndex : number , pricingIndex : number ) {
form . account _stats _pricing _rules [ ruleIndex ] . pricing . splice ( pricingIndex , 1 )
}
function getGroupNameById ( groupId : number ) : string {
const group = allGroups . value . find ( g => g . id === groupId )
return group ? group . name : ` # ${ groupId } `
}
/** Collect all group_ids from enabled platform sections */
const allFormGroupIds = computed ( ( ) => {
const ids = new Set < number > ( )
for ( const section of form . platforms ) {
if ( ! section . enabled ) continue
for ( const gid of section . group _ids ) {
ids . add ( gid )
}
}
return [ ... ids ]
} )
function parseAccountIdsInput ( value : string ) : number [ ] {
return value
. split ( ',' )
. map ( s => parseInt ( s . trim ( ) ) )
. filter ( n => ! isNaN ( n ) && n > 0 )
}
function accountStatsRulesToAPI ( ) : AccountStatsPricingRule [ ] {
return form . account _stats _pricing _rules . map ( rule => ( {
name : rule . name ,
group _ids : rule . group _ids ,
account _ids : rule . account _ids ,
pricing : rule . pricing
. filter ( p => p . models . length > 0 )
. map ( p => ( {
platform : '' ,
models : p . models ,
billing _mode : p . billing _mode ,
input _price : mTokToPerToken ( p . input _price ) ,
output _price : mTokToPerToken ( p . output _price ) ,
cache _write _price : mTokToPerToken ( p . cache _write _price ) ,
cache _read _price : mTokToPerToken ( p . cache _read _price ) ,
image _output _price : mTokToPerToken ( p . image _output _price ) ,
per _request _price : p . per _request _price != null && p . per _request _price !== '' ? Number ( p . per _request _price ) : null ,
intervals : formIntervalsToAPI ( p . intervals || [ ] )
} ) )
} ) )
}
2026-03-30 15:04:30 +08:00
// ── Form ↔ API conversion ──
2026-04-12 15:59:45 +08:00
function formToAPI ( ) : { group _ids : number [ ] , model _pricing : ChannelModelPricing [ ] , model _mapping : Record < string , Record < string , string > > , features _config : Record < string , unknown > } {
2026-03-30 15:04:30 +08:00
const group _ids : number [ ] = [ ]
const model _pricing : ChannelModelPricing [ ] = [ ]
const model _mapping : Record < string , Record < string , string > > = { }
2026-04-12 15:59:45 +08:00
// Preserve existing features_config fields not managed by the form
const featuresConfig : Record < string , unknown > = editingChannel . value ? . features _config
? { ... editingChannel . value . features _config }
: { }
2026-03-30 15:04:30 +08:00
for ( const section of form . platforms ) {
2026-03-31 01:34:16 +08:00
if ( ! section . enabled ) continue
2026-03-30 15:04:30 +08:00
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 || [ ] )
} )
}
}
2026-04-12 15:59:45 +08:00
// Collect web_search_emulation (only anthropic platform supports it)
const wsEmulation : Record < string , boolean > = { }
for ( const section of form . platforms ) {
if ( ! section . enabled ) continue
if ( section . web _search _emulation && section . platform === 'anthropic' ) {
wsEmulation [ section . platform ] = true
}
}
if ( Object . keys ( wsEmulation ) . length > 0 ) {
featuresConfig . web _search _emulation = wsEmulation
}
return { group _ids , model _pricing , model _mapping , features _config : featuresConfig }
2026-03-30 15:04:30 +08:00
}
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 ) )
2026-04-12 15:59:45 +08:00
// Read web_search_emulation from features_config
const fc = channel . features _config
const wsEmulation = fc ? . web _search _emulation as Record < string , boolean > | undefined
const webSearchEnabled = wsEmulation ? . [ platform ] === true
2026-03-30 15:04:30 +08:00
sections . push ( {
platform ,
2026-03-31 01:34:16 +08:00
enabled : true ,
2026-03-30 15:04:30 +08:00
collapsed : false ,
group _ids : groupIds ,
model _mapping : { ... mapping } ,
2026-04-12 15:59:45 +08:00
model _pricing : pricing ,
web _search _emulation : webSearchEnabled ,
2026-03-30 15:04:30 +08:00
} )
}
return sections
2026-03-30 13:26:05 +08:00
}
2026-04-04 11:00:55 +08:00
// ── 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 ,
2026-04-09 18:14:28 +08:00
search : searchQuery . value || undefined ,
sort _by : sortState . sort _by ,
sort _order : sortState . sort _order
2026-04-04 11:00:55 +08:00
} , { signal : ctrl . signal } )
if ( ctrl . signal . aborted || abortController !== ctrl ) return
channels . value = response . items || [ ]
pagination . total = response . total
2026-04-12 15:59:45 +08:00
} catch ( error : unknown ) {
const e = error as { name ? : string ; code ? : string }
if ( e ? . name === 'AbortError' || e ? . code === 'ERR_CANCELED' ) return
appStore . showError ( extractApiErrorMessage ( error , t ( 'admin.channels.loadError' , 'Failed to load channels' ) ) )
2026-04-04 11:00:55 +08:00
} 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
}
}
2026-04-02 23:47:37 +08:00
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
}
}
2026-04-04 11:00:55 +08:00
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 ( )
}
2026-04-09 18:14:28 +08:00
function handleSort ( key : string , order : 'asc' | 'desc' ) {
sortState . sort _by = key
sortState . sort _order = order
pagination . page = 1
loadChannels ( )
}
2026-04-04 11:00:55 +08:00
// ── Dialog ──
function resetForm ( ) {
form . name = ''
form . description = ''
form . status = 'active'
2026-03-30 13:26:05 +08:00
form . restrict _models = false
2026-04-01 15:08:57 +08:00
form . billing _model _source = 'channel_mapped'
2026-03-30 15:04:30 +08:00
form . platforms = [ ]
2026-04-11 23:39:49 +08:00
form . apply _pricing _to _account _stats = false
form . account _stats _pricing _rules = [ ]
2026-03-30 19:06:03 +08:00
activeTab . value = 'basic'
2026-04-04 11:00:55 +08:00
}
2026-03-30 17:50:48 +08:00
async function openCreateDialog ( ) {
2026-04-04 11:00:55 +08:00
editingChannel . value = null
resetForm ( )
2026-04-02 23:47:37 +08:00
await Promise . all ( [ loadGroups ( ) , loadAllChannelsForConflict ( ) ] )
2026-04-04 11:00:55 +08:00
showDialog . value = true
}
2026-03-30 17:50:48 +08:00
async function openEditDialog ( channel : Channel ) {
2026-04-04 11:00:55 +08:00
editingChannel . value = channel
form . name = channel . name
form . description = channel . description || ''
form . status = channel . status
2026-03-30 13:26:05 +08:00
form . restrict _models = channel . restrict _models || false
2026-04-01 15:08:57 +08:00
form . billing _model _source = channel . billing _model _source || 'channel_mapped'
2026-04-11 23:39:49 +08:00
form . apply _pricing _to _account _stats = channel . apply _pricing _to _account _stats || false
form . account _stats _pricing _rules = ( channel . account _stats _pricing _rules || [ ] ) . map ( rule => ( {
name : rule . name || '' ,
group _ids : [ ... ( rule . group _ids || [ ] ) ] ,
account _ids : [ ... ( rule . account _ids || [ ] ) ] ,
pricing : ( rule . pricing || [ ] ) . 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 ) )
} ) )
2026-03-30 15:04:30 +08:00
// Must load groups first so apiToForm can map groupID → platform
2026-04-02 23:47:37 +08:00
await Promise . all ( [ loadGroups ( ) , loadAllChannelsForConflict ( ) ] )
2026-03-30 17:50:48 +08:00
form . platforms = apiToForm ( channel )
2026-04-04 11:00:55 +08:00
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
}
2026-03-31 19:22:48 +08:00
// 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
}
}
}
2026-04-01 01:51:19 +08:00
// Check model pattern conflicts per platform (duplicate / wildcard overlap)
2026-03-31 19:40:07 +08:00
for ( const section of form . platforms . filter ( s => s . enabled ) ) {
2026-04-01 01:51:19 +08:00
// Collect all pricing models for this platform
const allModels : string [ ] = [ ]
2026-03-31 19:40:07 +08:00
for ( const entry of section . model _pricing ) {
2026-04-01 01:51:19 +08:00
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
2026-03-31 19:40:07 +08:00
}
}
2026-03-30 02:36:04 +08:00
}
2026-03-31 01:34:16 +08:00
// 校验 per_request/image 模式必须有价格 (只校验启用的平台)
for ( const section of form . platforms . filter ( s => s . enabled ) ) {
2026-03-31 00:23:45 +08:00
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 ) ) {
2026-04-01 23:13:58 +08:00
appStore . showError ( t ( 'admin.channels.form.perRequestPriceRequired' , '按次/图片计费模式必须设置默认价格或至少一个计费层级' ) )
2026-03-31 00:23:45 +08:00
return
}
}
}
2026-04-02 20:28:04 +08:00
// 校验区间合法性(范围、重叠等)
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
}
}
}
2026-04-12 15:59:45 +08:00
const { group _ids , model _pricing , model _mapping , features _config } = formToAPI ( )
2026-03-30 15:04:30 +08:00
2026-04-04 11:00:55 +08:00
submitting . value = true
try {
if ( editingChannel . value ) {
const req : UpdateChannelRequest = {
name : form . name . trim ( ) ,
description : form . description . trim ( ) || undefined ,
status : form . status ,
2026-03-30 15:04:30 +08:00
group _ids ,
model _pricing ,
2026-04-01 15:08:57 +08:00
model _mapping : Object . keys ( model _mapping ) . length > 0 ? model _mapping : { } ,
2026-03-30 13:26:05 +08:00
billing _model _source : form . billing _model _source ,
feat(gateway): add web search emulation for Anthropic API Key accounts
Inject web search capability for Claude Console (API Key) accounts that
don't natively support Anthropic's web_search tool. When a pure
web_search request is detected, the gateway calls Brave Search or Tavily
API directly and constructs an Anthropic-protocol-compliant SSE/JSON
response without forwarding to upstream.
Backend:
- New `pkg/websearch/` SDK: Brave and Tavily provider implementations
with io.LimitReader, proxy support, and Redis-based quota tracking
(Lua atomic INCR + TTL, DECR rollback on failure)
- Global config via `settings.web_search_emulation_config` (JSON) with
in-process cache + singleflight, input validation, API key merge on
save, and sanitized API responses
- Channel-level toggle via `channels.features_config` JSONB column
(DB migration 101)
- Account-level toggle via `accounts.extra.web_search_emulation`
- Request interception in `Forward()` with SSE streaming response
construction using json.Marshal (no manual string concatenation)
- Manager hot-reload: `RebuildWebSearchManager()` called on config save
and startup via `SetWebSearchRedisClient()`
- 70 unit tests covering providers, manager, config validation,
sanitization, tool detection, query extraction, and response building
Frontend:
- Settings → Gateway tab: Web Search Emulation config card with global
toggle, provider list (add/remove, API key, priority, quota, proxy)
- Channels → Anthropic tab: web search emulation toggle with global
state linkage (disabled when global off)
- Account Create/Edit modals: web search emulation toggle for API Key
type with Toggle component
- Full i18n coverage (zh + en)
2026-04-12 00:02:26 +08:00
restrict _models : form . restrict _models ,
2026-04-12 15:59:45 +08:00
features _config ,
2026-04-11 23:39:49 +08:00
apply _pricing _to _account _stats : form . apply _pricing _to _account _stats ,
account _stats _pricing _rules : accountStatsRulesToAPI ( )
2026-04-04 11:00:55 +08:00
}
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 ,
2026-03-30 15:04:30 +08:00
group _ids ,
model _pricing ,
2026-04-01 15:08:57 +08:00
model _mapping : Object . keys ( model _mapping ) . length > 0 ? model _mapping : { } ,
2026-03-30 13:26:05 +08:00
billing _model _source : form . billing _model _source ,
feat(gateway): add web search emulation for Anthropic API Key accounts
Inject web search capability for Claude Console (API Key) accounts that
don't natively support Anthropic's web_search tool. When a pure
web_search request is detected, the gateway calls Brave Search or Tavily
API directly and constructs an Anthropic-protocol-compliant SSE/JSON
response without forwarding to upstream.
Backend:
- New `pkg/websearch/` SDK: Brave and Tavily provider implementations
with io.LimitReader, proxy support, and Redis-based quota tracking
(Lua atomic INCR + TTL, DECR rollback on failure)
- Global config via `settings.web_search_emulation_config` (JSON) with
in-process cache + singleflight, input validation, API key merge on
save, and sanitized API responses
- Channel-level toggle via `channels.features_config` JSONB column
(DB migration 101)
- Account-level toggle via `accounts.extra.web_search_emulation`
- Request interception in `Forward()` with SSE streaming response
construction using json.Marshal (no manual string concatenation)
- Manager hot-reload: `RebuildWebSearchManager()` called on config save
and startup via `SetWebSearchRedisClient()`
- 70 unit tests covering providers, manager, config validation,
sanitization, tool detection, query extraction, and response building
Frontend:
- Settings → Gateway tab: Web Search Emulation config card with global
toggle, provider list (add/remove, API key, priority, quota, proxy)
- Channels → Anthropic tab: web search emulation toggle with global
state linkage (disabled when global off)
- Account Create/Edit modals: web search emulation toggle for API Key
type with Toggle component
- Full i18n coverage (zh + en)
2026-04-12 00:02:26 +08:00
restrict _models : form . restrict _models ,
2026-04-12 15:59:45 +08:00
features _config ,
2026-04-11 23:39:49 +08:00
apply _pricing _to _account _stats : form . apply _pricing _to _account _stats ,
account _stats _pricing _rules : accountStatsRulesToAPI ( )
2026-04-04 11:00:55 +08:00
}
await adminAPI . channels . create ( req )
appStore . showSuccess ( t ( 'admin.channels.createSuccess' , 'Channel created' ) )
}
closeDialog ( )
loadChannels ( )
2026-04-12 15:59:45 +08:00
} catch ( error : unknown ) {
appStore . showError ( extractApiErrorMessage ( error , editingChannel . value
2026-04-04 11:00:55 +08:00
? t ( 'admin.channels.updateError' , 'Failed to update channel' )
2026-04-12 15:59:45 +08:00
: t ( 'admin.channels.createError' , 'Failed to create channel' ) ) )
2026-04-04 11:00:55 +08:00
} finally {
submitting . value = false
}
}
2026-03-31 21:21:03 +08:00
// ── Toggle status ──
async function toggleChannelStatus ( channel : Channel ) {
const newStatus = channel . status === 'active' ? 'disabled' : 'active'
try {
await adminAPI . channels . update ( channel . id , { status : newStatus } )
2026-04-02 23:47:37 +08:00
if ( filters . status && filters . status !== newStatus ) {
// Item no longer matches the active filter — reload list
await loadChannels ( )
} else {
channel . status = newStatus
}
2026-03-31 21:21:03 +08:00
} catch ( error ) {
appStore . showError ( t ( 'admin.channels.updateError' , 'Failed to update channel' ) )
console . error ( 'Error toggling channel status:' , error )
}
}
2026-04-04 11:00:55 +08:00
// ── 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 ( )
2026-04-12 15:59:45 +08:00
} catch ( error : unknown ) {
appStore . showError ( extractApiErrorMessage ( error , t ( 'admin.channels.deleteError' , 'Failed to delete channel' ) ) )
2026-04-04 11:00:55 +08:00
}
}
// ── Lifecycle ──
onMounted ( ( ) => {
loadChannels ( )
2026-03-30 17:50:48 +08:00
loadGroups ( )
2026-04-12 15:59:45 +08:00
loadWebSearchGlobalState ( )
2026-04-04 11:00:55 +08:00
} )
onUnmounted ( ( ) => {
clearTimeout ( searchTimeout )
abortController ? . abort ( )
} )
< / script >
2026-03-30 19:06:03 +08:00
< style scoped >
. channel - dialog - body {
display : flex ;
flex - direction : column ;
height : 70 vh ;
min - height : 400 px ;
}
. 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 >