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:
erio
2026-04-21 21:44:34 +08:00
parent 84b03efa0b
commit ff4ef1b574
9 changed files with 316 additions and 119 deletions

View File

@@ -1,8 +1,8 @@
<template>
<div class="card overflow-hidden">
<div class="card">
<!-- 表头 -->
<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"
>
<div>{{ columns.name }}</div>
@@ -20,12 +20,13 @@
<p class="text-sm text-gray-500 dark:text-gray-400">{{ emptyLabel }}</p>
</div>
<!-- 渠道分组每个渠道一个 section内部按 platform 顺序铺开 -->
<!-- 渠道分组每个渠道一个 section内部按 platform 顺序铺开
外层无 overflow-hidden避免裁掉 SupportedModelChip 的价格浮层 -->
<div
v-else
v-for="(channel, chIdx) in rows"
: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
v-for="(section, secIdx) in channel.platforms"
@@ -60,19 +61,51 @@
</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),
]"
<!-- 分组专属分组在前紫色 shield 公开分组在后灰色 globe
订阅分组由 GroupBadge 内部按 subscription_type 自动加深背景 -->
<div class="flex flex-col gap-1.5">
<div
v-if="exclusiveGroups(section).length > 0"
class="flex flex-wrap items-center gap-1.5"
>
<PlatformIcon :platform="section.platform as GroupPlatform" size="xs" />
{{ g.name }}
</span>
<span
class="inline-flex items-center gap-0.5 text-[10px] font-medium uppercase text-purple-600 dark:text-purple-400"
: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>
</div>
@@ -97,17 +130,19 @@
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import Icon from '@/components/icons/Icon.vue'
import PlatformIcon from '@/components/common/PlatformIcon.vue'
import GroupBadge from '@/components/common/GroupBadge.vue'
import SupportedModelChip from './SupportedModelChip.vue'
import type { UserAvailableChannel } from '@/api/channels'
import type { GroupPlatform } from '@/types'
import { platformBadgeClass, platformBadgeLightClass } from '@/utils/platformColors'
import type { UserAvailableChannel, UserAvailableGroup, UserChannelPlatformSection } from '@/api/channels'
import type { GroupPlatform, SubscriptionType } from '@/types'
import { platformBadgeClass } from '@/utils/platformColors'
/** 四列 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: {
name: string
platform: string
@@ -120,5 +155,21 @@ defineProps<{
noPricingLabel: string
noModelsLabel: 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>

View File

@@ -1,12 +1,18 @@
<template>
<div class="group relative inline-block">
<div class="relative inline-block">
<span
ref="triggerEl"
: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',
]"
@mouseenter="onEnter"
@mouseleave="onLeave"
@focusin="onEnter"
@focusout="onLeave"
tabindex="0"
>
<PlatformIcon
v-if="effectivePlatform"
@@ -22,112 +28,123 @@
{{ model.name }}
</span>
<div
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"
>
<!-- Teleport to body so the popover is not clipped by card/overflow-hidden
ancestors. Fixed-position coords are computed from the trigger's
bounding rect; re-measured on enter / scroll / resize. -->
<Teleport to="body">
<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
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
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 }}
</span>
</div>
<div v-if="!model.pricing" class="text-gray-500 dark:text-gray-400">
{{ noPricingLabel }}
</div>
<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 class="p-3">
<div v-if="!model.pricing" class="text-gray-500 dark:text-gray-400">
{{ noPricingLabel }}
</div>
<template v-if="model.pricing.billing_mode === BILLING_MODE_TOKEN">
<PricingRow
:label="t(prefixKey('inputPrice'))"
:value="model.pricing.input_price"
: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 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 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>
<template v-if="model.pricing.billing_mode === BILLING_MODE_TOKEN">
<PricingRow
:label="t(prefixKey('inputPrice'))"
:value="model.pricing.input_price"
: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 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>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, nextTick, onBeforeUnmount, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import PricingRow from './PricingRow.vue'
import { formatScaled } from '@/utils/pricing'
@@ -142,7 +159,7 @@ import {
import type { UserPricingInterval, UserSupportedModel } from '@/api/channels'
import PlatformIcon from '@/components/common/PlatformIcon.vue'
import type { GroupPlatform } from '@/types'
import { platformBadgeClass } from '@/utils/platformColors'
import { platformBadgeClass, platformBorderClass, platformBadgeLightClass } from '@/utils/platformColors'
const props = withDefaults(
defineProps<{
@@ -172,6 +189,19 @@ const { t } = useI18n()
/** 按 token 定价展示时的换算单位:每百万 token。 */
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 {
return `${props.pricingKeyPrefix}.${k}`
}
@@ -203,4 +233,62 @@ function formatInterval(iv: UserPricingInterval, mode: BillingMode): string {
const output = formatScaled(iv.output_price, perMillionScale)
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>