| <template> |
| <AppLayout> |
| <TablePageLayout> |
| <template #filters> |
| <div class="flex flex-col justify-between gap-4 lg:flex-row lg:items-start"> |
| |
| <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> |
| |
| |
| <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> |
| |
| |
| <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"> |
| |
| <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"> |
| |
| <button |
| type="button" |
| @click="activeTab = 'basic'" |
| class="channel-tab" |
| :class="activeTab === 'basic' ? 'channel-tab-active' : 'channel-tab-inactive'" |
| > |
| {{ t('admin.channels.form.basicSettings', '基础设置') }} |
| </button> |
| |
| <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> |
| |
| |
| <form id="channel-form" @submit.prevent="handleSubmit" class="flex-1 overflow-y-auto pt-4"> |
| |
| <div v-show="activeTab === 'basic'" class="space-y-5"> |
| |
| <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> |
| |
| |
| <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> |
| |
| |
| <div v-if="editingChannel"> |
| <label class="input-label">{{ t('admin.channels.form.status', 'Status') }}</label> |
| <Select v-model="form.status" :options="statusEditOptions" /> |
| </div> |
| |
| |
| <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> |
| |
| |
| <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> |
| |
| |
| <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> |
| |
| |
| <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> |
| |
| |
| <div |
| v-for="(section, sIdx) in form.platforms" |
| :key="'tab-' + section.platform" |
| v-show="section.enabled && activeTab === section.platform" |
| class="space-y-4" |
| > |
| |
| <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> |
| |
| |
| <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-gray-700 dark:text-gray-300"> |
| {{ t('admin.channels.form.webSearchEmulation') }} |
| </label> |
| <p class="mt-0.5 text-[11px] text-red-500 dark:text-red-400"> |
| {{ t('admin.channels.form.webSearchEmulationHint') }} |
| </p> |
| </div> |
| <Toggle v-model="section.web_search_emulation" /> |
| </div> |
| </div> |
| |
| |
| <div v-if="section.platform === 'openai'" class="border-t border-gray-200 pt-3 dark:border-dark-600"> |
| <div class="flex items-center justify-between gap-4"> |
| <div> |
| <label class="text-xs font-medium text-gray-700 dark:text-gray-300"> |
| {{ t('admin.channels.form.codexImageGenerationBridge') }} |
| </label> |
| <p class="mt-0.5 text-[11px] text-amber-600 dark:text-amber-400"> |
| {{ t('admin.channels.form.codexImageGenerationBridgeHint') }} |
| </p> |
| </div> |
| <Toggle v-model="section.codex_image_generation_bridge" /> |
| </div> |
| </div> |
| |
| |
| <div v-if="section.platform === 'anthropic'" class="border-t border-gray-200 pt-3 dark:border-dark-600"> |
| <div class="flex items-center justify-between gap-4"> |
| <div> |
| <label class="text-xs font-medium text-gray-700 dark:text-gray-300"> |
| {{ t('admin.channels.form.bedrockCCCompat') }} |
| </label> |
| <p class="mt-0.5 text-[11px] text-amber-600 dark:text-amber-400"> |
| {{ t('admin.channels.form.bedrockCCCompatHint') }} |
| </p> |
| </div> |
| <Toggle v-model="section.bedrock_cc_compat" /> |
| </div> |
| </div> |
| |
| |
| <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> |
| |
| |
| <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> |
| <div class="flex items-center gap-2"> |
| <button |
| type="button" |
| @click="syncLatestModels(sIdx)" |
| :disabled="syncingPlatform === section.platform" |
| class="text-xs text-gray-500 hover:text-primary-600 disabled:opacity-50" |
| > |
| {{ syncingPlatform === section.platform ? t('admin.channels.form.syncingModels') : t('admin.channels.form.syncLatestModels') }} |
| </button> |
| <button type="button" @click="addPricingEntry(sIdx)" class="text-xs text-primary-600 hover:text-primary-700"> |
| + {{ t('common.add', 'Add') }} |
| </button> |
| </div> |
| </div> |
| <div |
| v-if="section.model_pricing.length === 0" |
| class="rounded border border-dashed border-gray-300 p-2 text-center text-xs text-gray-400 dark:border-dark-500" |
| > |
| {{ t('admin.channels.form.noPricingRules', 'No pricing rules yet. Click "Add" to create one.') }} |
| </div> |
| <div v-else class="space-y-2"> |
| <PricingEntryCard |
| v-for="(entry, idx) in section.model_pricing" |
| :key="idx" |
| :entry="entry" |
| :platform="section.platform" |
| @update="updatePricingEntry(sIdx, idx, $event)" |
| @remove="removePricingEntry(sIdx, idx)" |
| /> |
| </div> |
| </div> |
| |
| |
| <div 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> |
| |
| |
| <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> |
| |
| <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> |
| |
| <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)" |
| /> |
| |
| <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> |
| |
| |
| <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() |
| |
| |
| 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 |
| } |
| } |
| |
| |
| interface FormPricingRule { |
| name: string |
| group_ids: number[] |
| account_ids: number[] |
| pricing: PricingFormEntry[] |
| } |
| |
| |
| interface PlatformSection { |
| platform: GroupPlatform |
| enabled: boolean |
| collapsed: boolean |
| group_ids: number[] |
| model_mapping: Record<string, string> |
| model_pricing: PricingFormEntry[] |
| web_search_emulation: boolean |
| codex_image_generation_bridge: boolean |
| bedrock_cc_compat: boolean |
| account_stats_pricing_rules: FormPricingRule[] |
| } |
| |
| |
| 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') } |
| ]) |
| |
| |
| 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' |
| }) |
| |
| |
| 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') |
| |
| |
| const allGroups = ref<AdminGroup[]>([]) |
| const groupsLoading = ref(false) |
| |
| |
| const allChannelsForConflict = ref<Channel[]>([]) |
| |
| |
| 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 |
| |
| |
| const platformOrder: GroupPlatform[] = ['anthropic', 'openai', 'gemini', 'antigravity'] |
| |
| |
| function formatDate(value: string): string { |
| if (!value) return '-' |
| return new Date(value).toLocaleDateString() |
| } |
| |
| |
| 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, |
| codex_image_generation_bridge: false, |
| bedrock_cc_compat: 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) |
| } |
| |
| |
| 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) |
| } |
| } |
| |
| |
| 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: [] |
| }) |
| } |
| |
| const syncingPlatform = ref<string | null>(null) |
| |
| async function syncLatestModels(sectionIdx: number) { |
| const platform = form.platforms[sectionIdx].platform |
| if (syncingPlatform.value) return |
| syncingPlatform.value = platform |
| try { |
| const result = await adminAPI.channels.syncPricingModels(platform) |
| |
| const existingModels = new Set<string>() |
| for (const entry of form.platforms[sectionIdx].model_pricing) { |
| for (const m of entry.models) existingModels.add(m) |
| } |
| const newModels = result.models.filter(m => !existingModels.has(m)) |
| if (newModels.length === 0) { |
| appStore.showSuccess(t('admin.channels.form.syncModelsAlreadyUpToDate')) |
| return |
| } |
| |
| form.platforms[sectionIdx].model_pricing.push({ |
| models: newModels, |
| 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: [] |
| }) |
| appStore.showSuccess(t('admin.channels.form.syncModelsSuccess', { count: newModels.length })) |
| } catch (error) { |
| appStore.showError(extractApiErrorMessage(error, t('admin.channels.form.syncModelsError'))) |
| } finally { |
| syncingPlatform.value = null |
| } |
| } |
| |
| 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) |
| } |
| |
| |
| 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 |
| } |
| |
| |
| 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) |
| |
| 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}` |
| } |
| |
| |
| 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>>({}) |
| |
| 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 |
| } |
| |
| |
| 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>> = {} |
| |
| 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) |
| |
| |
| if (Object.keys(section.model_mapping).length > 0) { |
| model_mapping[section.platform] = { ...section.model_mapping } |
| } |
| |
| |
| 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 || []) |
| }) |
| } |
| } |
| |
| |
| |
| |
| const wsEmulation: Record<string, boolean> = {} |
| for (const section of form.platforms) { |
| if (!section.enabled) continue |
| if (section.platform === 'anthropic') { |
| wsEmulation[section.platform] = !!section.web_search_emulation |
| } |
| } |
| if (Object.keys(wsEmulation).length > 0) { |
| featuresConfig.web_search_emulation = wsEmulation |
| } else { |
| delete featuresConfig.web_search_emulation |
| } |
| |
| const codexImageGenerationBridge: Record<string, boolean> = {} |
| for (const section of form.platforms) { |
| if (!section.enabled) continue |
| if (section.platform === 'openai') { |
| codexImageGenerationBridge[section.platform] = !!section.codex_image_generation_bridge |
| } |
| } |
| if (Object.keys(codexImageGenerationBridge).length > 0) { |
| featuresConfig.codex_image_generation_bridge = codexImageGenerationBridge |
| } else { |
| delete featuresConfig.codex_image_generation_bridge |
| } |
| |
| const bedrockCCCompat: Record<string, boolean> = {} |
| for (const section of form.platforms) { |
| if (!section.enabled) continue |
| if (section.platform === 'anthropic') { |
| bedrockCCCompat[section.platform] = !!section.bedrock_cc_compat |
| } |
| } |
| if (Object.keys(bedrockCCCompat).length > 0) { |
| featuresConfig.bedrock_cc_compat = bedrockCCCompat |
| } else { |
| delete featuresConfig.bedrock_cc_compat |
| } |
| |
| return { group_ids, model_pricing, model_mapping, features_config: featuresConfig } |
| } |
| |
| function apiToForm(channel: Channel): PlatformSection[] { |
| |
| const groupPlatformMap = new Map<number, GroupPlatform>() |
| for (const g of allGroups.value) { |
| groupPlatformMap.set(g.id, g.platform) |
| } |
| |
| |
| 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) |
| } |
| |
| |
| 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)) |
| |
| |
| const fc = channel.features_config |
| const wsEmulation = fc?.web_search_emulation as Record<string, boolean> | undefined |
| const webSearchEnabled = wsEmulation?.[platform] === true |
| const codexImageGenerationBridge = fc?.codex_image_generation_bridge as Record<string, boolean> | undefined |
| const codexImageGenerationBridgeEnabled = codexImageGenerationBridge?.[platform] === true |
| const bedrockCCCompat = fc?.bedrock_cc_compat as Record<string, boolean> | undefined |
| const bedrockCCCompatEnabled = bedrockCCCompat?.[platform] === true |
| |
| sections.push({ |
| platform, |
| enabled: true, |
| collapsed: false, |
| group_ids: groupIds, |
| model_mapping: { ...mapping }, |
| model_pricing: pricing, |
| web_search_emulation: webSearchEnabled, |
| codex_image_generation_bridge: codexImageGenerationBridgeEnabled, |
| bedrock_cc_compat: bedrockCCCompatEnabled, |
| account_stats_pricing_rules: [], |
| }) |
| } |
| |
| return sections |
| } |
| |
| |
| 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) { |
| |
| 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() |
| } |
| |
| |
| 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 |
| |
| await Promise.all([loadGroups(), loadAllChannelsForConflict()]) |
| form.platforms = apiToForm(channel) |
| |
| |
| distributeRulesToPlatforms(channel.account_stats_pricing_rules || []) |
| |
| |
| await populateRuleAccountNameCache() |
| |
| showDialog.value = true |
| } |
| |
| |
| function distributeRulesToPlatforms(apiRules: AccountStatsPricingRule[]) { |
| |
| const groupPlatformMap = new Map<number, GroupPlatform>() |
| for (const g of allGroups.value) { |
| groupPlatformMap.set(g.id, g.platform) |
| } |
| |
| for (const apiRule of apiRules) { |
| |
| const platforms = new Set<GroupPlatform>() |
| for (const gid of apiRule.group_ids || []) { |
| const p = groupPlatformMap.get(gid) |
| if (p) platforms.add(p) |
| } |
| |
| 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) |
| } |
| } |
| |
| |
| 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 |
| |
| |
| 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 |
| } |
| |
| } |
| } |
| |
| 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 |
| } |
| |
| |
| 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 |
| } |
| } |
| } |
| |
| |
| for (const section of form.platforms.filter(s => s.enabled)) { |
| |
| 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 |
| } |
| |
| 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 |
| } |
| } |
| } |
| |
| |
| 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, entry.billing_mode) |
| 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 |
| } |
| } |
| |
| |
| 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) { |
| |
| 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) |
| } |
| } |
| |
| |
| 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'))) |
| } |
| } |
| |
| |
| 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> |
| |