feat(channels): themed model popover + group-badge with rate, subscription & exclusivity
Pricing popover: - Teleport to body + fixed-position re-measuring on hover/scroll/resize so it escapes the card's overflow-hidden clip that was chopping off the lower half of the panel. - Header + border adopt the platform theme via platformBorderClass / platformBadgeLightClass so each model card reads at a glance. Accessible groups: - Backend AvailableGroupRef / user DTO now expose subscription_type, rate_multiplier and is_exclusive. User-specific rates continue to come from /groups/rates (same pattern as the API keys page). - Table renders groups through the shared GroupBadge, which already deepens subscription-type colors and shows default vs custom rate as <s>default</s> <b>custom</b>. - Exclusive groups are labelled with a purple shield row, public groups with a grey globe row so admins and users can tell at a glance which groups they got via explicit grant vs. public access. i18n keys for exclusive / public / their tooltips added to zh + en.
This commit is contained in:
@@ -48,10 +48,17 @@ func (h *AvailableChannelHandler) featureEnabled(c *gin.Context) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// userAvailableGroup 用户可见的分组概要(白名单字段)。
|
// userAvailableGroup 用户可见的分组概要(白名单字段)。
|
||||||
|
//
|
||||||
|
// 前端据此区分专属 vs 公开分组(IsExclusive)、订阅 vs 标准分组(SubscriptionType,
|
||||||
|
// 订阅视觉加深),并用 RateMultiplier 作为默认倍率;用户专属倍率前端走
|
||||||
|
// /groups/rates,和 API 密钥页面保持一致。
|
||||||
type userAvailableGroup struct {
|
type userAvailableGroup struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Platform string `json:"platform"`
|
Platform string `json:"platform"`
|
||||||
|
SubscriptionType string `json:"subscription_type"`
|
||||||
|
RateMultiplier float64 `json:"rate_multiplier"`
|
||||||
|
IsExclusive bool `json:"is_exclusive"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// userSupportedModelPricing 用户可见的定价字段白名单。
|
// userSupportedModelPricing 用户可见的定价字段白名单。
|
||||||
@@ -206,9 +213,12 @@ func filterUserVisibleGroups(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
visible = append(visible, userAvailableGroup{
|
visible = append(visible, userAvailableGroup{
|
||||||
ID: g.ID,
|
ID: g.ID,
|
||||||
Name: g.Name,
|
Name: g.Name,
|
||||||
Platform: g.Platform,
|
Platform: g.Platform,
|
||||||
|
SubscriptionType: g.SubscriptionType,
|
||||||
|
RateMultiplier: g.RateMultiplier,
|
||||||
|
IsExclusive: g.IsExclusive,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return visible
|
return visible
|
||||||
|
|||||||
@@ -101,6 +101,17 @@ func TestUserAvailableChannel_FieldWhitelist(t *testing.T) {
|
|||||||
require.Truef(t, exists, "platform section must expose %q", key)
|
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 interval 白名单:不应暴露 id / sort_order。
|
||||||
pricing := toUserPricing(&service.ChannelModelPricing{
|
pricing := toUserPricing(&service.ChannelModelPricing{
|
||||||
BillingMode: service.BillingModeToken,
|
BillingMode: service.BillingModeToken,
|
||||||
|
|||||||
@@ -8,10 +8,17 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// AvailableGroupRef 渠道视图中关联分组的简要信息。
|
// AvailableGroupRef 渠道视图中关联分组的简要信息。
|
||||||
|
//
|
||||||
|
// 用户侧「可用渠道」页面据此展示:专属分组 vs 公开分组(IsExclusive)、
|
||||||
|
// 订阅 vs 标准(SubscriptionType)、默认倍率(RateMultiplier)。用户专属倍率
|
||||||
|
// 不在这里暴露,前端自己通过 /groups/rates 拉取,和 API 密钥页面保持一致。
|
||||||
type AvailableGroupRef struct {
|
type AvailableGroupRef struct {
|
||||||
ID int64
|
ID int64
|
||||||
Name string
|
Name string
|
||||||
Platform string
|
Platform string
|
||||||
|
SubscriptionType string
|
||||||
|
RateMultiplier float64
|
||||||
|
IsExclusive bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// AvailableChannel 可用渠道视图:用于「可用渠道」页面展示渠道基础信息 +
|
// AvailableChannel 可用渠道视图:用于「可用渠道」页面展示渠道基础信息 +
|
||||||
@@ -49,9 +56,12 @@ func (s *ChannelService) ListAvailable(ctx context.Context) ([]AvailableChannel,
|
|||||||
for i := range groups {
|
for i := range groups {
|
||||||
g := groups[i]
|
g := groups[i]
|
||||||
groupByID[g.ID] = AvailableGroupRef{
|
groupByID[g.ID] = AvailableGroupRef{
|
||||||
ID: g.ID,
|
ID: g.ID,
|
||||||
Name: g.Name,
|
Name: g.Name,
|
||||||
Platform: g.Platform,
|
Platform: g.Platform,
|
||||||
|
SubscriptionType: g.SubscriptionType,
|
||||||
|
RateMultiplier: g.RateMultiplier,
|
||||||
|
IsExclusive: g.IsExclusive,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ export interface UserAvailableGroup {
|
|||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
platform: string
|
platform: string
|
||||||
|
/** 'standard' | 'subscription' — 订阅分组视觉加深,和 API 密钥页保持一致。 */
|
||||||
|
subscription_type: string
|
||||||
|
/** 分组默认倍率。用户专属倍率(若有)通过 /groups/rates 获取后在前端 join。 */
|
||||||
|
rate_multiplier: number
|
||||||
|
/** true = 专属分组(小范围授权);false = 公开分组。 */
|
||||||
|
is_exclusive: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserPricingInterval {
|
export interface UserPricingInterval {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="card overflow-hidden">
|
<div class="card">
|
||||||
<!-- 表头 -->
|
<!-- 表头 -->
|
||||||
<div
|
<div
|
||||||
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"
|
class="grid items-center rounded-t-lg 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"
|
||||||
:style="gridStyle"
|
:style="gridStyle"
|
||||||
>
|
>
|
||||||
<div>{{ columns.name }}</div>
|
<div>{{ columns.name }}</div>
|
||||||
@@ -20,12 +20,13 @@
|
|||||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ emptyLabel }}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{{ emptyLabel }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 渠道分组:每个渠道一个 section,内部按 platform 顺序铺开 -->
|
<!-- 渠道分组:每个渠道一个 section,内部按 platform 顺序铺开。
|
||||||
|
外层无 overflow-hidden,避免裁掉 SupportedModelChip 的价格浮层。 -->
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
v-for="(channel, chIdx) in rows"
|
v-for="(channel, chIdx) in rows"
|
||||||
:key="`${channel.name}-${chIdx}`"
|
:key="`${channel.name}-${chIdx}`"
|
||||||
class="border-b border-gray-100 last:border-b-0 dark:border-dark-700"
|
class="border-b border-gray-100 last:rounded-b-lg last:border-b-0 dark:border-dark-700"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-for="(section, secIdx) in channel.platforms"
|
v-for="(section, secIdx) in channel.platforms"
|
||||||
@@ -60,19 +61,51 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 分组 -->
|
<!-- 分组:专属分组在前(紫色 shield 行),公开分组在后(灰色 globe 行)。
|
||||||
<div class="flex flex-wrap gap-1">
|
订阅分组由 GroupBadge 内部按 subscription_type 自动加深背景。 -->
|
||||||
<span
|
<div class="flex flex-col gap-1.5">
|
||||||
v-for="g in section.groups"
|
<div
|
||||||
:key="g.id"
|
v-if="exclusiveGroups(section).length > 0"
|
||||||
:class="[
|
class="flex flex-wrap items-center gap-1.5"
|
||||||
'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" />
|
<span
|
||||||
{{ g.name }}
|
class="inline-flex items-center gap-0.5 text-[10px] font-medium uppercase text-purple-600 dark:text-purple-400"
|
||||||
</span>
|
:title="t('availableChannels.exclusiveTooltip')"
|
||||||
|
>
|
||||||
|
<Icon name="shield" size="xs" class="h-3 w-3" />
|
||||||
|
{{ t('availableChannels.exclusive') }}
|
||||||
|
</span>
|
||||||
|
<GroupBadge
|
||||||
|
v-for="g in exclusiveGroups(section)"
|
||||||
|
:key="`ex-${g.id}`"
|
||||||
|
:name="g.name"
|
||||||
|
:platform="g.platform as GroupPlatform"
|
||||||
|
:subscription-type="(g.subscription_type || 'standard') as SubscriptionType"
|
||||||
|
:rate-multiplier="g.rate_multiplier"
|
||||||
|
:user-rate-multiplier="userGroupRates[g.id] ?? null"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="publicGroups(section).length > 0"
|
||||||
|
class="flex flex-wrap items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center gap-0.5 text-[10px] font-medium uppercase text-gray-500 dark:text-gray-400"
|
||||||
|
:title="t('availableChannels.publicTooltip')"
|
||||||
|
>
|
||||||
|
<Icon name="globe" size="xs" class="h-3 w-3" />
|
||||||
|
{{ t('availableChannels.public') }}
|
||||||
|
</span>
|
||||||
|
<GroupBadge
|
||||||
|
v-for="g in publicGroups(section)"
|
||||||
|
:key="`pub-${g.id}`"
|
||||||
|
:name="g.name"
|
||||||
|
:platform="g.platform as GroupPlatform"
|
||||||
|
:subscription-type="(g.subscription_type || 'standard') as SubscriptionType"
|
||||||
|
:rate-multiplier="g.rate_multiplier"
|
||||||
|
:user-rate-multiplier="userGroupRates[g.id] ?? null"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<span v-if="section.groups.length === 0" class="text-xs text-gray-400">-</span>
|
<span v-if="section.groups.length === 0" class="text-xs text-gray-400">-</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -97,17 +130,19 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
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 GroupBadge from '@/components/common/GroupBadge.vue'
|
||||||
import SupportedModelChip from './SupportedModelChip.vue'
|
import SupportedModelChip from './SupportedModelChip.vue'
|
||||||
import type { UserAvailableChannel } from '@/api/channels'
|
import type { UserAvailableChannel, UserAvailableGroup, UserChannelPlatformSection } from '@/api/channels'
|
||||||
import type { GroupPlatform } from '@/types'
|
import type { GroupPlatform, SubscriptionType } from '@/types'
|
||||||
import { platformBadgeClass, platformBadgeLightClass } from '@/utils/platformColors'
|
import { platformBadgeClass } from '@/utils/platformColors'
|
||||||
|
|
||||||
/** 四列 grid 的 template-columns;与表头、每个 section 行共享。 */
|
/** 四列 grid 的 template-columns;与表头、每个 section 行共享。 */
|
||||||
const gridStyle = 'grid-template-columns: 220px 140px minmax(200px, 1fr) minmax(280px, 2fr); display: grid;'
|
const gridStyle = 'grid-template-columns: 220px 140px minmax(240px, 1fr) minmax(280px, 2fr); display: grid;'
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
columns: {
|
columns: {
|
||||||
name: string
|
name: string
|
||||||
platform: string
|
platform: string
|
||||||
@@ -120,5 +155,21 @@ defineProps<{
|
|||||||
noPricingLabel: string
|
noPricingLabel: string
|
||||||
noModelsLabel: string
|
noModelsLabel: string
|
||||||
emptyLabel: string
|
emptyLabel: string
|
||||||
|
/** 用户专属倍率(group_id → multiplier);无专属时由 GroupBadge 仅显示默认倍率。 */
|
||||||
|
userGroupRates: Record<number, number>
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
// Suppress unused warning — props is accessed via template automatically but
|
||||||
|
// the explicit reference here keeps the linter from flagging userGroupRates.
|
||||||
|
void props.userGroupRates
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
function exclusiveGroups(section: UserChannelPlatformSection): UserAvailableGroup[] {
|
||||||
|
return section.groups.filter((g) => g.is_exclusive)
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicGroups(section: UserChannelPlatformSection): UserAvailableGroup[] {
|
||||||
|
return section.groups.filter((g) => !g.is_exclusive)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="group relative inline-block">
|
<div class="relative inline-block">
|
||||||
<span
|
<span
|
||||||
|
ref="triggerEl"
|
||||||
:class="[
|
:class="[
|
||||||
'inline-flex cursor-help items-center gap-1 rounded-md border px-2 py-0.5 text-xs font-medium transition-colors',
|
'inline-flex cursor-help items-center gap-1 rounded-md border px-2 py-0.5 text-xs font-medium transition-colors',
|
||||||
effectivePlatform
|
effectivePlatform
|
||||||
? platformBadgeClass(effectivePlatform)
|
? platformBadgeClass(effectivePlatform)
|
||||||
: 'border-gray-200 bg-gray-50 text-gray-700 dark:border-dark-600 dark:bg-dark-800 dark:text-gray-300',
|
: 'border-gray-200 bg-gray-50 text-gray-700 dark:border-dark-600 dark:bg-dark-800 dark:text-gray-300',
|
||||||
]"
|
]"
|
||||||
|
@mouseenter="onEnter"
|
||||||
|
@mouseleave="onLeave"
|
||||||
|
@focusin="onEnter"
|
||||||
|
@focusout="onLeave"
|
||||||
|
tabindex="0"
|
||||||
>
|
>
|
||||||
<PlatformIcon
|
<PlatformIcon
|
||||||
v-if="effectivePlatform"
|
v-if="effectivePlatform"
|
||||||
@@ -22,112 +28,123 @@
|
|||||||
{{ model.name }}
|
{{ model.name }}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div
|
<!-- Teleport to body so the popover is not clipped by card/overflow-hidden
|
||||||
class="pointer-events-none invisible absolute left-1/2 z-50 mt-2 w-80 -translate-x-1/2 opacity-0 transition-opacity group-hover:visible group-hover:opacity-100"
|
ancestors. Fixed-position coords are computed from the trigger's
|
||||||
>
|
bounding rect; re-measured on enter / scroll / resize. -->
|
||||||
|
<Teleport to="body">
|
||||||
<div
|
<div
|
||||||
class="rounded-lg border border-gray-200 bg-white p-3 text-xs shadow-lg dark:border-dark-600 dark:bg-dark-800"
|
v-show="show"
|
||||||
|
ref="popoverEl"
|
||||||
|
role="tooltip"
|
||||||
|
class="pointer-events-none fixed z-[99999] w-80 max-w-[min(22rem,calc(100vw-1rem))] rounded-lg border bg-white text-xs shadow-xl dark:bg-dark-800"
|
||||||
|
:class="[popoverBorderClass]"
|
||||||
|
:style="popoverStyle"
|
||||||
>
|
>
|
||||||
|
<!-- Header:平台主题色背景,含模型名 + 平台徽章 -->
|
||||||
<div
|
<div
|
||||||
class="mb-2 flex items-center justify-between gap-2 border-b border-gray-200 pb-2 dark:border-dark-600"
|
class="flex items-center justify-between gap-2 rounded-t-lg border-b px-3 py-2"
|
||||||
|
:class="[popoverHeaderClass, popoverBorderClass]"
|
||||||
>
|
>
|
||||||
<span class="font-semibold text-gray-900 dark:text-gray-100">{{ model.name }}</span>
|
<span class="truncate font-semibold">{{ model.name }}</span>
|
||||||
<span
|
<span
|
||||||
v-if="model.platform"
|
v-if="model.platform"
|
||||||
class="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] uppercase text-gray-600 dark:bg-dark-700 dark:text-gray-400"
|
class="flex-shrink-0 rounded bg-white/70 px-1.5 py-0.5 text-[10px] uppercase tracking-wide dark:bg-dark-900/60"
|
||||||
>
|
>
|
||||||
{{ model.platform }}
|
{{ model.platform }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="!model.pricing" class="text-gray-500 dark:text-gray-400">
|
<div class="p-3">
|
||||||
{{ noPricingLabel }}
|
<div v-if="!model.pricing" class="text-gray-500 dark:text-gray-400">
|
||||||
</div>
|
{{ noPricingLabel }}
|
||||||
|
|
||||||
<div v-else class="space-y-2 text-gray-700 dark:text-gray-300">
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span class="text-gray-500 dark:text-gray-400">{{ t(prefixKey('billingMode')) }}</span>
|
|
||||||
<span>{{ billingModeLabel }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-if="model.pricing.billing_mode === BILLING_MODE_TOKEN">
|
<div v-else class="space-y-2 text-gray-700 dark:text-gray-300">
|
||||||
<PricingRow
|
<div class="flex justify-between">
|
||||||
:label="t(prefixKey('inputPrice'))"
|
<span class="text-gray-500 dark:text-gray-400">{{ t(prefixKey('billingMode')) }}</span>
|
||||||
:value="model.pricing.input_price"
|
<span>{{ billingModeLabel }}</span>
|
||||||
:unit="t(prefixKey('unitPerMillion'))"
|
|
||||||
:scale="perMillionScale"
|
|
||||||
/>
|
|
||||||
<PricingRow
|
|
||||||
:label="t(prefixKey('outputPrice'))"
|
|
||||||
:value="model.pricing.output_price"
|
|
||||||
:unit="t(prefixKey('unitPerMillion'))"
|
|
||||||
:scale="perMillionScale"
|
|
||||||
/>
|
|
||||||
<PricingRow
|
|
||||||
:label="t(prefixKey('cacheWritePrice'))"
|
|
||||||
:value="model.pricing.cache_write_price"
|
|
||||||
:unit="t(prefixKey('unitPerMillion'))"
|
|
||||||
:scale="perMillionScale"
|
|
||||||
/>
|
|
||||||
<PricingRow
|
|
||||||
:label="t(prefixKey('cacheReadPrice'))"
|
|
||||||
:value="model.pricing.cache_read_price"
|
|
||||||
:unit="t(prefixKey('unitPerMillion'))"
|
|
||||||
:scale="perMillionScale"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<PricingRow
|
|
||||||
v-if="
|
|
||||||
model.pricing.billing_mode === BILLING_MODE_PER_REQUEST &&
|
|
||||||
model.pricing.per_request_price != null
|
|
||||||
"
|
|
||||||
:label="t(prefixKey('perRequestPrice'))"
|
|
||||||
:value="model.pricing.per_request_price"
|
|
||||||
:unit="t(prefixKey('unitPerRequest'))"
|
|
||||||
:scale="1"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<PricingRow
|
|
||||||
v-if="
|
|
||||||
model.pricing.billing_mode === BILLING_MODE_IMAGE &&
|
|
||||||
model.pricing.image_output_price != null
|
|
||||||
"
|
|
||||||
:label="t(prefixKey('imageOutputPrice'))"
|
|
||||||
:value="model.pricing.image_output_price"
|
|
||||||
:unit="t(prefixKey('unitPerRequest'))"
|
|
||||||
:scale="1"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="model.pricing.intervals && model.pricing.intervals.length > 0"
|
|
||||||
class="mt-2 border-t border-gray-200 pt-2 dark:border-dark-600"
|
|
||||||
>
|
|
||||||
<div class="mb-1 font-medium text-gray-600 dark:text-gray-400">
|
|
||||||
{{ t(prefixKey('intervals')) }}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1">
|
|
||||||
<div
|
<template v-if="model.pricing.billing_mode === BILLING_MODE_TOKEN">
|
||||||
v-for="(iv, idx) in model.pricing.intervals"
|
<PricingRow
|
||||||
:key="idx"
|
:label="t(prefixKey('inputPrice'))"
|
||||||
class="flex justify-between text-[11px]"
|
:value="model.pricing.input_price"
|
||||||
>
|
:unit="t(prefixKey('unitPerMillion'))"
|
||||||
<span class="text-gray-500 dark:text-gray-400">
|
:scale="perMillionScale"
|
||||||
<template v-if="iv.tier_label">{{ iv.tier_label }}</template>
|
/>
|
||||||
<template v-else>{{ formatRange(iv.min_tokens, iv.max_tokens) }}</template>
|
<PricingRow
|
||||||
</span>
|
:label="t(prefixKey('outputPrice'))"
|
||||||
<span>{{ formatInterval(iv, model.pricing.billing_mode) }}</span>
|
:value="model.pricing.output_price"
|
||||||
|
:unit="t(prefixKey('unitPerMillion'))"
|
||||||
|
:scale="perMillionScale"
|
||||||
|
/>
|
||||||
|
<PricingRow
|
||||||
|
:label="t(prefixKey('cacheWritePrice'))"
|
||||||
|
:value="model.pricing.cache_write_price"
|
||||||
|
:unit="t(prefixKey('unitPerMillion'))"
|
||||||
|
:scale="perMillionScale"
|
||||||
|
/>
|
||||||
|
<PricingRow
|
||||||
|
:label="t(prefixKey('cacheReadPrice'))"
|
||||||
|
:value="model.pricing.cache_read_price"
|
||||||
|
:unit="t(prefixKey('unitPerMillion'))"
|
||||||
|
:scale="perMillionScale"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<PricingRow
|
||||||
|
v-if="
|
||||||
|
model.pricing.billing_mode === BILLING_MODE_PER_REQUEST &&
|
||||||
|
model.pricing.per_request_price != null
|
||||||
|
"
|
||||||
|
:label="t(prefixKey('perRequestPrice'))"
|
||||||
|
:value="model.pricing.per_request_price"
|
||||||
|
:unit="t(prefixKey('unitPerRequest'))"
|
||||||
|
:scale="1"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PricingRow
|
||||||
|
v-if="
|
||||||
|
model.pricing.billing_mode === BILLING_MODE_IMAGE &&
|
||||||
|
model.pricing.image_output_price != null
|
||||||
|
"
|
||||||
|
:label="t(prefixKey('imageOutputPrice'))"
|
||||||
|
:value="model.pricing.image_output_price"
|
||||||
|
:unit="t(prefixKey('unitPerRequest'))"
|
||||||
|
:scale="1"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="model.pricing.intervals && model.pricing.intervals.length > 0"
|
||||||
|
class="mt-2 border-t pt-2"
|
||||||
|
:class="[popoverBorderClass]"
|
||||||
|
>
|
||||||
|
<div class="mb-1 font-medium text-gray-600 dark:text-gray-400">
|
||||||
|
{{ t(prefixKey('intervals')) }}
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div
|
||||||
|
v-for="(iv, idx) in model.pricing.intervals"
|
||||||
|
:key="idx"
|
||||||
|
class="flex justify-between text-[11px]"
|
||||||
|
>
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">
|
||||||
|
<template v-if="iv.tier_label">{{ iv.tier_label }}</template>
|
||||||
|
<template v-else>{{ formatRange(iv.min_tokens, iv.max_tokens) }}</template>
|
||||||
|
</span>
|
||||||
|
<span>{{ formatInterval(iv, model.pricing.billing_mode) }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Teleport>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import PricingRow from './PricingRow.vue'
|
import PricingRow from './PricingRow.vue'
|
||||||
import { formatScaled } from '@/utils/pricing'
|
import { formatScaled } from '@/utils/pricing'
|
||||||
@@ -142,7 +159,7 @@ import {
|
|||||||
import type { UserPricingInterval, UserSupportedModel } from '@/api/channels'
|
import type { UserPricingInterval, UserSupportedModel } from '@/api/channels'
|
||||||
import PlatformIcon from '@/components/common/PlatformIcon.vue'
|
import PlatformIcon from '@/components/common/PlatformIcon.vue'
|
||||||
import type { GroupPlatform } from '@/types'
|
import type { GroupPlatform } from '@/types'
|
||||||
import { platformBadgeClass } from '@/utils/platformColors'
|
import { platformBadgeClass, platformBorderClass, platformBadgeLightClass } from '@/utils/platformColors'
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -172,6 +189,19 @@ const { t } = useI18n()
|
|||||||
/** 按 token 定价展示时的换算单位:每百万 token。 */
|
/** 按 token 定价展示时的换算单位:每百万 token。 */
|
||||||
const perMillionScale = 1_000_000
|
const perMillionScale = 1_000_000
|
||||||
|
|
||||||
|
// Popover border + header classes echo the platform theme so each card reads
|
||||||
|
// at a glance which model family it belongs to.
|
||||||
|
const popoverBorderClass = computed(() =>
|
||||||
|
effectivePlatform.value
|
||||||
|
? platformBorderClass(effectivePlatform.value)
|
||||||
|
: 'border-gray-200 dark:border-dark-600',
|
||||||
|
)
|
||||||
|
const popoverHeaderClass = computed(() =>
|
||||||
|
effectivePlatform.value
|
||||||
|
? platformBadgeLightClass(effectivePlatform.value)
|
||||||
|
: 'bg-gray-50 text-gray-700 dark:bg-dark-700/60 dark:text-gray-300',
|
||||||
|
)
|
||||||
|
|
||||||
function prefixKey(k: string): string {
|
function prefixKey(k: string): string {
|
||||||
return `${props.pricingKeyPrefix}.${k}`
|
return `${props.pricingKeyPrefix}.${k}`
|
||||||
}
|
}
|
||||||
@@ -203,4 +233,62 @@ function formatInterval(iv: UserPricingInterval, mode: BillingMode): string {
|
|||||||
const output = formatScaled(iv.output_price, perMillionScale)
|
const output = formatScaled(iv.output_price, perMillionScale)
|
||||||
return `${input} / ${output}`
|
return `${input} / ${output}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Popover positioning ─────────────────────────────────────────────
|
||||||
|
// Teleport-to-body + fixed positioning avoids being clipped by
|
||||||
|
// overflow-hidden ancestors (the parent table card). We re-measure on
|
||||||
|
// hover enter, scroll, and resize. Pinning to the trigger's top-center
|
||||||
|
// with a flip when the viewport edge is near keeps it aligned without a
|
||||||
|
// full-blown positioning lib.
|
||||||
|
const show = ref(false)
|
||||||
|
const triggerEl = ref<HTMLElement | null>(null)
|
||||||
|
const popoverEl = ref<HTMLElement | null>(null)
|
||||||
|
const popoverStyle = ref<Record<string, string>>({ top: '0px', left: '0px' })
|
||||||
|
|
||||||
|
function updatePosition() {
|
||||||
|
const trigger = triggerEl.value
|
||||||
|
if (!trigger) return
|
||||||
|
const rect = trigger.getBoundingClientRect()
|
||||||
|
const margin = 8
|
||||||
|
const popover = popoverEl.value
|
||||||
|
const popWidth = popover?.offsetWidth ?? 320
|
||||||
|
const popHeight = popover?.offsetHeight ?? 240
|
||||||
|
const vw = window.innerWidth
|
||||||
|
const vh = window.innerHeight
|
||||||
|
|
||||||
|
let top = rect.bottom + margin
|
||||||
|
// Flip upward if it would overflow below.
|
||||||
|
if (top + popHeight > vh - margin) {
|
||||||
|
top = Math.max(margin, rect.top - popHeight - margin)
|
||||||
|
}
|
||||||
|
|
||||||
|
let left = rect.left + rect.width / 2 - popWidth / 2
|
||||||
|
if (left < margin) left = margin
|
||||||
|
if (left + popWidth > vw - margin) left = vw - margin - popWidth
|
||||||
|
|
||||||
|
popoverStyle.value = {
|
||||||
|
top: `${Math.round(top)}px`,
|
||||||
|
left: `${Math.round(left)}px`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEnter() {
|
||||||
|
show.value = true
|
||||||
|
nextTick(() => {
|
||||||
|
updatePosition()
|
||||||
|
window.addEventListener('scroll', updatePosition, true)
|
||||||
|
window.addEventListener('resize', updatePosition)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLeave() {
|
||||||
|
show.value = false
|
||||||
|
window.removeEventListener('scroll', updatePosition, true)
|
||||||
|
window.removeEventListener('resize', updatePosition)
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('scroll', updatePosition, true)
|
||||||
|
window.removeEventListener('resize', updatePosition)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -938,6 +938,10 @@ export default {
|
|||||||
empty: 'No available channels',
|
empty: 'No available channels',
|
||||||
noModels: 'No models configured',
|
noModels: 'No models configured',
|
||||||
noPricing: 'Pricing not 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: {
|
columns: {
|
||||||
name: 'Channel',
|
name: 'Channel',
|
||||||
platform: 'Platform',
|
platform: 'Platform',
|
||||||
|
|||||||
@@ -942,6 +942,10 @@ export default {
|
|||||||
empty: '暂无可用渠道',
|
empty: '暂无可用渠道',
|
||||||
noModels: '未配置模型',
|
noModels: '未配置模型',
|
||||||
noPricing: '未配置定价',
|
noPricing: '未配置定价',
|
||||||
|
exclusive: '专属',
|
||||||
|
public: '公开',
|
||||||
|
exclusiveTooltip: '管理员授权给你的专属分组',
|
||||||
|
publicTooltip: '对所有用户公开的分组',
|
||||||
columns: {
|
columns: {
|
||||||
name: '渠道名',
|
name: '渠道名',
|
||||||
platform: '平台',
|
platform: '平台',
|
||||||
|
|||||||
@@ -37,6 +37,7 @@
|
|||||||
:columns="columnLabels"
|
:columns="columnLabels"
|
||||||
:rows="filteredChannels"
|
:rows="filteredChannels"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
|
:user-group-rates="userGroupRates"
|
||||||
pricing-key-prefix="availableChannels.pricing"
|
pricing-key-prefix="availableChannels.pricing"
|
||||||
:no-pricing-label="t('availableChannels.noPricing')"
|
:no-pricing-label="t('availableChannels.noPricing')"
|
||||||
:no-models-label="t('availableChannels.noModels')"
|
: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 Icon from '@/components/icons/Icon.vue'
|
||||||
import AvailableChannelsTable from '@/components/channels/AvailableChannelsTable.vue'
|
import AvailableChannelsTable from '@/components/channels/AvailableChannelsTable.vue'
|
||||||
import userChannelsAPI, { type UserAvailableChannel } from '@/api/channels'
|
import userChannelsAPI, { type UserAvailableChannel } from '@/api/channels'
|
||||||
|
import userGroupsAPI from '@/api/groups'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
import { extractApiErrorMessage } from '@/utils/apiError'
|
import { extractApiErrorMessage } from '@/utils/apiError'
|
||||||
|
|
||||||
@@ -62,6 +64,7 @@ const { t } = useI18n()
|
|||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
|
|
||||||
const channels = ref<UserAvailableChannel[]>([])
|
const channels = ref<UserAvailableChannel[]>([])
|
||||||
|
const userGroupRates = ref<Record<number, number>>({})
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
|
|
||||||
@@ -101,7 +104,17 @@ const filteredChannels = computed(() => {
|
|||||||
async function loadChannels() {
|
async function loadChannels() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
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<number, number>
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
channels.value = list
|
||||||
|
userGroupRates.value = rates
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
Reference in New Issue
Block a user