2025-12-10 20:52:44 +08:00
|
|
|
|
<template>
|
|
|
|
|
|
<div class="space-y-6 pb-8">
|
|
|
|
|
|
<!-- 模型列表 -->
|
|
|
|
|
|
<Card class="overflow-hidden">
|
|
|
|
|
|
<!-- 标题和操作栏 -->
|
2025-12-13 22:26:47 +08:00
|
|
|
|
<div class="px-4 sm:px-6 py-3 sm:py-3.5 border-b border-border/60">
|
|
|
|
|
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<!-- 左侧:标题 -->
|
2025-12-13 22:26:47 +08:00
|
|
|
|
<h3 class="text-sm sm:text-base font-semibold shrink-0">
|
2025-12-12 16:15:54 +08:00
|
|
|
|
可用模型
|
|
|
|
|
|
</h3>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
<!-- 右侧:操作区 -->
|
2025-12-13 22:26:47 +08:00
|
|
|
|
<div class="flex flex-wrap items-center gap-2">
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<!-- 搜索框 -->
|
|
|
|
|
|
<div class="relative">
|
|
|
|
|
|
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
|
|
|
|
|
<Input
|
|
|
|
|
|
id="model-search"
|
|
|
|
|
|
v-model="searchQuery"
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
placeholder="搜索模型名称..."
|
2025-12-13 22:26:47 +08:00
|
|
|
|
class="w-32 sm:w-44 pl-8 pr-3 h-8 text-sm bg-background/50 border-border/60 focus:border-primary/40 transition-colors"
|
2025-12-10 20:52:44 +08:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 刷新按钮 -->
|
2025-12-12 16:15:54 +08:00
|
|
|
|
<RefreshButton
|
|
|
|
|
|
:loading="loading"
|
|
|
|
|
|
@click="refreshData"
|
|
|
|
|
|
/>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="overflow-x-auto">
|
2025-12-13 22:26:47 +08:00
|
|
|
|
<Table class="hidden xl:table table-fixed w-full">
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<TableHeader>
|
|
|
|
|
|
<TableRow class="border-b border-border/60 hover:bg-transparent">
|
2025-12-12 16:15:54 +08:00
|
|
|
|
<TableHead class="w-[140px] h-12 font-semibold">
|
|
|
|
|
|
模型名称
|
|
|
|
|
|
</TableHead>
|
|
|
|
|
|
<TableHead class="w-[140px] h-12 font-semibold text-center">
|
|
|
|
|
|
价格 ($/M)
|
|
|
|
|
|
</TableHead>
|
2026-02-28 11:44:08 +08:00
|
|
|
|
<TableHead class="w-[80px] h-12 font-semibold text-center">
|
|
|
|
|
|
调用次数
|
|
|
|
|
|
</TableHead>
|
2025-12-12 16:15:54 +08:00
|
|
|
|
<TableHead class="w-[70px] h-12 font-semibold text-center">
|
|
|
|
|
|
状态
|
|
|
|
|
|
</TableHead>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</TableRow>
|
|
|
|
|
|
</TableHeader>
|
|
|
|
|
|
<TableBody>
|
|
|
|
|
|
<TableRow v-if="loading">
|
2025-12-12 16:15:54 +08:00
|
|
|
|
<TableCell
|
2026-02-03 18:48:39 +08:00
|
|
|
|
colspan="4"
|
2025-12-12 16:15:54 +08:00
|
|
|
|
class="text-center py-12"
|
|
|
|
|
|
>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<Loader2 class="w-6 h-6 animate-spin mx-auto" />
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
</TableRow>
|
|
|
|
|
|
<TableRow v-else-if="filteredModels.length === 0">
|
2025-12-12 16:15:54 +08:00
|
|
|
|
<TableCell
|
2026-02-03 18:48:39 +08:00
|
|
|
|
colspan="4"
|
2025-12-12 16:15:54 +08:00
|
|
|
|
class="text-center py-12 text-muted-foreground"
|
|
|
|
|
|
>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
没有找到匹配的模型
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
</TableRow>
|
|
|
|
|
|
<template v-else>
|
|
|
|
|
|
<TableRow
|
|
|
|
|
|
v-for="model in paginatedModels"
|
|
|
|
|
|
:key="model.id"
|
|
|
|
|
|
class="border-b border-border/40 hover:bg-muted/30 transition-colors cursor-pointer"
|
|
|
|
|
|
@mousedown="handleMouseDown"
|
|
|
|
|
|
@click="openModelDetail(model, $event)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<TableCell class="py-4">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div class="flex items-center gap-2">
|
|
|
|
|
|
<span class="font-medium hover:text-primary transition-colors">{{ model.display_name || model.name }}</span>
|
2026-05-03 17:32:41 +08:00
|
|
|
|
<Badge
|
|
|
|
|
|
v-for="capability in getModelCapabilityLabels(model)"
|
|
|
|
|
|
:key="capability"
|
|
|
|
|
|
variant="secondary"
|
|
|
|
|
|
class="text-[10px] px-1.5 py-0"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ capability }}
|
|
|
|
|
|
</Badge>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="text-xs text-muted-foreground flex items-center gap-1 mt-0.5">
|
|
|
|
|
|
<span>{{ model.name }}</span>
|
|
|
|
|
|
<button
|
|
|
|
|
|
class="p-0.5 rounded hover:bg-muted transition-colors"
|
|
|
|
|
|
title="复制模型 ID"
|
|
|
|
|
|
@click.stop="copyToClipboard(model.name)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Copy class="w-3 h-3" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
<TableCell class="py-4 text-center">
|
|
|
|
|
|
<div class="text-xs space-y-0.5">
|
|
|
|
|
|
<!-- 按 Token 计费 -->
|
|
|
|
|
|
<div v-if="getFirstTierPrice(model, 'input') || getFirstTierPrice(model, 'output')">
|
|
|
|
|
|
<span class="text-muted-foreground">In:</span>
|
|
|
|
|
|
<span class="font-mono ml-1">{{ getFirstTierPrice(model, 'input')?.toFixed(2) || '-' }}</span>
|
|
|
|
|
|
<span class="text-muted-foreground mx-1">/</span>
|
|
|
|
|
|
<span class="text-muted-foreground">Out:</span>
|
|
|
|
|
|
<span class="font-mono ml-1">{{ getFirstTierPrice(model, 'output')?.toFixed(2) || '-' }}</span>
|
2025-12-12 16:15:54 +08:00
|
|
|
|
<span
|
|
|
|
|
|
v-if="hasTieredPricing(model)"
|
|
|
|
|
|
class="ml-1 text-muted-foreground"
|
|
|
|
|
|
title="阶梯计费"
|
|
|
|
|
|
>[阶梯]</span>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<!-- 按次计费 -->
|
|
|
|
|
|
<div v-if="model.default_price_per_request && model.default_price_per_request > 0">
|
|
|
|
|
|
<span class="text-muted-foreground">按次:</span>
|
|
|
|
|
|
<span class="font-mono ml-1">${{ model.default_price_per_request.toFixed(3) }}/次</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<!-- 无计费配置 -->
|
2025-12-12 16:15:54 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="!getFirstTierPrice(model, 'input') && !getFirstTierPrice(model, 'output') && !model.default_price_per_request"
|
|
|
|
|
|
class="text-muted-foreground"
|
|
|
|
|
|
>
|
|
|
|
|
|
-
|
|
|
|
|
|
</div>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</TableCell>
|
2026-02-28 11:44:08 +08:00
|
|
|
|
<TableCell class="py-4 text-center">
|
|
|
|
|
|
<span class="text-sm font-mono">{{ formatUsageCount(model.usage_count || 0) }}</span>
|
|
|
|
|
|
</TableCell>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<TableCell class="py-4 text-center">
|
|
|
|
|
|
<Badge :variant="model.is_active ? 'success' : 'secondary'">
|
|
|
|
|
|
{{ model.is_active ? '可用' : '停用' }}
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
</TableRow>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</TableBody>
|
|
|
|
|
|
</Table>
|
2025-12-13 22:26:47 +08:00
|
|
|
|
|
|
|
|
|
|
<!-- 移动端卡片列表 -->
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="!loading && filteredModels.length > 0"
|
|
|
|
|
|
class="xl:hidden divide-y divide-border/40"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-for="model in paginatedModels"
|
|
|
|
|
|
:key="model.id"
|
|
|
|
|
|
class="p-4 space-y-3 hover:bg-muted/30 cursor-pointer transition-colors"
|
|
|
|
|
|
@click="selectedModel = model; drawerOpen = true"
|
|
|
|
|
|
>
|
|
|
|
|
|
<!-- 第一行:名称 + 状态 -->
|
|
|
|
|
|
<div class="flex items-start justify-between gap-3">
|
|
|
|
|
|
<div class="flex-1 min-w-0">
|
|
|
|
|
|
<span class="font-medium truncate block">{{ model.display_name || model.name }}</span>
|
2026-05-03 17:32:41 +08:00
|
|
|
|
<div class="flex flex-wrap gap-1 mt-1">
|
|
|
|
|
|
<Badge
|
|
|
|
|
|
v-for="capability in getModelCapabilityLabels(model)"
|
|
|
|
|
|
:key="capability"
|
|
|
|
|
|
variant="secondary"
|
|
|
|
|
|
class="text-[10px] px-1.5 py-0"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ capability }}
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
</div>
|
2025-12-13 22:26:47 +08:00
|
|
|
|
<div class="text-xs text-muted-foreground flex items-center gap-1 mt-0.5">
|
|
|
|
|
|
<span class="truncate">{{ model.name }}</span>
|
|
|
|
|
|
<button
|
|
|
|
|
|
class="p-0.5 rounded hover:bg-muted transition-colors shrink-0"
|
|
|
|
|
|
@click.stop="copyToClipboard(model.name)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Copy class="w-3 h-3" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Badge :variant="model.is_active ? 'success' : 'secondary'">
|
|
|
|
|
|
{{ model.is_active ? '可用' : '停用' }}
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-02-28 11:44:08 +08:00
|
|
|
|
<!-- 第二行:价格 + 调用次数 -->
|
|
|
|
|
|
<div class="flex items-center gap-3 text-xs text-muted-foreground">
|
|
|
|
|
|
<span
|
|
|
|
|
|
v-if="getFirstTierPrice(model, 'input') || getFirstTierPrice(model, 'output')"
|
|
|
|
|
|
class="font-mono"
|
2025-12-13 22:26:47 +08:00
|
|
|
|
>
|
2026-02-28 11:44:08 +08:00
|
|
|
|
In: ${{ getFirstTierPrice(model, 'input')?.toFixed(2) || '-' }} / Out: ${{ getFirstTierPrice(model, 'output')?.toFixed(2) || '-' }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span class="font-mono">{{ formatUsageCount(model.usage_count || 0) }} 次</span>
|
2025-12-13 22:26:47 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 分页 -->
|
|
|
|
|
|
<Pagination
|
|
|
|
|
|
v-if="!loading && filteredModels.length > 0"
|
|
|
|
|
|
:current="currentPage"
|
|
|
|
|
|
:total="filteredModels.length"
|
|
|
|
|
|
:page-size="pageSize"
|
2026-01-17 20:21:03 +08:00
|
|
|
|
cache-key="model-catalog-page-size"
|
2025-12-10 20:52:44 +08:00
|
|
|
|
@update:current="currentPage = $event"
|
2025-12-12 16:15:54 +08:00
|
|
|
|
@update:page-size="pageSize = $event"
|
2025-12-10 20:52:44 +08:00
|
|
|
|
/>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 模型详情抽屉 -->
|
|
|
|
|
|
<UserModelDetailDrawer
|
|
|
|
|
|
v-model:open="drawerOpen"
|
|
|
|
|
|
:model="selectedModel"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
|
import { ref, computed, onMounted, watch } from 'vue'
|
|
|
|
|
|
import {
|
|
|
|
|
|
Loader2,
|
|
|
|
|
|
Search,
|
|
|
|
|
|
Copy,
|
|
|
|
|
|
} from 'lucide-vue-next'
|
|
|
|
|
|
import { useToast } from '@/composables/useToast'
|
2025-12-28 20:41:52 +08:00
|
|
|
|
import { useClipboard } from '@/composables/useClipboard'
|
2025-12-10 20:52:44 +08:00
|
|
|
|
import {
|
|
|
|
|
|
Card,
|
|
|
|
|
|
Table,
|
|
|
|
|
|
TableHeader,
|
|
|
|
|
|
TableBody,
|
|
|
|
|
|
TableRow,
|
|
|
|
|
|
TableHead,
|
|
|
|
|
|
TableCell,
|
|
|
|
|
|
Badge,
|
|
|
|
|
|
Input,
|
|
|
|
|
|
Pagination,
|
|
|
|
|
|
RefreshButton,
|
|
|
|
|
|
} from '@/components/ui'
|
2026-01-28 01:01:05 +08:00
|
|
|
|
import { type PublicGlobalModel } from '@/api/public-models'
|
2026-02-28 11:44:08 +08:00
|
|
|
|
import { formatUsageCount } from '@/utils/format'
|
2025-12-10 20:52:44 +08:00
|
|
|
|
import { meApi } from '@/api/me'
|
|
|
|
|
|
import UserModelDetailDrawer from './components/UserModelDetailDrawer.vue'
|
|
|
|
|
|
import { useRowClick } from '@/composables/useRowClick'
|
2025-12-12 20:22:15 +08:00
|
|
|
|
import { log } from '@/utils/logger'
|
2026-02-22 00:43:41 +08:00
|
|
|
|
import { parseApiError } from '@/utils/errorParser'
|
2026-05-03 17:32:41 +08:00
|
|
|
|
import { getModelCapabilityLabels } from './model-catalog-helpers'
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
2026-01-15 17:03:19 +08:00
|
|
|
|
const { error: showError } = useToast()
|
2025-12-28 20:41:52 +08:00
|
|
|
|
const { copyToClipboard } = useClipboard()
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
// 状态
|
|
|
|
|
|
const loading = ref(false)
|
|
|
|
|
|
const searchQuery = ref('')
|
|
|
|
|
|
const models = ref<PublicGlobalModel[]>([])
|
|
|
|
|
|
|
|
|
|
|
|
// 抽屉状态
|
|
|
|
|
|
const drawerOpen = ref(false)
|
|
|
|
|
|
const selectedModel = ref<PublicGlobalModel | null>(null)
|
|
|
|
|
|
|
|
|
|
|
|
// 使用复用的行点击逻辑
|
|
|
|
|
|
const { handleMouseDown, shouldTriggerRowClick } = useRowClick()
|
|
|
|
|
|
|
|
|
|
|
|
function openModelDetail(model: PublicGlobalModel, event: MouseEvent) {
|
|
|
|
|
|
if (!shouldTriggerRowClick(event)) return
|
|
|
|
|
|
selectedModel.value = model
|
|
|
|
|
|
drawerOpen.value = true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 分页
|
|
|
|
|
|
const currentPage = ref(1)
|
|
|
|
|
|
const pageSize = ref(20)
|
|
|
|
|
|
|
|
|
|
|
|
// 筛选后的模型列表
|
|
|
|
|
|
const filteredModels = computed(() => {
|
|
|
|
|
|
let result = models.value
|
|
|
|
|
|
|
2025-12-17 16:41:10 +08:00
|
|
|
|
// 搜索(支持空格分隔的多关键词 AND 搜索)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
if (searchQuery.value) {
|
2025-12-17 16:41:10 +08:00
|
|
|
|
const keywords = searchQuery.value.toLowerCase().split(/\s+/).filter(k => k.length > 0)
|
|
|
|
|
|
result = result.filter(m => {
|
|
|
|
|
|
const searchableText = `${m.name} ${m.display_name || ''}`.toLowerCase()
|
|
|
|
|
|
return keywords.every(keyword => searchableText.includes(keyword))
|
|
|
|
|
|
})
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return result
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 分页计算
|
|
|
|
|
|
const paginatedModels = computed(() => {
|
|
|
|
|
|
const start = (currentPage.value - 1) * pageSize.value
|
|
|
|
|
|
const end = start + pageSize.value
|
|
|
|
|
|
return filteredModels.value.slice(start, end)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-02-28 11:44:08 +08:00
|
|
|
|
// 搜索变化时重置到第一页
|
|
|
|
|
|
watch(searchQuery, () => {
|
2025-12-10 20:52:44 +08:00
|
|
|
|
currentPage.value = 1
|
2026-02-28 11:44:08 +08:00
|
|
|
|
})
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
async function loadModels() {
|
|
|
|
|
|
loading.value = true
|
|
|
|
|
|
try {
|
2026-01-28 01:01:05 +08:00
|
|
|
|
// 使用用户认证端点,只获取用户有权限使用的模型
|
|
|
|
|
|
const response = await meApi.getAvailableModels({ limit: 1000 })
|
|
|
|
|
|
models.value = (response.models || []) as PublicGlobalModel[]
|
2026-02-22 00:43:41 +08:00
|
|
|
|
} catch (err: unknown) {
|
2025-12-12 20:22:15 +08:00
|
|
|
|
log.error('加载模型失败:', err)
|
2026-02-22 00:43:41 +08:00
|
|
|
|
showError(parseApiError(err, ''), '加载模型失败')
|
2025-12-10 20:52:44 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
loading.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function refreshData() {
|
2026-02-28 11:44:08 +08:00
|
|
|
|
await loadModels()
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 从 PublicGlobalModel 的 default_tiered_pricing 获取第一阶梯价格
|
|
|
|
|
|
function getFirstTierPrice(model: PublicGlobalModel, type: 'input' | 'output'): number | null {
|
|
|
|
|
|
const tiered = model.default_tiered_pricing
|
|
|
|
|
|
if (!tiered?.tiers?.length) return null
|
|
|
|
|
|
const firstTier = tiered.tiers[0]
|
|
|
|
|
|
if (type === 'input') {
|
|
|
|
|
|
return firstTier.input_price_per_1m || null
|
|
|
|
|
|
}
|
|
|
|
|
|
return firstTier.output_price_per_1m || null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 检测是否有阶梯计费(多于一个阶梯)
|
|
|
|
|
|
function hasTieredPricing(model: PublicGlobalModel): boolean {
|
|
|
|
|
|
const tiered = model.default_tiered_pricing
|
|
|
|
|
|
return (tiered?.tiers?.length || 0) > 1
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
|
refreshData()
|
|
|
|
|
|
})
|
|
|
|
|
|
</script>
|