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

@@ -34,7 +34,7 @@
<template #table>
<AvailableChannelsTable
:columns="columns"
:columns="columnLabels"
:rows="filteredChannels"
:loading="loading"
pricing-key-prefix="availableChannels.pricing"
@@ -65,22 +65,37 @@ const channels = ref<UserAvailableChannel[]>([])
const loading = ref(false)
const searchQuery = ref('')
const columns = computed(() => [
{ key: 'name', label: t('availableChannels.columns.name') },
{ key: 'groups', label: t('availableChannels.columns.groups') },
{ key: 'supported_models', label: t('availableChannels.columns.supportedModels') }
])
const columnLabels = computed(() => ({
name: t('availableChannels.columns.name'),
platform: t('availableChannels.columns.platform'),
groups: t('availableChannels.columns.groups'),
supportedModels: t('availableChannels.columns.supportedModels'),
}))
/**
* 搜索过滤:
* - 命中渠道名/描述 → 整个渠道(所有 platforms都保留
* - 否则按 platform/group/model 维度在 sections 里过滤,保留有匹配的 section
* - 所有 sections 都不匹配时,渠道本身被过滤掉
*/
const filteredChannels = computed(() => {
const q = searchQuery.value.trim().toLowerCase()
if (!q) return channels.value
return channels.value.filter((ch) => {
if (ch.name.toLowerCase().includes(q)) return true
if ((ch.description || '').toLowerCase().includes(q)) return true
if (ch.groups.some((g) => g.name.toLowerCase().includes(q))) return true
if (ch.supported_models.some((m) => m.name.toLowerCase().includes(q))) return true
return false
})
return channels.value
.map((ch) => {
const nameHit = ch.name.toLowerCase().includes(q)
const descHit = (ch.description || '').toLowerCase().includes(q)
if (nameHit || descHit) return ch
const matchingSections = ch.platforms.filter(
(p) =>
p.platform.toLowerCase().includes(q) ||
p.groups.some((g) => g.name.toLowerCase().includes(q)) ||
p.supported_models.some((m) => m.name.toLowerCase().includes(q)),
)
if (matchingSections.length === 0) return null
return { ...ch, platforms: matchingSections }
})
.filter((ch): ch is UserAvailableChannel => ch !== null)
})
async function loadChannels() {