2025-12-10 20:52:44 +08:00
|
|
|
|
<template>
|
|
|
|
|
|
<Card class="overflow-hidden">
|
|
|
|
|
|
<!-- 标题头部 -->
|
|
|
|
|
|
<div class="p-4 border-b border-border/60">
|
|
|
|
|
|
<div class="flex items-center justify-between">
|
|
|
|
|
|
<h3 class="text-sm font-semibold flex items-center gap-2">
|
|
|
|
|
|
模型列表
|
|
|
|
|
|
</h3>
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
class="h-8"
|
|
|
|
|
|
@click="openBatchAssignDialog"
|
|
|
|
|
|
>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<Layers class="w-3.5 h-3.5 mr-1.5" />
|
|
|
|
|
|
关联模型
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 加载状态 -->
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="loading"
|
|
|
|
|
|
class="flex items-center justify-center py-12"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 模型列表 -->
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-else-if="models.length > 0"
|
|
|
|
|
|
class="overflow-hidden"
|
|
|
|
|
|
>
|
2026-02-09 02:56:21 +08:00
|
|
|
|
<table
|
|
|
|
|
|
ref="modelsListRef"
|
|
|
|
|
|
class="w-full text-sm table-fixed"
|
|
|
|
|
|
>
|
2026-01-28 13:17:50 +08:00
|
|
|
|
<colgroup>
|
|
|
|
|
|
<col class="w-[45%]">
|
|
|
|
|
|
<col class="w-[30%]">
|
|
|
|
|
|
<col class="w-[25%]">
|
|
|
|
|
|
</colgroup>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<tbody>
|
|
|
|
|
|
<tr
|
2026-02-09 02:56:21 +08:00
|
|
|
|
v-for="model in paginatedModels"
|
2025-12-10 20:52:44 +08:00
|
|
|
|
:key="model.id"
|
|
|
|
|
|
class="border-b border-border/40 last:border-b-0 hover:bg-muted/30 transition-colors"
|
|
|
|
|
|
>
|
|
|
|
|
|
<td class="align-top px-4 py-3">
|
|
|
|
|
|
<div class="flex items-center gap-2.5">
|
|
|
|
|
|
<!-- 状态指示灯 -->
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="w-2 h-2 rounded-full shrink-0"
|
|
|
|
|
|
:class="getStatusIndicatorClass(model)"
|
|
|
|
|
|
:title="getStatusTitle(model)"
|
2025-12-12 16:15:36 +08:00
|
|
|
|
/>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<!-- 模型信息 -->
|
|
|
|
|
|
<div class="text-left flex-1 min-w-0">
|
2026-01-14 13:12:19 +08:00
|
|
|
|
<div class="flex items-center gap-1.5">
|
|
|
|
|
|
<span class="font-semibold text-sm">
|
|
|
|
|
|
{{ model.global_model_display_name || model.provider_model_name }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<div class="text-xs text-muted-foreground mt-1 flex items-center gap-1">
|
|
|
|
|
|
<span class="font-mono truncate">{{ model.provider_model_name }}</span>
|
|
|
|
|
|
<button
|
|
|
|
|
|
class="p-0.5 hover:bg-muted rounded transition-colors shrink-0"
|
|
|
|
|
|
title="复制模型 ID"
|
2025-12-12 16:15:36 +08:00
|
|
|
|
@click.stop="copyModelId(model.provider_model_name)"
|
2025-12-10 20:52:44 +08:00
|
|
|
|
>
|
|
|
|
|
|
<Copy class="w-3 h-3" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td class="align-top px-4 py-3 text-xs whitespace-nowrap">
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<div
|
|
|
|
|
|
class="grid gap-1"
|
|
|
|
|
|
style="grid-template-columns: auto 1fr;"
|
|
|
|
|
|
>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<!-- 按 Token 计费 -->
|
|
|
|
|
|
<template v-if="hasTokenPricing(model)">
|
2026-01-12 09:10:40 +08:00
|
|
|
|
<span class="text-muted-foreground text-right">入/出:</span>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<span class="font-mono font-semibold">
|
|
|
|
|
|
${{ formatPrice(model.effective_input_price) }}/${{ formatPrice(model.effective_output_price) }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<template v-if="getEffectiveCachePrice(model, 'creation') > 0 || getEffectiveCachePrice(model, 'read') > 0">
|
|
|
|
|
|
<span class="text-muted-foreground text-right">缓存:</span>
|
|
|
|
|
|
<span class="font-mono font-semibold">
|
|
|
|
|
|
${{ formatPrice(getEffectiveCachePrice(model, 'creation')) }}/${{ formatPrice(getEffectiveCachePrice(model, 'read')) }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<!-- 1h 缓存价格 -->
|
|
|
|
|
|
<template v-if="get1hCachePrice(model) > 0">
|
|
|
|
|
|
<span class="text-muted-foreground text-right">1h 缓存:</span>
|
|
|
|
|
|
<span class="font-mono font-semibold">
|
|
|
|
|
|
${{ formatPrice(get1hCachePrice(model)) }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<!-- 按次计费 -->
|
|
|
|
|
|
<template v-if="hasRequestPricing(model)">
|
|
|
|
|
|
<span class="text-muted-foreground text-right">按次:</span>
|
|
|
|
|
|
<span class="font-mono font-semibold">
|
|
|
|
|
|
${{ formatPrice(model.effective_price_per_request ?? model.price_per_request) }}/次
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</template>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
<!-- 视频费用计费 -->
|
|
|
|
|
|
<template v-if="hasVideoPricing(model)">
|
|
|
|
|
|
<span class="text-muted-foreground text-right">视频:</span>
|
|
|
|
|
|
<span
|
|
|
|
|
|
class="font-mono font-semibold"
|
|
|
|
|
|
:title="getVideoPricingTooltip(model)"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ getVideoPricingDisplay(model) }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</template>
|
2026-01-13 20:08:47 +08:00
|
|
|
|
<!-- 无计费配置 -->
|
2026-02-03 18:48:39 +08:00
|
|
|
|
<template v-if="!hasTokenPricing(model) && !hasRequestPricing(model) && !hasVideoPricing(model)">
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<span class="text-muted-foreground">—</span>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td class="align-top px-4 py-3">
|
2026-01-28 13:17:50 +08:00
|
|
|
|
<div class="flex justify-end gap-1">
|
2026-01-13 20:08:47 +08:00
|
|
|
|
<!-- 测试按钮(支持多格式选择) -->
|
2026-01-22 20:01:55 +08:00
|
|
|
|
<DropdownMenu
|
|
|
|
|
|
v-if="availableApiFormats.length > 1"
|
|
|
|
|
|
v-model:open="formatMenuOpen[model.id]"
|
|
|
|
|
|
>
|
|
|
|
|
|
<DropdownMenuTrigger as-child>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-8 w-8"
|
|
|
|
|
|
title="测试模型"
|
|
|
|
|
|
:disabled="testingModelId === model.id"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Loader2
|
|
|
|
|
|
v-if="testingModelId === model.id"
|
|
|
|
|
|
class="w-3.5 h-3.5 animate-spin"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Play
|
|
|
|
|
|
v-else
|
|
|
|
|
|
class="w-3.5 h-3.5"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</DropdownMenuTrigger>
|
|
|
|
|
|
<DropdownMenuContent align="start">
|
|
|
|
|
|
<DropdownMenuItem
|
2026-01-13 20:08:47 +08:00
|
|
|
|
v-for="fmt in availableApiFormats"
|
|
|
|
|
|
:key="fmt"
|
2026-01-22 20:01:55 +08:00
|
|
|
|
@select="testModelConnection(model, fmt)"
|
2026-01-13 20:08:47 +08:00
|
|
|
|
>
|
|
|
|
|
|
{{ fmt }}
|
2026-01-22 20:01:55 +08:00
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
|
</DropdownMenuContent>
|
|
|
|
|
|
</DropdownMenu>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
v-else
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-8 w-8"
|
|
|
|
|
|
title="测试模型"
|
|
|
|
|
|
:disabled="testingModelId === model.id"
|
|
|
|
|
|
@click="testModelConnection(model, availableApiFormats[0])"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Loader2
|
|
|
|
|
|
v-if="testingModelId === model.id"
|
|
|
|
|
|
class="w-3.5 h-3.5 animate-spin"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Play
|
|
|
|
|
|
v-else
|
|
|
|
|
|
class="w-3.5 h-3.5"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</Button>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-8 w-8"
|
|
|
|
|
|
title="编辑"
|
2025-12-12 16:15:36 +08:00
|
|
|
|
@click="editModel(model)"
|
2025-12-10 20:52:44 +08:00
|
|
|
|
>
|
|
|
|
|
|
<Edit class="w-3.5 h-3.5" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-8 w-8"
|
|
|
|
|
|
:disabled="togglingModelId === model.id"
|
|
|
|
|
|
:title="model.is_active ? '点击停用' : '点击启用'"
|
2025-12-12 16:15:36 +08:00
|
|
|
|
@click="toggleModelActive(model)"
|
2025-12-10 20:52:44 +08:00
|
|
|
|
>
|
|
|
|
|
|
<Power class="w-3.5 h-3.5" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
2026-02-09 02:56:21 +08:00
|
|
|
|
<!-- 分页控制 -->
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="shouldPaginateModels"
|
|
|
|
|
|
class="px-4 py-2 border-t border-border/40 flex items-center justify-between text-xs text-muted-foreground"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span>共 {{ sortedModels.length }} 个模型</span>
|
|
|
|
|
|
<div class="flex items-center gap-1.5">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
class="h-6 px-2 text-xs"
|
|
|
|
|
|
:disabled="currentModelPage <= 1"
|
|
|
|
|
|
@click="currentModelPage--"
|
|
|
|
|
|
>
|
|
|
|
|
|
‹
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<span class="tabular-nums">{{ currentModelPage }} / {{ totalModelPages }}</span>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
class="h-6 px-2 text-xs"
|
|
|
|
|
|
:disabled="currentModelPage >= totalModelPages"
|
|
|
|
|
|
@click="currentModelPage++"
|
|
|
|
|
|
>
|
|
|
|
|
|
›
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 空状态 -->
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-else
|
|
|
|
|
|
class="p-8 text-center text-muted-foreground"
|
|
|
|
|
|
>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<Box class="w-12 h-12 mx-auto mb-3 opacity-50" />
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<p class="text-sm">
|
|
|
|
|
|
暂无模型
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<p class="text-xs mt-1">
|
|
|
|
|
|
请前往"模型目录"页面添加模型
|
|
|
|
|
|
</p>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
2026-02-10 23:47:47 +08:00
|
|
|
|
import { ref, computed } from 'vue'
|
2026-02-09 02:56:21 +08:00
|
|
|
|
import { useSmartPagination } from '@/composables/useSmartPagination'
|
2026-01-28 13:17:50 +08:00
|
|
|
|
import { Box, Edit, Layers, Power, Copy, Loader2, Play } from 'lucide-vue-next'
|
2025-12-10 20:52:44 +08:00
|
|
|
|
import Card from '@/components/ui/card.vue'
|
|
|
|
|
|
import Button from '@/components/ui/button.vue'
|
2026-01-22 20:01:55 +08:00
|
|
|
|
import {
|
|
|
|
|
|
DropdownMenu,
|
|
|
|
|
|
DropdownMenuTrigger,
|
|
|
|
|
|
DropdownMenuContent,
|
|
|
|
|
|
DropdownMenuItem
|
|
|
|
|
|
} from '@/components/ui'
|
2025-12-10 20:52:44 +08:00
|
|
|
|
import { useToast } from '@/composables/useToast'
|
2025-12-28 20:41:52 +08:00
|
|
|
|
import { useClipboard } from '@/composables/useClipboard'
|
2026-02-03 18:48:39 +08:00
|
|
|
|
import { sortResolutionEntries } from '@/utils/form'
|
2026-01-15 17:26:52 +08:00
|
|
|
|
import {
|
|
|
|
|
|
testModel,
|
|
|
|
|
|
type Model,
|
|
|
|
|
|
type ProviderMappingPreviewResponse
|
|
|
|
|
|
} from '@/api/endpoints'
|
2025-12-10 20:52:44 +08:00
|
|
|
|
import { updateModel } from '@/api/endpoints/models'
|
2026-02-22 00:43:41 +08:00
|
|
|
|
import { parseApiError, parseTestModelError } from '@/utils/errorParser'
|
|
|
|
|
|
import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
|
2026-01-13 20:08:47 +08:00
|
|
|
|
|
|
|
|
|
|
interface Endpoint {
|
|
|
|
|
|
id: string
|
|
|
|
|
|
api_format: string
|
|
|
|
|
|
is_active: boolean
|
|
|
|
|
|
active_keys?: number
|
|
|
|
|
|
}
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
const props = defineProps<{
|
2026-02-22 00:43:41 +08:00
|
|
|
|
provider: ProviderWithEndpointsSummary
|
2026-01-13 20:08:47 +08:00
|
|
|
|
endpoints?: Endpoint[]
|
2026-02-10 23:47:47 +08:00
|
|
|
|
models?: Model[]
|
|
|
|
|
|
mappingPreview?: ProviderMappingPreviewResponse | null
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}>()
|
|
|
|
|
|
|
|
|
|
|
|
const emit = defineEmits<{
|
2025-12-12 20:22:27 +08:00
|
|
|
|
'editModel': [model: Model]
|
|
|
|
|
|
'batchAssign': []
|
2026-02-10 23:47:47 +08:00
|
|
|
|
'refresh': []
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}>()
|
|
|
|
|
|
|
|
|
|
|
|
const { error: showError, success: showSuccess } = useToast()
|
2025-12-28 20:41:52 +08:00
|
|
|
|
const { copyToClipboard } = useClipboard()
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
// 状态
|
|
|
|
|
|
const loading = ref(false)
|
2026-02-10 23:47:47 +08:00
|
|
|
|
const localModels = ref<Model[]>([])
|
|
|
|
|
|
const localMappingPreview = ref<ProviderMappingPreviewResponse | null>(null)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
const togglingModelId = ref<string | null>(null)
|
2026-01-13 20:08:47 +08:00
|
|
|
|
const testingModelId = ref<string | null>(null)
|
2026-01-22 20:01:55 +08:00
|
|
|
|
const formatMenuOpen = ref<Record<string, boolean>>({})
|
2026-01-13 20:08:47 +08:00
|
|
|
|
|
2026-02-10 23:47:47 +08:00
|
|
|
|
// 使用 props 传入的数据,或使用本地数据
|
|
|
|
|
|
const models = computed(() => props.models ?? localModels.value)
|
|
|
|
|
|
const mappingPreview = computed(() => props.mappingPreview ?? localMappingPreview.value)
|
|
|
|
|
|
|
2026-01-13 20:08:47 +08:00
|
|
|
|
// 获取可用的 API 格式(有活跃端点且有活跃 Key)
|
|
|
|
|
|
const availableApiFormats = computed(() => {
|
|
|
|
|
|
if (!props.endpoints) return []
|
|
|
|
|
|
return props.endpoints
|
|
|
|
|
|
.filter(ep => ep.is_active && (ep.active_keys ?? 0) > 0)
|
|
|
|
|
|
.map(ep => ep.api_format)
|
|
|
|
|
|
})
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
// 按名称排序的模型列表
|
|
|
|
|
|
const sortedModels = computed(() => {
|
|
|
|
|
|
return [...models.value].sort((a, b) => {
|
|
|
|
|
|
const nameA = (a.global_model_display_name || a.provider_model_name || '').toLowerCase()
|
|
|
|
|
|
const nameB = (b.global_model_display_name || b.provider_model_name || '').toLowerCase()
|
|
|
|
|
|
return nameA.localeCompare(nameB)
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-02-09 02:56:21 +08:00
|
|
|
|
// ===== 模型列表智能分页 =====
|
|
|
|
|
|
const modelsListRef = ref<HTMLElement | null>(null)
|
|
|
|
|
|
const {
|
|
|
|
|
|
currentPage: currentModelPage,
|
|
|
|
|
|
totalPages: totalModelPages,
|
|
|
|
|
|
shouldPaginate: shouldPaginateModels,
|
|
|
|
|
|
paginatedItems: paginatedModels,
|
|
|
|
|
|
} = useSmartPagination(sortedModels, modelsListRef)
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
// 复制模型 ID 到剪贴板
|
|
|
|
|
|
async function copyModelId(modelId: string) {
|
2025-12-28 20:41:52 +08:00
|
|
|
|
await copyToClipboard(modelId)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-10 23:47:47 +08:00
|
|
|
|
// 刷新数据(通知父组件刷新)
|
|
|
|
|
|
function refresh() {
|
|
|
|
|
|
emit('refresh')
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 格式化价格显示
|
|
|
|
|
|
function formatPrice(price: number | null | undefined): string {
|
|
|
|
|
|
if (price === null || price === undefined) return '-'
|
|
|
|
|
|
// 如果是整数或小数点后只有1-2位,直接显示
|
|
|
|
|
|
if (price >= 0.01 || price === 0) {
|
|
|
|
|
|
return price.toFixed(2)
|
|
|
|
|
|
}
|
|
|
|
|
|
// 对于非常小的数字,使用科学计数法
|
|
|
|
|
|
if (price < 0.0001) {
|
|
|
|
|
|
return price.toExponential(2)
|
|
|
|
|
|
}
|
|
|
|
|
|
// 其他情况保留4位小数
|
|
|
|
|
|
return price.toFixed(4)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 检查是否有按 Token 计费
|
|
|
|
|
|
function hasTokenPricing(model: Model): boolean {
|
|
|
|
|
|
const inputPrice = model.effective_input_price
|
|
|
|
|
|
const outputPrice = model.effective_output_price
|
|
|
|
|
|
return (inputPrice != null && inputPrice > 0) || (outputPrice != null && outputPrice > 0)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取有效的缓存价格(从 effective_tiered_pricing 或 tiered_pricing 中提取)
|
|
|
|
|
|
function getEffectiveCachePrice(model: Model, type: 'creation' | 'read'): number {
|
|
|
|
|
|
const tiered = model.effective_tiered_pricing || model.tiered_pricing
|
|
|
|
|
|
if (!tiered?.tiers?.length) return 0
|
|
|
|
|
|
const firstTier = tiered.tiers[0]
|
|
|
|
|
|
if (type === 'creation') {
|
|
|
|
|
|
return firstTier.cache_creation_price_per_1m || 0
|
|
|
|
|
|
}
|
|
|
|
|
|
return firstTier.cache_read_price_per_1m || 0
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取 1h 缓存价格
|
|
|
|
|
|
function get1hCachePrice(model: Model): number {
|
|
|
|
|
|
const tiered = model.effective_tiered_pricing || model.tiered_pricing
|
|
|
|
|
|
if (!tiered?.tiers?.length) return 0
|
|
|
|
|
|
const firstTier = tiered.tiers[0]
|
|
|
|
|
|
const ttl1h = firstTier.cache_ttl_pricing?.find(t => t.ttl_minutes === 60)
|
|
|
|
|
|
return ttl1h?.cache_creation_price_per_1m || 0
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 检查是否有按次计费
|
|
|
|
|
|
function hasRequestPricing(model: Model): boolean {
|
|
|
|
|
|
const requestPrice = model.effective_price_per_request ?? model.price_per_request
|
|
|
|
|
|
return requestPrice != null && requestPrice > 0
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-03 18:48:39 +08:00
|
|
|
|
// 检查是否有视频分辨率计费配置
|
|
|
|
|
|
function hasVideoPricing(model: Model): boolean {
|
|
|
|
|
|
const priceByResolution = model.effective_config?.billing?.video?.price_per_second_by_resolution
|
|
|
|
|
|
|| model.config?.billing?.video?.price_per_second_by_resolution
|
|
|
|
|
|
return priceByResolution && typeof priceByResolution === 'object' && Object.keys(priceByResolution).length > 0
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取视频计费的显示文本
|
|
|
|
|
|
function getVideoPricingDisplay(model: Model): string {
|
|
|
|
|
|
const priceByResolution = model.effective_config?.billing?.video?.price_per_second_by_resolution
|
|
|
|
|
|
|| model.config?.billing?.video?.price_per_second_by_resolution
|
|
|
|
|
|
if (!priceByResolution || typeof priceByResolution !== 'object') return ''
|
|
|
|
|
|
const entries = sortResolutionEntries(Object.entries(priceByResolution))
|
|
|
|
|
|
if (entries.length === 0) return ''
|
|
|
|
|
|
// 获取最低分辨率和价格
|
|
|
|
|
|
const [firstRes, firstPrice] = entries[0]
|
|
|
|
|
|
const priceStr = `${firstRes} $${(firstPrice as number).toFixed(2)}/s`
|
|
|
|
|
|
if (entries.length > 1) {
|
|
|
|
|
|
return `${priceStr} [${entries.length}种]`
|
|
|
|
|
|
}
|
|
|
|
|
|
return priceStr
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取视频计费详情的 tooltip
|
|
|
|
|
|
function getVideoPricingTooltip(model: Model): string {
|
|
|
|
|
|
const priceByResolution = model.effective_config?.billing?.video?.price_per_second_by_resolution
|
|
|
|
|
|
|| model.config?.billing?.video?.price_per_second_by_resolution
|
|
|
|
|
|
if (!priceByResolution || typeof priceByResolution !== 'object') return ''
|
|
|
|
|
|
const entries = sortResolutionEntries(Object.entries(priceByResolution))
|
|
|
|
|
|
return entries.map(([res, price]) => `${res}: $${(price as number).toFixed(4)}/s`).join('\n')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
// 获取状态指示灯样式
|
|
|
|
|
|
function getStatusIndicatorClass(model: Model): string {
|
|
|
|
|
|
if (!model.is_active) {
|
|
|
|
|
|
// 已停用 - 灰色
|
|
|
|
|
|
return 'bg-gray-400 dark:bg-gray-600'
|
|
|
|
|
|
}
|
|
|
|
|
|
if (model.is_available) {
|
|
|
|
|
|
// 活跃且可用 - 绿色
|
|
|
|
|
|
return 'bg-green-500 dark:bg-green-400'
|
|
|
|
|
|
}
|
|
|
|
|
|
// 活跃但不可用 - 红色
|
|
|
|
|
|
return 'bg-red-500 dark:bg-red-400'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取状态提示文本
|
|
|
|
|
|
function getStatusTitle(model: Model): string {
|
|
|
|
|
|
if (!model.is_active) {
|
|
|
|
|
|
return '已停用'
|
|
|
|
|
|
}
|
|
|
|
|
|
if (model.is_available) {
|
|
|
|
|
|
return '活跃且可用'
|
|
|
|
|
|
}
|
|
|
|
|
|
return '活跃但不可用'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-13 20:08:47 +08:00
|
|
|
|
// 编辑模型
|
2025-12-10 20:52:44 +08:00
|
|
|
|
function editModel(model: Model) {
|
2025-12-12 20:22:27 +08:00
|
|
|
|
emit('editModel', model)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 打开批量关联对话框
|
|
|
|
|
|
function openBatchAssignDialog() {
|
2025-12-12 20:22:27 +08:00
|
|
|
|
emit('batchAssign')
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 切换模型启用状态
|
|
|
|
|
|
async function toggleModelActive(model: Model) {
|
|
|
|
|
|
if (togglingModelId.value) return
|
|
|
|
|
|
|
|
|
|
|
|
togglingModelId.value = model.id
|
|
|
|
|
|
try {
|
|
|
|
|
|
const newStatus = !model.is_active
|
|
|
|
|
|
await updateModel(props.provider.id, model.id, { is_active: newStatus })
|
|
|
|
|
|
model.is_active = newStatus
|
|
|
|
|
|
showSuccess(newStatus ? '模型已启用' : '模型已停用')
|
2026-02-22 00:43:41 +08:00
|
|
|
|
} catch (err: unknown) {
|
|
|
|
|
|
showError(parseApiError(err, '操作失败'), '错误')
|
2025-12-10 20:52:44 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
togglingModelId.value = null
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-15 17:26:52 +08:00
|
|
|
|
// 查找模型的正则映射信息(返回第一个匹配的活跃 key 和映射名称)
|
|
|
|
|
|
function findRegexMapping(model: Model): { keyId: string; mappedName: string } | null {
|
|
|
|
|
|
if (!mappingPreview.value) return null
|
|
|
|
|
|
|
|
|
|
|
|
// 在映射预览中查找该模型的全局模型 ID
|
|
|
|
|
|
const globalModelId = model.global_model_id
|
|
|
|
|
|
if (!globalModelId) return null
|
|
|
|
|
|
|
|
|
|
|
|
for (const keyInfo of mappingPreview.value.keys) {
|
|
|
|
|
|
// 跳过未激活的 key
|
|
|
|
|
|
if (!keyInfo.is_active) continue
|
|
|
|
|
|
|
|
|
|
|
|
for (const gm of keyInfo.matching_global_models) {
|
|
|
|
|
|
if (gm.global_model_id === globalModelId && gm.matched_models.length > 0) {
|
|
|
|
|
|
// 返回第一个匹配的映射名称
|
|
|
|
|
|
return {
|
|
|
|
|
|
keyId: keyInfo.key_id,
|
|
|
|
|
|
mappedName: gm.matched_models[0].allowed_model
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-13 20:08:47 +08:00
|
|
|
|
// 测试模型连接性
|
|
|
|
|
|
async function testModelConnection(model: Model, apiFormat?: string) {
|
|
|
|
|
|
if (testingModelId.value) return
|
|
|
|
|
|
|
|
|
|
|
|
testingModelId.value = model.id
|
2026-01-22 20:01:55 +08:00
|
|
|
|
formatMenuOpen.value[model.id] = false
|
2026-01-13 20:08:47 +08:00
|
|
|
|
try {
|
2026-01-15 17:26:52 +08:00
|
|
|
|
// 检查是否有正则映射,如果有则使用映射名称和指定 key
|
|
|
|
|
|
const regexMapping = findRegexMapping(model)
|
|
|
|
|
|
const modelName = regexMapping?.mappedName || model.provider_model_name
|
|
|
|
|
|
const apiKeyId = regexMapping?.keyId
|
|
|
|
|
|
|
2026-01-13 20:08:47 +08:00
|
|
|
|
const result = await testModel({
|
|
|
|
|
|
provider_id: props.provider.id,
|
2026-01-15 17:26:52 +08:00
|
|
|
|
model_name: modelName,
|
2026-01-13 20:08:47 +08:00
|
|
|
|
message: "hello",
|
2026-01-15 17:26:52 +08:00
|
|
|
|
api_format: apiFormat,
|
|
|
|
|
|
api_key_id: apiKeyId
|
2026-01-13 20:08:47 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
if (result.success) {
|
|
|
|
|
|
// 根据响应内容显示不同的成功消息
|
|
|
|
|
|
if (result.data?.response?.choices?.[0]?.message?.content) {
|
|
|
|
|
|
const content = result.data.response.choices[0].message.content
|
|
|
|
|
|
showSuccess(`测试成功,响应: ${content.substring(0, 100)}${content.length > 100 ? '...' : ''}`)
|
|
|
|
|
|
} else if (result.data?.content_preview) {
|
|
|
|
|
|
showSuccess(`流式测试成功,预览: ${result.data.content_preview}`)
|
|
|
|
|
|
} else {
|
2026-01-15 17:26:52 +08:00
|
|
|
|
showSuccess(`模型 "${modelName}" 测试成功`)
|
2026-01-13 20:08:47 +08:00
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
showError(`模型测试失败: ${parseTestModelError(result)}`)
|
|
|
|
|
|
}
|
2026-02-22 00:43:41 +08:00
|
|
|
|
} catch (err: unknown) {
|
|
|
|
|
|
showError(`模型测试失败: ${parseApiError(err, '测试请求失败')}`)
|
2026-01-13 20:08:47 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
testingModelId.value = null
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-15 17:26:52 +08:00
|
|
|
|
// 暴露给父组件
|
|
|
|
|
|
defineExpose({
|
2026-02-10 23:47:47 +08:00
|
|
|
|
reload: refresh
|
2026-01-15 17:26:52 +08:00
|
|
|
|
})
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</script>
|