feat(channels): explode available channels by platform + apply platform theme

Backend: one source channel → N output rows, one per platform that
has user-visible groups. Each row carries a single platform, so the
frontend can color/icon an entire row without mixing sources.

- userAvailableChannel: add Platform field
- new explodeChannelByPlatform helper; drop now-redundant
  collectGroupPlatforms

Frontend: use the row platform to drive theming and stop repeating
"ANTHROPIC" / "OPENAI" labels on every model chip.

- api/channels.ts: UserAvailableChannel.platform
- AvailableChannelsTable: name cell — PlatformBadge next to channel
  name (replaces the two-line name/description block; description
  moves to the badge's title tooltip); groups cell — each chip uses
  platformBadgeLightClass + PlatformIcon; model list passes
  show-platform=false + platform-hint to child chips
- SupportedModelChip: chip bg/border driven by platformBadgeClass,
  leading PlatformIcon; platform-hint fallback when model.platform
  missing
This commit is contained in:
erio
2026-04-21 18:47:54 +08:00
parent 9ba42aa556
commit 800802b8aa
5 changed files with 98 additions and 38 deletions

View File

@@ -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 仅含该平台的 visibleGroupssupported_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 仅保留用户可访问的分组。

View File

@@ -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) {
// 用户可访问分组只覆盖 anthropicanthropic 平台的模型保留openai 模型被剔除。
src := []service.SupportedModel{

View File

@@ -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[]
}

View File

@@ -1,12 +1,19 @@
<template>
<DataTable :columns="columns" :data="rows" :loading="loading">
<template #cell-name="{ row }">
<div class="font-medium text-gray-900 dark:text-white">{{ row.name }}</div>
<div
v-if="row.description"
class="mt-0.5 text-xs text-gray-500 dark:text-gray-400"
>
{{ row.description }}
<div class="flex items-center gap-2">
<span class="font-medium text-gray-900 dark:text-white">{{ row.name }}</span>
<span
v-if="row.platform"
:class="[
'inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 text-[11px] font-medium uppercase',
platformBadgeClass(row.platform),
]"
:title="row.description || undefined"
>
<PlatformIcon :platform="row.platform as GroupPlatform" size="xs" />
{{ row.platform }}
</span>
</div>
</template>
@@ -18,8 +25,16 @@
<span
v-for="g in row.groups"
:key="g.id"
class="inline-flex items-center rounded bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-300"
:class="[
'inline-flex items-center gap-1 rounded px-2 py-0.5 text-xs font-medium',
platformBadgeLightClass(g.platform || row.platform || ''),
]"
>
<PlatformIcon
v-if="g.platform || row.platform"
:platform="(g.platform || row.platform) as GroupPlatform"
size="xs"
/>
{{ g.name }}
</span>
</div>
@@ -36,6 +51,8 @@
:model="m"
:pricing-key-prefix="pricingKeyPrefix"
:no-pricing-label="noPricingLabel"
:show-platform="false"
:platform-hint="row.platform"
/>
</div>
</template>
@@ -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 侧最小 DTOadmin 侧 SupportedModel 结构上是其超集,可直接传入。
supported_models: UserSupportedModel[]

View File

@@ -1,11 +1,21 @@
<template>
<div class="group relative inline-block">
<span
class="inline-flex cursor-help items-center rounded-md border border-gray-200 bg-gray-50 px-2 py-0.5 text-xs font-medium text-gray-700 transition-colors hover:border-brand-400 hover:bg-brand-50 hover:text-brand-700 dark:border-dark-600 dark:bg-dark-800 dark:text-gray-300 dark:hover:border-brand-500 dark:hover:bg-brand-900/30 dark:hover:text-brand-300"
:class="[
'inline-flex cursor-help items-center gap-1 rounded-md border px-2 py-0.5 text-xs font-medium transition-colors',
effectivePlatform
? platformBadgeClass(effectivePlatform)
: 'border-gray-200 bg-gray-50 text-gray-700 dark:border-dark-600 dark:bg-dark-800 dark:text-gray-300',
]"
>
<PlatformIcon
v-if="effectivePlatform"
:platform="effectivePlatform as GroupPlatform"
size="xs"
/>
<span
v-if="showPlatform && model.platform"
class="mr-1 rounded bg-gray-200 px-1 text-[10px] uppercase text-gray-600 dark:bg-dark-700 dark:text-gray-400"
class="rounded bg-gray-200/60 px-1 text-[10px] uppercase text-gray-600 dark:bg-dark-700 dark:text-gray-400"
>
{{ model.platform }}
</span>
@@ -130,6 +140,9 @@ import {
// 复用 api/channels.ts 的用户侧最小形态 DTO
// admin ChannelModelPricing 字段更多但结构上是用户 DTO 的超集admin 视图传入可直接通过结构化子类型检查
import type { UserPricingInterval, UserSupportedModel } from '@/api/channels'
import PlatformIcon from '@/components/common/PlatformIcon.vue'
import type { GroupPlatform } from '@/types'
import { platformBadgeClass } from '@/utils/platformColors'
const props = withDefaults(
defineProps<{
@@ -138,14 +151,22 @@ const props = withDefaults(
pricingKeyPrefix?: string
noPricingLabel?: string
showPlatform?: boolean
/**
* model.platform 缺失 admin 聚合场景用父行的平台作为兜底着色
* 仅用于视觉不影响业务逻辑
*/
platformHint?: string
}>(),
{
pricingKeyPrefix: 'availableChannels.pricing',
noPricingLabel: '',
showPlatform: true
showPlatform: true,
platformHint: ''
}
)
const effectivePlatform = computed<string>(() => props.model.platform || props.platformHint || '')
const { t } = useI18n()
/** 按 token 定价展示时的换算单位:每百万 token。 */