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

@@ -85,19 +85,25 @@ type userSupportedModel struct {
Pricing *userSupportedModelPricing `json:"pricing"`
}
// userAvailableChannel 用户可见的渠道条目(白名单字段)。
//
// 同一个渠道若在多个平台上都有用户可见的分组,会被摊开成多条记录 —— 每条对应
// 一个平台groups 和 supported_models 都只包含该平台的内容。这样前端无需在
// 一行内混排多平台信息,也能直接为整行应用平台色/图标。
type userAvailableChannel struct {
Name string `json:"name"`
Description string `json:"description"`
// userChannelPlatformSection 单渠道内某个平台的子视图:用户可见的分组 + 该平台
// 支持的模型。按 platform 聚合后让前端可以把渠道名作为 row-group 一次渲染,
// 后面的平台行按 sections 顺序铺开。
type userChannelPlatformSection struct {
Platform string `json:"platform"`
Groups []userAvailableGroup `json:"groups"`
SupportedModels []userSupportedModel `json:"supported_models"`
}
// userAvailableChannel 用户可见的渠道条目(白名单字段)。
//
// 每个渠道聚合为一条记录,内嵌 platforms 子数组:每个 section 对应一个平台,
// 包含该平台的 groups 和 supported_models。
type userAvailableChannel struct {
Name string `json:"name"`
Description string `json:"description"`
Platforms []userChannelPlatformSection `json:"platforms"`
}
// List 列出当前用户可见的「可用渠道」。
// GET /api/v1/channels/available
func (h *AvailableChannelHandler) List(c *gin.Context) {
@@ -139,19 +145,27 @@ func (h *AvailableChannelHandler) List(c *gin.Context) {
if len(visibleGroups) == 0 {
continue
}
out = append(out, explodeChannelByPlatform(ch, visibleGroups)...)
sections := buildPlatformSections(ch, visibleGroups)
if len(sections) == 0 {
continue
}
out = append(out, userAvailableChannel{
Name: ch.Name,
Description: ch.Description,
Platforms: sections,
})
}
response.Success(c, out)
}
// explodeChannelByPlatform 将单个渠道按 visibleGroups 的平台集合摊开成多条记录。
// 每条记录对应一个平台groups 仅含该平台的 visibleGroupssupported_models 仅含
// 该平台的模型。输出按 platform 字母序稳定排序,便于前端等效比较与回归测试。
func explodeChannelByPlatform(
// buildPlatformSections 把一个渠道按 visibleGroups 的平台集合拆成有序的 section 列表:
// 每个 section 对应一个平台,只包含该平台的 groupssupported_models
// 输出按 platform 字母序稳定排序,便于前端等效比较与回归测试。
func buildPlatformSections(
ch service.AvailableChannel,
visibleGroups []userAvailableGroup,
) []userAvailableChannel {
) []userChannelPlatformSection {
groupsByPlatform := make(map[string][]userAvailableGroup, 4)
for _, g := range visibleGroups {
if g.Platform == "" {
@@ -169,18 +183,16 @@ func explodeChannelByPlatform(
}
sort.Strings(platforms)
out := make([]userAvailableChannel, 0, len(platforms))
sections := make([]userChannelPlatformSection, 0, len(platforms))
for _, platform := range platforms {
platformSet := map[string]struct{}{platform: {}}
out = append(out, userAvailableChannel{
Name: ch.Name,
Description: ch.Description,
sections = append(sections, userChannelPlatformSection{
Platform: platform,
Groups: groupsByPlatform[platform],
SupportedModels: toUserSupportedModels(ch.SupportedModels, platformSet),
})
}
return out
return sections
}
// filterUserVisibleGroups 仅保留用户可访问的分组。