improve(log):优化模型广场页面

This commit is contained in:
qian committed 2026-08-29 00:13:01 +08:00
1 parent 483ffded09
commit d1711b702b
4 files changed
+721 -2720

No files matched your search

File diff suppressed because it is too large. Load diff
@@ -0,0 +1,163 @@
<script setup lang="ts">
import { computed } from 'vue'
import Icon from '@/components/common/Icon.vue'
import type { MarketplaceModel } from '@/api/marketplace'
import { formatPrice, formatTokens, getCategoryMeta, getCapabilityList } from '../marketplaceMeta'
const props = defineProps<{
model: MarketplaceModel
index: number
}>()
const emit = defineEmits<{
open: [model: MarketplaceModel]
copy: [modelId: string]
}>()
const meta = computed(() => getCategoryMeta(props.model.category))
const capabilities = computed(() => getCapabilityList(props.model, 4))
// 价格文案按计费模式派生,避免模板里堆叠条件
const priceView = computed(() => {
const model = props.model
if (model.billing_mode === 'per_request') {
return { caption: '按次计费', main: formatPrice(model.per_request_price), secondary: '', unit: '/ 次' }
}
return {
caption: model.billing_mode === 'tiered' ? '输入 / 输出(首档起)' : '输入 / 输出',
main: formatPrice(model.input_price),
secondary: formatPrice(model.output_price),
unit: '/ 1M tokens',
}
})
// 类别渐变注入 CSS 变量;入场延迟按序号递增形成 stagger(封顶 11 张)
const cardStyle = computed(() => ({
'--cat-from': meta.value.from,
'--cat-to': meta.value.to,
'--cat-glow': meta.value.glow,
animationDelay: `${Math.min(props.index, 11) * 45}ms`,
}))
</script>
<template>
<article
class="card-in group relative flex cursor-pointer flex-col overflow-hidden rounded-[20px] border border-white/80 bg-white/85 shadow-card backdrop-blur-xl transition-all duration-300 hover:-translate-y-1.5 hover:border-primary-200/70 hover:shadow-card-hover"
:style="cardStyle"
@click="emit('open', model)"
>
<!-- 顶部类别渐变线:hover 时从左展开 -->
<span
aria-hidden="true"
class="absolute inset-x-0 top-0 z-10 h-[3px] origin-left scale-x-0 bg-gradient-to-r from-[var(--cat-from)] to-[var(--cat-to)] transition-transform duration-300 group-hover:scale-x-100"
></span>
<!-- 右上角类别光晕:hover 淡入 -->
<span
aria-hidden="true"
class="pointer-events-none absolute -right-12 -top-12 h-32 w-32 rounded-full opacity-0 blur-2xl transition-opacity duration-300 group-hover:opacity-25"
style="background: var(--cat-to)"
></span>
<div class="relative flex-1 pb-4">
<!-- 头部:类别磁贴 + 名称 / 模型 ID -->
<div class="flex items-start gap-4 p-5 pb-0">
<div
class="grid h-14 w-14 shrink-0 place-items-center rounded-2xl text-white transition-transform duration-300 group-hover:-rotate-3 group-hover:scale-105"
:style="{ background: 'linear-gradient(135deg, var(--cat-from), var(--cat-to))', boxShadow: '0 8px 24px rgba(var(--cat-glow), 0.35)' }"
>
<Icon :name="meta.icon" size="lg" />
</div>
<div class="min-w-0 flex-1">
<div class="flex items-start gap-2">
<h3 class="line-clamp-1 text-base font-semibold text-gray-900">{{ model.model_name || model.model_id }}</h3>
<span v-if="model.discount_label" class="shrink-0 rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-semibold text-amber-700">
{{ model.discount_label }}
</span>
</div>
<button
type="button"
:aria-label="`复制模型 ID ${model.model_id}`"
title="复制模型 ID"
class="group/copy mt-1 flex max-w-full items-center gap-1.5 text-left"
@click.stop="emit('copy', model.model_id)"
>
<code class="truncate font-mono text-xs text-gray-400 transition-colors group-hover/copy:text-primary-600">{{ model.model_id }}</code>
<Icon name="copy" size="xs" class="shrink-0 text-gray-300 transition-colors group-hover/copy:text-primary-600" />
</button>
</div>
</div>
<!-- 简介 -->
<p class="line-clamp-2 min-h-[2.5rem] px-5 pt-3 text-sm leading-relaxed text-gray-500">
{{ model.description || '暂无模型描述,点击查看技术规格与计费信息。' }}
</p>
<!-- 规格:上下文 / 最大输出 -->
<div class="flex gap-2 px-5 pt-3">
<div class="flex-1 rounded-xl bg-gray-50/90 px-3 py-2">
<span class="text-[11px] text-gray-400">上下文</span>
<strong class="block text-sm font-semibold tabular-nums text-gray-800">{{ formatTokens(model.max_context_tokens) }}</strong>
</div>
<div class="flex-1 rounded-xl bg-gray-50/90 px-3 py-2">
<span class="text-[11px] text-gray-400">最大输出</span>
<strong class="block text-sm font-semibold tabular-nums text-gray-800">{{ formatTokens(model.max_output_tokens) }}</strong>
</div>
</div>
<!-- 能力芯片:最多 3 个 -->
<div v-if="capabilities.length" class="flex flex-wrap gap-1.5 px-5 pt-3">
<span
v-for="item in capabilities"
:key="item"
class="inline-flex items-center gap-1 rounded-full bg-primary-50 px-2 py-0.5 text-[11px] font-medium text-primary-700"
>
<Icon name="check" size="xs" />{{ item }}
</span>
</div>
</div>
<!-- 底栏:价格 + 详情入口 -->
<div class="flex items-end justify-between gap-3 border-t border-gray-100/80 px-5 pb-4 pt-4">
<div class="min-w-0">
<span class="text-[11px] text-gray-400">{{ priceView.caption }}</span>
<div class="flex items-baseline gap-1">
<strong class="text-lg font-bold tabular-nums text-gray-900">{{ priceView.main }}</strong>
<strong v-if="priceView.secondary" class="text-sm font-semibold tabular-nums text-gray-600">{{ priceView.secondary }}</strong>
<span class="whitespace-nowrap text-[11px] text-gray-400">{{ priceView.unit }}</span>
</div>
</div>
<button
type="button"
class="inline-flex shrink-0 items-center gap-1.5 rounded-xl px-3.5 py-2 text-sm font-medium text-primary-600 transition-all duration-200 hover:bg-primary-50 active:scale-[0.98] group-hover:bg-primary-500 group-hover:text-white group-hover:shadow-glow"
@click.stop="emit('open', model)"
>
详情
<Icon name="arrowRight" size="sm" class="transition-transform duration-200 group-hover:translate-x-0.5" />
</button>
</div>
</article>
</template>
<style scoped>
/* 卡片入场:backwards 填充保证延迟期间隐藏、结束后不覆盖 hover 位移 */
.card-in {
animation: card-in 0.5s cubic-bezier(0.22, 1, 0.36, 1) backwards;
}
@keyframes card-in {
from {
opacity: 0;
transform: translateY(14px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.card-in {
animation: none;
}
}
</style>
@@ -0,0 +1,225 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import { NDrawer, NDrawerContent } from 'naive-ui'
import Icon from '@/components/common/Icon.vue'
import type { MarketplaceModel } from '@/api/marketplace'
import { useIsMobile } from '@/composables/useIsMobile'
import {
billingModeLabel,
formatPrice,
formatTokens,
getCapabilityList,
getCategoryMeta,
timePriceText,
timeWindow,
} from '../marketplaceMeta'
const props = defineProps<{
show: boolean
model: MarketplaceModel | null
loading: boolean
isLoggedIn: boolean
primaryActionRoute: RouteLocationRaw
}>()
const emit = defineEmits<{
'update:show': [value: boolean]
copy: [modelId: string]
}>()
const { isMobile } = useIsMobile()
const meta = computed(() => getCategoryMeta(props.model?.category))
// 计费瓦片:按计费模式决定展示哪些价格,有值的缓存价附加在后面
const priceTiles = computed(() => {
const model = props.model
if (!model) return []
if (model.billing_mode === 'per_request') {
return [{ label: '单次调用', value: formatPrice(model.per_request_price), unit: 'USD / 次', primary: true, wide: true }]
}
const tiles = [
{ label: '输入', value: formatPrice(model.input_price), unit: 'USD / 1M tokens', primary: true, wide: false },
{ label: '输出', value: formatPrice(model.output_price), unit: 'USD / 1M tokens', primary: false, wide: false },
]
// 后端未设置缓存价时返回 0 而非 null,0 价瓦片属于展示噪音,直接隐藏
if (model.cache_read_price) {
tiles.push({ label: '缓存读取', value: formatPrice(model.cache_read_price), unit: 'USD / 1M tokens', primary: false, wide: false })
}
if (model.cache_creation_price) {
tiles.push({ label: '缓存创建', value: formatPrice(model.cache_creation_price), unit: 'USD / 1M tokens', primary: false, wide: false })
}
return tiles
})
// 抽屉内展示全部能力,不做数量截断
const capabilities = computed(() => getCapabilityList(props.model ?? ({} as MarketplaceModel), Number.MAX_SAFE_INTEGER))
const tags = computed(() => props.model?.tags || [])
const tileStyle = computed(() => ({
background: `linear-gradient(135deg, ${meta.value.from}, ${meta.value.to})`,
boxShadow: `0 8px 24px rgba(${meta.value.glow}, 0.35)`,
}))
const categoryBadgeStyle = computed(() => ({
color: meta.value.to,
backgroundColor: `rgba(${meta.value.glow}, 0.1)`,
}))
const drawerWidth = computed(() => (isMobile.value ? '100%' : 560))
</script>
<template>
<n-drawer
:show="show"
:width="drawerWidth"
placement="right"
:auto-focus="false"
@update:show="(value: boolean) => emit('update:show', value)"
>
<n-drawer-content v-if="model" closable :body-content-style="{ padding: 0 }">
<template #header>
<div class="flex min-w-0 flex-wrap items-center gap-x-4 gap-y-2">
<div class="grid h-14 w-14 shrink-0 place-items-center rounded-2xl text-white" :style="tileStyle">
<Icon :name="meta.icon" size="lg" />
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<h2 class="truncate text-lg font-bold text-gray-900">{{ model.model_name || model.model_id }}</h2>
<span v-if="model.discount_label" class="badge badge-warning shrink-0">{{ model.discount_label }}</span>
</div>
<button type="button" class="group mt-0.5 flex max-w-full items-center gap-1.5" title="复制模型 ID" @click="emit('copy', model.model_id)">
<code class="truncate font-mono text-xs text-gray-500">{{ model.model_id }}</code>
<Icon name="copy" size="xs" class="shrink-0 text-gray-400 transition-colors group-hover:text-primary-600" />
</button>
</div>
<span class="shrink-0 rounded-full px-2.5 py-0.5 text-xs font-medium" :style="categoryBadgeStyle">{{ meta.label }}</span>
</div>
</template>
<div class="animate-fade-in space-y-6 px-5 py-5 sm:px-6">
<!-- 详情接口加载中:列表数据先行展示,仅提示补充信息加载 -->
<p v-if="loading" class="flex items-center gap-2 text-xs text-gray-400">
<span class="spinner border-primary-500" />正在加载完整模型信息...
</p>
<!-- 模型简介 -->
<section>
<h3 class="mb-2 flex items-center gap-2 text-sm font-semibold text-gray-900">
<Icon name="document" size="sm" class="text-primary-500" />模型简介
</h3>
<p class="text-sm leading-relaxed text-gray-600">{{ model.description || '暂无模型描述。' }}</p>
</section>
<!-- 计费信息 -->
<section>
<h3 class="mb-3 flex items-center gap-2 text-sm font-semibold text-gray-900">
<Icon name="creditCard" size="sm" class="text-primary-500" />计费信息
<span v-if="model.billing_mode === 'tiered'" class="rounded-full bg-amber-50 px-2 py-0.5 text-[11px] font-medium text-amber-700">
阶梯计费 · 以下为首档起价
</span>
</h3>
<div class="rounded-2xl border border-primary-100/80 bg-gradient-to-br from-primary-50/70 via-white to-white p-4 shadow-glass-sm">
<div class="grid grid-cols-2 gap-3">
<div
v-for="tile in priceTiles"
:key="tile.label"
class="rounded-xl bg-white/85 p-3 shadow-sm ring-1 ring-gray-100"
:class="tile.wide ? 'col-span-2' : ''"
>
<span class="text-xs text-gray-400">{{ tile.label }}</span>
<div class="mt-0.5 text-xl font-bold tabular-nums" :class="tile.primary ? 'text-primary-700' : 'text-gray-900'">
{{ tile.value }}
</div>
<span class="text-[11px] text-gray-400">{{ tile.unit }}</span>
</div>
</div>
<!-- 分时段价目 -->
<div v-if="model.time_prices?.length" class="mt-4 border-t border-primary-100/70 pt-3">
<p class="mb-1 text-xs font-medium text-gray-500">
分时段价格
<small class="ml-1 font-normal text-gray-400">命中时段按下方价格计费,未命中按默认价</small>
</p>
<div v-for="(tp, index) in model.time_prices" :key="index" class="flex items-center justify-between gap-3 py-1.5">
<span class="min-w-0 text-xs text-gray-600">
{{ tp.name }}
<small class="block text-[11px] text-gray-400">{{ timeWindow(tp) }}</small>
</span>
<strong class="shrink-0 text-right text-xs font-semibold tabular-nums text-gray-800">{{ timePriceText(model, tp) }}</strong>
</div>
</div>
<p v-if="model.price_change_note" class="mt-3 flex items-start gap-1.5 text-xs text-amber-700">
<Icon name="infoCircle" size="xs" class="mt-0.5 shrink-0" />{{ model.price_change_note }}
</p>
</div>
</section>
<!-- 技术规格 -->
<section>
<h3 class="mb-3 flex items-center gap-2 text-sm font-semibold text-gray-900">
<Icon name="cog" size="sm" class="text-primary-500" />技术规格
</h3>
<div class="divide-y divide-gray-100 rounded-2xl border border-gray-100 bg-white/70">
<div class="flex items-center justify-between px-4 py-3">
<span class="flex items-center gap-2 text-sm text-gray-500"><Icon name="document" size="sm" class="text-gray-400" />最大上下文</span>
<strong class="text-sm font-semibold tabular-nums text-gray-900">{{ formatTokens(model.max_context_tokens) }} tokens</strong>
</div>
<div class="flex items-center justify-between px-4 py-3">
<span class="flex items-center gap-2 text-sm text-gray-500"><Icon name="expand" size="sm" class="text-gray-400" />最大输出</span>
<strong class="text-sm font-semibold tabular-nums text-gray-900">{{ formatTokens(model.max_output_tokens) }} tokens</strong>
</div>
<div class="flex items-center justify-between px-4 py-3">
<span class="flex items-center gap-2 text-sm text-gray-500"><Icon name="grid" size="sm" class="text-gray-400" />模型类别</span>
<strong class="text-sm font-semibold text-gray-900">{{ meta.label }}</strong>
</div>
<div class="flex items-center justify-between px-4 py-3">
<span class="flex items-center gap-2 text-sm text-gray-500"><Icon name="creditCard" size="sm" class="text-gray-400" />计费模式</span>
<strong class="text-sm font-semibold text-gray-900">{{ billingModeLabel(model.billing_mode) }}</strong>
</div>
</div>
</section>
<!-- 能力支持 -->
<section>
<h3 class="mb-3 flex items-center gap-2 text-sm font-semibold text-gray-900">
<Icon name="checkCircle" size="sm" class="text-primary-500" />能力支持
</h3>
<div class="flex flex-wrap gap-2">
<span
v-for="item in capabilities"
:key="item"
class="inline-flex items-center gap-1.5 rounded-full bg-emerald-50 px-2.5 py-1 text-xs font-medium text-emerald-700"
>
<Icon name="checkCircle" size="xs" />{{ item }}
</span>
<span v-if="capabilities.length === 0" class="text-xs text-gray-400">标准 API 接入</span>
</div>
</section>
<!-- 模型标签 -->
<section v-if="tags.length">
<h3 class="mb-3 flex items-center gap-2 text-sm font-semibold text-gray-900">
<Icon name="link" size="sm" class="text-primary-500" />模型标签
</h3>
<div class="flex flex-wrap gap-2">
<span v-for="tag in tags" :key="tag" class="rounded-lg bg-gray-100 px-2 py-1 text-xs text-gray-600">{{ tag }}</span>
</div>
</section>
</div>
<template #footer>
<div class="flex w-full flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p class="text-xs text-gray-400">实际可用范围以账户套餐与 API 密钥权限为准</p>
<router-link :to="primaryActionRoute" class="btn btn-primary btn-sm shrink-0" @click="emit('update:show', false)">
{{ isLoggedIn ? '前往使用' : '免费开始' }}
<Icon name="arrowRight" size="sm" />
</router-link>
</div>
</template>
</n-drawer-content>
</n-drawer>
</template>
@@ -0,0 +1,119 @@
import type { MarketplaceModel, TimePriceItem } from '@/api/marketplace'
// 分类元数据:卡片磁贴渐变、筛选 pills 与各类徽章共用同一份配置
export interface CategoryMeta {
key: string
// null 表示「全部」,请求时不传 category
value: string | null
// 完整名称:卡片类别徽章 / 抽屉 / 规格行
label: string
// 两个字:筛选 pills 用
shortLabel: string
// hover 提示
description: string
// Icon.vue 内置图标名
icon: string
// 渐变起止色
from: string
to: string
// 发光阴影 RGB("r, g, b"),供 rgba() 拼接 box-shadow
glow: string
}
// 色值与全局 .icon-tile-* 系列保持一致,chat 占据主色 teal
export const categoryMetaList: CategoryMeta[] = [
{ key: 'all', value: null, label: '全部模型', shortLabel: '全部', description: '浏览完整模型目录', icon: 'grid', from: '#2dd4bf', to: '#0d9488', glow: '20, 184, 166' },
{ key: 'chat', value: 'chat', label: '对话与推理', shortLabel: '对话', description: '文本生成、推理与工具调用', icon: 'chat', from: '#2dd4bf', to: '#0d9488', glow: '20, 184, 166' },
{ key: 'embedding', value: 'embedding', label: '向量嵌入', shortLabel: '向量', description: '语义检索与知识库构建', icon: 'cube', from: '#a78bfa', to: '#8b5cf6', glow: '139, 92, 246' },
{ key: 'image', value: 'image', label: '图像生成', shortLabel: '图像', description: '文生图、图像理解与编辑', icon: 'eye', from: '#f472b6', to: '#ec4899', glow: '236, 72, 153' },
{ key: 'audio', value: 'audio', label: '音频处理', shortLabel: '音频', description: '语音识别、合成与音乐生成', icon: 'bell', from: '#fbbf24', to: '#f59e0b', glow: '245, 158, 11' },
{ key: 'rerank', value: 'rerank', label: '重排序', shortLabel: '重排', description: '检索结果相关性排序', icon: 'trendingUp', from: '#22d3ee', to: '#06b6d4', glow: '6, 182, 212' },
]
// 未知分类的渲染兜底:仅用于展示,绝不进入筛选 pills
export const fallbackCategoryMeta: CategoryMeta = {
key: 'other', value: null, label: '其他模型', shortLabel: '其他', description: '其他模型能力', icon: 'bolt', from: '#d1d5db', to: '#9ca3af', glow: '156, 163, 175',
}
export function getCategoryMeta(category?: string | null): CategoryMeta {
if (!category) return categoryMetaList[0]
return categoryMetaList.find(item => item.value === category) || fallbackCategoryMeta
}
// 能力中文标签:合并控制台模型页的映射,缺失的 key 回退为可读文本
const capabilityLabels: Record<string, string> = {
streaming: '流式输出',
function_calling: '工具调用',
tool_calling: '工具调用',
parallel_function_calling: '并行工具调用',
tool_choice: '工具选择',
vision: '视觉理解',
multimodal: '多模态',
json_mode: 'JSON 模式',
response_schema: '结构化输出',
system_messages: '系统消息',
reasoning: '深度思考',
web_search: '联网搜索',
image_generation: '图像生成',
prompt_caching: '提示词缓存',
audio: '音频能力',
audio_input: '音频输入',
audio_output: '音频输出',
pdf_input: 'PDF 输入',
embedding_image: '图像嵌入',
}
// capabilities 是 { key: boolean } 形态,只取为真的项并转成中文标签
export function getCapabilityList(model: MarketplaceModel, limit = 4): string[] {
if (!model.capabilities || typeof model.capabilities !== 'object') return []
return Object.entries(model.capabilities)
.filter(([, value]) => value === true || value === 1 || value === 'true')
.map(([key]) => capabilityLabels[key] || key.replaceAll('_', ' '))
.filter((item, index, list) => list.indexOf(item) === index)
.slice(0, limit)
}
export function formatTokens(tokens?: number | null): string {
const value = Number(tokens || 0)
if (!value) return '—'
if (value >= 1_000_000) return `${Number((value / 1_000_000).toFixed(1))}M`
if (value >= 1_000) return `${Number((value / 1_000).toFixed(1))}K`
return value.toLocaleString('en-US')
}
export function formatPrice(price?: number | null): string {
const value = Number(price)
if (!Number.isFinite(value)) return '—'
// 最多保留 6 位小数(与后端 NUMERIC(20,10) 精度对齐),自动去掉末尾的 0
return `$${Number(value.toFixed(6))}`
}
export function billingModeLabel(mode?: string | null): string {
if (mode === 'per_request') return '按次计费'
if (mode === 'tiered') return '阶梯计费'
return '按量计费'
}
// ── 时段价目展示辅助(价格为后端换算好的展示价,前端直接渲染) ──
function daysLabel(days?: number[] | null): string {
if (!days || days.length === 0) return '每天'
const sorted = [...days].sort((a, b) => a - b)
if (sorted.join(',') === '1,2,3,4,5') return '工作日'
if (sorted.join(',') === '6,7') return '周末'
const names = ['', '周一', '周二', '周三', '周四', '周五', '周六', '周日']
return sorted.map((d) => names[d] || String(d)).join('、')
}
export function timeWindow(tp: TimePriceItem): string {
const parts: string[] = [daysLabel(tp.days)]
if (tp.start_time && tp.end_time) parts.push(`${tp.start_time}~${tp.end_time}`)
else parts.push('全天')
if (tp.valid_from) parts.push(tp.valid_to ? `${tp.valid_from}~${tp.valid_to}` : `${tp.valid_from} 起`)
return parts.join(' · ')
}
export function timePriceText(model: MarketplaceModel, tp: TimePriceItem): string {
if (model.billing_mode === 'per_request') return `${formatPrice(tp.per_request_price)} /次`
return `输入 ${formatPrice(tp.input_price)} · 输出 ${formatPrice(tp.output_price)}${model.billing_mode === 'tiered' ? ' 起' : ''}`
}