Merge pull request #5085 from feeeei/main

feat(model-plaza): Filter bar line alignment, model sorting, and table spacing
This commit is contained in:
Wesley Liddick
2026-08-01 08:54:11 +08:00
committed by GitHub
4 changed files with 145 additions and 70 deletions
@@ -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()