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
215 lines
7.2 KiB
Vue
215 lines
7.2 KiB
Vue
<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>
|