Files
sub2api/frontend/src/views/admin/ChannelsView.vue
erio a9880ee7b9 fix: round-2 audit fixes — security, code quality, and UI improvements
Security (HIGH):
- Normalize all Redis cache keys to lowercase (verifyCode, passwordReset)
- Fix verify code TTL renewal on failed attempts: use remaining TTL via
  ExpiresAt field instead of resetting to full 15-minute window
- Add 3 missing fields to diffSettings audit log (promo_code, invitation_code,
  custom_endpoints)

Code quality (MEDIUM):
- Extract filterVerifiedEmails shared helper (balance_notify_service.go)
- Add Pricing array non-empty validation for channel pricing rules
- Add platform token semantics comment in gateway_service.go
- Complete validatePlanPatch test coverage (+10 test cases)
- Replace string types with QuotaThresholdType/QuotaResetMode across frontend
- Remove duplicate getPlatformTextColor/getRateBadgeClass in ChannelsView
- Return EMAIL_NOT_FOUND error on RemoveNotifyEmail miss

UI improvements:
- Reorder cost tooltip: user billing above separator, account billing below
- Add NaN guard to accountBilled function
- Move timezone selector inline into reset-mode row (no longer standalone)
2026-04-14 09:35:05 +08:00

1516 lines
60 KiB
Vue

<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"
:server-side-sort="true"
default-sort-key="created_at"
default-sort-order="desc"
@sort="handleSort"
>
<template #cell-name="{ value }">
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
</template>
<template #cell-description="{ value }">
<span class="text-sm text-gray-600 dark:text-gray-400">{{ value || '-' }}</span>
</template>
<template #cell-status="{ row }">
<Toggle
:modelValue="row.status === 'active'"
@update:modelValue="toggleChannelStatus(row)"
/>
</template>
<template #cell-group_count="{ row }">
<span
class="inline-flex items-center rounded bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-800 dark:bg-dark-600 dark:text-gray-300"
>
{{ (row.group_ids || []).length }}
{{ t('admin.channels.groupsUnit', 'groups') }}
</span>
</template>
<template #cell-pricing_count="{ row }">
<span
class="inline-flex items-center rounded bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-800 dark:bg-dark-600 dark:text-gray-300"
>
{{ (row.model_pricing || []).length }}
{{ t('admin.channels.pricingUnit', 'pricing rules') }}
</span>
</template>
<template #cell-created_at="{ value }">
<span class="text-sm text-gray-600 dark:text-gray-400">
{{ formatDate(value) }}
</span>
</template>
<template #cell-actions="{ row }">
<div class="flex items-center gap-1">
<button
@click="openEditDialog(row)"
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400"
>
<Icon name="edit" size="sm" />
<span class="text-xs">{{ t('common.edit', 'Edit') }}</span>
</button>
<button
@click="handleDelete(row)"
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
>
<Icon name="trash" size="sm" />
<span class="text-xs">{{ t('common.delete', 'Delete') }}</span>
</button>
</div>
</template>
<template #empty>
<EmptyState
:title="t('admin.channels.noChannelsYet', 'No Channels Yet')"
:description="t('admin.channels.createFirstChannel', 'Create your first channel to manage model pricing')"
:action-text="t('admin.channels.createChannel', 'Create Channel')"
@action="openCreateDialog"
/>
</template>
</DataTable>
</template>
<template #pagination>
<Pagination
v-if="pagination.total > 0"
:page="pagination.page"
:total="pagination.total"
:page-size="pagination.page_size"
@update:page="handlePageChange"
@update:pageSize="handlePageSizeChange"
/>
</template>
</TablePageLayout>
<!-- Create/Edit Dialog -->
<BaseDialog
:show="showDialog"
:title="editingChannel ? t('admin.channels.editChannel', 'Edit Channel') : t('admin.channels.createChannel', 'Create Channel')"
width="extra-wide"
@close="closeDialog"
>
<div class="channel-dialog-body">
<!-- Tab Bar -->
<div class="flex items-center border-b border-gray-200 dark:border-dark-700 flex-shrink-0 -mx-4 sm:-mx-6 px-4 sm:px-6 -mt-3 sm:-mt-4">
<!-- Basic Settings Tab -->
<button
type="button"
@click="activeTab = 'basic'"
class="channel-tab"
:class="activeTab === 'basic' ? 'channel-tab-active' : 'channel-tab-inactive'"
>
{{ t('admin.channels.form.basicSettings', '基础设置') }}
</button>
<!-- Platform Tabs (only enabled) -->
<button
v-for="section in form.platforms.filter(s => s.enabled)"
:key="section.platform"
type="button"
@click="activeTab = section.platform"
class="channel-tab group"
:class="activeTab === section.platform ? 'channel-tab-active' : 'channel-tab-inactive'"
>
<PlatformIcon :platform="section.platform" size="xs" :class="platformTextClass(section.platform)" />
<span :class="platformTextClass(section.platform)">{{ t('admin.groups.platforms.' + section.platform, section.platform) }}</span>
</button>
</div>
<!-- Tab Content -->
<form id="channel-form" @submit.prevent="handleSubmit" class="flex-1 overflow-y-auto pt-4">
<!-- Basic Settings Tab -->
<div v-show="activeTab === 'basic'" class="space-y-5">
<!-- Name -->
<div>
<label class="input-label">{{ t('admin.channels.form.name', 'Name') }} <span class="text-red-500">*</span></label>
<input
v-model="form.name"
type="text"
required
class="input"
:placeholder="t('admin.channels.form.namePlaceholder', 'Enter channel name')"
/>
</div>
<!-- Description -->
<div>
<label class="input-label">{{ t('admin.channels.form.description', 'Description') }}</label>
<textarea
v-model="form.description"
rows="2"
class="input"
:placeholder="t('admin.channels.form.descriptionPlaceholder', 'Optional description')"
></textarea>
</div>
<!-- Status (edit only) -->
<div v-if="editingChannel">
<label class="input-label">{{ t('admin.channels.form.status', 'Status') }}</label>
<Select v-model="form.status" :options="statusEditOptions" />
</div>
<!-- Model Restriction -->
<div>
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
v-model="form.restrict_models"
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
<span class="input-label mb-0">{{ t('admin.channels.form.restrictModels', 'Restrict Models') }}</span>
</label>
<p class="mt-1 ml-6 text-xs text-gray-400">
{{ t('admin.channels.form.restrictModelsHint', 'When enabled, only models in the pricing list are allowed. Others will be rejected.') }}
</p>
</div>
<!-- Billing Basis -->
<div>
<label class="input-label">{{ t('admin.channels.form.billingModelSource', 'Billing Basis') }}</label>
<Select v-model="form.billing_model_source" :options="billingModelSourceOptions" />
<p class="mt-1 text-xs text-gray-400">
{{ t('admin.channels.form.billingModelSourceHint', 'Controls which model name is used for pricing lookup') }}
</p>
</div>
<!-- Platform Management -->
<div class="space-y-3">
<label class="input-label mb-0">{{ t('admin.channels.form.platformConfig', '平台配置') }}</label>
<div class="flex flex-wrap gap-2">
<label
v-for="p in platformOrder"
:key="p"
class="inline-flex cursor-pointer items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors"
:class="activePlatforms.includes(p)
? 'bg-primary-50 border-primary-300 dark:bg-primary-900/20 dark:border-primary-700'
: 'border-gray-200 hover:bg-gray-50 dark:border-dark-600 dark:hover:bg-dark-700'"
>
<input
type="checkbox"
:checked="activePlatforms.includes(p)"
class="h-3.5 w-3.5 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
@change="togglePlatform(p)"
/>
<PlatformIcon :platform="p" size="xs" :class="platformTextClass(p)" />
<span :class="platformTextClass(p)">{{ t('admin.groups.platforms.' + p, p) }}</span>
</label>
</div>
</div>
<!-- Apply Pricing to Account Stats (toggle only in basic settings) -->
<div class="border-t border-gray-200 pt-4 dark:border-dark-700">
<div class="flex items-center justify-between">
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.channels.form.applyPricingToAccountStats') }}
</label>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.channels.form.applyPricingToAccountStatsDesc') }}
</p>
</div>
<Toggle
:modelValue="form.apply_pricing_to_account_stats"
@update:modelValue="form.apply_pricing_to_account_stats = $event"
/>
</div>
</div>
</div>
<!-- Platform Tab Content -->
<div
v-for="(section, sIdx) in form.platforms"
:key="'tab-' + section.platform"
v-show="section.enabled && activeTab === section.platform"
class="space-y-4"
>
<!-- Groups -->
<div>
<label class="input-label text-xs">
{{ t('admin.channels.form.groups', 'Associated Groups') }} <span class="text-red-500">*</span>
<span v-if="section.group_ids.length > 0" class="ml-1 font-normal text-gray-400">
({{ t('admin.channels.form.selectedCount', { count: section.group_ids.length }, `已选 ${section.group_ids.length} `) }})
</span>
</label>
<div class="max-h-40 overflow-auto rounded-lg border border-gray-200 bg-gray-50 p-2 dark:border-dark-600 dark:bg-dark-900">
<div v-if="groupsLoading" class="py-2 text-center text-xs text-gray-500">
{{ t('common.loading', 'Loading...') }}
</div>
<div v-else-if="getGroupsForPlatform(section.platform).length === 0" class="py-2 text-center text-xs text-gray-500">
{{ t('admin.channels.form.noGroupsAvailable', 'No groups available') }}
</div>
<div v-else class="flex flex-wrap gap-1">
<label
v-for="group in getGroupsForPlatform(section.platform)"
:key="group.id"
class="inline-flex cursor-pointer items-center gap-1.5 rounded-md border border-gray-200 px-2 py-1 text-xs transition-colors hover:bg-gray-50 dark:border-dark-600 dark:hover:bg-dark-700"
:class="[
section.group_ids.includes(group.id) ? 'bg-primary-50 border-primary-300 dark:bg-primary-900/20 dark:border-primary-700' : '',
isGroupInOtherChannel(group.id, section.platform) ? 'opacity-40' : ''
]"
>
<input
type="checkbox"
:checked="section.group_ids.includes(group.id)"
:disabled="isGroupInOtherChannel(group.id, section.platform)"
class="h-3 w-3 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
@change="toggleGroupInSection(sIdx, group.id)"
/>
<span :class="['font-medium', platformTextClass(group.platform)]">{{ group.name }}</span>
<span
:class="['rounded-full px-1 py-0 text-[10px]', platformBadgeLightClass(group.platform)]"
>{{ group.rate_multiplier }}x</span>
<span class="text-[10px] text-gray-400">{{ group.account_count || 0 }}</span>
<span
v-if="isGroupInOtherChannel(group.id, section.platform)"
class="text-[10px] text-gray-400"
>{{ getGroupInOtherChannelLabel(group.id) }}</span>
</label>
</div>
</div>
</div>
<!-- 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>
<!-- Model Mapping -->
<div>
<div class="mb-1 flex items-center justify-between">
<label class="input-label text-xs mb-0">{{ t('admin.channels.form.modelMapping', 'Model Mapping') }}</label>
<button type="button" @click="addMappingEntry(sIdx)" class="text-xs text-primary-600 hover:text-primary-700">
+ {{ t('common.add', 'Add') }}
</button>
</div>
<div
v-if="Object.keys(section.model_mapping).length === 0"
class="rounded border border-dashed border-gray-300 p-2 text-center text-xs text-gray-400 dark:border-dark-500"
>
{{ t('admin.channels.form.noMappingRules', 'No mapping rules. Click "Add" to create one.') }}
</div>
<div v-else class="space-y-1">
<div
v-for="(_, srcModel) in section.model_mapping"
:key="srcModel"
class="flex items-center gap-2"
>
<input
:value="srcModel"
type="text"
class="input flex-1 text-xs"
:class="platformTextClass(section.platform)"
:placeholder="t('admin.channels.form.mappingSource', 'Source model')"
@change="renameMappingKey(sIdx, srcModel, ($event.target as HTMLInputElement).value)"
/>
<span class="text-gray-400 text-xs"></span>
<input
:value="section.model_mapping[srcModel]"
type="text"
class="input flex-1 text-xs"
:class="platformTextClass(section.platform)"
:placeholder="t('admin.channels.form.mappingTarget', 'Target model')"
@input="section.model_mapping[srcModel] = ($event.target as HTMLInputElement).value"
/>
<button
type="button"
@click="removeMappingEntry(sIdx, srcModel)"
class="rounded p-0.5 text-gray-400 hover:text-red-500"
>
<Icon name="trash" size="sm" />
</button>
</div>
</div>
</div>
<!-- Model Pricing -->
<div>
<div class="mb-1 flex items-center justify-between">
<label class="input-label text-xs mb-0">{{ t('admin.channels.form.modelPricing', 'Model Pricing') }}</label>
<button type="button" @click="addPricingEntry(sIdx)" class="text-xs text-primary-600 hover:text-primary-700">
+ {{ t('common.add', 'Add') }}
</button>
</div>
<div
v-if="section.model_pricing.length === 0"
class="rounded border border-dashed border-gray-300 p-2 text-center text-xs text-gray-400 dark:border-dark-500"
>
{{ t('admin.channels.form.noPricingRules', 'No pricing rules yet. Click "Add" to create one.') }}
</div>
<div v-else class="space-y-2">
<PricingEntryCard
v-for="(entry, idx) in section.model_pricing"
:key="idx"
:entry="entry"
:platform="section.platform"
@update="updatePricingEntry(sIdx, idx, $event)"
@remove="removePricingEntry(sIdx, idx)"
/>
</div>
</div>
<!-- Account Stats Pricing Rules (per-platform, always visible) -->
<div class="mt-4 border-t border-gray-200 pt-4 dark:border-dark-700 space-y-3">
<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') }}
</h4>
<button
type="button"
@click="addAccountStatsRule(sIdx)"
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') }}
</button>
</div>
<!-- Filter rules for this platform's groups -->
<p
v-if="section.account_stats_pricing_rules.length === 0"
class="text-xs italic text-gray-400 dark:text-gray-500"
>
{{ t('admin.channels.form.noRulesConfigured') }}
</p>
<div
v-for="(rule, ruleIndex) in section.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')"
class="bg-transparent text-sm font-medium text-gray-700 placeholder-gray-400 outline-none dark:text-gray-300"
/>
<button type="button" @click="removeAccountStatsRule(sIdx, ruleIndex)" class="text-xs text-red-500 hover:text-red-700">
{{ t('common.delete') }}
</button>
</div>
<div>
<label class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.channels.form.ruleGroups') }}</label>
<div class="mt-1 flex flex-wrap gap-1">
<label
v-for="gid in section.group_ids"
: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 :class="['font-medium', platformTextClass(section.platform)]">{{ getGroupNameById(gid) }}</span>
</label>
</div>
<p v-if="section.group_ids.length === 0" class="mt-1 text-xs text-gray-400">
{{ t('admin.channels.form.noGroupsInChannel') }}
</p>
</div>
<div>
<label class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.channels.form.ruleAccounts') }}</label>
<!-- Selected account chips -->
<div class="mt-1 flex flex-wrap gap-1">
<span
v-for="accountId in rule.account_ids"
:key="accountId"
class="inline-flex items-center gap-1 rounded-md border border-primary-300 bg-primary-50 px-2 py-0.5 text-xs dark:border-primary-700 dark:bg-primary-900/20"
>
<span :class="['font-medium', platformTextClass(section.platform)]">{{ getRuleAccountLabel(accountId) }}</span>
<button type="button" @click="removeRuleAccount(rule, accountId)" class="text-gray-400 hover:text-red-500">
<Icon name="x" size="xs" />
</button>
</span>
</div>
<!-- Account search input -->
<div class="relative mt-1 rule-account-search-container">
<input
v-model="ruleAccountSearchKeyword[`${section.platform}-${ruleIndex}`]"
type="text"
class="input text-sm"
:placeholder="t('admin.channels.form.searchAccountPlaceholder')"
@input="onRuleAccountSearchInput(section.platform, ruleIndex)"
@focus="onRuleAccountSearchFocus(section.platform, ruleIndex)"
/>
<!-- Search results dropdown -->
<div
v-if="showRuleAccountDropdown[`${section.platform}-${ruleIndex}`] && (ruleAccountSearchResults[`${section.platform}-${ruleIndex}`]?.length ?? 0) > 0"
class="absolute z-50 mt-1 max-h-48 w-full overflow-auto rounded-lg border bg-white shadow-lg dark:border-dark-600 dark:bg-dark-800"
>
<button
v-for="account in ruleAccountSearchResults[`${section.platform}-${ruleIndex}`]"
:key="account.id"
type="button"
@click="selectRuleAccount(rule, account, section.platform, ruleIndex)"
class="w-full px-3 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-dark-700"
:class="{ 'opacity-50': rule.account_ids.includes(account.id) }"
:disabled="rule.account_ids.includes(account.id)"
>
<span :class="platformTextClass(account.platform)">{{ account.name }}</span>
<span class="ml-2 text-xs text-gray-400">#{{ account.id }}</span>
</button>
</div>
</div>
<p class="mt-1 text-xs text-gray-400">
{{ t('admin.channels.form.ruleAccountsHint') }}
</p>
</div>
<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') }}</label>
<button type="button" @click="addRulePricingEntry(sIdx, ruleIndex)" class="text-xs text-primary-600 hover:text-primary-700">
+ {{ t('common.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') }}
</div>
<div v-else class="space-y-2">
<PricingEntryCard
v-for="(entry, pIdx) in rule.pricing"
:key="pIdx"
:entry="entry"
:platform="section.platform"
@update="rule.pricing.splice(pIdx, 1, $event)"
@remove="removeRulePricingEntry(sIdx, ruleIndex, pIdx)"
/>
</div>
</div>
</div>
</div>
</div>
</form>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<button @click="closeDialog" type="button" class="btn btn-secondary">
{{ t('common.cancel', 'Cancel') }}
</button>
<button
type="submit"
form="channel-form"
:disabled="submitting"
class="btn btn-primary"
>
{{ submitting
? t('common.submitting', 'Submitting...')
: editingChannel
? t('common.update', 'Update')
: t('common.create', 'Create')
}}
</button>
</div>
</template>
</BaseDialog>
<!-- Delete Confirmation -->
<ConfirmDialog
:show="showDeleteDialog"
:title="t('admin.channels.deleteChannel', 'Delete Channel')"
:message="deleteConfirmMessage"
:confirm-text="t('common.delete', 'Delete')"
:cancel-text="t('common.cancel', 'Cancel')"
:danger="true"
@confirm="confirmDelete"
@cancel="showDeleteDialog = false"
/>
</AppLayout>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { extractApiErrorMessage } from '@/utils/apiError'
import { adminAPI } from '@/api/admin'
import type { Channel, ChannelModelPricing, CreateChannelRequest, UpdateChannelRequest, AccountStatsPricingRule } from '@/api/admin/channels'
import type { PricingFormEntry } from '@/components/admin/channel/types'
import { mTokToPerToken, perTokenToMTok, apiIntervalsToForm, formIntervalsToAPI, findModelConflict, validateIntervals } from '@/components/admin/channel/types'
import type { AdminGroup, GroupPlatform } from '@/types'
import type { Column } from '@/components/common/types'
import { platformTextClass, platformBadgeLightClass } from '@/utils/platformColors'
import AppLayout from '@/components/layout/AppLayout.vue'
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
import DataTable from '@/components/common/DataTable.vue'
import Pagination from '@/components/common/Pagination.vue'
import BaseDialog from '@/components/common/BaseDialog.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import Select from '@/components/common/Select.vue'
import Icon from '@/components/icons/Icon.vue'
import PlatformIcon from '@/components/common/PlatformIcon.vue'
import Toggle from '@/components/common/Toggle.vue'
import PricingEntryCard from '@/components/admin/channel/PricingEntryCard.vue'
import { getPersistedPageSize } from '@/composables/usePersistedPageSize'
import { useKeyedDebouncedSearch } from '@/composables/useKeyedDebouncedSearch'
const { t } = useI18n()
const appStore = useAppStore()
// 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
}
}
// ── Form-level pricing rule type (per-platform) ──
interface FormPricingRule {
name: string
group_ids: number[]
account_ids: number[]
pricing: PricingFormEntry[]
}
// ── Platform Section type ──
interface PlatformSection {
platform: GroupPlatform
enabled: boolean
collapsed: boolean
group_ids: number[]
model_mapping: Record<string, string>
model_pricing: PricingFormEntry[]
web_search_emulation: boolean
account_stats_pricing_rules: FormPricingRule[]
}
// ── Table columns ──
const columns = computed<Column[]>(() => [
{ key: 'name', label: t('admin.channels.columns.name', 'Name'), sortable: true },
{ key: 'description', label: t('admin.channels.columns.description', 'Description'), sortable: false },
{ key: 'status', label: t('admin.channels.columns.status', 'Status'), sortable: true },
{ key: 'group_count', label: t('admin.channels.columns.groups', 'Groups'), sortable: false },
{ key: 'pricing_count', label: t('admin.channels.columns.pricing', 'Pricing'), sortable: false },
{ key: 'created_at', label: t('admin.channels.columns.createdAt', 'Created'), sortable: true },
{ key: 'actions', label: t('admin.channels.columns.actions', 'Actions'), sortable: false }
])
const statusFilterOptions = computed(() => [
{ value: '', label: t('admin.channels.allStatus', 'All Status') },
{ value: 'active', label: t('admin.channels.statusActive', 'Active') },
{ value: 'disabled', label: t('admin.channels.statusDisabled', 'Disabled') }
])
const statusEditOptions = computed(() => [
{ value: 'active', label: t('admin.channels.statusActive', 'Active') },
{ value: 'disabled', label: t('admin.channels.statusDisabled', 'Disabled') }
])
const billingModelSourceOptions = computed(() => [
{ value: 'channel_mapped', label: t('admin.channels.form.billingModelSourceChannelMapped', 'Bill by channel-mapped model') },
{ value: 'requested', label: t('admin.channels.form.billingModelSourceRequested', 'Bill by requested model') },
{ value: 'upstream', label: t('admin.channels.form.billingModelSourceUpstream', 'Bill by final upstream model') }
])
// ── State ──
const channels = ref<Channel[]>([])
const loading = ref(false)
const searchQuery = ref('')
const filters = reactive({ status: '' })
const pagination = reactive({
page: 1,
page_size: getPersistedPageSize(),
total: 0
})
const sortState = reactive({
sort_by: 'created_at',
sort_order: 'desc' as 'asc' | 'desc'
})
// Dialog state
const showDialog = ref(false)
const editingChannel = ref<Channel | null>(null)
const submitting = ref(false)
const showDeleteDialog = ref(false)
const deletingChannel = ref<Channel | null>(null)
const activeTab = ref<string>('basic')
// Groups
const allGroups = ref<AdminGroup[]>([])
const groupsLoading = ref(false)
// All channels for group-conflict detection (independent of current page)
const allChannelsForConflict = ref<Channel[]>([])
// Form data
const form = reactive({
name: '',
description: '',
status: 'active',
restrict_models: false,
billing_model_source: 'channel_mapped' as string,
platforms: [] as PlatformSection[],
apply_pricing_to_account_stats: false,
})
let abortController: AbortController | null = null
// ── Platform config ──
const platformOrder: GroupPlatform[] = ['anthropic', 'openai', 'gemini', 'antigravity']
// ── Helpers ──
function formatDate(value: string): string {
if (!value) return '-'
return new Date(value).toLocaleDateString()
}
// ── Platform section helpers ──
const activePlatforms = computed(() => form.platforms.filter(s => s.enabled).map(s => s.platform))
function addPlatformSection(platform: GroupPlatform) {
form.platforms.push({
platform,
enabled: true,
collapsed: false,
group_ids: [],
model_mapping: {},
model_pricing: [],
web_search_emulation: false,
account_stats_pricing_rules: [],
})
}
function togglePlatform(platform: GroupPlatform) {
const section = form.platforms.find(s => s.platform === platform)
if (section) {
section.enabled = !section.enabled
if (!section.enabled && activeTab.value === platform) {
activeTab.value = 'basic'
}
} else {
addPlatformSection(platform)
}
}
function getGroupsForPlatform(platform: GroupPlatform): AdminGroup[] {
return allGroups.value.filter(g => g.platform === platform)
}
// ── Group helpers ──
const groupToChannelMap = computed(() => {
const map = new Map<number, Channel>()
for (const ch of allChannelsForConflict.value) {
if (editingChannel.value && ch.id === editingChannel.value.id) continue
for (const gid of ch.group_ids || []) {
map.set(gid, ch)
}
}
return map
})
function isGroupInOtherChannel(groupId: number, _platform: string): boolean {
return groupToChannelMap.value.has(groupId)
}
function getGroupChannelName(groupId: number): string {
return groupToChannelMap.value.get(groupId)?.name || ''
}
function getGroupInOtherChannelLabel(groupId: number): string {
const name = getGroupChannelName(groupId)
return t('admin.channels.form.inOtherChannel', { name }, `In "${name}"`)
}
const deleteConfirmMessage = computed(() => {
const name = deletingChannel.value?.name || ''
return t(
'admin.channels.deleteConfirm',
{ name },
`Are you sure you want to delete channel "${name}"? This action cannot be undone.`
)
})
function toggleGroupInSection(sectionIdx: number, groupId: number) {
const section = form.platforms[sectionIdx]
const idx = section.group_ids.indexOf(groupId)
if (idx >= 0) {
section.group_ids.splice(idx, 1)
} else {
section.group_ids.push(groupId)
}
}
// ── Pricing helpers ──
function addPricingEntry(sectionIdx: number) {
form.platforms[sectionIdx].model_pricing.push({
models: [],
billing_mode: 'token',
input_price: null,
output_price: null,
cache_write_price: null,
cache_read_price: null,
image_output_price: null,
per_request_price: null,
intervals: []
})
}
function updatePricingEntry(sectionIdx: number, idx: number, updated: PricingFormEntry) {
form.platforms[sectionIdx].model_pricing.splice(idx, 1, updated)
}
function removePricingEntry(sectionIdx: number, idx: number) {
form.platforms[sectionIdx].model_pricing.splice(idx, 1)
}
// ── Model Mapping helpers ──
function addMappingEntry(sectionIdx: number) {
const mapping = form.platforms[sectionIdx].model_mapping
let key = ''
let i = 1
while (key === '' || key in mapping) {
key = `model-${i}`
i++
}
mapping[key] = ''
}
function removeMappingEntry(sectionIdx: number, key: string) {
delete form.platforms[sectionIdx].model_mapping[key]
}
function renameMappingKey(sectionIdx: number, oldKey: string, newKey: string) {
newKey = newKey.trim()
if (!newKey || newKey === oldKey) return
const mapping = form.platforms[sectionIdx].model_mapping
if (newKey in mapping) return
const value = mapping[oldKey]
delete mapping[oldKey]
mapping[newKey] = value
}
// ── Account Stats Pricing helpers ──
function addAccountStatsRule(sectionIdx: number) {
form.platforms[sectionIdx].account_stats_pricing_rules.push({
name: '',
group_ids: [],
account_ids: [],
pricing: []
})
}
function addRulePricingEntry(sectionIdx: number, ruleIndex: number) {
form.platforms[sectionIdx].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(sectionIdx: number, ruleIndex: number) {
form.platforms[sectionIdx].account_stats_pricing_rules.splice(ruleIndex, 1)
// Clear all search state since indices shift after removal
ruleAccountSearchRunner.clearAll()
clearAllRuleAccountSearchState()
}
function removeRulePricingEntry(sectionIdx: number, ruleIndex: number, pricingIndex: number) {
form.platforms[sectionIdx].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}`
}
// ── Account search for pricing rules ──
interface SimpleAccount { id: number; name: string; platform: string }
const ruleAccountSearchKeyword = ref<Record<string, string>>({})
const ruleAccountSearchResults = ref<Record<string, SimpleAccount[]>>({})
const showRuleAccountDropdown = ref<Record<string, boolean>>({})
// Cache: account ID → name, populated when search results are selected
const ruleAccountNameCache = ref<Record<number, string>>({})
const ruleAccountSearchRunner = useKeyedDebouncedSearch<SimpleAccount[]>({
delay: 300,
search: async (keyword, { key, signal }) => {
const platform = key.split('-')[0]
const res = await adminAPI.accounts.list(1, 20, { platform, search: keyword }, { signal })
return res.items.map(a => ({ id: a.id, name: a.name, platform: a.platform }))
},
onSuccess: (key, result) => { ruleAccountSearchResults.value[key] = result },
onError: (key) => { ruleAccountSearchResults.value[key] = [] },
})
function onRuleAccountSearchInput(platform: string, ruleIndex: number) {
const key = `${platform}-${ruleIndex}`
showRuleAccountDropdown.value[key] = true
ruleAccountSearchRunner.trigger(key, ruleAccountSearchKeyword.value[key] || '')
}
function onRuleAccountSearchFocus(platform: string, ruleIndex: number) {
const key = `${platform}-${ruleIndex}`
showRuleAccountDropdown.value[key] = true
if (!ruleAccountSearchResults.value[key]?.length) {
ruleAccountSearchRunner.trigger(key, ruleAccountSearchKeyword.value[key] || '')
}
}
function selectRuleAccount(
rule: { account_ids: number[] },
account: SimpleAccount,
platform: string,
ruleIndex: number,
) {
if (!rule.account_ids.includes(account.id)) {
rule.account_ids.push(account.id)
ruleAccountNameCache.value[account.id] = account.name
}
const key = `${platform}-${ruleIndex}`
ruleAccountSearchKeyword.value[key] = ''
showRuleAccountDropdown.value[key] = false
}
function removeRuleAccount(rule: { account_ids: number[] }, accountId: number) {
const idx = rule.account_ids.indexOf(accountId)
if (idx !== -1) rule.account_ids.splice(idx, 1)
}
function getRuleAccountLabel(accountId: number): string {
const name = ruleAccountNameCache.value[accountId]
return name ? `${name} #${accountId}` : `#${accountId}`
}
function handleRuleAccountClickOutside(event: MouseEvent) {
const target = event.target as HTMLElement
if (!target.closest('.rule-account-search-container')) {
Object.keys(showRuleAccountDropdown.value).forEach(key => {
showRuleAccountDropdown.value[key] = false
})
}
}
function clearAllRuleAccountSearchState() {
ruleAccountSearchKeyword.value = {}
ruleAccountSearchResults.value = {}
showRuleAccountDropdown.value = {}
}
function accountStatsRulesToAPI(): AccountStatsPricingRule[] {
const rules: AccountStatsPricingRule[] = []
for (const section of form.platforms) {
if (!section.enabled) continue
for (const rule of section.account_stats_pricing_rules) {
rules.push({
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: section.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 || [])
}))
})
}
}
return rules
}
// ── Form ↔ API conversion ──
function formToAPI(): { group_ids: number[], model_pricing: ChannelModelPricing[], model_mapping: Record<string, Record<string, string>>, features_config: Record<string, unknown> } {
const group_ids: number[] = []
const model_pricing: ChannelModelPricing[] = []
const model_mapping: Record<string, Record<string, string>> = {}
// Preserve existing features_config fields not managed by the form
const featuresConfig: Record<string, unknown> = editingChannel.value?.features_config
? { ...editingChannel.value.features_config }
: {}
for (const section of form.platforms) {
if (!section.enabled) continue
group_ids.push(...section.group_ids)
// Model mapping per platform
if (Object.keys(section.model_mapping).length > 0) {
model_mapping[section.platform] = { ...section.model_mapping }
}
// Model pricing with platform tag
for (const entry of section.model_pricing) {
if (entry.models.length === 0) continue
model_pricing.push({
platform: section.platform,
models: entry.models,
billing_mode: entry.billing_mode,
input_price: mTokToPerToken(entry.input_price),
output_price: mTokToPerToken(entry.output_price),
cache_write_price: mTokToPerToken(entry.cache_write_price),
cache_read_price: mTokToPerToken(entry.cache_read_price),
image_output_price: mTokToPerToken(entry.image_output_price),
per_request_price: entry.per_request_price != null && entry.per_request_price !== '' ? Number(entry.per_request_price) : null,
intervals: formIntervalsToAPI(entry.intervals || [])
})
}
}
// 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 }
}
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))
// 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
sections.push({
platform,
enabled: true,
collapsed: false,
group_ids: groupIds,
model_mapping: { ...mapping },
model_pricing: pricing,
web_search_emulation: webSearchEnabled,
account_stats_pricing_rules: [],
})
}
return sections
}
// ── Load data ──
async function loadChannels() {
if (abortController) abortController.abort()
const ctrl = new AbortController()
abortController = ctrl
loading.value = true
try {
const response = await adminAPI.channels.list(pagination.page, pagination.page_size, {
status: filters.status || undefined,
search: searchQuery.value || undefined,
sort_by: sortState.sort_by,
sort_order: sortState.sort_order
}, { signal: ctrl.signal })
if (ctrl.signal.aborted || abortController !== ctrl) return
channels.value = response.items || []
pagination.total = response.total
} 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')))
} finally {
if (abortController === ctrl) {
loading.value = false
abortController = null
}
}
}
async function loadGroups() {
groupsLoading.value = true
try {
allGroups.value = await adminAPI.groups.getAll()
} catch (error) {
console.error('Error loading groups:', error)
} finally {
groupsLoading.value = false
}
}
async function loadAllChannelsForConflict() {
try {
const response = await adminAPI.channels.list(1, 1000)
allChannelsForConflict.value = response.items || []
} catch (error) {
// Fallback to current page data
allChannelsForConflict.value = channels.value
}
}
let searchTimeout: ReturnType<typeof setTimeout>
function handleSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
pagination.page = 1
loadChannels()
}, 300)
}
function handlePageChange(page: number) {
pagination.page = page
loadChannels()
}
function handlePageSizeChange(pageSize: number) {
pagination.page_size = pageSize
pagination.page = 1
loadChannels()
}
function handleSort(key: string, order: 'asc' | 'desc') {
sortState.sort_by = key
sortState.sort_order = order
pagination.page = 1
loadChannels()
}
// ── Dialog ──
function resetForm() {
form.name = ''
form.description = ''
form.status = 'active'
form.restrict_models = false
form.billing_model_source = 'channel_mapped'
form.platforms = []
form.apply_pricing_to_account_stats = false
activeTab.value = 'basic'
ruleAccountSearchRunner.clearAll()
clearAllRuleAccountSearchState()
ruleAccountNameCache.value = {}
}
async function openCreateDialog() {
editingChannel.value = null
resetForm()
await Promise.all([loadGroups(), loadAllChannelsForConflict()])
showDialog.value = true
}
async function openEditDialog(channel: Channel) {
editingChannel.value = channel
form.name = channel.name
form.description = channel.description || ''
form.status = channel.status
form.restrict_models = channel.restrict_models || false
form.billing_model_source = channel.billing_model_source || 'channel_mapped'
form.apply_pricing_to_account_stats = channel.apply_pricing_to_account_stats || false
// Must load groups first so apiToForm can map groupID → platform
await Promise.all([loadGroups(), loadAllChannelsForConflict()])
form.platforms = apiToForm(channel)
// Distribute channel-level rules into per-platform sections
distributeRulesToPlatforms(channel.account_stats_pricing_rules || [])
// Populate ruleAccountNameCache for existing rule accounts
await populateRuleAccountNameCache()
showDialog.value = true
}
/** Distribute flat channel-level rules into the matching platform section based on group_ids */
function distributeRulesToPlatforms(apiRules: AccountStatsPricingRule[]) {
// Build groupID → platform lookup
const groupPlatformMap = new Map<number, GroupPlatform>()
for (const g of allGroups.value) {
groupPlatformMap.set(g.id, g.platform)
}
for (const apiRule of apiRules) {
// Infer platform from group_ids
const platforms = new Set<GroupPlatform>()
for (const gid of apiRule.group_ids || []) {
const p = groupPlatformMap.get(gid)
if (p) platforms.add(p)
}
// If pricing has a platform field, use that as fallback
if (platforms.size === 0 && apiRule.pricing?.length > 0) {
const p = apiRule.pricing[0].platform as GroupPlatform | undefined
if (p) platforms.add(p)
}
const targetPlatform = platforms.size >= 1 ? [...platforms][0] : null
if (!targetPlatform) continue
const section = form.platforms.find(s => s.platform === targetPlatform)
if (!section) continue
const formRule: FormPricingRule = {
name: apiRule.name || '',
group_ids: [...(apiRule.group_ids || [])],
account_ids: [...(apiRule.account_ids || [])],
pricing: (apiRule.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))
}
section.account_stats_pricing_rules.push(formRule)
}
}
/** Populate ruleAccountNameCache by fetching account details for all account_ids in rules */
async function populateRuleAccountNameCache() {
const allAccountIds = new Set<number>()
for (const section of form.platforms) {
for (const rule of section.account_stats_pricing_rules) {
for (const id of rule.account_ids) {
allAccountIds.add(id)
}
}
}
if (allAccountIds.size === 0) return
// Fetch account details in parallel (batch of individual getById calls)
const ids = [...allAccountIds]
const results = await Promise.allSettled(
ids.map(id => adminAPI.accounts.getById(id))
)
for (let i = 0; i < ids.length; i++) {
const result = results[i]
if (result.status === 'fulfilled') {
ruleAccountNameCache.value[ids[i]] = result.value.name
}
// If rejected, the cache won't have the name, so it'll show "#ID" which is acceptable
}
}
function closeDialog() {
showDialog.value = false
editingChannel.value = null
resetForm()
}
async function handleSubmit() {
if (submitting.value) return
if (!form.name.trim()) {
appStore.showError(t('admin.channels.nameRequired', 'Please enter a channel name'))
return
}
// Check for pricing entries with empty models (would be silently skipped)
for (const section of form.platforms.filter(s => s.enabled)) {
if (section.group_ids.length === 0) {
const platformLabel = t('admin.groups.platforms.' + section.platform, section.platform)
appStore.showError(t('admin.channels.noGroupsSelected', { platform: platformLabel }, `${platformLabel} 平台未选择分组,请至少选择一个分组或禁用该平台`))
activeTab.value = section.platform
return
}
for (const entry of section.model_pricing) {
if (entry.models.length === 0) {
const platformLabel = t('admin.groups.platforms.' + section.platform, section.platform)
appStore.showError(t('admin.channels.emptyModelsInPricing', { platform: platformLabel }, `${platformLabel} 平台下有定价条目未添加模型,请添加模型或删除该条目`))
activeTab.value = section.platform
return
}
}
}
// Check model pattern conflicts per platform (duplicate / wildcard overlap)
for (const section of form.platforms.filter(s => s.enabled)) {
// Collect all pricing models for this platform
const allModels: string[] = []
for (const entry of section.model_pricing) {
allModels.push(...entry.models)
}
const pricingConflict = findModelConflict(allModels)
if (pricingConflict) {
appStore.showError(
t('admin.channels.modelConflict',
{ model1: pricingConflict[0], model2: pricingConflict[1] },
`模型模式 '${pricingConflict[0]}' 和 '${pricingConflict[1]}' 冲突:匹配范围重叠`)
)
activeTab.value = section.platform
return
}
// Check model mapping source pattern conflicts
const mappingKeys = Object.keys(section.model_mapping)
if (mappingKeys.length > 0) {
const mappingConflict = findModelConflict(mappingKeys)
if (mappingConflict) {
appStore.showError(
t('admin.channels.mappingConflict',
{ model1: mappingConflict[0], model2: mappingConflict[1] },
`模型映射源 '${mappingConflict[0]}' 和 '${mappingConflict[1]}' 冲突:匹配范围重叠`)
)
activeTab.value = section.platform
return
}
}
}
// 校验 per_request/image 模式必须有价格 (只校验启用的平台)
for (const section of form.platforms.filter(s => s.enabled)) {
for (const entry of section.model_pricing) {
if (entry.models.length === 0) continue
if ((entry.billing_mode === 'per_request' || entry.billing_mode === 'image') &&
(entry.per_request_price == null || entry.per_request_price === '') &&
(!entry.intervals || entry.intervals.length === 0)) {
appStore.showError(t('admin.channels.form.perRequestPriceRequired', '按次/图片计费模式必须设置默认价格或至少一个计费层级'))
return
}
}
}
// 校验区间合法性(范围、重叠等)
for (const section of form.platforms.filter(s => s.enabled)) {
for (const entry of section.model_pricing) {
if (!entry.intervals || entry.intervals.length === 0) continue
const intervalErr = validateIntervals(entry.intervals)
if (intervalErr) {
const platformLabel = t('admin.groups.platforms.' + section.platform, section.platform)
const modelLabel = entry.models.join(', ') || t('admin.channels.form.unnamed')
appStore.showError(`${platformLabel} - ${modelLabel}: ${intervalErr}`)
activeTab.value = section.platform
return
}
}
}
const { group_ids, model_pricing, model_mapping, features_config } = formToAPI()
submitting.value = true
try {
if (editingChannel.value) {
const req: UpdateChannelRequest = {
name: form.name.trim(),
description: form.description.trim() || undefined,
status: form.status,
group_ids,
model_pricing,
model_mapping: Object.keys(model_mapping).length > 0 ? model_mapping : {},
billing_model_source: form.billing_model_source,
restrict_models: form.restrict_models,
features_config,
apply_pricing_to_account_stats: form.apply_pricing_to_account_stats,
account_stats_pricing_rules: accountStatsRulesToAPI()
}
await adminAPI.channels.update(editingChannel.value.id, req)
appStore.showSuccess(t('admin.channels.updateSuccess', 'Channel updated'))
} else {
const req: CreateChannelRequest = {
name: form.name.trim(),
description: form.description.trim() || undefined,
group_ids,
model_pricing,
model_mapping: Object.keys(model_mapping).length > 0 ? model_mapping : {},
billing_model_source: form.billing_model_source,
restrict_models: form.restrict_models,
features_config,
apply_pricing_to_account_stats: form.apply_pricing_to_account_stats,
account_stats_pricing_rules: accountStatsRulesToAPI()
}
await adminAPI.channels.create(req)
appStore.showSuccess(t('admin.channels.createSuccess', 'Channel created'))
}
closeDialog()
loadChannels()
} catch (error: unknown) {
appStore.showError(extractApiErrorMessage(error, editingChannel.value
? t('admin.channels.updateError', 'Failed to update channel')
: t('admin.channels.createError', 'Failed to create channel')))
} finally {
submitting.value = false
}
}
// ── Toggle status ──
async function toggleChannelStatus(channel: Channel) {
const newStatus = channel.status === 'active' ? 'disabled' : 'active'
try {
await adminAPI.channels.update(channel.id, { status: newStatus })
if (filters.status && filters.status !== newStatus) {
// Item no longer matches the active filter — reload list
await loadChannels()
} else {
channel.status = newStatus
}
} catch (error) {
appStore.showError(t('admin.channels.updateError', 'Failed to update channel'))
console.error('Error toggling channel status:', error)
}
}
// ── Delete ──
function handleDelete(channel: Channel) {
deletingChannel.value = channel
showDeleteDialog.value = true
}
async function confirmDelete() {
if (!deletingChannel.value) return
try {
await adminAPI.channels.remove(deletingChannel.value.id)
appStore.showSuccess(t('admin.channels.deleteSuccess', 'Channel deleted'))
showDeleteDialog.value = false
deletingChannel.value = null
loadChannels()
} catch (error: unknown) {
appStore.showError(extractApiErrorMessage(error, t('admin.channels.deleteError', 'Failed to delete channel')))
}
}
// ── Lifecycle ──
onMounted(() => {
loadChannels()
loadGroups()
loadWebSearchGlobalState()
document.addEventListener('click', handleRuleAccountClickOutside)
})
onUnmounted(() => {
clearTimeout(searchTimeout)
abortController?.abort()
document.removeEventListener('click', handleRuleAccountClickOutside)
ruleAccountSearchRunner.clearAll()
clearAllRuleAccountSearchState()
})
</script>
<style scoped>
.channel-dialog-body {
display: flex;
flex-direction: column;
height: 70vh;
min-height: 400px;
}
.channel-tab {
@apply flex items-center gap-1.5 px-3 py-2.5 text-sm font-medium border-b-2 transition-colors whitespace-nowrap;
}
.channel-tab-active {
@apply border-primary-600 text-primary-600 dark:border-primary-400 dark:text-primary-400;
}
.channel-tab-inactive {
@apply border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300;
}
</style>