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
2026-04-21 00:27:10 +08:00
|
|
|
<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'
|
2026-04-21 01:05:14 +08:00
|
|
|
import { formatScaled } from '@/utils/pricing'
|
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
2026-04-21 00:27:10 +08:00
|
|
|
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 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>
|