improve(log):优化模型广场页面
This commit is contained in:
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' ? ' 起' : ''}`
|
||||
}
|
||||
Reference in new issue
Block a user