diff --git a/backend/internal/handler/available_channel_handler.go b/backend/internal/handler/available_channel_handler.go index d1e48399..e325768c 100644 --- a/backend/internal/handler/available_channel_handler.go +++ b/backend/internal/handler/available_channel_handler.go @@ -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 仅保留用户可访问的分组。 diff --git a/backend/internal/handler/available_channel_handler_test.go b/backend/internal/handler/available_channel_handler_test.go index c83c5e6a..6ff9201d 100644 --- a/backend/internal/handler/available_channel_handler_test.go +++ b/backend/internal/handler/available_channel_handler_test.go @@ -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) +} diff --git a/frontend/src/api/channels.ts b/frontend/src/api/channels.ts index 65b2052a..3244ab35 100644 --- a/frontend/src/api/channels.ts +++ b/frontend/src/api/channels.ts @@ -40,18 +40,23 @@ export interface UserSupportedModel { pricing: UserSupportedModelPricing | null } -export interface UserAvailableChannel { - name: string - description: string - /** - * 所属平台(anthropic / openai / antigravity / gemini ...)。后端按平台把一个渠道 - * 摊开成多条记录,因此此字段决定整行的配色与图标。 - */ +/** + * 渠道下单个平台的子视图:用户可访问的分组 + 该平台支持的模型。 + * 后端把一个渠道按平台聚合成 sections,前端可以把渠道名作为 row-group + * 一次渲染,后面按 sections 顺序用 rowspan 铺开。 + */ +export interface UserChannelPlatformSection { platform: string groups: UserAvailableGroup[] supported_models: UserSupportedModel[] } +export interface UserAvailableChannel { + name: string + description: string + platforms: UserChannelPlatformSection[] +} + /** 列出当前用户可见的「可用渠道」(与 /groups/available 保持一致,返回平数组)。 */ export async function getAvailable(options?: { signal?: AbortSignal }): Promise { const { data } = await apiClient.get('/channels/available', { diff --git a/frontend/src/components/channels/AvailableChannelsTable.vue b/frontend/src/components/channels/AvailableChannelsTable.vue index 1a9fd38d..cd1744d7 100644 --- a/frontend/src/components/channels/AvailableChannelsTable.vue +++ b/frontend/src/components/channels/AvailableChannelsTable.vue @@ -1,131 +1,124 @@ diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index e48f7acc..0350e4a5 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -940,6 +940,7 @@ export default { noPricing: 'Pricing not configured', columns: { name: 'Channel', + platform: 'Platform', groups: 'Your Accessible Groups', supportedModels: 'Supported Models' }, diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index b9da4d45..621c076d 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -944,6 +944,7 @@ export default { noPricing: '未配置定价', columns: { name: '渠道名', + platform: '平台', groups: '我可访问的分组', supportedModels: '支持模型' }, diff --git a/frontend/src/views/user/AvailableChannelsView.vue b/frontend/src/views/user/AvailableChannelsView.vue index 44ee456e..28e8fef2 100644 --- a/frontend/src/views/user/AvailableChannelsView.vue +++ b/frontend/src/views/user/AvailableChannelsView.vue @@ -34,7 +34,7 @@