feat(channels): aggregate by channel with platform sections + rowspan table

Switch the user-facing 'Available Channels' view from "one row per
platform" to "one channel row-group with N platform sections".

Backend: userAvailableChannel now holds Platforms []section instead
of being exploded. buildPlatformSections replaces
explodeChannelByPlatform with the same per-platform grouping logic.

Frontend: drop the DataTable wrapper for this view and write a
four-column grid table (渠道名 / 平台 / 分组 / 支持模型) where the
channel name only renders on the first platform row of each channel —
visual rowspan without hacking DataTable.

- api/channels.ts: UserChannelPlatformSection + platforms[]
- AvailableChannelsTable: rewritten as native grid (header + per-
  channel section with hover row highlight)
- AvailableChannelsView: search now filters platforms sub-array;
  channel-name / description hits still keep the whole channel
- i18n: add availableChannels.columns.platform (zh/en)
This commit is contained in:
erio
2026-04-21 19:46:55 +08:00
parent 800802b8aa
commit 3cdd5754df
7 changed files with 220 additions and 153 deletions

View File

@@ -1,131 +1,124 @@
<template>
<DataTable :columns="columns" :data="rows" :loading="loading">
<template #cell-name="{ row }">
<div class="flex items-center gap-2">
<span class="font-medium text-gray-900 dark:text-white">{{ row.name }}</span>
<span
v-if="row.platform"
:class="[
'inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 text-[11px] font-medium uppercase',
platformBadgeClass(row.platform),
]"
:title="row.description || undefined"
>
<PlatformIcon :platform="row.platform as GroupPlatform" size="xs" />
{{ row.platform }}
</span>
</div>
</template>
<div class="card overflow-hidden">
<!-- 表头 -->
<div
class="grid items-center border-b border-gray-100 bg-gray-50/50 px-4 py-3 text-xs font-medium uppercase tracking-wide text-gray-500 dark:border-dark-700 dark:bg-dark-800/50 dark:text-gray-400"
:style="gridStyle"
>
<div>{{ columns.name }}</div>
<div>{{ columns.platform }}</div>
<div>{{ columns.groups }}</div>
<div>{{ columns.supportedModels }}</div>
</div>
<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 gap-1 rounded px-2 py-0.5 text-xs font-medium',
platformBadgeLightClass(g.platform || row.platform || ''),
]"
>
<PlatformIcon
v-if="g.platform || row.platform"
:platform="(g.platform || row.platform) as GroupPlatform"
size="xs"
/>
{{ g.name }}
</span>
</div>
</template>
<div v-if="loading" class="flex justify-center py-10">
<Icon name="refresh" size="lg" class="animate-spin text-gray-400" />
</div>
<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"
:show-platform="false"
:platform-hint="row.platform"
/>
</div>
</template>
<div v-else-if="rows.length === 0" class="flex flex-col items-center py-12">
<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>
<!-- 允许父组件为额外列提供自定义渲染 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>
<!-- 渠道分组每个渠道一个 section内部按 platform 顺序铺开 -->
<div
v-else
v-for="(channel, chIdx) in rows"
:key="`${channel.name}-${chIdx}`"
class="border-b border-gray-100 last:border-b-0 dark:border-dark-700"
>
<div
v-for="(section, secIdx) in channel.platforms"
:key="`${channel.name}-${section.platform}`"
class="grid items-center px-4 py-3 transition-colors hover:bg-gray-50/40 dark:hover:bg-dark-800/40"
:class="{ 'border-t border-gray-100/70 dark:border-dark-700/50': secIdx > 0 }"
:style="gridStyle"
>
<!-- 渠道名仅第一行渲染后续行留空视觉上的 rowspan -->
<div>
<template v-if="secIdx === 0">
<div class="font-medium text-gray-900 dark:text-white">{{ channel.name }}</div>
<div
v-if="channel.description"
class="mt-0.5 text-xs text-gray-500 dark:text-gray-400"
>
{{ channel.description }}
</div>
</template>
</div>
</slot>
</template>
</DataTable>
<!-- 平台徽章 -->
<div>
<span
:class="[
'inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-[11px] font-medium uppercase',
platformBadgeClass(section.platform),
]"
>
<PlatformIcon :platform="section.platform as GroupPlatform" size="xs" />
{{ section.platform }}
</span>
</div>
<!-- 分组 -->
<div class="flex flex-wrap gap-1">
<span
v-for="g in section.groups"
:key="g.id"
:class="[
'inline-flex items-center gap-1 rounded px-2 py-0.5 text-xs font-medium',
platformBadgeLightClass(section.platform),
]"
>
<PlatformIcon :platform="section.platform as GroupPlatform" size="xs" />
{{ g.name }}
</span>
<span v-if="section.groups.length === 0" class="text-xs text-gray-400">-</span>
</div>
<!-- 支持模型 -->
<div class="flex flex-wrap gap-1">
<SupportedModelChip
v-for="m in section.supported_models"
:key="`${section.platform}-${m.name}`"
:model="m"
:pricing-key-prefix="pricingKeyPrefix"
:no-pricing-label="noPricingLabel"
:show-platform="false"
:platform-hint="section.platform"
/>
<span v-if="section.supported_models.length === 0" class="text-xs text-gray-400">
{{ noModelsLabel }}
</span>
</div>
</div>
</div>
</div>
</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 PlatformIcon from '@/components/common/PlatformIcon.vue'
import SupportedModelChip from './SupportedModelChip.vue'
import type { UserSupportedModel } from '@/api/channels'
import type { ChannelStatus, BillingModelSource } from '@/constants/channel'
import type { UserAvailableChannel } from '@/api/channels'
import type { GroupPlatform } from '@/types'
import { platformBadgeClass, platformBadgeLightClass } from '@/utils/platformColors'
interface GroupRef {
id: number
name: string
platform?: string
}
interface Row {
name: string
description?: string
/** 单条记录归属的平台后端按平台摊开后每行一个。admin 场景可能缺失,因此允许 optional。 */
platform?: string
groups: GroupRef[]
// 复用 user 侧最小 DTOadmin 侧 SupportedModel 结构上是其超集,可直接传入。
supported_models: UserSupportedModel[]
// admin 独有字段:用精确类型代替 `unknown`,让消费端无需 `as` 断言,
// 也能在后端新增 union 成员时让前端 Record 查表立刻出空而非崩溃。
status?: ChannelStatus
billing_model_source?: BillingModelSource
}
interface Column {
key: string
label: string
}
/** 四列 grid 的 template-columns与表头、每个 section 行共享。 */
const gridStyle = 'grid-template-columns: 220px 140px minmax(200px, 1fr) minmax(280px, 2fr); display: grid;'
defineProps<{
columns: Column[]
rows: Row[]
columns: {
name: string
platform: string
groups: string
supportedModels: string
}
rows: UserAvailableChannel[]
loading: boolean
pricingKeyPrefix: string
noPricingLabel: string
noModelsLabel: string
emptyLabel: string
}>()
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>