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:
@@ -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 仅含该平台的 visibleGroups,supported_models 仅含
|
||||
// 该平台的模型。输出按 platform 字母序稳定排序,便于前端等效比较与回归测试。
|
||||
func explodeChannelByPlatform(
|
||||
// buildPlatformSections 把一个渠道按 visibleGroups 的平台集合拆成有序的 section 列表:
|
||||
// 每个 section 对应一个平台,只包含该平台的 groups 和 supported_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 仅保留用户可访问的分组。
|
||||
|
||||
@@ -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, §ionDecoded))
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user