diff --git a/backend/internal/handler/available_channel_handler.go b/backend/internal/handler/available_channel_handler.go index e325768c..8982b80d 100644 --- a/backend/internal/handler/available_channel_handler.go +++ b/backend/internal/handler/available_channel_handler.go @@ -48,10 +48,17 @@ func (h *AvailableChannelHandler) featureEnabled(c *gin.Context) bool { } // userAvailableGroup 用户可见的分组概要(白名单字段)。 +// +// 前端据此区分专属 vs 公开分组(IsExclusive)、订阅 vs 标准分组(SubscriptionType, +// 订阅视觉加深),并用 RateMultiplier 作为默认倍率;用户专属倍率前端走 +// /groups/rates,和 API 密钥页面保持一致。 type userAvailableGroup struct { - ID int64 `json:"id"` - Name string `json:"name"` - Platform string `json:"platform"` + ID int64 `json:"id"` + Name string `json:"name"` + Platform string `json:"platform"` + SubscriptionType string `json:"subscription_type"` + RateMultiplier float64 `json:"rate_multiplier"` + IsExclusive bool `json:"is_exclusive"` } // userSupportedModelPricing 用户可见的定价字段白名单。 @@ -206,9 +213,12 @@ func filterUserVisibleGroups( continue } visible = append(visible, userAvailableGroup{ - ID: g.ID, - Name: g.Name, - Platform: g.Platform, + ID: g.ID, + Name: g.Name, + Platform: g.Platform, + SubscriptionType: g.SubscriptionType, + RateMultiplier: g.RateMultiplier, + IsExclusive: g.IsExclusive, }) } return visible diff --git a/backend/internal/handler/available_channel_handler_test.go b/backend/internal/handler/available_channel_handler_test.go index 6ff9201d..0a7ce6c4 100644 --- a/backend/internal/handler/available_channel_handler_test.go +++ b/backend/internal/handler/available_channel_handler_test.go @@ -101,6 +101,17 @@ func TestUserAvailableChannel_FieldWhitelist(t *testing.T) { require.Truef(t, exists, "platform section must expose %q", key) } + // Group DTO 暴露区分专属/公开、订阅类型、默认倍率所需的字段, + // 前端据此渲染 GroupBadge 并与 API 密钥页保持一致的视觉。 + rawGroup, err := json.Marshal(row.Platforms[0].Groups[0]) + require.NoError(t, err) + var groupDecoded map[string]any + require.NoError(t, json.Unmarshal(rawGroup, &groupDecoded)) + for _, key := range []string{"id", "name", "platform", "subscription_type", "rate_multiplier", "is_exclusive"} { + _, exists := groupDecoded[key] + require.Truef(t, exists, "group DTO must expose %q", key) + } + // pricing interval 白名单:不应暴露 id / sort_order。 pricing := toUserPricing(&service.ChannelModelPricing{ BillingMode: service.BillingModeToken, diff --git a/backend/internal/service/channel_available.go b/backend/internal/service/channel_available.go index 7f6d1e85..49f711ab 100644 --- a/backend/internal/service/channel_available.go +++ b/backend/internal/service/channel_available.go @@ -8,10 +8,17 @@ import ( ) // AvailableGroupRef 渠道视图中关联分组的简要信息。 +// +// 用户侧「可用渠道」页面据此展示:专属分组 vs 公开分组(IsExclusive)、 +// 订阅 vs 标准(SubscriptionType)、默认倍率(RateMultiplier)。用户专属倍率 +// 不在这里暴露,前端自己通过 /groups/rates 拉取,和 API 密钥页面保持一致。 type AvailableGroupRef struct { - ID int64 - Name string - Platform string + ID int64 + Name string + Platform string + SubscriptionType string + RateMultiplier float64 + IsExclusive bool } // AvailableChannel 可用渠道视图:用于「可用渠道」页面展示渠道基础信息 + @@ -49,9 +56,12 @@ func (s *ChannelService) ListAvailable(ctx context.Context) ([]AvailableChannel, for i := range groups { g := groups[i] groupByID[g.ID] = AvailableGroupRef{ - ID: g.ID, - Name: g.Name, - Platform: g.Platform, + ID: g.ID, + Name: g.Name, + Platform: g.Platform, + SubscriptionType: g.SubscriptionType, + RateMultiplier: g.RateMultiplier, + IsExclusive: g.IsExclusive, } } diff --git a/frontend/src/api/channels.ts b/frontend/src/api/channels.ts index 3244ab35..8962af2c 100644 --- a/frontend/src/api/channels.ts +++ b/frontend/src/api/channels.ts @@ -10,6 +10,12 @@ export interface UserAvailableGroup { id: number name: string platform: string + /** 'standard' | 'subscription' — 订阅分组视觉加深,和 API 密钥页保持一致。 */ + subscription_type: string + /** 分组默认倍率。用户专属倍率(若有)通过 /groups/rates 获取后在前端 join。 */ + rate_multiplier: number + /** true = 专属分组(小范围授权);false = 公开分组。 */ + is_exclusive: boolean } export interface UserPricingInterval { diff --git a/frontend/src/components/channels/AvailableChannelsTable.vue b/frontend/src/components/channels/AvailableChannelsTable.vue index cd1744d7..5963daff 100644 --- a/frontend/src/components/channels/AvailableChannelsTable.vue +++ b/frontend/src/components/channels/AvailableChannelsTable.vue @@ -1,8 +1,8 @@ diff --git a/frontend/src/components/channels/SupportedModelChip.vue b/frontend/src/components/channels/SupportedModelChip.vue index 7f4ace83..8fc332a9 100644 --- a/frontend/src/components/channels/SupportedModelChip.vue +++ b/frontend/src/components/channels/SupportedModelChip.vue @@ -1,12 +1,18 @@ diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 0350e4a5..ef3c351c 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -938,6 +938,10 @@ export default { empty: 'No available channels', noModels: 'No models configured', noPricing: 'Pricing not configured', + exclusive: 'Exclusive', + public: 'Public', + exclusiveTooltip: 'Exclusive groups granted to you by an admin', + publicTooltip: 'Groups open to all users', columns: { name: 'Channel', platform: 'Platform', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 621c076d..2bd42fcd 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -942,6 +942,10 @@ export default { empty: '暂无可用渠道', noModels: '未配置模型', noPricing: '未配置定价', + exclusive: '专属', + public: '公开', + exclusiveTooltip: '管理员授权给你的专属分组', + publicTooltip: '对所有用户公开的分组', columns: { name: '渠道名', platform: '平台', diff --git a/frontend/src/views/user/AvailableChannelsView.vue b/frontend/src/views/user/AvailableChannelsView.vue index 28e8fef2..8392f815 100644 --- a/frontend/src/views/user/AvailableChannelsView.vue +++ b/frontend/src/views/user/AvailableChannelsView.vue @@ -37,6 +37,7 @@ :columns="columnLabels" :rows="filteredChannels" :loading="loading" + :user-group-rates="userGroupRates" pricing-key-prefix="availableChannels.pricing" :no-pricing-label="t('availableChannels.noPricing')" :no-models-label="t('availableChannels.noModels')" @@ -55,6 +56,7 @@ import TablePageLayout from '@/components/layout/TablePageLayout.vue' import Icon from '@/components/icons/Icon.vue' import AvailableChannelsTable from '@/components/channels/AvailableChannelsTable.vue' import userChannelsAPI, { type UserAvailableChannel } from '@/api/channels' +import userGroupsAPI from '@/api/groups' import { useAppStore } from '@/stores/app' import { extractApiErrorMessage } from '@/utils/apiError' @@ -62,6 +64,7 @@ const { t } = useI18n() const appStore = useAppStore() const channels = ref([]) +const userGroupRates = ref>({}) const loading = ref(false) const searchQuery = ref('') @@ -101,7 +104,17 @@ const filteredChannels = computed(() => { async function loadChannels() { loading.value = true try { - channels.value = await userChannelsAPI.getAvailable() + // 渠道列表和用户专属倍率并发拉取。专属倍率失败不阻塞渠道展示—— + // 失败时只是无法渲染专属倍率角标,降级为仅显示默认倍率。 + const [list, rates] = await Promise.all([ + userChannelsAPI.getAvailable(), + userGroupsAPI.getUserGroupRates().catch((err: unknown) => { + console.error('Failed to load user group rates:', err) + return {} as Record + }), + ]) + channels.value = list + userGroupRates.value = rates } catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'))) } finally {