Merge pull request #5085 from feeeei/main
feat(model-plaza): Filter bar line alignment, model sorting, and table spacing
This commit is contained in:
@@ -1,81 +1,87 @@
|
||||
<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">
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="w-10 shrink-0 pt-2 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 class="flex flex-wrap items-center gap-2">
|
||||
<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>
|
||||
|
||||
<!-- 二级:分组(按所属平台着色,当前组合下无结果的置灰) -->
|
||||
<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">
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="w-10 shrink-0 pt-2 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 class="flex flex-wrap items-center gap-2">
|
||||
<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>
|
||||
|
||||
<!-- 三级:倍率(当前组合下不存在的置灰) -->
|
||||
<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">
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="w-10 shrink-0 pt-2 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 class="flex flex-wrap items-center gap-2">
|
||||
<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>
|
||||
|
||||
<!-- 四级:模型名搜索(纯前端过滤) -->
|
||||
<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">
|
||||
<div class="flex flex-wrap items-start gap-2">
|
||||
<span class="w-10 shrink-0 pt-2 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">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<section
|
||||
class="rounded-2xl border bg-white shadow-card dark:bg-dark-800/50"
|
||||
class="overflow-hidden rounded-2xl border bg-white shadow-card dark:bg-dark-800/50"
|
||||
:class="[platformBorderStrongClass(group.platform)]"
|
||||
>
|
||||
<!-- 分组头部:名称/平台/倍率徽章/专属/订阅徽章 + 描述 -->
|
||||
@@ -44,8 +44,8 @@
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- 模型价格表 -->
|
||||
<div class="px-5">
|
||||
<!-- 模型价格表:整行(含 hover 底色/分区底色)顶到卡片边缘,左右留白由表格首列/末列的 padding 提供 -->
|
||||
<div>
|
||||
<PlazaModelPricingTable
|
||||
v-if="group.models.length > 0"
|
||||
:models="group.models"
|
||||
@@ -53,7 +53,7 @@
|
||||
: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">
|
||||
<p v-else class="px-5 py-4 text-center text-sm text-gray-400 dark:text-dark-500">
|
||||
{{ t('modelPlaza.detail.noModels') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
>
|
||||
<th
|
||||
rowspan="2"
|
||||
class="border-r border-gray-100 py-2.5 pr-4 text-left align-middle dark:border-dark-700/60"
|
||||
class="border-r border-gray-100 py-2.5 pl-5 pr-4 text-left align-middle dark:border-dark-700/60"
|
||||
>
|
||||
{{ t('modelPlaza.table.model') }}
|
||||
</th>
|
||||
@@ -38,7 +38,7 @@
|
||||
</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"
|
||||
class="border-l border-gray-100 py-2.5 pl-3 pr-5 text-right align-middle dark:border-dark-700/60"
|
||||
>
|
||||
{{ t('modelPlaza.table.rate') }}
|
||||
</th>
|
||||
@@ -63,7 +63,7 @@
|
||||
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">
|
||||
<td class="border-r border-gray-100 py-2.5 pl-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
|
||||
@@ -180,7 +180,7 @@
|
||||
|
||||
<!-- 折扣倍率(专属倍率划线展示原倍率) -->
|
||||
<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"
|
||||
class="border-l border-gray-100 py-2.5 pl-3 pr-5 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>
|
||||
@@ -224,15 +224,23 @@ const accentStyle = computed(() => ({ '--plaza-accent': platformAccentColor(prop
|
||||
|
||||
const PER_MILLION = 1_000_000
|
||||
|
||||
/** 展示顺序:官方输出价从高到低;无官方价的排最后;同价按名称升序。 */
|
||||
/**
|
||||
* 展示顺序:
|
||||
* 1. token 计费的排在前,按图/按次计费的沉到末尾——它们的官方 token 价与实付的按张/按次价不同量纲,混排无意义;
|
||||
* 2. 组内按官方输出价从高到低,无官方价的排最后;
|
||||
* 3. 同价按名称降序(新版本号在前,如 gpt-5.6 先于 gpt-5.5)。
|
||||
*/
|
||||
const sortedModels = computed(() => {
|
||||
return [...props.models].sort((a, b) => {
|
||||
const ta = billingMode(a) === BILLING_MODE_TOKEN
|
||||
const tb = billingMode(b) === BILLING_MODE_TOKEN
|
||||
if (ta !== tb) return ta ? -1 : 1
|
||||
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)
|
||||
return b.name.localeCompare(a.name)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -111,6 +111,67 @@ describe('PlazaModelPricingTable', () => {
|
||||
expect(names).toEqual(['model-expensive', 'model-cheap', 'model-no-official'])
|
||||
})
|
||||
|
||||
it('官方输出价相同时按模型名降序(新版本号在前)', () => {
|
||||
const older = tokenModel({ name: 'gpt-5.5' })
|
||||
const newer = tokenModel({ name: 'gpt-5.6-sol' })
|
||||
|
||||
const wrapper = mountTable([older, newer], 1)
|
||||
const names = wrapper.findAll('tbody tr').map((tr) => tr.find('td').text())
|
||||
expect(names).toEqual(['gpt-5.6-sol', 'gpt-5.5'])
|
||||
})
|
||||
|
||||
it('按图片/按次计费的模型沉到末尾,不与 token 模型按官方价混排', () => {
|
||||
// 官方输出价 $10,介于下面两个 token 模型之间,但因计费模式不同应排最后
|
||||
const image = 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,
|
||||
image_output_price: null,
|
||||
per_request_price: 0.002,
|
||||
intervals: []
|
||||
},
|
||||
official_pricing: {
|
||||
input_price: 5e-6,
|
||||
output_price: 1e-5,
|
||||
cache_write_price: null,
|
||||
cache_write_1h_price: null,
|
||||
cache_read_price: 1.25e-6
|
||||
}
|
||||
})
|
||||
const pricier = tokenModel({
|
||||
name: 'gpt-5.6-terra',
|
||||
official_pricing: {
|
||||
input_price: 2.5e-6,
|
||||
output_price: 1.5e-5,
|
||||
cache_write_price: null,
|
||||
cache_write_1h_price: null,
|
||||
cache_read_price: null
|
||||
}
|
||||
})
|
||||
const cheaper = tokenModel({
|
||||
name: 'gpt-5.6-luna',
|
||||
official_pricing: {
|
||||
input_price: 1e-6,
|
||||
output_price: 6e-6,
|
||||
cache_write_price: null,
|
||||
cache_write_1h_price: null,
|
||||
cache_read_price: null
|
||||
}
|
||||
})
|
||||
|
||||
const wrapper = mountTable([pricier, image, cheaper], 1)
|
||||
const names = wrapper.findAll('tbody tr').map((tr) => tr.find('td').text())
|
||||
expect(names[0]).toBe('gpt-5.6-terra')
|
||||
expect(names[1]).toBe('gpt-5.6-luna')
|
||||
// 首列含「按图片计费」徽章文本,只断言模型名
|
||||
expect(names[2]).toContain('gpt-image-2')
|
||||
})
|
||||
|
||||
it('两级表头:实付区与官方区各拆输入/输出/缓存列', () => {
|
||||
const wrapper = mountTable([tokenModel()], 1)
|
||||
const text = wrapper.text()
|
||||
|
||||
Reference in New Issue
Block a user