feat: add model plaza with group-scoped pricing showcase

- public /model-plaza page (standalone + admin-embedded) listing groups
  with discounted effective prices alongside LiteLLM official reference
- faceted platform/group/rate filters: cross-dimension options gray out
  instead of disappearing, platform-tinted chips via accent color-mix
- paid-price columns highlighted with per-platform tint band
- OptionalJWT middleware so anonymous and signed-in users share one route
- admin settings: enable switch, require-auth switch, markdown description
This commit is contained in:
feeeei
2026-07-28 16:19:41 +08:00
parent 2e432173f7
commit 720c405e35
49 changed files with 2612 additions and 11 deletions
+10
View File
@@ -659,6 +659,11 @@ export interface SystemSettings {
// Available Channels feature switch
available_channels_enabled: boolean;
// Model Plaza feature switches + description
model_plaza_enabled: boolean;
model_plaza_require_auth: boolean;
model_plaza_description: string;
// Affiliate (邀请返利) feature switch
affiliate_enabled: boolean;
@@ -930,6 +935,11 @@ export interface UpdateSettingsRequest {
// Available Channels feature switch
available_channels_enabled?: boolean;
// Model Plaza feature switches + description
model_plaza_enabled?: boolean;
model_plaza_require_auth?: boolean;
model_plaza_description?: string;
// Affiliate (邀请返利) feature switch
affiliate_enabled?: boolean;
+62
View File
@@ -0,0 +1,62 @@
/**
* Model Plaza API(公开端点,可匿名访问)
* 以分组为中心的模型价目:分组信息 + 模型渠道定价 + LiteLLM 官方参考价。
* 带 token 请求时后端会额外返回专属分组与用户专属倍率。
*/
import { apiClient } from './client'
import type { UserSupportedModelPricing } from './channels'
/** LiteLLM 官方参考价(USD per token,字段缺失 = 官方数据未覆盖)。 */
export interface PlazaOfficialPricing {
input_price: number | null
output_price: number | null
/** 5m 缓存写入(= LiteLLM cache_creation)。 */
cache_write_price: number | null
/** 1h 缓存写入(LiteLLM cache_creation_above_1hr),多数模型缺失。 */
cache_write_1h_price?: number | null
cache_read_price: number | null
}
export interface PlazaModel {
name: string
platform: string
pricing: UserSupportedModelPricing | null
official_pricing: PlazaOfficialPricing | null
}
export interface ModelPlazaGroup {
id: number
name: string
description: string
platform: string
/** 'standard' | 'subscription' */
subscription_type: string
rate_multiplier: number
/** 登录且管理员为该用户配了专属倍率时返回;生效倍率 = user_rate ?? rate_multiplier。 */
user_rate_multiplier?: number
peak_rate_enabled: boolean
peak_start: string
peak_end: string
peak_rate_multiplier: number
is_exclusive: boolean
models: PlazaModel[]
}
export interface ModelPlazaResponse {
/** 管理员配置的全局价格说明(Markdown)。 */
description: string
groups: ModelPlazaGroup[]
}
/** 获取模型广场数据。开关未启用时后端返回 404。 */
export async function getModelPlaza(options?: { signal?: AbortSignal }): Promise<ModelPlazaResponse> {
const { data } = await apiClient.get<ModelPlazaResponse>('/model-plaza', {
signal: options?.signal
})
return data
}
export const modelPlazaAPI = { getModelPlaza }
export default modelPlazaAPI
@@ -38,6 +38,16 @@
<span class="hidden sm:inline">{{ t('nav.docs') }}</span>
</a>
<!-- Model Plaza Entry -->
<router-link
v-if="user && modelPlazaEnabled"
:to="{ path: '/model-plaza', query: { embedded: '1' } }"
class="hidden items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:text-dark-400 dark:hover:bg-dark-800 dark:hover:text-white sm:flex"
>
<Icon name="grid" size="sm" />
<span class="hidden sm:inline">{{ t('nav.modelPlaza') }}</span>
</router-link>
<!-- Language Switcher -->
<LocaleSwitcher />
@@ -250,6 +260,7 @@ import SubscriptionProgressMini from '@/components/common/SubscriptionProgressMi
import AnnouncementBell from '@/components/common/AnnouncementBell.vue'
import Icon from '@/components/icons/Icon.vue'
import { sanitizeUrl } from '@/utils/url'
import { FeatureFlags, isFeatureFlagEnabled } from '@/utils/featureFlags'
const router = useRouter()
const route = useRoute()
@@ -264,6 +275,7 @@ const dropdownOpen = ref(false)
const dropdownRef = ref<HTMLElement | null>(null)
const contactInfo = computed(() => appStore.contactInfo)
const docUrl = computed(() => sanitizeUrl(appStore.docUrl))
const modelPlazaEnabled = computed(() => isFeatureFlagEnabled(FeatureFlags.modelPlaza))
const avatarUrl = computed(() => user.value?.avatar_url?.trim() || '')
const availableBalance = computed(() => Number(user.value?.balance || 0))
const frozenBalance = computed(() => Number(user.value?.frozen_balance || 0))
@@ -0,0 +1,195 @@
<template>
<div class="space-y-5">
<!-- 页头(独立形态下展示标题;后台形态 AppHeader 已有页面标题) -->
<div v-if="!embedded">
<h1 class="text-2xl font-bold tracking-tight text-gray-900 dark:text-white sm:text-3xl">{{ t('modelPlaza.title') }}</h1>
<p class="mt-1.5 text-sm text-gray-500 dark:text-dark-400">{{ t('modelPlaza.description') }}</p>
</div>
<!-- 全局价格说明(管理员配置,Markdown) -->
<div
v-if="descriptionHtml"
class="plaza-description rounded-2xl border border-gray-100 bg-white px-5 py-4 text-sm shadow-card dark:border-dark-700/50 dark:bg-dark-800/50"
v-html="descriptionHtml"
></div>
<!-- 未登录提示 -->
<p
v-if="!isAuthenticated"
class="flex items-center gap-1.5 text-xs text-gray-400 dark:text-dark-500"
>
<Icon name="infoCircle" size="xs" class="h-3.5 w-3.5" />
{{ t('modelPlaza.anonymousHint') }}
</p>
<!-- 加载/错误/空 -->
<div v-if="loading" class="flex min-h-[240px] items-center justify-center">
<div class="h-8 w-8 animate-spin rounded-full border-2 border-primary-600/25 border-t-primary-600 dark:border-primary-400/25 dark:border-t-primary-400"></div>
</div>
<div
v-else-if="error"
class="rounded-2xl border border-red-200 bg-red-50 px-5 py-8 text-center text-sm text-red-600 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-300"
>
{{ t('modelPlaza.loadFailed') }}
</div>
<template v-else>
<!-- 筛选区:平台 → 分组 → 倍率 -->
<PlazaFilterBar
:platforms="platforms"
:groups="groupOptions"
:rates="rates"
:platform="selectedPlatform"
:group-id="selectedGroupId"
:rate="selectedRate"
:search="searchQuery"
@update:platform="selectedPlatform = $event"
@update:group-id="selectedGroupId = $event"
@update:rate="selectedRate = $event"
@update:search="searchQuery = $event"
/>
<!-- 分组分节的模型清单(默认按生效倍率升序) -->
<div v-if="filteredGroups.length > 0" class="space-y-5">
<PlazaGroupSection v-for="g in filteredGroups" :key="g.id" :group="g" />
</div>
<div
v-else
class="rounded-2xl border border-dashed border-gray-300 px-5 py-12 text-center text-sm text-gray-500 dark:border-dark-600 dark:text-dark-400"
>
{{ searchActive ? t('modelPlaza.noSearchResult') : t('modelPlaza.empty') }}
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { marked } from 'marked'
import DOMPurify from 'dompurify'
import Icon from '@/components/icons/Icon.vue'
import PlazaFilterBar from './PlazaFilterBar.vue'
import PlazaGroupSection from './PlazaGroupSection.vue'
import type { ModelPlazaGroup, ModelPlazaResponse } from '@/api/modelPlaza'
import { useAuthStore } from '@/stores/auth'
const props = defineProps<{
response: ModelPlazaResponse | null
loading: boolean
error?: boolean
/** 后台内嵌形态(AppLayout 内):隐藏页头。 */
embedded?: boolean
}>()
const { t } = useI18n()
const authStore = useAuthStore()
const isAuthenticated = computed(() => authStore.isAuthenticated)
const selectedPlatform = ref<string>('all')
const selectedGroupId = ref<number | 'all'>('all')
const selectedRate = ref<number | 'all'>('all')
const searchQuery = ref('')
const searchActive = computed(() => searchQuery.value.trim() !== '')
const descriptionHtml = computed(() => {
const md = props.response?.description?.trim()
if (!md) return ''
return DOMPurify.sanitize(marked.parse(md) as string)
})
/** 生效倍率 = 用户专属倍率 ?? 分组默认倍率。 */
function effectiveRate(g: ModelPlazaGroup): number {
return g.user_rate_multiplier ?? g.rate_multiplier
}
const platforms = computed(() =>
[...new Set((props.response?.groups ?? []).map((g) => g.platform).filter(Boolean))].sort()
)
const groupOptions = computed(() =>
(props.response?.groups ?? []).map((g) => ({
id: g.id,
name: g.name,
platform: g.platform,
rate: effectiveRate(g)
}))
)
/** 全量生效倍率;当前组合下不可用的项由 FilterBar 置灰而非隐藏。 */
const rates = computed(() =>
[...new Set((props.response?.groups ?? []).map(effectiveRate))].sort((a, b) => a - b)
)
/** 数据刷新后选中的倍率可能不复存在,重置为全部。 */
watch(rates, (list) => {
if (selectedRate.value !== 'all' && !list.includes(selectedRate.value)) {
selectedRate.value = 'all'
}
})
const filteredGroups = computed(() => {
let groups = props.response?.groups ?? []
if (selectedPlatform.value !== 'all') {
groups = groups.filter((g) => g.platform === selectedPlatform.value)
}
if (selectedGroupId.value !== 'all') {
groups = groups.filter((g) => g.id === selectedGroupId.value)
}
if (selectedRate.value !== 'all') {
groups = groups.filter((g) => effectiveRate(g) === selectedRate.value)
}
// 模型名搜索:分组内只留命中的模型,整组无命中则隐藏该分组。
const q = searchQuery.value.trim().toLowerCase()
if (q) {
groups = groups
.map((g) => ({ ...g, models: g.models.filter((m) => m.name.toLowerCase().includes(q)) }))
.filter((g) => g.models.length > 0)
}
// 专属倍率会改变生效值,不能只依赖后端按默认倍率的排序。
return [...groups].sort(
(a, b) => effectiveRate(a) - effectiveRate(b) || a.name.localeCompare(b.name)
)
})
</script>
<style scoped>
.plaza-description {
line-height: 1.7;
overflow-wrap: anywhere;
}
.plaza-description :deep(h1),
.plaza-description :deep(h2),
.plaza-description :deep(h3) {
@apply mb-2 mt-3 font-semibold text-gray-900 first:mt-0 dark:text-white;
}
.plaza-description :deep(p) {
@apply mb-2 text-gray-700 last:mb-0 dark:text-dark-200;
}
.plaza-description :deep(a) {
@apply text-primary-600 underline underline-offset-4 hover:text-primary-700 dark:text-primary-300;
}
.plaza-description :deep(ul) {
@apply mb-2 list-disc pl-5;
}
.plaza-description :deep(ol) {
@apply mb-2 list-decimal pl-5;
}
.plaza-description :deep(li) {
@apply mb-0.5 text-gray-700 dark:text-dark-200;
}
.plaza-description :deep(code) {
@apply rounded bg-gray-100 px-1.5 py-0.5 font-mono text-xs dark:bg-dark-800;
}
.plaza-description :deep(blockquote) {
@apply my-2 border-l-4 border-gray-300 pl-3 text-gray-600 dark:border-dark-600 dark:text-dark-300;
}
</style>
@@ -0,0 +1,215 @@
<template>
<div class="space-y-3">
<!-- 一级:平台 -->
<div class="flex flex-wrap items-center gap-2">
<span class="w-10 shrink-0 text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-dark-500">
{{ t('modelPlaza.filters.platformLabel') }}
</span>
<button
v-for="p in ['all', ...platforms]"
:key="`platform-${p}`"
type="button"
class="inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium transition disabled:cursor-not-allowed disabled:opacity-40 disabled:grayscale"
:class="p === 'all' ? chipClass(platform === 'all') : platform === p ? 'chip-tinted-active' : 'chip-tinted'"
:style="p === 'all' ? undefined : { '--chip-accent': platformAccentColor(p) }"
:disabled="p !== 'all' && !platformEnabled(p)"
@click="$emit('update:platform', p)"
>
<PlatformIcon v-if="p !== 'all'" :platform="p as GroupPlatform" size="xs" />
{{ p === 'all' ? t('modelPlaza.filters.all') : p }}
</button>
</div>
<!-- 二级:分组(按所属平台着色,当前组合下无结果的置灰) -->
<div class="flex flex-wrap items-center gap-2">
<span class="w-10 shrink-0 text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-dark-500">
{{ t('modelPlaza.filters.groupLabel') }}
</span>
<button
type="button"
class="rounded-lg px-3 py-1.5 text-sm font-medium transition"
:class="chipClass(groupId === 'all')"
@click="$emit('update:groupId', 'all')"
>
{{ t('modelPlaza.filters.all') }}
</button>
<button
v-for="g in groups"
:key="`group-${g.id}`"
type="button"
class="rounded-lg px-3 py-1.5 text-sm font-medium transition disabled:cursor-not-allowed disabled:opacity-40 disabled:grayscale"
:class="groupId === g.id ? 'chip-tinted-active' : 'chip-tinted'"
:style="{ '--chip-accent': platformAccentColor(g.platform) }"
:disabled="!groupEnabled(g)"
@click="$emit('update:groupId', g.id)"
>
{{ g.name }}
</button>
</div>
<!-- 三级:倍率(当前组合下不存在的置灰) -->
<div class="flex flex-wrap items-center gap-2">
<span class="w-10 shrink-0 text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-dark-500">
{{ t('modelPlaza.filters.rateLabel') }}
</span>
<button
type="button"
class="rounded-lg px-3 py-1.5 text-sm font-medium transition"
:class="chipClass(rate === 'all')"
@click="$emit('update:rate', 'all')"
>
{{ t('modelPlaza.filters.all') }}
</button>
<button
v-for="r in rates"
:key="`rate-${r}`"
type="button"
class="rounded-lg px-3 py-1.5 font-mono text-sm font-medium transition disabled:cursor-not-allowed disabled:opacity-40 disabled:grayscale"
:class="chipClass(rate === r)"
:disabled="!rateEnabled(r)"
@click="$emit('update:rate', r)"
>
{{ r }}x
</button>
</div>
<!-- 四级:模型名搜索(纯前端过滤) -->
<div class="flex flex-wrap items-center gap-2">
<span class="w-10 shrink-0 text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-dark-500">
{{ t('modelPlaza.filters.modelLabel') }}
</span>
<div class="relative w-full sm:w-72">
<Icon
name="search"
size="sm"
class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-dark-500"
/>
<input
:value="search"
type="text"
:placeholder="t('modelPlaza.filters.searchPlaceholder')"
class="input rounded-lg py-1.5 pl-9 pr-9"
@input="$emit('update:search', ($event.target as HTMLInputElement).value)"
/>
<button
v-if="search"
type="button"
class="absolute right-2.5 top-1/2 -translate-y-1/2 text-gray-400 transition-colors hover:text-gray-600 dark:text-dark-500 dark:hover:text-gray-300"
@click="$emit('update:search', '')"
>
<Icon name="x" size="xs" class="h-3.5 w-3.5" />
</button>
</div>
</div>
</div>
</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 { platformAccentColor } from '@/utils/platformColors'
import type { GroupPlatform } from '@/types'
const props = defineProps<{
/** 数据中出现的平台(去重排序后)。 */
platforms: string[]
/** 全量分组(含平台与生效倍率),三个维度的置灰联动由此推导。 */
groups: Array<{ id: number; name: string; platform: string; rate: number }>
/** 全量生效倍率去重升序。 */
rates: number[]
platform: string
groupId: number | 'all'
rate: number | 'all'
/** 模型名搜索词(纯前端过滤)。 */
search: string
}>()
defineEmits<{
'update:platform': [value: string]
'update:groupId': [value: number | 'all']
'update:rate': [value: number | 'all']
'update:search': [value: string]
}>()
const { t } = useI18n()
/**
* 三个维度互为约束(faceted):某选项可点 ⟺ 在「其他两维」当前选择下仍有分组命中。
* 「全部」永远可点,作为解除本维约束的出口;可点项组合恒有结果,无需选择修正。
*/
function platformEnabled(p: string): boolean {
return props.groups.some(
(g) =>
g.platform === p &&
(props.groupId === 'all' || g.id === props.groupId) &&
(props.rate === 'all' || g.rate === props.rate)
)
}
function groupEnabled(g: { platform: string; rate: number }): boolean {
return (
(props.platform === 'all' || g.platform === props.platform) &&
(props.rate === 'all' || g.rate === props.rate)
)
}
function rateEnabled(r: number): boolean {
return props.groups.some(
(g) =>
g.rate === r &&
(props.platform === 'all' || g.platform === props.platform) &&
(props.groupId === 'all' || g.id === props.groupId)
)
}
function chipClass(active: boolean): string {
return active
? 'bg-gradient-to-r from-primary-500 to-primary-600 text-white shadow-sm shadow-primary-500/30'
: 'bg-white text-gray-600 ring-1 ring-inset ring-gray-200 enabled:hover:bg-gray-50 enabled:hover:text-gray-900 enabled:hover:ring-gray-300 dark:bg-dark-800/60 dark:text-dark-300 dark:ring-dark-700 dark:enabled:hover:bg-dark-800 dark:enabled:hover:text-white'
}
</script>
<style scoped>
/* 平台/分组 chip 的配色统一从 --chip-accent(平台主色)派生,新增平台无需扩展样式。
激活态与非激活态在模板上互斥挂载,避免选择器优先级互相覆盖。 */
.chip-tinted {
color: var(--chip-accent);
color: color-mix(in srgb, var(--chip-accent) 78%, black);
background-color: color-mix(in srgb, var(--chip-accent) 9%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--chip-accent) 25%, transparent);
}
.chip-tinted:not(:disabled):hover {
background-color: color-mix(in srgb, var(--chip-accent) 16%, transparent);
}
.dark .chip-tinted {
color: color-mix(in srgb, var(--chip-accent) 72%, white);
background-color: color-mix(in srgb, var(--chip-accent) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--chip-accent) 30%, transparent);
}
.dark .chip-tinted:not(:disabled):hover {
background-color: color-mix(in srgb, var(--chip-accent) 18%, transparent);
}
.chip-tinted-active {
color: #fff;
background-color: var(--chip-accent);
background-color: color-mix(in srgb, var(--chip-accent) 85%, black);
box-shadow: 0 1px 2px 0 color-mix(in srgb, var(--chip-accent) 35%, transparent);
}
.chip-tinted-active:not(:disabled):hover {
background-color: color-mix(in srgb, var(--chip-accent) 75%, black);
}
.dark .chip-tinted-active {
background-color: color-mix(in srgb, var(--chip-accent) 80%, transparent);
}
.dark .chip-tinted-active:not(:disabled):hover {
background-color: var(--chip-accent);
}
</style>
@@ -0,0 +1,93 @@
<template>
<section
class="rounded-2xl border bg-white shadow-card dark:bg-dark-800/50"
:class="[platformBorderStrongClass(group.platform)]"
>
<!-- 分组头部:名称/平台/倍率徽章/专属/订阅徽章 + 描述 -->
<header class="border-b border-gray-100 px-5 py-4 dark:border-dark-700/60">
<div class="flex flex-wrap items-center gap-2">
<GroupBadge
:name="group.name"
:platform="group.platform as GroupPlatform"
:subscription-type="(group.subscription_type || 'standard') as SubscriptionType"
:rate-multiplier="group.rate_multiplier"
:user-rate-multiplier="group.user_rate_multiplier ?? null"
:peak-rate-enabled="group.peak_rate_enabled"
:peak-start="group.peak_start"
:peak-end="group.peak_end"
:peak-rate-multiplier="group.peak_rate_multiplier"
always-show-rate
/>
<span
v-if="group.is_exclusive"
class="inline-flex items-center gap-1 rounded-md bg-purple-50 px-2 py-0.5 text-xs font-medium text-purple-600 dark:bg-purple-900/20 dark:text-purple-400"
>
<Icon name="shield" size="xs" class="h-3 w-3" />
{{ t('modelPlaza.badges.exclusive') }}
</span>
<span
v-if="group.subscription_type === 'subscription'"
class="inline-flex items-center rounded-md bg-violet-50 px-2 py-0.5 text-xs font-medium text-violet-600 dark:bg-violet-900/20 dark:text-violet-400"
>
{{ t('modelPlaza.badges.subscription') }}
</span>
</div>
<p v-if="group.description" class="mt-2 text-sm text-gray-500 dark:text-dark-400">
{{ group.description }}
</p>
<p
v-if="peakNote"
class="mt-1.5 inline-flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400"
>
<Icon name="clock" size="xs" class="h-3 w-3" />
{{ peakNote }}
</p>
</header>
<!-- 模型价格表 -->
<div class="px-5">
<PlazaModelPricingTable
v-if="group.models.length > 0"
:models="group.models"
:platform="group.platform"
:rate-multiplier="group.rate_multiplier"
:user-rate-multiplier="group.user_rate_multiplier ?? null"
/>
<p v-else class="py-4 text-center text-sm text-gray-400 dark:text-dark-500">
{{ t('modelPlaza.detail.noModels') }}
</p>
</div>
</section>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Icon from '@/components/icons/Icon.vue'
import GroupBadge from '@/components/common/GroupBadge.vue'
import PlazaModelPricingTable from './PlazaModelPricingTable.vue'
import type { ModelPlazaGroup } from '@/api/modelPlaza'
import type { GroupPlatform, SubscriptionType } from '@/types'
import { platformBorderStrongClass } from '@/utils/platformColors'
import { hasPeakRate, formatPeakRateWindow, serverTimezoneLabel } from '@/utils/peak-rate'
import { useAppStore } from '@/stores/app'
const props = defineProps<{
group: ModelPlazaGroup
}>()
const { t } = useI18n()
const appStore = useAppStore()
const peakNote = computed(() => {
if (!hasPeakRate(props.group)) return ''
const window = formatPeakRateWindow(
props.group,
serverTimezoneLabel(appStore.cachedPublicSettings?.server_utc_offset)
)
return t('modelPlaza.detail.peakNote', {
window,
multiplier: props.group.peak_rate_multiplier
})
})
</script>
@@ -0,0 +1,357 @@
<template>
<div class="plaza-pricing-table overflow-x-auto" :style="accentStyle">
<table class="w-full min-w-[860px] table-fixed border-collapse text-sm tabular-nums">
<colgroup>
<col class="w-[22%]" />
<col class="w-[10%]" />
<col class="w-[10%]" />
<col class="w-[14%]" />
<col class="w-[10%]" />
<col class="w-[10%]" />
<col class="w-[14%]" />
<col class="w-[10%]" />
</colgroup>
<thead>
<tr
class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-dark-400"
>
<th
rowspan="2"
class="border-r border-gray-100 py-2.5 pr-4 text-left align-middle dark:border-dark-700/60"
>
{{ t('modelPlaza.table.model') }}
</th>
<th colspan="3" class="pz-bg pt-2 text-center">
<div class="pz-title border-b pb-2 font-semibold">
{{ t('modelPlaza.table.paidPrice') }}
<span class="pz-unit ml-1 normal-case font-normal">{{ t('modelPlaza.table.unitPerMillion') }}</span>
</div>
</th>
<th
colspan="3"
class="border-l border-gray-100 pt-2 text-center dark:border-dark-700/60"
>
<div class="border-b border-gray-200 pb-2 text-gray-400 dark:border-dark-600 dark:text-dark-500">
{{ t('modelPlaza.table.officialPrice') }}
<span class="ml-1 normal-case font-normal text-gray-400 dark:text-dark-500">{{ t('modelPlaza.table.unitPerMillion') }}</span>
</div>
</th>
<th
rowspan="2"
class="border-l border-gray-100 py-2.5 pl-3 pr-1 text-right align-middle dark:border-dark-700/60"
>
{{ t('modelPlaza.table.rate') }}
</th>
</tr>
<tr
class="border-b border-gray-200 text-left text-[11px] font-medium uppercase leading-4 tracking-wide text-gray-400 dark:border-dark-700 dark:text-dark-500"
>
<th class="pz-bg px-3 py-2 font-medium">{{ t('modelPlaza.table.input') }}</th>
<th class="pz-bg px-3 py-2 font-medium">{{ t('modelPlaza.table.output') }}</th>
<th class="pz-bg px-3 py-2 font-medium">{{ t('modelPlaza.table.cache') }}</th>
<th class="border-l border-gray-100 px-3 py-2 font-medium dark:border-dark-700/60">
{{ t('modelPlaza.table.input') }}
</th>
<th class="px-3 py-2 font-medium">{{ t('modelPlaza.table.output') }}</th>
<th class="px-3 py-2 font-medium">{{ t('modelPlaza.table.cache') }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="m in sortedModels"
:key="m.name"
class="border-b border-gray-100 transition-colors last:border-b-0 hover:bg-gray-50/70 dark:border-dark-800 dark:hover:bg-dark-800/50"
>
<!-- 模型名 + 非 token 计费模式徽章 -->
<td class="border-r border-gray-100 py-2.5 pr-4 align-middle dark:border-dark-700/60">
<div class="flex flex-wrap items-center gap-1.5">
<span class="font-medium text-gray-900 dark:text-white">{{ m.name }}</span>
<span
v-if="billingMode(m) !== BILLING_MODE_TOKEN"
class="rounded-md bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium text-gray-500 dark:bg-dark-700/70 dark:text-dark-300"
>
{{ billingModeLabel(m) }}
</span>
</div>
</td>
<!-- token 计费:输入 / 输出(阶梯内联)/ 缓存(写/读) -->
<template v-if="billingMode(m) === BILLING_MODE_TOKEN">
<td class="pz-cell px-3 py-2.5 align-middle font-mono font-semibold text-gray-900 dark:text-gray-50">
<template v-if="tokenIntervals(m).length">
<div
v-for="(iv, idx) in tokenIntervals(m)"
:key="idx"
class="whitespace-nowrap text-xs leading-5"
>
<span class="mr-1 font-sans font-normal text-gray-400 dark:text-dark-500">{{ tierLabel(iv) }}</span>
{{ paidPerMillion(iv.input_price) }}
</div>
</template>
<template v-else>{{ paidPerMillion(m.pricing?.input_price) }}</template>
</td>
<td class="pz-cell px-3 py-2.5 align-middle font-mono font-semibold text-gray-900 dark:text-gray-50">
<template v-if="tokenIntervals(m).length">
<div
v-for="(iv, idx) in tokenIntervals(m)"
:key="idx"
class="whitespace-nowrap text-xs leading-5"
>
<span class="mr-1 font-sans font-normal text-gray-400 dark:text-dark-500">{{ tierLabel(iv) }}</span>
{{ paidPerMillion(iv.output_price) }}
</div>
</template>
<template v-else>{{ paidPerMillion(m.pricing?.output_price) }}</template>
</td>
<td class="pz-cell px-3 py-2.5 align-middle">
<div
v-if="hasCachePricing(m)"
class="space-y-0.5 font-mono text-xs text-gray-800 dark:text-gray-200"
>
<div>
<span class="mr-1 font-sans font-normal text-gray-400 dark:text-dark-500">{{ t('modelPlaza.table.cacheWrite') }}</span>
{{ paidPerMillion(m.pricing?.cache_write_price) }}
</div>
<div>
<span class="mr-1 font-sans font-normal text-gray-400 dark:text-dark-500">{{ t('modelPlaza.table.cacheRead') }}</span>
{{ paidPerMillion(m.pricing?.cache_read_price) }}
</div>
</div>
<span v-else class="text-gray-400 dark:text-dark-500">-</span>
</td>
</template>
<!-- 按次 / 按图片计费:实付区整体合并,阶梯芯片或单一按次价 -->
<template v-else>
<td colspan="3" class="pz-cell px-3 py-2.5 align-middle">
<div
v-if="requestIntervals(m).length"
class="flex flex-wrap items-center gap-1.5"
>
<span
v-for="(iv, idx) in requestIntervals(m)"
:key="idx"
class="inline-flex items-center gap-1 rounded-md bg-gray-100 px-2 py-0.5 font-mono text-xs text-gray-800 dark:bg-dark-700/60 dark:text-gray-200"
>
<span class="font-sans text-gray-400 dark:text-dark-500">{{ tierLabel(iv) }}</span>
{{ paidRequestPrice(iv.per_request_price)
}}<span class="font-sans text-gray-400 dark:text-dark-500">{{ perUnitSuffix(m) }}</span>
</span>
</div>
<template v-else-if="m.pricing?.per_request_price != null">
<span class="font-mono font-semibold text-gray-900 dark:text-gray-50">
{{ paidRequestPrice(m.pricing.per_request_price) }}
</span>
<span class="ml-1 text-xs text-gray-400 dark:text-dark-500">{{ perUnitSuffix(m) }}</span>
</template>
<span v-else class="text-gray-400 dark:text-dark-500">-</span>
</td>
</template>
<!-- 官方价格(LiteLLM 参考价,不乘倍率) -->
<td
class="border-l border-gray-100 px-3 py-2.5 align-middle font-mono text-xs text-gray-500 dark:border-dark-700/60 dark:text-dark-400"
>
{{ official(m.official_pricing?.input_price) }}
</td>
<td class="px-3 py-2.5 align-middle font-mono text-xs text-gray-500 dark:text-dark-400">
{{ official(m.official_pricing?.output_price) }}
</td>
<td class="px-3 py-2.5 align-middle">
<div
v-if="m.official_pricing && hasOfficialCache(m.official_pricing)"
class="space-y-0.5 font-mono text-xs text-gray-500 dark:text-dark-400"
>
<div>
<span class="mr-1 font-sans font-normal text-gray-400 dark:text-dark-500">{{ t('modelPlaza.table.cacheWrite') }}</span>
{{ official(m.official_pricing.cache_write_price)
}}<template v-if="m.official_pricing.cache_write_1h_price != null"
><span class="font-sans text-gray-400 dark:text-dark-500"> (1h </span>{{ official(m.official_pricing.cache_write_1h_price)
}}<span class="font-sans text-gray-400 dark:text-dark-500">)</span></template
>
</div>
<div>
<span class="mr-1 font-sans font-normal text-gray-400 dark:text-dark-500">{{ t('modelPlaza.table.cacheRead') }}</span>
{{ official(m.official_pricing.cache_read_price) }}
</div>
</div>
<span v-else class="text-gray-400 dark:text-dark-500">-</span>
</td>
<!-- 折扣倍率(专属倍率划线展示原倍率) -->
<td
class="border-l border-gray-100 py-2.5 pl-3 pr-1 text-right align-middle font-mono text-xs dark:border-dark-700/60"
>
<template v-if="hasCustomRate">
<span class="mr-1 text-gray-400 line-through dark:text-dark-500">{{ rateMultiplier }}x</span>
<span class="font-bold text-primary-600 dark:text-primary-400">{{ effectiveRate }}x</span>
</template>
<span v-else class="font-bold text-gray-700 dark:text-gray-300">{{ effectiveRate }}x</span>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatScaled } from '@/utils/pricing'
import { platformAccentColor } from '@/utils/platformColors'
import {
BILLING_MODE_TOKEN,
BILLING_MODE_IMAGE,
type BillingMode
} from '@/constants/channel'
import type { PlazaModel } from '@/api/modelPlaza'
import type { UserPricingInterval } from '@/api/channels'
const props = defineProps<{
models: PlazaModel[]
/** 分组平台;实付分区底色随平台着色,未知平台回退品牌青。 */
platform?: string
/** 分组默认倍率。 */
rateMultiplier: number
/** 用户专属倍率;与默认不同,实付价按此计算并划线展示原倍率。 */
userRateMultiplier?: number | null
}>()
const { t } = useI18n()
/** 实付分区只从平台拿一个主色,浅底/标题/下划线全部由 scoped CSS 用 color-mix 派生。 */
const accentStyle = computed(() => ({ '--plaza-accent': platformAccentColor(props.platform ?? '') }))
const PER_MILLION = 1_000_000
/** 展示顺序:官方输出价从高到低;无官方价的排最后;同价按名称升序。 */
const sortedModels = computed(() => {
return [...props.models].sort((a, b) => {
const pa = a.official_pricing?.output_price ?? null
const pb = b.official_pricing?.output_price ?? null
if (pa != null && pb != null && pa !== pb) return pb - pa
if (pa != null && pb == null) return -1
if (pa == null && pb != null) return 1
return a.name.localeCompare(b.name)
})
})
const effectiveRate = computed(() => props.userRateMultiplier ?? props.rateMultiplier)
const hasCustomRate = computed(
() => props.userRateMultiplier != null && props.userRateMultiplier !== props.rateMultiplier
)
function billingMode(m: PlazaModel): BillingMode {
return (m.pricing?.billing_mode || BILLING_MODE_TOKEN) as BillingMode
}
function billingModeLabel(m: PlazaModel): string {
return billingMode(m) === BILLING_MODE_IMAGE
? t('modelPlaza.table.perImage')
: t('modelPlaza.table.perRequest')
}
/** 价格统一保底 2 位小数,更长的有效小数原样保留。 */
const MIN_DECIMALS = 2
/** 实付价 = 渠道单价 × 生效倍率,按 $/1M token 展示。 */
function paidPerMillion(value: number | null | undefined): string {
if (value == null) return '-'
return formatScaled(value * effectiveRate.value, PER_MILLION, MIN_DECIMALS)
}
/** 按次 / 按图片单价(乘生效倍率,不换算 1M)。 */
function paidRequestPrice(value: number | null | undefined): string {
if (value == null) return '-'
return formatScaled(value * effectiveRate.value, 1, MIN_DECIMALS)
}
/** 官方参考价不乘倍率。 */
function official(value: number | null | undefined): string {
if (value == null) return '-'
return formatScaled(value, PER_MILLION, MIN_DECIMALS)
}
/** 非 token 计费的单位后缀:按图片 → “/ 张”,按次 → “/ 次”。 */
function perUnitSuffix(m: PlazaModel): string {
return billingMode(m) === BILLING_MODE_IMAGE
? t('modelPlaza.table.perUnitImage')
: t('modelPlaza.table.perUnitRequest')
}
function hasCachePricing(m: PlazaModel): boolean {
return m.pricing?.cache_write_price != null || m.pricing?.cache_read_price != null
}
function hasOfficialCache(o: NonNullable<PlazaModel['official_pricing']>): boolean {
return o.cache_write_price != null || o.cache_read_price != null || o.cache_write_1h_price != null
}
/** token 模式的阶梯定价(内联进输入/输出列)。 */
function tokenIntervals(m: PlazaModel): UserPricingInterval[] {
return m.pricing?.intervals ?? []
}
/** 按次/按图模式的阶梯定价(仅保留配了按次价的档位)。 */
function requestIntervals(m: PlazaModel): UserPricingInterval[] {
return (m.pricing?.intervals ?? []).filter((iv) => iv.per_request_price != null)
}
/** 档位标签:优先管理员配置的 tier_label,否则按 token 区间生成(≤200K / >200K / 200K–1M)。 */
function tierLabel(iv: UserPricingInterval): string {
if (iv.tier_label) return iv.tier_label
const { min_tokens: min, max_tokens: max } = iv
if (max == null) return `>${formatTokenCount(min)}`
if (min === 0) return `≤${formatTokenCount(max)}`
return `${formatTokenCount(min)}–${formatTokenCount(max)}`
}
function formatTokenCount(n: number): string {
if (n >= 1_000_000) return `${trimZero(n / 1_000_000)}M`
if (n >= 1_000) return `${trimZero(n / 1_000)}K`
return String(n)
}
function trimZero(n: number): string {
return String(Math.round(n * 100) / 100)
}
</script>
<style scoped>
/* 实付分区配色统一从 --plaza-accent(平台主色)派生,新增平台无需扩展样式 */
.plaza-pricing-table {
--pz-title: color-mix(in srgb, var(--plaza-accent) 88%, black);
--pz-bg: color-mix(in srgb, var(--plaza-accent) 7%, transparent);
--pz-bg-hover: color-mix(in srgb, var(--plaza-accent) 13%, transparent);
}
.dark .plaza-pricing-table {
--pz-title: color-mix(in srgb, var(--plaza-accent) 70%, white);
--pz-bg: color-mix(in srgb, var(--plaza-accent) 6%, transparent);
--pz-bg-hover: color-mix(in srgb, var(--plaza-accent) 10%, transparent);
}
.pz-bg,
.pz-cell {
background-color: var(--pz-bg);
}
.pz-cell {
transition: background-color 150ms cubic-bezier(0.4, 0, 0.2, 1);
}
tbody tr:hover .pz-cell {
background-color: var(--pz-bg-hover);
}
.pz-title {
/* color-mix 不可用的老浏览器回退为平台原色 */
color: var(--plaza-accent);
color: var(--pz-title);
border-color: color-mix(in srgb, var(--pz-title) 30%, transparent);
}
.pz-unit {
color: color-mix(in srgb, var(--pz-title) 62%, transparent);
}
</style>
@@ -0,0 +1,61 @@
<template>
<header
class="glass sticky top-0 z-30 border-b border-gray-200/50 dark:border-dark-700/50"
>
<div class="mx-auto flex max-w-7xl items-center justify-between gap-4 px-4 py-3.5 sm:px-6">
<!-- 左:站点 logo + 名称 -->
<div class="flex min-w-0 items-center gap-3">
<template v-if="settings">
<span
class="flex h-9 w-9 flex-shrink-0 items-center justify-center overflow-hidden rounded-xl bg-white shadow-sm ring-1 ring-gray-200 dark:bg-dark-800 dark:ring-dark-700"
>
<img :src="siteLogo || '/logo.svg'" alt="Logo" class="h-full w-full object-contain" />
</span>
<span class="truncate text-base font-semibold text-gray-950 dark:text-white">
{{ siteName }}
</span>
</template>
<template v-else>
<span class="h-9 w-9 flex-shrink-0 animate-pulse rounded-xl bg-gray-200 dark:bg-dark-700" aria-hidden="true"></span>
<span class="h-5 w-28 animate-pulse rounded bg-gray-200 dark:bg-dark-700" aria-hidden="true"></span>
</template>
</div>
<!-- 右:登录 / 回到后台 -->
<RouterLink
v-if="isAuthenticated"
:to="backTarget"
class="inline-flex flex-shrink-0 items-center justify-center gap-1.5 rounded-xl bg-gradient-to-r from-primary-500 to-primary-600 px-4 py-2 text-sm font-semibold text-white shadow-md shadow-primary-500/25 transition-all duration-200 hover:from-primary-600 hover:to-primary-700 hover:shadow-lg hover:shadow-primary-500/30 active:scale-[0.98] dark:shadow-primary-500/20"
>
{{ t('modelPlaza.nav.backToDashboard') }}
</RouterLink>
<RouterLink
v-else
:to="{ path: '/login', query: { redirect: '/model-plaza' } }"
class="inline-flex flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-r from-primary-500 to-primary-600 px-4 py-2 text-sm font-semibold text-white shadow-md shadow-primary-500/25 transition-all duration-200 hover:from-primary-600 hover:to-primary-700 hover:shadow-lg hover:shadow-primary-500/30 active:scale-[0.98] dark:shadow-primary-500/20"
>
{{ t('modelPlaza.nav.login') }}
</RouterLink>
</div>
</header>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { sanitizeUrl } from '@/utils/url'
import { useAppStore } from '@/stores/app'
import { useAuthStore } from '@/stores/auth'
const { t } = useI18n()
const appStore = useAppStore()
const authStore = useAuthStore()
const settings = computed(() => appStore.cachedPublicSettings)
const siteName = computed(() => settings.value?.site_name || 'Sub2API')
const siteLogo = computed(() =>
sanitizeUrl(settings.value?.site_logo || '', { allowRelative: true, allowDataUrl: true })
)
const isAuthenticated = computed(() => authStore.isAuthenticated)
const backTarget = computed(() => (authStore.isAdmin ? '/admin/dashboard' : '/dashboard'))
</script>
@@ -0,0 +1,257 @@
import { describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import PlazaModelPricingTable from '../PlazaModelPricingTable.vue'
import type { PlazaModel } from '@/api/modelPlaza'
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({
t: (key: string) => key
})
}
})
function tokenModel(overrides: Partial<PlazaModel> = {}): PlazaModel {
return {
name: 'claude-sonnet',
platform: 'anthropic',
pricing: {
billing_mode: 'token',
input_price: 3e-6,
output_price: 1.5e-5,
cache_write_price: 3.75e-6,
cache_read_price: 3e-7,
image_input_price: null,
image_output_price: null,
per_request_price: null,
intervals: []
},
official_pricing: {
input_price: 3e-6,
output_price: 1.5e-5,
cache_write_price: 3.75e-6,
cache_write_1h_price: 6e-6,
cache_read_price: 3e-7
},
...overrides
}
}
function mountTable(models: PlazaModel[], rateMultiplier: number, userRateMultiplier?: number | null) {
return mount(PlazaModelPricingTable, {
props: { models, rateMultiplier, userRateMultiplier: userRateMultiplier ?? null }
})
}
describe('PlazaModelPricingTable', () => {
it('倍率为 1 时展示渠道单价原值($/1M),价格保底 2 位小数', () => {
const wrapper = mountTable([tokenModel()], 1)
const text = wrapper.text()
expect(text).toContain('$3.00')
expect(text).toContain('$15.00')
// 缓存写 / 读(超过 2 位小数原样保留)
expect(text).toContain('$3.75')
expect(text).toContain('$0.30')
// 倍率列
expect(text).toContain('1x')
})
it('倍率 ≠ 1 时价格列为折后实付价,官方价列保持原价', () => {
const wrapper = mountTable([tokenModel()], 0.5)
const text = wrapper.text()
// 实付 = 3 × 0.5 / 15 × 0.5
expect(text).toContain('$1.50')
expect(text).toContain('$7.50')
// 官方价原值仍在(官方列不乘倍率)
expect(text).toContain('$3.00')
expect(text).toContain('$15.00')
expect(text).toContain('0.5x')
})
it('用户专属倍率覆盖分组倍率,并划线展示原倍率', () => {
const wrapper = mountTable([tokenModel()], 1, 0.8)
const text = wrapper.text()
// 实付按 0.8:3 × 0.8 = 2.4
expect(text).toContain('$2.40')
expect(text).toContain('$12.00')
// 倍率列:原倍率划线 + 专属倍率
const struck = wrapper.find('td .line-through')
expect(struck.exists()).toBe(true)
expect(struck.text()).toBe('1x')
expect(text).toContain('0.8x')
})
it('模型按官方输出价从高到低排序,无官方价的排最后', () => {
const expensive = tokenModel({
name: 'model-expensive',
official_pricing: {
input_price: 1e-5,
output_price: 7.5e-5,
cache_write_price: null,
cache_write_1h_price: null,
cache_read_price: null
}
})
const cheap = tokenModel({
name: 'model-cheap',
official_pricing: {
input_price: 1e-6,
output_price: 5e-6,
cache_write_price: null,
cache_write_1h_price: null,
cache_read_price: null
}
})
const noOfficial = tokenModel({ name: 'model-no-official', official_pricing: null })
const wrapper = mountTable([cheap, noOfficial, expensive], 1)
const names = wrapper.findAll('tbody tr').map((tr) => tr.find('td').text())
expect(names).toEqual(['model-expensive', 'model-cheap', 'model-no-official'])
})
it('两级表头:实付区与官方区各拆输入/输出/缓存列', () => {
const wrapper = mountTable([tokenModel()], 1)
const text = wrapper.text()
expect(text).toContain('modelPlaza.table.paidPrice')
expect(text).toContain('modelPlaza.table.officialPrice')
// token 行:模型 + 实付 3 列 + 官方 3 列 + 倍率
expect(wrapper.findAll('tbody td')).toHaveLength(8)
})
it('官方价包含 1h 缓存写入价;official_pricing 为 null 时官方三列显示 -', () => {
const withOfficial = mountTable([tokenModel()], 1)
expect(withOfficial.text()).toContain('$6.00')
expect(withOfficial.text()).toContain('(1h')
const withoutOfficial = mountTable([tokenModel({ official_pricing: null })], 1)
const cells = withoutOfficial.findAll('tbody td')
// 官方 输入/输出/缓存 三列均为 -
expect(cells[4].text().trim()).toBe('-')
expect(cells[5].text().trim()).toBe('-')
expect(cells[6].text().trim()).toBe('-')
})
it('per_request 模型按单次价 × 倍率展示,官方价列显示 -', () => {
const model = tokenModel({
name: 'search-tool',
pricing: {
billing_mode: 'per_request',
input_price: null,
output_price: null,
cache_write_price: null,
cache_read_price: null,
image_input_price: null,
image_output_price: null,
per_request_price: 0.04,
intervals: []
},
official_pricing: null
})
const wrapper = mountTable([model], 0.5)
const text = wrapper.text()
// 0.04 × 0.5 = 0.02,scale=1
expect(text).toContain('$0.02')
expect(text).toContain('modelPlaza.table.perRequest')
// 单位后缀跟在价格后(按次 → / 次)
expect(text).toContain('modelPlaza.table.perUnitRequest')
})
it('token 模型阶梯定价内联进输入/输出列,按倍率折算', () => {
const model = tokenModel({
pricing: {
billing_mode: 'token',
input_price: 3e-6,
output_price: 1.5e-5,
cache_write_price: null,
cache_read_price: null,
image_input_price: null,
image_output_price: null,
per_request_price: null,
intervals: [
{
min_tokens: 0,
max_tokens: 200000,
tier_label: '',
input_price: 3e-6,
output_price: 1.5e-5,
cache_write_price: null,
cache_read_price: null,
per_request_price: null
},
{
min_tokens: 200000,
max_tokens: null,
tier_label: '',
input_price: 6e-6,
output_price: 3e-5,
cache_write_price: null,
cache_read_price: null,
per_request_price: null
}
]
}
})
const wrapper = mountTable([model], 0.5)
const text = wrapper.text()
// 区间标签按 token 数生成
expect(text).toContain('≤200K')
expect(text).toContain('>200K')
// 折后:输入 1.5 / 3,输出 7.5 / 15
expect(text).toContain('$1.50')
expect(text).toContain('$7.50')
expect(text).toContain('$15.00')
})
it('按图模型主行展示阶梯芯片,不把 image_output_price(每 token)当按次价', () => {
const model = tokenModel({
name: 'gpt-image-2',
pricing: {
billing_mode: 'image',
input_price: null,
output_price: null,
cache_write_price: null,
cache_read_price: null,
image_input_price: null,
// 每 token 图片输出价:不应被当作按次单价展示
image_output_price: 3e-5,
per_request_price: null,
intervals: [
{
min_tokens: 0,
max_tokens: null,
tier_label: '1K',
input_price: null,
output_price: null,
cache_write_price: null,
cache_read_price: null,
per_request_price: 0.01
},
{
min_tokens: 0,
max_tokens: null,
tier_label: '2K',
input_price: null,
output_price: null,
cache_write_price: null,
cache_read_price: null,
per_request_price: 0.02
}
]
},
official_pricing: null
})
const wrapper = mountTable([model], 0.1)
const text = wrapper.text()
expect(text).toContain('modelPlaza.table.perImage')
// 芯片:1K $0.001 / 2K $0.002,单位后缀内嵌(按图 → / 张)
expect(text).toContain('1K')
expect(text).toContain('$0.001')
expect(text).toContain('2K')
expect(text).toContain('$0.002')
expect(text).toContain('modelPlaza.table.perUnitImage')
// 旧 bug:image_output_price × 0.1 = 0.000003 被当按次价
expect(text).not.toContain('$0.000003')
})
})
@@ -30,6 +30,16 @@ export default {
enabled: 'Enable Available Channels',
enabledHint: 'When off, the sidebar entry is hidden and the endpoint returns an empty list.',
},
modelPlaza: {
title: 'Model Plaza',
description: 'A public page showcasing available models and pricing by group. Disabled by default.',
enabled: 'Enable Model Plaza',
enabledHint: 'When enabled, an entry appears in the header and the page is reachable at /model-plaza.',
requireAuth: 'Require sign-in to access',
requireAuthHint: 'When on, anonymous visitors are redirected to the login page; when off, the page is public and anonymous visitors only see non-exclusive groups.',
priceDescription: 'Pricing notes (Markdown)',
priceDescriptionHint: 'Rendered at the top of the plaza page. Use it for billing rules, exchange rates, promotions, etc.',
},
riskControl: {
title: 'Risk Control',
description: 'Enable the content moderation menu and gateway audit entry point. Disabled by default.',
+1
View File
@@ -166,6 +166,7 @@ export default {
groups: 'Groups',
channels: 'Channels',
availableChannels: 'Available Channels',
modelPlaza: 'Model Plaza',
subscriptions: 'Subscriptions',
accounts: 'Accounts',
proxies: 'Proxies',
+48
View File
@@ -520,6 +520,54 @@ export default {
}
},
// Model Plaza (public group/model pricing showcase)
modelPlaza: {
title: 'Model Plaza',
description: 'Browse available models and pricing by group',
loading: 'Loading...',
empty: 'No groups to display',
loadFailed: 'Failed to load model plaza',
noSearchResult: 'No matching models',
anonymousHint: 'Sign in to see your exclusive groups and personal rates',
filters: {
platformLabel: 'Platform',
groupLabel: 'Group',
rateLabel: 'Rate',
modelLabel: 'Model',
searchPlaceholder: 'Search models',
all: 'All'
},
badges: {
exclusive: 'Exclusive',
subscription: 'Subscription'
},
detail: {
noModels: 'No models configured for this group',
noPricing: 'Pricing not configured',
peakNote: 'Peak hours {window}: billing rate ×{multiplier}'
},
table: {
model: 'Model',
input: 'Input',
output: 'Output',
cache: 'Cache',
cacheWrite: 'Write',
cacheRead: 'Read',
paidPrice: 'Your Price (Discounted)',
officialPrice: 'Official Price',
rate: 'Rate',
unitPerMillion: '$ / 1M tokens',
perUnitRequest: '/ request',
perUnitImage: '/ image',
perRequest: 'Per request',
perImage: 'Per image'
},
nav: {
login: 'Sign In',
backToDashboard: 'Back to Console'
}
},
affiliate: {
title: 'Affiliate Rebates',
description: 'Invite new users and convert your rebate quota into account balance',
@@ -30,6 +30,16 @@ export default {
enabled: '启用可用渠道',
enabledHint: '关闭后用户端侧边栏入口隐藏,接口返回空数组。',
},
modelPlaza: {
title: '模型广场',
description: '以分组为单位向访客展示可用模型与价格的公开页面。默认关闭。',
enabled: '启用模型广场',
enabledHint: '开启后顶栏显示入口,页面可通过 /model-plaza 独立访问。',
requireAuth: '需要登录才可访问',
requireAuthHint: '开启后未登录访问将跳转登录页;关闭则公开可见,匿名访客仅展示非专属分组。',
priceDescription: '价格说明(Markdown)',
priceDescriptionHint: '展示在模型广场页面顶部,可用于说明计费规则、汇率、优惠活动等。',
},
riskControl: {
title: '风控中心',
description: '启用内容审计菜单和全端点请求审核入口。默认关闭。',
+1
View File
@@ -166,6 +166,7 @@ export default {
groups: '分组管理',
channels: '渠道管理',
availableChannels: '可用渠道',
modelPlaza: '模型广场',
subscriptions: '订阅管理',
accounts: '账号管理',
proxies: 'IP管理',
+48
View File
@@ -525,6 +525,54 @@ export default {
}
},
// Model Plaza (public group/model pricing showcase)
modelPlaza: {
title: '模型广场',
description: '按分组浏览可用模型与价格',
loading: '加载中...',
empty: '暂无可展示的分组',
loadFailed: '加载模型广场失败',
noSearchResult: '没有匹配的模型',
anonymousHint: '登录后可查看你的专属分组与专属倍率',
filters: {
platformLabel: '平台',
groupLabel: '分组',
rateLabel: '倍率',
modelLabel: '模型',
searchPlaceholder: '搜索模型名称',
all: '全部'
},
badges: {
exclusive: '专属分组',
subscription: '订阅'
},
detail: {
noModels: '该分组暂未配置模型',
noPricing: '未配置定价',
peakNote: '高峰时段 {window} 计费倍率 ×{multiplier}'
},
table: {
model: '模型',
input: '输入',
output: '输出',
cache: '缓存',
cacheWrite: '写入',
cacheRead: '读取',
paidPrice: '实付价格(折后)',
officialPrice: '官方价格',
rate: '折扣倍率',
unitPerMillion: '$ / 1M token',
perUnitRequest: '/ 次',
perUnitImage: '/ 张',
perRequest: '按次计费',
perImage: '按图片计费'
},
nav: {
login: '登录',
backToDashboard: '回到后台'
}
},
affiliate: {
title: '邀请返利',
description: '邀请新用户注册,并将返利额度转入账户余额',
+41
View File
@@ -175,6 +175,16 @@ const routes: RouteRecordRaw[] = [
title: 'Legal Document'
}
},
{
path: '/model-plaza',
name: 'ModelPlaza',
component: () => import('@/views/ModelPlazaView.vue'),
meta: {
requiresAuth: false,
title: 'Model Plaza',
titleKey: 'modelPlaza.title'
}
},
// ==================== User Routes ====================
{
@@ -807,6 +817,37 @@ router.beforeEach(async (to, _from, next) => {
next(authStore.isAdmin ? '/admin/dashboard' : '/dashboard')
return
}
// Model Plaza:公开路由但受「启用开关 + 可选强制登录」双重控制(后端同口径 fail-closed)
if (to.path === '/model-plaza') {
if (!appStore.publicSettingsLoaded) {
try {
await appStore.fetchPublicSettings()
} catch (error) {
console.warn('Failed to load public settings in route guard', error)
}
}
const plazaSettings = appStore.cachedPublicSettings
// 仅在设置成功加载且明确为 false 时拦截(瞬时加载失败视为未知,由后端 404 兜底)
if (appStore.publicSettingsLoaded && plazaSettings?.model_plaza_enabled === false) {
next(
authStore.isAuthenticated
? authStore.isAdmin
? '/admin/dashboard'
: '/dashboard'
: '/home'
)
return
}
if (plazaSettings?.model_plaza_require_auth === true && !authStore.isAuthenticated) {
next({ path: '/login', query: { redirect: to.fullPath } })
return
}
// Backend mode:登录的非管理员也不可见(匿名由下方公共拦截处理,广场不在白名单)
if (appStore.backendModeEnabled && authStore.isAuthenticated && !authStore.isAdmin) {
next('/login')
return
}
}
// Backend mode: block public pages for unauthenticated users (except login, key-usage, setup)
if (appStore.backendModeEnabled && !authStore.isAuthenticated) {
const isAllowed = isBackendModePublicRouteAllowed(to.path, authStore.hasPendingAuthSession)
@@ -54,6 +54,8 @@ function createPublicSettings(overrides: Partial<PublicSettings> = {}): PublicSe
channel_monitor_enabled: true,
channel_monitor_default_interval_seconds: 60,
available_channels_enabled: false,
model_plaza_enabled: false,
model_plaza_require_auth: false,
service_quota_enabled: false,
affiliate_enabled: false,
...overrides,
+2
View File
@@ -366,6 +366,8 @@ export const useAppStore = defineStore('app', () => {
channel_monitor_enabled: true,
channel_monitor_default_interval_seconds: 60,
available_channels_enabled: false,
model_plaza_enabled: false,
model_plaza_require_auth: false,
risk_control_enabled: false,
service_quota_enabled: false,
affiliate_enabled: false,
+2
View File
@@ -239,6 +239,8 @@ export interface PublicSettings {
channel_monitor_enabled: boolean
channel_monitor_default_interval_seconds: number
available_channels_enabled: boolean
model_plaza_enabled: boolean
model_plaza_require_auth: boolean
service_quota_enabled: boolean
affiliate_enabled: boolean
allow_user_view_error_requests?: boolean
+5
View File
@@ -104,6 +104,11 @@ export const FeatureFlags = {
mode: 'opt-in',
label: 'Available Channels',
}),
modelPlaza: defineFlag({
key: 'model_plaza_enabled',
mode: 'opt-in',
label: 'Model Plaza',
}),
payment: defineFlag({
key: 'payment_enabled',
mode: 'opt-out',
+31
View File
@@ -39,6 +39,29 @@ const BORDER: Record<Platform, string> = {
}
const BORDER_DEFAULT = 'border-gray-200 dark:border-dark-700'
// ── Border strong (higher-contrast platform tint, e.g. plaza group cards) ──
const BORDER_STRONG: Record<Platform, string> = {
anthropic: 'border-orange-500/35 dark:border-orange-500/30',
openai: 'border-green-500/35 dark:border-green-500/30',
antigravity: 'border-purple-500/35 dark:border-purple-500/30',
gemini: 'border-blue-500/35 dark:border-blue-500/30',
grok: 'border-zinc-800/35 dark:border-zinc-500/35',
composite: 'border-cyan-500/35 dark:border-cyan-500/30',
}
const BORDER_STRONG_DEFAULT = 'border-gray-300 dark:border-dark-600'
// ── Accent (single raw color per platform; consumers derive washes/tints
// from it via CSS color-mix, e.g. plaza paid-price zone) ──
const ACCENT: Record<Platform, string> = {
anthropic: '#f97316', // orange-500
openai: '#22c55e', // green-500
antigravity: '#a855f7', // purple-500
gemini: '#3b82f6', // blue-500
grok: '#71717a', // zinc-500
composite: '#06b6d4', // cyan-500
}
const ACCENT_DEFAULT = '#14b8a6' // primary-500 (teal)
// ── Accent bar (gradient) ───────────────────────────────────────────
const ACCENT_BAR: Record<Platform, string> = {
anthropic: 'bg-gradient-to-r from-orange-400 to-orange-500',
@@ -144,6 +167,14 @@ export function platformBorderClass(p: string): string {
return isPlatform(p) ? BORDER[p] : BORDER_DEFAULT
}
export function platformBorderStrongClass(p: string): string {
return isPlatform(p) ? BORDER_STRONG[p] : BORDER_STRONG_DEFAULT
}
export function platformAccentColor(p: string): string {
return isPlatform(p) ? ACCENT[p] : ACCENT_DEFAULT
}
export function platformAccentBarClass(p: string): string {
return isPlatform(p) ? ACCENT_BAR[p] : ACCENT_BAR_DEFAULT
}
+16 -5
View File
@@ -1,13 +1,24 @@
/**
* formatScaled formats a per-token (or per-request) USD price scaled by `scale`.
*
* formatScaled(0.000003, 1_000_000) → "$3" // per 1M tokens
* formatScaled(0.5, 1) → "$0.5" // per request
* formatScaled(null, 1_000_000) → "-"
* formatScaled(0.000003, 1_000_000) → "$3" // per 1M tokens
* formatScaled(0.5, 1) → "$0.5" // per request
* formatScaled(null, 1_000_000) → "-"
* formatScaled(0.000003, 1_000_000, 2) → "$3.00" // pad to ≥2 decimals
* formatScaled(1.25e-8, 1_000_000, 2) → "$0.0125" // longer decimals kept as-is
*
* Uses toPrecision(10) then strips trailing zeros to avoid IEEE 754 display noise.
* `minFractionDigits` pads the result back up to a minimum number of decimals.
*/
export function formatScaled(value: number | null, scale: number): string {
export function formatScaled(value: number | null, scale: number, minFractionDigits = 0): string {
if (value == null) return '-'
return `$${(value * scale).toPrecision(10).replace(/\.?0+$/, '')}`
let s = (value * scale).toPrecision(10).replace(/\.?0+$/, '')
if (minFractionDigits > 0 && !s.includes('e')) {
const dot = s.indexOf('.')
const digits = dot === -1 ? 0 : s.length - dot - 1
if (digits < minFractionDigits) {
s = (dot === -1 ? `${s}.` : s) + '0'.repeat(minFractionDigits - digits)
}
}
return `$${s}`
}
+48
View File
@@ -0,0 +1,48 @@
<template>
<!-- 后台内嵌形态:?embedded=1 且已登录,套完整后台布局 -->
<AppLayout v-if="isEmbedded">
<ModelPlazaContent :response="data" :loading="loading" :error="loadFailed" embedded />
</AppLayout>
<!-- 独立形态:自带导航条(logo/站名 + 登录/回后台) -->
<div v-else class="min-h-screen bg-gray-50 dark:bg-dark-950">
<PlazaNavBar />
<main class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8 lg:py-8">
<ModelPlazaContent :response="data" :loading="loading" :error="loadFailed" />
</main>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import AppLayout from '@/components/layout/AppLayout.vue'
import PlazaNavBar from '@/components/modelPlaza/PlazaNavBar.vue'
import ModelPlazaContent from '@/components/modelPlaza/ModelPlazaContent.vue'
import { getModelPlaza, type ModelPlazaResponse } from '@/api/modelPlaza'
import { useAppStore } from '@/stores/app'
import { useAuthStore } from '@/stores/auth'
const route = useRoute()
const appStore = useAppStore()
const authStore = useAuthStore()
// embedded=1 但未登录(如转发的链接)自动降级为独立形态。
const isEmbedded = computed(() => route.query.embedded === '1' && authStore.isAuthenticated)
const data = ref<ModelPlazaResponse | null>(null)
const loading = ref(true)
const loadFailed = ref(false)
onMounted(async () => {
// 独立形态导航条需要站点名/Logo;有 __APP_CONFIG__ 注入时同步命中缓存。
void appStore.fetchPublicSettings()
try {
data.value = await getModelPlaza()
} catch {
loadFailed.value = true
} finally {
loading.value = false
}
})
</script>
+58
View File
@@ -6472,6 +6472,56 @@
</div>
</div>
<div class="card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ t('admin.settings.features.modelPlaza.title') }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.features.modelPlaza.description') }}
</p>
</div>
<div class="space-y-5 p-6">
<div class="flex items-center justify-between">
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.features.modelPlaza.enabled') }}
</label>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.features.modelPlaza.enabledHint') }}
</p>
</div>
<Toggle v-model="form.model_plaza_enabled" />
</div>
<div v-if="form.model_plaza_enabled" class="flex items-center justify-between">
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.features.modelPlaza.requireAuth') }}
</label>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.features.modelPlaza.requireAuthHint') }}
</p>
</div>
<Toggle v-model="form.model_plaza_require_auth" />
</div>
<div v-if="form.model_plaza_enabled">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.features.modelPlaza.priceDescription') }}
</label>
<p class="mb-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.features.modelPlaza.priceDescriptionHint') }}
</p>
<textarea
v-model="form.model_plaza_description"
rows="6"
class="input font-mono text-sm"
></textarea>
</div>
</div>
</div>
<div class="card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
@@ -8990,6 +9040,10 @@ const form = reactive<SettingsForm>({
channel_monitor_default_interval_seconds: 60,
// Available Channels feature switch
available_channels_enabled: false,
// Model Plaza feature switches + description
model_plaza_enabled: false,
model_plaza_require_auth: false,
model_plaza_description: '',
// Affiliate (邀请返利) feature switch
affiliate_enabled: false,
// Allow user view error requests
@@ -10535,6 +10589,10 @@ async function saveSettings() {
Number(form.channel_monitor_default_interval_seconds) || 60,
// Available Channels feature switch
available_channels_enabled: form.available_channels_enabled,
// Model Plaza feature switches + description
model_plaza_enabled: form.model_plaza_enabled,
model_plaza_require_auth: form.model_plaza_require_auth,
model_plaza_description: form.model_plaza_description,
// Affiliate (邀请返利) feature switch
affiliate_enabled: form.affiliate_enabled,
allow_user_view_error_requests: form.allow_user_view_error_requests,