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:
@@ -1,6 +1,8 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"sort"
|
||||||
|
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
@@ -84,9 +86,14 @@ type userSupportedModel struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// userAvailableChannel 用户可见的渠道条目(白名单字段)。
|
// userAvailableChannel 用户可见的渠道条目(白名单字段)。
|
||||||
|
//
|
||||||
|
// 同一个渠道若在多个平台上都有用户可见的分组,会被摊开成多条记录 —— 每条对应
|
||||||
|
// 一个平台,groups 和 supported_models 都只包含该平台的内容。这样前端无需在
|
||||||
|
// 一行内混排多平台信息,也能直接为整行应用平台色/图标。
|
||||||
type userAvailableChannel struct {
|
type userAvailableChannel struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
|
Platform string `json:"platform"`
|
||||||
Groups []userAvailableGroup `json:"groups"`
|
Groups []userAvailableGroup `json:"groups"`
|
||||||
SupportedModels []userSupportedModel `json:"supported_models"`
|
SupportedModels []userSupportedModel `json:"supported_models"`
|
||||||
}
|
}
|
||||||
@@ -132,28 +139,48 @@ func (h *AvailableChannelHandler) List(c *gin.Context) {
|
|||||||
if len(visibleGroups) == 0 {
|
if len(visibleGroups) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
allowedPlatforms := collectGroupPlatforms(visibleGroups)
|
out = append(out, explodeChannelByPlatform(ch, visibleGroups)...)
|
||||||
out = append(out, userAvailableChannel{
|
|
||||||
Name: ch.Name,
|
|
||||||
Description: ch.Description,
|
|
||||||
Groups: visibleGroups,
|
|
||||||
SupportedModels: toUserSupportedModels(ch.SupportedModels, allowedPlatforms),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
response.Success(c, out)
|
response.Success(c, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
// collectGroupPlatforms 聚合 visible groups 覆盖的平台集合,用于过滤 SupportedModels。
|
// explodeChannelByPlatform 将单个渠道按 visibleGroups 的平台集合摊开成多条记录。
|
||||||
func collectGroupPlatforms(groups []userAvailableGroup) map[string]struct{} {
|
// 每条记录对应一个平台:groups 仅含该平台的 visibleGroups,supported_models 仅含
|
||||||
set := make(map[string]struct{}, len(groups))
|
// 该平台的模型。输出按 platform 字母序稳定排序,便于前端等效比较与回归测试。
|
||||||
for _, g := range groups {
|
func explodeChannelByPlatform(
|
||||||
|
ch service.AvailableChannel,
|
||||||
|
visibleGroups []userAvailableGroup,
|
||||||
|
) []userAvailableChannel {
|
||||||
|
groupsByPlatform := make(map[string][]userAvailableGroup, 4)
|
||||||
|
for _, g := range visibleGroups {
|
||||||
if g.Platform == "" {
|
if g.Platform == "" {
|
||||||
continue
|
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 仅保留用户可访问的分组。
|
// filterUserVisibleGroups 仅保留用户可访问的分组。
|
||||||
|
|||||||
@@ -42,21 +42,6 @@ func TestFilterUserVisibleGroups_IntersectionOnly(t *testing.T) {
|
|||||||
require.ElementsMatch(t, []int64{1, 3}, ids)
|
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) {
|
func TestToUserSupportedModels_FiltersByAllowedPlatforms(t *testing.T) {
|
||||||
// 用户可访问分组只覆盖 anthropic;anthropic 平台的模型保留,openai 模型被剔除。
|
// 用户可访问分组只覆盖 anthropic;anthropic 平台的模型保留,openai 模型被剔除。
|
||||||
src := []service.SupportedModel{
|
src := []service.SupportedModel{
|
||||||
|
|||||||
@@ -43,6 +43,11 @@ export interface UserSupportedModel {
|
|||||||
export interface UserAvailableChannel {
|
export interface UserAvailableChannel {
|
||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
|
/**
|
||||||
|
* 所属平台(anthropic / openai / antigravity / gemini ...)。后端按平台把一个渠道
|
||||||
|
* 摊开成多条记录,因此此字段决定整行的配色与图标。
|
||||||
|
*/
|
||||||
|
platform: string
|
||||||
groups: UserAvailableGroup[]
|
groups: UserAvailableGroup[]
|
||||||
supported_models: UserSupportedModel[]
|
supported_models: UserSupportedModel[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
<template>
|
<template>
|
||||||
<DataTable :columns="columns" :data="rows" :loading="loading">
|
<DataTable :columns="columns" :data="rows" :loading="loading">
|
||||||
<template #cell-name="{ row }">
|
<template #cell-name="{ row }">
|
||||||
<div class="font-medium text-gray-900 dark:text-white">{{ row.name }}</div>
|
<div class="flex items-center gap-2">
|
||||||
<div
|
<span class="font-medium text-gray-900 dark:text-white">{{ row.name }}</span>
|
||||||
v-if="row.description"
|
<span
|
||||||
class="mt-0.5 text-xs text-gray-500 dark:text-gray-400"
|
v-if="row.platform"
|
||||||
>
|
:class="[
|
||||||
{{ row.description }}
|
'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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -18,8 +25,16 @@
|
|||||||
<span
|
<span
|
||||||
v-for="g in row.groups"
|
v-for="g in row.groups"
|
||||||
:key="g.id"
|
: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 }}
|
{{ g.name }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -36,6 +51,8 @@
|
|||||||
:model="m"
|
:model="m"
|
||||||
:pricing-key-prefix="pricingKeyPrefix"
|
:pricing-key-prefix="pricingKeyPrefix"
|
||||||
:no-pricing-label="noPricingLabel"
|
:no-pricing-label="noPricingLabel"
|
||||||
|
:show-platform="false"
|
||||||
|
:platform-hint="row.platform"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -60,9 +77,12 @@
|
|||||||
import { computed, useSlots } from 'vue'
|
import { computed, useSlots } from 'vue'
|
||||||
import DataTable from '@/components/common/DataTable.vue'
|
import DataTable from '@/components/common/DataTable.vue'
|
||||||
import Icon from '@/components/icons/Icon.vue'
|
import Icon from '@/components/icons/Icon.vue'
|
||||||
|
import PlatformIcon from '@/components/common/PlatformIcon.vue'
|
||||||
import SupportedModelChip from './SupportedModelChip.vue'
|
import SupportedModelChip from './SupportedModelChip.vue'
|
||||||
import type { UserSupportedModel } from '@/api/channels'
|
import type { UserSupportedModel } from '@/api/channels'
|
||||||
import type { ChannelStatus, BillingModelSource } from '@/constants/channel'
|
import type { ChannelStatus, BillingModelSource } from '@/constants/channel'
|
||||||
|
import type { GroupPlatform } from '@/types'
|
||||||
|
import { platformBadgeClass, platformBadgeLightClass } from '@/utils/platformColors'
|
||||||
|
|
||||||
interface GroupRef {
|
interface GroupRef {
|
||||||
id: number
|
id: number
|
||||||
@@ -73,6 +93,8 @@ interface GroupRef {
|
|||||||
interface Row {
|
interface Row {
|
||||||
name: string
|
name: string
|
||||||
description?: string
|
description?: string
|
||||||
|
/** 单条记录归属的平台;后端按平台摊开后每行一个。admin 场景可能缺失,因此允许 optional。 */
|
||||||
|
platform?: string
|
||||||
groups: GroupRef[]
|
groups: GroupRef[]
|
||||||
// 复用 user 侧最小 DTO;admin 侧 SupportedModel 结构上是其超集,可直接传入。
|
// 复用 user 侧最小 DTO;admin 侧 SupportedModel 结构上是其超集,可直接传入。
|
||||||
supported_models: UserSupportedModel[]
|
supported_models: UserSupportedModel[]
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="group relative inline-block">
|
<div class="group relative inline-block">
|
||||||
<span
|
<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
|
<span
|
||||||
v-if="showPlatform && model.platform"
|
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 }}
|
{{ model.platform }}
|
||||||
</span>
|
</span>
|
||||||
@@ -130,6 +140,9 @@ import {
|
|||||||
// 复用 api/channels.ts 的用户侧最小形态 DTO。
|
// 复用 api/channels.ts 的用户侧最小形态 DTO。
|
||||||
// admin 侧 ChannelModelPricing 字段更多,但结构上是用户 DTO 的超集,admin 视图传入可直接通过结构化子类型检查。
|
// admin 侧 ChannelModelPricing 字段更多,但结构上是用户 DTO 的超集,admin 视图传入可直接通过结构化子类型检查。
|
||||||
import type { UserPricingInterval, UserSupportedModel } from '@/api/channels'
|
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(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -138,14 +151,22 @@ const props = withDefaults(
|
|||||||
pricingKeyPrefix?: string
|
pricingKeyPrefix?: string
|
||||||
noPricingLabel?: string
|
noPricingLabel?: string
|
||||||
showPlatform?: boolean
|
showPlatform?: boolean
|
||||||
|
/**
|
||||||
|
* 当 model.platform 缺失(如 admin 聚合场景)时,用父行的平台作为兜底着色。
|
||||||
|
* 仅用于视觉,不影响业务逻辑。
|
||||||
|
*/
|
||||||
|
platformHint?: string
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
pricingKeyPrefix: 'availableChannels.pricing',
|
pricingKeyPrefix: 'availableChannels.pricing',
|
||||||
noPricingLabel: '',
|
noPricingLabel: '',
|
||||||
showPlatform: true
|
showPlatform: true,
|
||||||
|
platformHint: ''
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const effectivePlatform = computed<string>(() => props.model.platform || props.platformHint || '')
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
/** 按 token 定价展示时的换算单位:每百万 token。 */
|
/** 按 token 定价展示时的换算单位:每百万 token。 */
|
||||||
|
|||||||
Reference in New Issue
Block a user