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"`
|
Pricing *userSupportedModelPricing `json:"pricing"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// userAvailableChannel 用户可见的渠道条目(白名单字段)。
|
// userChannelPlatformSection 单渠道内某个平台的子视图:用户可见的分组 + 该平台
|
||||||
//
|
// 支持的模型。按 platform 聚合后让前端可以把渠道名作为 row-group 一次渲染,
|
||||||
// 同一个渠道若在多个平台上都有用户可见的分组,会被摊开成多条记录 —— 每条对应
|
// 后面的平台行按 sections 顺序铺开。
|
||||||
// 一个平台,groups 和 supported_models 都只包含该平台的内容。这样前端无需在
|
type userChannelPlatformSection struct {
|
||||||
// 一行内混排多平台信息,也能直接为整行应用平台色/图标。
|
|
||||||
type userAvailableChannel struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
Platform string `json:"platform"`
|
Platform string `json:"platform"`
|
||||||
Groups []userAvailableGroup `json:"groups"`
|
Groups []userAvailableGroup `json:"groups"`
|
||||||
SupportedModels []userSupportedModel `json:"supported_models"`
|
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 列出当前用户可见的「可用渠道」。
|
// List 列出当前用户可见的「可用渠道」。
|
||||||
// GET /api/v1/channels/available
|
// GET /api/v1/channels/available
|
||||||
func (h *AvailableChannelHandler) List(c *gin.Context) {
|
func (h *AvailableChannelHandler) List(c *gin.Context) {
|
||||||
@@ -139,19 +145,27 @@ func (h *AvailableChannelHandler) List(c *gin.Context) {
|
|||||||
if len(visibleGroups) == 0 {
|
if len(visibleGroups) == 0 {
|
||||||
continue
|
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)
|
response.Success(c, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
// explodeChannelByPlatform 将单个渠道按 visibleGroups 的平台集合摊开成多条记录。
|
// buildPlatformSections 把一个渠道按 visibleGroups 的平台集合拆成有序的 section 列表:
|
||||||
// 每条记录对应一个平台:groups 仅含该平台的 visibleGroups,supported_models 仅含
|
// 每个 section 对应一个平台,只包含该平台的 groups 和 supported_models。
|
||||||
// 该平台的模型。输出按 platform 字母序稳定排序,便于前端等效比较与回归测试。
|
// 输出按 platform 字母序稳定排序,便于前端等效比较与回归测试。
|
||||||
func explodeChannelByPlatform(
|
func buildPlatformSections(
|
||||||
ch service.AvailableChannel,
|
ch service.AvailableChannel,
|
||||||
visibleGroups []userAvailableGroup,
|
visibleGroups []userAvailableGroup,
|
||||||
) []userAvailableChannel {
|
) []userChannelPlatformSection {
|
||||||
groupsByPlatform := make(map[string][]userAvailableGroup, 4)
|
groupsByPlatform := make(map[string][]userAvailableGroup, 4)
|
||||||
for _, g := range visibleGroups {
|
for _, g := range visibleGroups {
|
||||||
if g.Platform == "" {
|
if g.Platform == "" {
|
||||||
@@ -169,18 +183,16 @@ func explodeChannelByPlatform(
|
|||||||
}
|
}
|
||||||
sort.Strings(platforms)
|
sort.Strings(platforms)
|
||||||
|
|
||||||
out := make([]userAvailableChannel, 0, len(platforms))
|
sections := make([]userChannelPlatformSection, 0, len(platforms))
|
||||||
for _, platform := range platforms {
|
for _, platform := range platforms {
|
||||||
platformSet := map[string]struct{}{platform: {}}
|
platformSet := map[string]struct{}{platform: {}}
|
||||||
out = append(out, userAvailableChannel{
|
sections = append(sections, userChannelPlatformSection{
|
||||||
Name: ch.Name,
|
|
||||||
Description: ch.Description,
|
|
||||||
Platform: platform,
|
Platform: platform,
|
||||||
Groups: groupsByPlatform[platform],
|
Groups: groupsByPlatform[platform],
|
||||||
SupportedModels: toUserSupportedModels(ch.SupportedModels, platformSet),
|
SupportedModels: toUserSupportedModels(ch.SupportedModels, platformSet),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return out
|
return sections
|
||||||
}
|
}
|
||||||
|
|
||||||
// filterUserVisibleGroups 仅保留用户可访问的分组。
|
// filterUserVisibleGroups 仅保留用户可访问的分组。
|
||||||
|
|||||||
@@ -65,12 +65,17 @@ func TestToUserSupportedModels_NilAllowedPlatformsKeepsAll(t *testing.T) {
|
|||||||
|
|
||||||
func TestUserAvailableChannel_FieldWhitelist(t *testing.T) {
|
func TestUserAvailableChannel_FieldWhitelist(t *testing.T) {
|
||||||
// 通过序列化 userAvailableChannel 结构体验证响应形状:
|
// 通过序列化 userAvailableChannel 结构体验证响应形状:
|
||||||
// 只有 name / description / groups / supported_models;不含管理端字段。
|
// 只有 name / description / platforms;不含管理端字段。
|
||||||
row := userAvailableChannel{
|
row := userAvailableChannel{
|
||||||
Name: "ch",
|
Name: "ch",
|
||||||
Description: "d",
|
Description: "d",
|
||||||
Groups: []userAvailableGroup{{ID: 1, Name: "g1", Platform: "anthropic"}},
|
Platforms: []userChannelPlatformSection{
|
||||||
SupportedModels: []userSupportedModel{},
|
{
|
||||||
|
Platform: "anthropic",
|
||||||
|
Groups: []userAvailableGroup{{ID: 1, Name: "g1", Platform: "anthropic"}},
|
||||||
|
SupportedModels: []userSupportedModel{},
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
raw, err := json.Marshal(row)
|
raw, err := json.Marshal(row)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -81,11 +86,21 @@ func TestUserAvailableChannel_FieldWhitelist(t *testing.T) {
|
|||||||
_, exists := decoded[key]
|
_, exists := decoded[key]
|
||||||
require.Falsef(t, exists, "user DTO must not expose %q", 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]
|
_, exists := decoded[key]
|
||||||
require.Truef(t, exists, "user DTO must expose %q", 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 interval 白名单:不应暴露 id / sort_order。
|
||||||
pricing := toUserPricing(&service.ChannelModelPricing{
|
pricing := toUserPricing(&service.ChannelModelPricing{
|
||||||
BillingMode: service.BillingModeToken,
|
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)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -40,18 +40,23 @@ export interface UserSupportedModel {
|
|||||||
pricing: UserSupportedModelPricing | null
|
pricing: UserSupportedModelPricing | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserAvailableChannel {
|
/**
|
||||||
name: string
|
* 渠道下单个平台的子视图:用户可访问的分组 + 该平台支持的模型。
|
||||||
description: string
|
* 后端把一个渠道按平台聚合成 sections,前端可以把渠道名作为 row-group
|
||||||
/**
|
* 一次渲染,后面按 sections 顺序用 rowspan 铺开。
|
||||||
* 所属平台(anthropic / openai / antigravity / gemini ...)。后端按平台把一个渠道
|
*/
|
||||||
* 摊开成多条记录,因此此字段决定整行的配色与图标。
|
export interface UserChannelPlatformSection {
|
||||||
*/
|
|
||||||
platform: string
|
platform: string
|
||||||
groups: UserAvailableGroup[]
|
groups: UserAvailableGroup[]
|
||||||
supported_models: UserSupportedModel[]
|
supported_models: UserSupportedModel[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserAvailableChannel {
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
platforms: UserChannelPlatformSection[]
|
||||||
|
}
|
||||||
|
|
||||||
/** 列出当前用户可见的「可用渠道」(与 /groups/available 保持一致,返回平数组)。 */
|
/** 列出当前用户可见的「可用渠道」(与 /groups/available 保持一致,返回平数组)。 */
|
||||||
export async function getAvailable(options?: { signal?: AbortSignal }): Promise<UserAvailableChannel[]> {
|
export async function getAvailable(options?: { signal?: AbortSignal }): Promise<UserAvailableChannel[]> {
|
||||||
const { data } = await apiClient.get<UserAvailableChannel[]>('/channels/available', {
|
const { data } = await apiClient.get<UserAvailableChannel[]>('/channels/available', {
|
||||||
|
|||||||
@@ -1,131 +1,124 @@
|
|||||||
<template>
|
<template>
|
||||||
<DataTable :columns="columns" :data="rows" :loading="loading">
|
<div class="card overflow-hidden">
|
||||||
<template #cell-name="{ row }">
|
<!-- 表头 -->
|
||||||
<div class="flex items-center gap-2">
|
<div
|
||||||
<span class="font-medium text-gray-900 dark:text-white">{{ row.name }}</span>
|
class="grid items-center border-b border-gray-100 bg-gray-50/50 px-4 py-3 text-xs font-medium uppercase tracking-wide text-gray-500 dark:border-dark-700 dark:bg-dark-800/50 dark:text-gray-400"
|
||||||
<span
|
:style="gridStyle"
|
||||||
v-if="row.platform"
|
>
|
||||||
:class="[
|
<div>{{ columns.name }}</div>
|
||||||
'inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 text-[11px] font-medium uppercase',
|
<div>{{ columns.platform }}</div>
|
||||||
platformBadgeClass(row.platform),
|
<div>{{ columns.groups }}</div>
|
||||||
]"
|
<div>{{ columns.supportedModels }}</div>
|
||||||
:title="row.description || undefined"
|
</div>
|
||||||
>
|
|
||||||
<PlatformIcon :platform="row.platform as GroupPlatform" size="xs" />
|
|
||||||
{{ row.platform }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #cell-groups="{ row }">
|
<div v-if="loading" class="flex justify-center py-10">
|
||||||
<div v-if="row.groups.length === 0" class="text-xs text-gray-400">
|
<Icon name="refresh" size="lg" class="animate-spin text-gray-400" />
|
||||||
<slot name="empty-groups">-</slot>
|
</div>
|
||||||
</div>
|
|
||||||
<div v-else class="flex flex-wrap gap-1">
|
|
||||||
<span
|
|
||||||
v-for="g in row.groups"
|
|
||||||
:key="g.id"
|
|
||||||
: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>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #cell-supported_models="{ row }">
|
<div v-else-if="rows.length === 0" class="flex flex-col items-center py-12">
|
||||||
<div v-if="row.supported_models.length === 0" class="text-xs text-gray-400">
|
<Icon name="inbox" size="xl" class="mb-3 h-12 w-12 text-gray-400" />
|
||||||
{{ noModelsLabel }}
|
<p class="text-sm text-gray-500 dark:text-gray-400">{{ emptyLabel }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="flex max-w-[560px] flex-wrap gap-1">
|
|
||||||
<SupportedModelChip
|
|
||||||
v-for="m in row.supported_models"
|
|
||||||
:key="`${m.platform}-${m.name}`"
|
|
||||||
:model="m"
|
|
||||||
:pricing-key-prefix="pricingKeyPrefix"
|
|
||||||
:no-pricing-label="noPricingLabel"
|
|
||||||
:show-platform="false"
|
|
||||||
:platform-hint="row.platform"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- 允许父组件为额外列提供自定义渲染(如 admin 的 status / billing_model_source)。 -->
|
<!-- 渠道分组:每个渠道一个 section,内部按 platform 顺序铺开 -->
|
||||||
<template v-for="slot in extraCellSlots" :key="slot" #[slot]="scope">
|
<div
|
||||||
<slot :name="slot" v-bind="scope" />
|
v-else
|
||||||
</template>
|
v-for="(channel, chIdx) in rows"
|
||||||
|
:key="`${channel.name}-${chIdx}`"
|
||||||
<template #empty>
|
class="border-b border-gray-100 last:border-b-0 dark:border-dark-700"
|
||||||
<slot name="empty">
|
>
|
||||||
<div class="flex flex-col items-center py-8">
|
<div
|
||||||
<Icon name="inbox" size="xl" class="mb-3 h-12 w-12 text-gray-400" />
|
v-for="(section, secIdx) in channel.platforms"
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ emptyLabel }}</p>
|
:key="`${channel.name}-${section.platform}`"
|
||||||
|
class="grid items-center px-4 py-3 transition-colors hover:bg-gray-50/40 dark:hover:bg-dark-800/40"
|
||||||
|
:class="{ 'border-t border-gray-100/70 dark:border-dark-700/50': secIdx > 0 }"
|
||||||
|
:style="gridStyle"
|
||||||
|
>
|
||||||
|
<!-- 渠道名:仅第一行渲染,后续行留空(视觉上的 rowspan) -->
|
||||||
|
<div>
|
||||||
|
<template v-if="secIdx === 0">
|
||||||
|
<div class="font-medium text-gray-900 dark:text-white">{{ channel.name }}</div>
|
||||||
|
<div
|
||||||
|
v-if="channel.description"
|
||||||
|
class="mt-0.5 text-xs text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
{{ channel.description }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</slot>
|
|
||||||
</template>
|
<!-- 平台徽章 -->
|
||||||
</DataTable>
|
<div>
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-[11px] font-medium uppercase',
|
||||||
|
platformBadgeClass(section.platform),
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<PlatformIcon :platform="section.platform as GroupPlatform" size="xs" />
|
||||||
|
{{ section.platform }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 分组 -->
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
<span
|
||||||
|
v-for="g in section.groups"
|
||||||
|
:key="g.id"
|
||||||
|
:class="[
|
||||||
|
'inline-flex items-center gap-1 rounded px-2 py-0.5 text-xs font-medium',
|
||||||
|
platformBadgeLightClass(section.platform),
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<PlatformIcon :platform="section.platform as GroupPlatform" size="xs" />
|
||||||
|
{{ g.name }}
|
||||||
|
</span>
|
||||||
|
<span v-if="section.groups.length === 0" class="text-xs text-gray-400">-</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 支持模型 -->
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
<SupportedModelChip
|
||||||
|
v-for="m in section.supported_models"
|
||||||
|
:key="`${section.platform}-${m.name}`"
|
||||||
|
:model="m"
|
||||||
|
:pricing-key-prefix="pricingKeyPrefix"
|
||||||
|
:no-pricing-label="noPricingLabel"
|
||||||
|
:show-platform="false"
|
||||||
|
:platform-hint="section.platform"
|
||||||
|
/>
|
||||||
|
<span v-if="section.supported_models.length === 0" class="text-xs text-gray-400">
|
||||||
|
{{ noModelsLabel }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, useSlots } from '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 PlatformIcon from '@/components/common/PlatformIcon.vue'
|
||||||
import SupportedModelChip from './SupportedModelChip.vue'
|
import SupportedModelChip from './SupportedModelChip.vue'
|
||||||
import type { UserSupportedModel } from '@/api/channels'
|
import type { UserAvailableChannel } from '@/api/channels'
|
||||||
import type { ChannelStatus, BillingModelSource } from '@/constants/channel'
|
|
||||||
import type { GroupPlatform } from '@/types'
|
import type { GroupPlatform } from '@/types'
|
||||||
import { platformBadgeClass, platformBadgeLightClass } from '@/utils/platformColors'
|
import { platformBadgeClass, platformBadgeLightClass } from '@/utils/platformColors'
|
||||||
|
|
||||||
interface GroupRef {
|
/** 四列 grid 的 template-columns;与表头、每个 section 行共享。 */
|
||||||
id: number
|
const gridStyle = 'grid-template-columns: 220px 140px minmax(200px, 1fr) minmax(280px, 2fr); display: grid;'
|
||||||
name: string
|
|
||||||
platform?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Row {
|
|
||||||
name: string
|
|
||||||
description?: string
|
|
||||||
/** 单条记录归属的平台;后端按平台摊开后每行一个。admin 场景可能缺失,因此允许 optional。 */
|
|
||||||
platform?: string
|
|
||||||
groups: GroupRef[]
|
|
||||||
// 复用 user 侧最小 DTO;admin 侧 SupportedModel 结构上是其超集,可直接传入。
|
|
||||||
supported_models: UserSupportedModel[]
|
|
||||||
// admin 独有字段:用精确类型代替 `unknown`,让消费端无需 `as` 断言,
|
|
||||||
// 也能在后端新增 union 成员时让前端 Record 查表立刻出空而非崩溃。
|
|
||||||
status?: ChannelStatus
|
|
||||||
billing_model_source?: BillingModelSource
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Column {
|
|
||||||
key: string
|
|
||||||
label: string
|
|
||||||
}
|
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
columns: Column[]
|
columns: {
|
||||||
rows: Row[]
|
name: string
|
||||||
|
platform: string
|
||||||
|
groups: string
|
||||||
|
supportedModels: string
|
||||||
|
}
|
||||||
|
rows: UserAvailableChannel[]
|
||||||
loading: boolean
|
loading: boolean
|
||||||
pricingKeyPrefix: string
|
pricingKeyPrefix: string
|
||||||
noPricingLabel: string
|
noPricingLabel: string
|
||||||
noModelsLabel: string
|
noModelsLabel: string
|
||||||
emptyLabel: string
|
emptyLabel: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const slots = useSlots()
|
|
||||||
/**
|
|
||||||
* 透传父组件提供的 cell-* 插槽(除本组件内置的 name/groups/supported_models/empty-groups/empty
|
|
||||||
* 之外),让 admin 场景可以自定义 status / billing_model_source 等列。
|
|
||||||
*/
|
|
||||||
const extraCellSlots = computed(() => {
|
|
||||||
const reserved = new Set(['cell-name', 'cell-groups', 'cell-supported_models', 'empty-groups', 'empty'])
|
|
||||||
return Object.keys(slots).filter((name) => name.startsWith('cell-') && !reserved.has(name))
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -940,6 +940,7 @@ export default {
|
|||||||
noPricing: 'Pricing not configured',
|
noPricing: 'Pricing not configured',
|
||||||
columns: {
|
columns: {
|
||||||
name: 'Channel',
|
name: 'Channel',
|
||||||
|
platform: 'Platform',
|
||||||
groups: 'Your Accessible Groups',
|
groups: 'Your Accessible Groups',
|
||||||
supportedModels: 'Supported Models'
|
supportedModels: 'Supported Models'
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -944,6 +944,7 @@ export default {
|
|||||||
noPricing: '未配置定价',
|
noPricing: '未配置定价',
|
||||||
columns: {
|
columns: {
|
||||||
name: '渠道名',
|
name: '渠道名',
|
||||||
|
platform: '平台',
|
||||||
groups: '我可访问的分组',
|
groups: '我可访问的分组',
|
||||||
supportedModels: '支持模型'
|
supportedModels: '支持模型'
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
|
|
||||||
<template #table>
|
<template #table>
|
||||||
<AvailableChannelsTable
|
<AvailableChannelsTable
|
||||||
:columns="columns"
|
:columns="columnLabels"
|
||||||
:rows="filteredChannels"
|
:rows="filteredChannels"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
pricing-key-prefix="availableChannels.pricing"
|
pricing-key-prefix="availableChannels.pricing"
|
||||||
@@ -65,22 +65,37 @@ const channels = ref<UserAvailableChannel[]>([])
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
|
|
||||||
const columns = computed(() => [
|
const columnLabels = computed(() => ({
|
||||||
{ key: 'name', label: t('availableChannels.columns.name') },
|
name: t('availableChannels.columns.name'),
|
||||||
{ key: 'groups', label: t('availableChannels.columns.groups') },
|
platform: t('availableChannels.columns.platform'),
|
||||||
{ key: 'supported_models', label: t('availableChannels.columns.supportedModels') }
|
groups: t('availableChannels.columns.groups'),
|
||||||
])
|
supportedModels: t('availableChannels.columns.supportedModels'),
|
||||||
|
}))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 搜索过滤:
|
||||||
|
* - 命中渠道名/描述 → 整个渠道(所有 platforms)都保留
|
||||||
|
* - 否则按 platform/group/model 维度在 sections 里过滤,保留有匹配的 section
|
||||||
|
* - 所有 sections 都不匹配时,渠道本身被过滤掉
|
||||||
|
*/
|
||||||
const filteredChannels = computed(() => {
|
const filteredChannels = computed(() => {
|
||||||
const q = searchQuery.value.trim().toLowerCase()
|
const q = searchQuery.value.trim().toLowerCase()
|
||||||
if (!q) return channels.value
|
if (!q) return channels.value
|
||||||
return channels.value.filter((ch) => {
|
return channels.value
|
||||||
if (ch.name.toLowerCase().includes(q)) return true
|
.map((ch) => {
|
||||||
if ((ch.description || '').toLowerCase().includes(q)) return true
|
const nameHit = ch.name.toLowerCase().includes(q)
|
||||||
if (ch.groups.some((g) => g.name.toLowerCase().includes(q))) return true
|
const descHit = (ch.description || '').toLowerCase().includes(q)
|
||||||
if (ch.supported_models.some((m) => m.name.toLowerCase().includes(q))) return true
|
if (nameHit || descHit) return ch
|
||||||
return false
|
const matchingSections = ch.platforms.filter(
|
||||||
})
|
(p) =>
|
||||||
|
p.platform.toLowerCase().includes(q) ||
|
||||||
|
p.groups.some((g) => g.name.toLowerCase().includes(q)) ||
|
||||||
|
p.supported_models.some((m) => m.name.toLowerCase().includes(q)),
|
||||||
|
)
|
||||||
|
if (matchingSections.length === 0) return null
|
||||||
|
return { ...ch, platforms: matchingSections }
|
||||||
|
})
|
||||||
|
.filter((ch): ch is UserAvailableChannel => ch !== null)
|
||||||
})
|
})
|
||||||
|
|
||||||
async function loadChannels() {
|
async function loadChannels() {
|
||||||
|
|||||||
Reference in New Issue
Block a user