- 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
302 lines
11 KiB
Vue
302 lines
11 KiB
Vue
<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>
|