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>
|
|
|
|
|
|
<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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-21 01:05:14 +08:00
|
|
|
|
defineProps<{
|
|
|
|
|
|
columns: Column[]
|
|
|
|
|
|
rows: Row[]
|
|
|
|
|
|
loading: boolean
|
|
|
|
|
|
pricingKeyPrefix: string
|
|
|
|
|
|
noPricingLabel: string
|
|
|
|
|
|
noModelsLabel: string
|
|
|
|
|
|
emptyLabel: string
|
|
|
|
|
|
}>()
|
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
|
|
|
|
|
|
|
|
|
|
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>
|