refactor(channels): centralize BillingModelSource normalization and exhaustive enum maps

- service: add normalizeBillingModelSource helper, apply in Create/GetByID/Update/List/ListAvailable outputs
- handler: drop channelToResponse fallback now that service owns the default; add passthrough test
- frontend: replace ternary status/billing-source lookups with Record<Enum, ...> maps so new union members fail the build
- chip/table: drop local type aliases, reuse UserSupportedModel/UserPricingInterval directly
- tests: assert short-circuit on ListAll error, wrap-prefix preservation, and Name-based default lookup
This commit is contained in:
erio
2026-04-21 11:31:54 +08:00
parent 88decb6e0c
commit 375aefa209
8 changed files with 122 additions and 53 deletions

View File

@@ -61,7 +61,7 @@ import { computed, useSlots } from 'vue'
import DataTable from '@/components/common/DataTable.vue'
import Icon from '@/components/icons/Icon.vue'
import SupportedModelChip from './SupportedModelChip.vue'
import type { UserSupportedModelPricing } from '@/api/channels'
import type { UserSupportedModel } from '@/api/channels'
interface GroupRef {
id: number
@@ -73,11 +73,8 @@ interface Row {
name: string
description?: string
groups: GroupRef[]
supported_models: Array<{
name: string
platform: string
pricing: UserSupportedModelPricing | null
}>
// 复用 user 侧最小 DTOadmin 侧 SupportedModel 结构上是其超集,可直接传入。
supported_models: UserSupportedModel[]
[key: string]: unknown
}

View File

@@ -127,19 +127,13 @@ import {
BILLING_MODE_IMAGE,
type BillingMode
} from '@/constants/channel'
// 复用 api/channels.ts 的用户侧最小形态 DTO
// admin ChannelModelPricing 字段更多但结构上是用户 DTO 的超集admin 视图传入可直接通过结构化子类型检查
import type { UserPricingInterval, UserSupportedModel } from '@/api/channels'
/**
* 复用 api/channels.ts 的用户侧最小形态 DTO
* admin ChannelModelPricing 字段更多但结构上是用户 DTO 的超集
* 因此 admin 视图传入时 TypeScript 结构化子类型会直接通过
*/
type PricingInterval = UserPricingInterval
type SupportedModelLike = UserSupportedModel
const props = withDefaults(
defineProps<{
model: SupportedModelLike
model: UserSupportedModel
/** i18n 前缀管理端传 `admin.availableChannels.pricing`用户端传 `availableChannels.pricing` */
pricingKeyPrefix?: string
noPricingLabel?: string
@@ -180,7 +174,7 @@ function formatRange(min: number, max: number | null): string {
return `(${min}, ${maxLabel}]`
}
function formatInterval(iv: PricingInterval, mode: BillingMode): string {
function formatInterval(iv: UserPricingInterval, mode: BillingMode): string {
if (mode === BILLING_MODE_PER_REQUEST || mode === BILLING_MODE_IMAGE) {
return formatScaled(iv.per_request_price, 1)
}

View File

@@ -46,20 +46,16 @@
<template #cell-status="{ row }">
<span
:class="
row.status === CHANNEL_STATUS_ACTIVE
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
: 'bg-gray-100 text-gray-600 dark:bg-dark-700 dark:text-gray-400'
"
:class="statusStyles[row.status as ChannelStatus].cls"
class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium"
>
{{ statusLabel(row.status) }}
{{ statusStyles[row.status as ChannelStatus].label }}
</span>
</template>
<template #cell-billing_model_source="{ row }">
<span class="text-xs text-gray-700 dark:text-gray-300">
{{ t(`admin.availableChannels.billingSource.${row.billing_model_source}`) }}
{{ billingSourceLabels[row.billing_model_source as BillingModelSource] }}
</span>
</template>
</AvailableChannelsTable>
@@ -78,7 +74,15 @@ import AvailableChannelsTable from '@/components/channels/AvailableChannelsTable
import channelsAPI, { type AvailableChannel } from '@/api/admin/channels'
import { useAppStore } from '@/stores/app'
import { extractApiErrorMessage } from '@/utils/apiError'
import { CHANNEL_STATUS_ACTIVE, type ChannelStatus } from '@/constants/channel'
import {
CHANNEL_STATUS_ACTIVE,
CHANNEL_STATUS_DISABLED,
BILLING_MODEL_SOURCE_REQUESTED,
BILLING_MODEL_SOURCE_UPSTREAM,
BILLING_MODEL_SOURCE_CHANNEL_MAPPED,
type ChannelStatus,
type BillingModelSource
} from '@/constants/channel'
const { t } = useI18n()
const appStore = useAppStore()
@@ -95,11 +99,30 @@ const columns = computed(() => [
{ key: 'supported_models', label: t('admin.availableChannels.columns.supportedModels') }
])
function statusLabel(status: ChannelStatus): string {
return status === CHANNEL_STATUS_ACTIVE
? t('admin.availableChannels.statusActive')
: t('admin.availableChannels.statusDisabled')
}
/**
* 显示样式i18n label + Tailwind class按 ChannelStatus 完整穷举。
* 用 Record<ChannelStatus, ...> 强制未来新增状态时 TS 编译失败,避免遗漏分支。
*/
const statusStyles = computed<Record<ChannelStatus, { label: string; cls: string }>>(() => ({
[CHANNEL_STATUS_ACTIVE]: {
label: t('admin.availableChannels.statusActive'),
cls: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
},
[CHANNEL_STATUS_DISABLED]: {
label: t('admin.availableChannels.statusDisabled'),
cls: 'bg-gray-100 text-gray-600 dark:bg-dark-700 dark:text-gray-400'
}
}))
/**
* BillingModelSource 显式映射:避免将后端 snake_case 字面量直接拼成 i18n key
* 同时在 BillingModelSource 扩展时 TS 编译失败以暴露遗漏。
*/
const billingSourceLabels = computed<Record<BillingModelSource, string>>(() => ({
[BILLING_MODEL_SOURCE_REQUESTED]: t('admin.availableChannels.billingSource.requested'),
[BILLING_MODEL_SOURCE_UPSTREAM]: t('admin.availableChannels.billingSource.upstream'),
[BILLING_MODEL_SOURCE_CHANNEL_MAPPED]: t('admin.availableChannels.billingSource.channel_mapped')
}))
const filteredChannels = computed(() => {
const q = searchQuery.value.trim().toLowerCase()