feat(channels): add "Available Channels" aggregate view
Add a read-only aggregate view per channel: its linked groups and a deterministic wildcard-free supported-model list with pricing details. Backend - service.Channel.SupportedModels(): combine ModelMapping keys with same-platform ModelPricing.Models; trailing "*" keys expand via pricing prefix match; platforms without a mapping produce no entries (intentional "no mapping = not shown" rule). - Extract splitWildcardSuffix() shared with toModelEntry. - Build a per-call pricing lookup map (platform+lowerName -> *pricing) to avoid O(N*M) scans in SupportedModels. - ChannelService.ListAvailable() aggregates channels + active groups; filters out group IDs no longer active. - Admin route GET /api/v1/admin/channels/available returns the full DTO (id, status, billing_model_source, restrict_models, groups, supported_models). - User route GET /api/v1/channels/available applies three filters: Status==active, visible-group intersection, and platform filter on supported_models (prevents cross-platform leak when a channel links to both a user-accessible group and an inaccessible one on another platform). Response is a plain array (matches the /groups/available sibling shape). Field whitelist omits billing_model_source, restrict_models, ids, status, sort_order. Frontend - New /admin/available-channels and /available-channels views backed by a shared AvailableChannelsTable component (admin adds status + billing-source columns via slots). - PricingRow extracted to its own SFC; SupportedModelChip references shared billing-mode constants in constants/channel.ts. - Sidebar: new entry above "渠道管理" for admin; matching entry in user nav. - i18n: zh + en coverage for both namespaces. Tests - SupportedModels: wildcard-only pricing skipped, prefix-matches- nothing, cross-platform bleed, case-insensitive dedup, empty platform mapping. - ListAvailable: nil groupRepo, inactive-group-ID dropped, stable case-insensitive name sort. - User handler: 401 on unauthenticated, visible-group intersection, platform filter on supported_models, JSON whitelist. - Admin handler: full DTO including default BillingModelSource fallback. Refs: issue #1729
This commit is contained in:
110
frontend/src/components/channels/AvailableChannelsTable.vue
Normal file
110
frontend/src/components/channels/AvailableChannelsTable.vue
Normal file
@@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<DataTable :columns="columns" :data="rows" :loading="loading">
|
||||
<template #cell-name="{ row }">
|
||||
<div class="font-medium text-gray-900 dark:text-white">{{ row.name }}</div>
|
||||
<div
|
||||
v-if="row.description"
|
||||
class="mt-0.5 text-xs text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{{ row.description }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-groups="{ row }">
|
||||
<div v-if="row.groups.length === 0" class="text-xs text-gray-400">
|
||||
<slot name="empty-groups">-</slot>
|
||||
</div>
|
||||
<div v-else class="flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="g in row.groups"
|
||||
:key="g.id"
|
||||
class="inline-flex items-center rounded bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-300"
|
||||
>
|
||||
{{ g.name }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-supported_models="{ row }">
|
||||
<div v-if="row.supported_models.length === 0" class="text-xs text-gray-400">
|
||||
{{ noModelsLabel }}
|
||||
</div>
|
||||
<div v-else class="flex max-w-[560px] flex-wrap gap-1">
|
||||
<SupportedModelChip
|
||||
v-for="m in row.supported_models"
|
||||
:key="`${m.platform}-${m.name}`"
|
||||
:model="m"
|
||||
:pricing-key-prefix="pricingKeyPrefix"
|
||||
:no-pricing-label="noPricingLabel"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 允许父组件为额外列提供自定义渲染(如 admin 的 status / billing_model_source)。 -->
|
||||
<template v-for="slot in extraCellSlots" :key="slot" #[slot]="scope">
|
||||
<slot :name="slot" v-bind="scope" />
|
||||
</template>
|
||||
|
||||
<template #empty>
|
||||
<slot name="empty">
|
||||
<div class="flex flex-col items-center py-8">
|
||||
<Icon name="inbox" size="xl" class="mb-3 h-12 w-12 text-gray-400" />
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ emptyLabel }}</p>
|
||||
</div>
|
||||
</slot>
|
||||
</template>
|
||||
</DataTable>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, useSlots } from 'vue'
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import SupportedModelChip from './SupportedModelChip.vue'
|
||||
|
||||
interface GroupRef {
|
||||
id: number
|
||||
name: string
|
||||
platform?: string
|
||||
}
|
||||
|
||||
interface Row {
|
||||
name: string
|
||||
description?: string
|
||||
groups: GroupRef[]
|
||||
supported_models: Array<{
|
||||
name: string
|
||||
platform: string
|
||||
pricing: unknown | null
|
||||
}>
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface Column {
|
||||
key: string
|
||||
label: string
|
||||
}
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
columns: Column[]
|
||||
rows: Row[]
|
||||
loading: boolean
|
||||
pricingKeyPrefix: string
|
||||
noPricingLabel: string
|
||||
noModelsLabel: string
|
||||
emptyLabel: string
|
||||
}>(),
|
||||
{ loading: false }
|
||||
)
|
||||
|
||||
const slots = useSlots()
|
||||
/**
|
||||
* 透传父组件提供的 cell-* 插槽(除本组件内置的 name/groups/supported_models/empty-groups/empty
|
||||
* 之外),让 admin 场景可以自定义 status / billing_model_source 等列。
|
||||
*/
|
||||
const extraCellSlots = computed(() => {
|
||||
const reserved = new Set(['cell-name', 'cell-groups', 'cell-supported_models', 'empty-groups', 'empty'])
|
||||
return Object.keys(slots).filter((name) => name.startsWith('cell-') && !reserved.has(name))
|
||||
})
|
||||
</script>
|
||||
29
frontend/src/components/channels/PricingRow.vue
Normal file
29
frontend/src/components/channels/PricingRow.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<div class="flex justify-between gap-2">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ label }}</span>
|
||||
<span class="font-mono">{{ display }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
label: string
|
||||
value: number | null
|
||||
unit: string
|
||||
scale: number
|
||||
}>(),
|
||||
{ value: null }
|
||||
)
|
||||
|
||||
function formatScaled(value: number | null, scale: number): string {
|
||||
if (value == null) return '-'
|
||||
return `$${(value * scale).toPrecision(10).replace(/\.?0+$/, '')}`
|
||||
}
|
||||
|
||||
const display = computed(() =>
|
||||
props.value == null ? '-' : `${formatScaled(props.value, props.scale)} ${props.unit}`
|
||||
)
|
||||
</script>
|
||||
214
frontend/src/components/channels/SupportedModelChip.vue
Normal file
214
frontend/src/components/channels/SupportedModelChip.vue
Normal file
@@ -0,0 +1,214 @@
|
||||
<template>
|
||||
<div class="group relative inline-block">
|
||||
<span
|
||||
class="inline-flex cursor-help items-center rounded-md border border-gray-200 bg-gray-50 px-2 py-0.5 text-xs font-medium text-gray-700 transition-colors hover:border-brand-400 hover:bg-brand-50 hover:text-brand-700 dark:border-dark-600 dark:bg-dark-800 dark:text-gray-300 dark:hover:border-brand-500 dark:hover:bg-brand-900/30 dark:hover:text-brand-300"
|
||||
>
|
||||
<span
|
||||
v-if="showPlatform && model.platform"
|
||||
class="mr-1 rounded bg-gray-200 px-1 text-[10px] uppercase text-gray-600 dark:bg-dark-700 dark:text-gray-400"
|
||||
>
|
||||
{{ model.platform }}
|
||||
</span>
|
||||
{{ model.name }}
|
||||
</span>
|
||||
|
||||
<div
|
||||
class="pointer-events-none invisible absolute left-1/2 z-50 mt-2 w-80 -translate-x-1/2 opacity-0 transition-opacity group-hover:visible group-hover:opacity-100"
|
||||
>
|
||||
<div
|
||||
class="rounded-lg border border-gray-200 bg-white p-3 text-xs shadow-lg dark:border-dark-600 dark:bg-dark-800"
|
||||
>
|
||||
<div
|
||||
class="mb-2 flex items-center justify-between gap-2 border-b border-gray-200 pb-2 dark:border-dark-600"
|
||||
>
|
||||
<span class="font-semibold text-gray-900 dark:text-gray-100">{{ model.name }}</span>
|
||||
<span
|
||||
v-if="model.platform"
|
||||
class="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] uppercase text-gray-600 dark:bg-dark-700 dark:text-gray-400"
|
||||
>
|
||||
{{ model.platform }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="!model.pricing" class="text-gray-500 dark:text-gray-400">
|
||||
{{ noPricingLabel }}
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-2 text-gray-700 dark:text-gray-300">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t(prefixKey('billingMode')) }}</span>
|
||||
<span>{{ billingModeLabel }}</span>
|
||||
</div>
|
||||
|
||||
<template v-if="model.pricing.billing_mode === BILLING_MODE_TOKEN">
|
||||
<PricingRow
|
||||
:label="t(prefixKey('inputPrice'))"
|
||||
:value="model.pricing.input_price"
|
||||
:unit="t(prefixKey('unitPerMillion'))"
|
||||
:scale="perMillionScale"
|
||||
/>
|
||||
<PricingRow
|
||||
:label="t(prefixKey('outputPrice'))"
|
||||
:value="model.pricing.output_price"
|
||||
:unit="t(prefixKey('unitPerMillion'))"
|
||||
:scale="perMillionScale"
|
||||
/>
|
||||
<PricingRow
|
||||
:label="t(prefixKey('cacheWritePrice'))"
|
||||
:value="model.pricing.cache_write_price"
|
||||
:unit="t(prefixKey('unitPerMillion'))"
|
||||
:scale="perMillionScale"
|
||||
/>
|
||||
<PricingRow
|
||||
:label="t(prefixKey('cacheReadPrice'))"
|
||||
:value="model.pricing.cache_read_price"
|
||||
:unit="t(prefixKey('unitPerMillion'))"
|
||||
:scale="perMillionScale"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<PricingRow
|
||||
v-if="
|
||||
model.pricing.billing_mode === BILLING_MODE_PER_REQUEST &&
|
||||
model.pricing.per_request_price != null
|
||||
"
|
||||
:label="t(prefixKey('perRequestPrice'))"
|
||||
:value="model.pricing.per_request_price"
|
||||
:unit="t(prefixKey('unitPerRequest'))"
|
||||
:scale="1"
|
||||
/>
|
||||
|
||||
<PricingRow
|
||||
v-if="
|
||||
model.pricing.billing_mode === BILLING_MODE_IMAGE &&
|
||||
model.pricing.image_output_price != null
|
||||
"
|
||||
:label="t(prefixKey('imageOutputPrice'))"
|
||||
:value="model.pricing.image_output_price"
|
||||
:unit="t(prefixKey('unitPerRequest'))"
|
||||
:scale="1"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="model.pricing.intervals && model.pricing.intervals.length > 0"
|
||||
class="mt-2 border-t border-gray-200 pt-2 dark:border-dark-600"
|
||||
>
|
||||
<div class="mb-1 font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ t(prefixKey('intervals')) }}
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div
|
||||
v-for="(iv, idx) in model.pricing.intervals"
|
||||
:key="idx"
|
||||
class="flex justify-between text-[11px]"
|
||||
>
|
||||
<span class="text-gray-500 dark:text-gray-400">
|
||||
<template v-if="iv.tier_label">{{ iv.tier_label }}</template>
|
||||
<template v-else>{{ formatRange(iv.min_tokens, iv.max_tokens) }}</template>
|
||||
</span>
|
||||
<span>{{ formatInterval(iv, model.pricing.billing_mode) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import PricingRow from './PricingRow.vue'
|
||||
import {
|
||||
BILLING_MODE_TOKEN,
|
||||
BILLING_MODE_PER_REQUEST,
|
||||
BILLING_MODE_IMAGE,
|
||||
type BillingMode
|
||||
} from '@/constants/channel'
|
||||
|
||||
interface PricingInterval {
|
||||
min_tokens: number
|
||||
max_tokens: number | null
|
||||
tier_label?: string
|
||||
input_price: number | null
|
||||
output_price: number | null
|
||||
cache_write_price: number | null
|
||||
cache_read_price: number | null
|
||||
per_request_price: number | null
|
||||
}
|
||||
|
||||
interface SupportedModelPricing {
|
||||
billing_mode: BillingMode
|
||||
input_price: number | null
|
||||
output_price: number | null
|
||||
cache_write_price: number | null
|
||||
cache_read_price: number | null
|
||||
image_output_price: number | null
|
||||
per_request_price: number | null
|
||||
intervals: PricingInterval[]
|
||||
}
|
||||
|
||||
interface SupportedModelLike {
|
||||
name: string
|
||||
platform: string
|
||||
pricing: SupportedModelPricing | null
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
model: SupportedModelLike
|
||||
/** i18n 前缀:管理端传 `admin.availableChannels.pricing`,用户端传 `availableChannels.pricing`。 */
|
||||
pricingKeyPrefix?: string
|
||||
noPricingLabel?: string
|
||||
showPlatform?: boolean
|
||||
}>(),
|
||||
{
|
||||
pricingKeyPrefix: 'availableChannels.pricing',
|
||||
noPricingLabel: '',
|
||||
showPlatform: true
|
||||
}
|
||||
)
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
/** 按 token 定价展示时的换算单位:每百万 token。 */
|
||||
const perMillionScale = 1_000_000
|
||||
|
||||
function prefixKey(k: string): string {
|
||||
return `${props.pricingKeyPrefix}.${k}`
|
||||
}
|
||||
|
||||
const billingModeLabel = computed(() => {
|
||||
const mode = props.model.pricing?.billing_mode
|
||||
switch (mode) {
|
||||
case BILLING_MODE_TOKEN:
|
||||
return t(prefixKey('billingModeToken'))
|
||||
case BILLING_MODE_PER_REQUEST:
|
||||
return t(prefixKey('billingModePerRequest'))
|
||||
case BILLING_MODE_IMAGE:
|
||||
return t(prefixKey('billingModeImage'))
|
||||
default:
|
||||
return '-'
|
||||
}
|
||||
})
|
||||
|
||||
function formatScaled(value: number | null | undefined, scale: number): string {
|
||||
if (value == null) return '-'
|
||||
return `$${(value * scale).toPrecision(10).replace(/\.?0+$/, '')}`
|
||||
}
|
||||
|
||||
function formatRange(min: number, max: number | null): string {
|
||||
const maxLabel = max == null ? '∞' : String(max)
|
||||
return `(${min}, ${maxLabel}]`
|
||||
}
|
||||
|
||||
function formatInterval(iv: PricingInterval, mode: BillingMode): string {
|
||||
if (mode === BILLING_MODE_PER_REQUEST || mode === BILLING_MODE_IMAGE) {
|
||||
return formatScaled(iv.per_request_price, 1)
|
||||
}
|
||||
const input = formatScaled(iv.input_price, perMillionScale)
|
||||
const output = formatScaled(iv.output_price, perMillionScale)
|
||||
return `${input} / ${output}`
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user