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

@@ -65,12 +65,17 @@ func TestToUserSupportedModels_NilAllowedPlatformsKeepsAll(t *testing.T) {
func TestUserAvailableChannel_FieldWhitelist(t *testing.T) {
// 通过序列化 userAvailableChannel 结构体验证响应形状:
// 只有 name / description / groups / supported_models不含管理端字段。
// 只有 name / description / platforms不含管理端字段。
row := userAvailableChannel{
Name: "ch",
Description: "d",
Groups: []userAvailableGroup{{ID: 1, Name: "g1", Platform: "anthropic"}},
SupportedModels: []userSupportedModel{},
Name: "ch",
Description: "d",
Platforms: []userChannelPlatformSection{
{
Platform: "anthropic",
Groups: []userAvailableGroup{{ID: 1, Name: "g1", Platform: "anthropic"}},
SupportedModels: []userSupportedModel{},
},
},
}
raw, err := json.Marshal(row)
require.NoError(t, err)
@@ -81,11 +86,21 @@ func TestUserAvailableChannel_FieldWhitelist(t *testing.T) {
_, exists := decoded[key]
require.Falsef(t, exists, "user DTO must not expose %q", key)
}
for _, key := range []string{"name", "description", "groups", "supported_models"} {
for _, key := range []string{"name", "description", "platforms"} {
_, exists := decoded[key]
require.Truef(t, exists, "user DTO must expose %q", key)
}
// 验证 section 的字段platform / groups / supported_models
rawSection, err := json.Marshal(row.Platforms[0])
require.NoError(t, err)
var sectionDecoded map[string]any
require.NoError(t, json.Unmarshal(rawSection, &sectionDecoded))
for _, key := range []string{"platform", "groups", "supported_models"} {
_, exists := sectionDecoded[key]
require.Truef(t, exists, "platform section must expose %q", key)
}
// pricing interval 白名单:不应暴露 id / sort_order。
pricing := toUserPricing(&service.ChannelModelPricing{
BillingMode: service.BillingModeToken,
@@ -104,3 +119,28 @@ func TestUserAvailableChannel_FieldWhitelist(t *testing.T) {
require.Falsef(t, exists, "user pricing interval must not expose %q", key)
}
}
func TestBuildPlatformSections_GroupsByPlatform(t *testing.T) {
// 一个渠道横跨 anthropic / openai / 空平台:应该生成 2 个 section
// 按 platform 字母序排序,各自 groups 和 supported_models 只含同平台条目。
ch := service.AvailableChannel{
Name: "ch",
SupportedModels: []service.SupportedModel{
{Name: "claude-sonnet-4-6", Platform: "anthropic"},
{Name: "gpt-4o", Platform: "openai"},
},
}
visible := []userAvailableGroup{
{ID: 1, Name: "g-openai", Platform: "openai"},
{ID: 2, Name: "g-ant", Platform: "anthropic"},
{ID: 3, Name: "g-empty", Platform: ""},
}
sections := buildPlatformSections(ch, visible)
require.Len(t, sections, 2)
require.Equal(t, "anthropic", sections[0].Platform)
require.Equal(t, "openai", sections[1].Platform)
require.Len(t, sections[0].Groups, 1)
require.Equal(t, int64(2), sections[0].Groups[0].ID)
require.Len(t, sections[0].SupportedModels, 1)
require.Equal(t, "claude-sonnet-4-6", sections[0].SupportedModels[0].Name)
}