diff --git a/backend/internal/handler/available_channel_handler.go b/backend/internal/handler/available_channel_handler.go index 8b489388..d1e48399 100644 --- a/backend/internal/handler/available_channel_handler.go +++ b/backend/internal/handler/available_channel_handler.go @@ -1,6 +1,8 @@ package handler import ( + "sort" + "github.com/Wei-Shaw/sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" @@ -84,9 +86,14 @@ type userSupportedModel struct { } // userAvailableChannel 用户可见的渠道条目(白名单字段)。 +// +// 同一个渠道若在多个平台上都有用户可见的分组,会被摊开成多条记录 —— 每条对应 +// 一个平台,groups 和 supported_models 都只包含该平台的内容。这样前端无需在 +// 一行内混排多平台信息,也能直接为整行应用平台色/图标。 type userAvailableChannel struct { Name string `json:"name"` Description string `json:"description"` + Platform string `json:"platform"` Groups []userAvailableGroup `json:"groups"` SupportedModels []userSupportedModel `json:"supported_models"` } @@ -132,28 +139,48 @@ func (h *AvailableChannelHandler) List(c *gin.Context) { if len(visibleGroups) == 0 { continue } - allowedPlatforms := collectGroupPlatforms(visibleGroups) - out = append(out, userAvailableChannel{ - Name: ch.Name, - Description: ch.Description, - Groups: visibleGroups, - SupportedModels: toUserSupportedModels(ch.SupportedModels, allowedPlatforms), - }) + out = append(out, explodeChannelByPlatform(ch, visibleGroups)...) } response.Success(c, out) } -// collectGroupPlatforms 聚合 visible groups 覆盖的平台集合,用于过滤 SupportedModels。 -func collectGroupPlatforms(groups []userAvailableGroup) map[string]struct{} { - set := make(map[string]struct{}, len(groups)) - for _, g := range groups { +// explodeChannelByPlatform 将单个渠道按 visibleGroups 的平台集合摊开成多条记录。 +// 每条记录对应一个平台:groups 仅含该平台的 visibleGroups,supported_models 仅含 +// 该平台的模型。输出按 platform 字母序稳定排序,便于前端等效比较与回归测试。 +func explodeChannelByPlatform( + ch service.AvailableChannel, + visibleGroups []userAvailableGroup, +) []userAvailableChannel { + groupsByPlatform := make(map[string][]userAvailableGroup, 4) + for _, g := range visibleGroups { if g.Platform == "" { continue } - set[g.Platform] = struct{}{} + groupsByPlatform[g.Platform] = append(groupsByPlatform[g.Platform], g) } - return set + if len(groupsByPlatform) == 0 { + return nil + } + + platforms := make([]string, 0, len(groupsByPlatform)) + for p := range groupsByPlatform { + platforms = append(platforms, p) + } + sort.Strings(platforms) + + out := make([]userAvailableChannel, 0, len(platforms)) + for _, platform := range platforms { + platformSet := map[string]struct{}{platform: {}} + out = append(out, userAvailableChannel{ + Name: ch.Name, + Description: ch.Description, + Platform: platform, + Groups: groupsByPlatform[platform], + SupportedModels: toUserSupportedModels(ch.SupportedModels, platformSet), + }) + } + return out } // filterUserVisibleGroups 仅保留用户可访问的分组。 diff --git a/backend/internal/handler/available_channel_handler_test.go b/backend/internal/handler/available_channel_handler_test.go index cc2ca33a..c83c5e6a 100644 --- a/backend/internal/handler/available_channel_handler_test.go +++ b/backend/internal/handler/available_channel_handler_test.go @@ -42,21 +42,6 @@ func TestFilterUserVisibleGroups_IntersectionOnly(t *testing.T) { require.ElementsMatch(t, []int64{1, 3}, ids) } -func TestCollectGroupPlatforms_DerivesAllowedSet(t *testing.T) { - groups := []userAvailableGroup{ - {ID: 1, Platform: "anthropic"}, - {ID: 2, Platform: "openai"}, - {ID: 3, Platform: "anthropic"}, // 去重 - {ID: 4, Platform: ""}, // 空平台忽略 - } - got := collectGroupPlatforms(groups) - require.Len(t, got, 2) - _, hasAnt := got["anthropic"] - _, hasOA := got["openai"] - require.True(t, hasAnt) - require.True(t, hasOA) -} - func TestToUserSupportedModels_FiltersByAllowedPlatforms(t *testing.T) { // 用户可访问分组只覆盖 anthropic;anthropic 平台的模型保留,openai 模型被剔除。 src := []service.SupportedModel{ diff --git a/frontend/src/api/channels.ts b/frontend/src/api/channels.ts index 98b890df..65b2052a 100644 --- a/frontend/src/api/channels.ts +++ b/frontend/src/api/channels.ts @@ -43,6 +43,11 @@ export interface UserSupportedModel { export interface UserAvailableChannel { name: string description: string + /** + * 所属平台(anthropic / openai / antigravity / gemini ...)。后端按平台把一个渠道 + * 摊开成多条记录,因此此字段决定整行的配色与图标。 + */ + platform: string groups: UserAvailableGroup[] supported_models: UserSupportedModel[] } diff --git a/frontend/src/components/channels/AvailableChannelsTable.vue b/frontend/src/components/channels/AvailableChannelsTable.vue index 96aa82a9..1a9fd38d 100644 --- a/frontend/src/components/channels/AvailableChannelsTable.vue +++ b/frontend/src/components/channels/AvailableChannelsTable.vue @@ -1,12 +1,19 @@ @@ -60,9 +77,12 @@ import { computed, useSlots } from 'vue' import DataTable from '@/components/common/DataTable.vue' import Icon from '@/components/icons/Icon.vue' +import PlatformIcon from '@/components/common/PlatformIcon.vue' import SupportedModelChip from './SupportedModelChip.vue' import type { UserSupportedModel } from '@/api/channels' import type { ChannelStatus, BillingModelSource } from '@/constants/channel' +import type { GroupPlatform } from '@/types' +import { platformBadgeClass, platformBadgeLightClass } from '@/utils/platformColors' interface GroupRef { id: number @@ -73,6 +93,8 @@ interface GroupRef { interface Row { name: string description?: string + /** 单条记录归属的平台;后端按平台摊开后每行一个。admin 场景可能缺失,因此允许 optional。 */ + platform?: string groups: GroupRef[] // 复用 user 侧最小 DTO;admin 侧 SupportedModel 结构上是其超集,可直接传入。 supported_models: UserSupportedModel[] diff --git a/frontend/src/components/channels/SupportedModelChip.vue b/frontend/src/components/channels/SupportedModelChip.vue index 600e3ef5..7f4ace83 100644 --- a/frontend/src/components/channels/SupportedModelChip.vue +++ b/frontend/src/components/channels/SupportedModelChip.vue @@ -1,11 +1,21 @@