Files
sub2api/frontend/src/components/channels/SupportedModelChip.vue
erio 748a84d871 sync: bring over remaining release/custom-0.1.115 changes
- Extract PublicSettingsInjectionPayload named struct with drift test
- Add channel_monitor_default_interval_seconds to SSR injection
- Add image_output_price to SupportedModelChip
- Simplify AppSidebar buildSelfNavItems (admins see available channels)
- Add gateway WARN logs for 503 no-available-accounts branches
- Wire ChannelMonitorRunner into provideCleanup for graceful shutdown
- Add migrations 130/131 (CC template userid fix + mimicry field cleanup)
- Clean up fork-only features (sora, claude max simulation, client affinity)
- Remove ~320 obsolete i18n keys
- Add codexUsage utility, WechatServiceButton, BulkEditAccountModal
- Tidy go.sum
2026-04-23 20:55:18 +08:00

302 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<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"
:platform="effectivePlatform as GroupPlatform"
size="xs"
/>
<span
v-if="showPlatform && model.platform"
class="rounded bg-gray-200/60 px-1 text-[10px] uppercase text-gray-600 dark:bg-dark-700 dark:text-gray-400"
>
{{ model.platform }}
</span>
{{ model.name }}
</span>
<!-- 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
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="flex items-center justify-between gap-2 rounded-t-lg border-b px-3 py-2"
:class="[popoverHeaderClass, popoverBorderClass]"
>
<span class="truncate font-semibold">{{ model.name }}</span>
<span
v-if="model.platform"
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 class="p-3">
<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>
<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"
/>
<PricingRow
v-if="model.pricing.image_output_price != null && model.pricing.image_output_price > 0"
:label="t(prefixKey('imageOutputPrice'))"
:value="model.pricing.image_output_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>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import PricingRow from './PricingRow.vue'
import { formatScaled } from '@/utils/pricing'
import {
BILLING_MODE_TOKEN,
BILLING_MODE_PER_REQUEST,
BILLING_MODE_IMAGE,
type BillingMode
} from '@/constants/channel'
// 复用 api/channels.ts 的用户侧最小形态 DTO。
// admin 侧 ChannelModelPricing 字段更多,但结构上是用户 DTO 的超集admin 视图传入可直接通过结构化子类型检查。
import type { UserPricingInterval, UserSupportedModel } from '@/api/channels'
import PlatformIcon from '@/components/common/PlatformIcon.vue'
import type { GroupPlatform } from '@/types'
import { platformBadgeClass, platformBorderClass, platformBadgeLightClass } from '@/utils/platformColors'
const props = withDefaults(
defineProps<{
model: UserSupportedModel
/** i18n 前缀:管理端传 `admin.availableChannels.pricing`,用户端传 `availableChannels.pricing`。 */
pricingKeyPrefix?: string
noPricingLabel?: string
showPlatform?: boolean
/**
* 当 model.platform 缺失(如 admin 聚合场景)时,用父行的平台作为兜底着色。
* 仅用于视觉,不影响业务逻辑。
*/
platformHint?: string
}>(),
{
pricingKeyPrefix: 'availableChannels.pricing',
noPricingLabel: '',
showPlatform: true,
platformHint: ''
}
)
const effectivePlatform = computed<string>(() => props.model.platform || props.platformHint || '')
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}`
}
const billingModeLabel = computed(() => {
const mode = props.model.pricing?.billing_mode
switch (mode) {
case BILLING_MODE_TOKEN:
return t(prefixKey('billingModeToken'))
case BILLING_MODE_PER_REQUEST:
return t(prefixKey('billingModePerRequest'))
case BILLING_MODE_IMAGE:
return t(prefixKey('billingModeImage'))
default:
return '-'
}
})
function formatRange(min: number, max: number | null): string {
const maxLabel = max == null ? '' : String(max)
return `(${min}, ${maxLabel}]`
}
function formatInterval(iv: UserPricingInterval, mode: BillingMode): string {
if (mode === BILLING_MODE_PER_REQUEST || mode === BILLING_MODE_IMAGE) {
return formatScaled(iv.per_request_price, 1)
}
const input = formatScaled(iv.input_price, perMillionScale)
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>