Files
Aether/frontend/src/views/admin/ModelManagement.vue
T

2127 lines
78 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="flex flex-col h-[calc(100vh-12rem)]">
<!-- 主内容区 -->
<div class="flex-1 flex flex-col min-w-0">
<!-- 模型列表 -->
<Card class="overflow-hidden">
<!-- 标题和操作栏 -->
<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">
<!-- 左侧标题 -->
<div class="flex min-w-0 items-baseline gap-2">
<h3 class="text-sm sm:text-base font-semibold shrink-0">
模型管理
</h3>
<span
v-if="selectedBatchManageModelIds.size > 0"
class="text-xs font-medium text-primary"
aria-live="polite"
>
{{ t('models.management.selectedCount', { count: selectedBatchManageModelIds.size }) }}
</span>
</div>
<!-- 右侧操作区 -->
<div class="flex flex-wrap items-center gap-2">
<!-- 搜索框 -->
<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/70 z-10 pointer-events-none" />
<Input
id="model-search"
v-model="searchQuery"
type="text"
placeholder="搜索模型名称..."
class="w-32 sm:w-44 pl-8 pr-3 h-8 text-sm bg-muted/30 border-border/50 focus:border-primary/50 transition-colors"
/>
</div>
<!-- 操作按钮 -->
<Button
variant="outline"
size="sm"
class="h-8 gap-1.5 px-2.5"
:title="selectedBatchManageModelIds.size > 0
? t('models.management.manageSelectedTitle', { count: selectedBatchManageModelIds.size })
: t('models.management.batch.title')"
@click="openBatchManageDialog"
>
<ListChecks class="w-3.5 h-3.5" />
<span>{{ selectedBatchManageModelIds.size > 0
? t('models.management.batchButtonSelected', { count: selectedBatchManageModelIds.size })
: t('models.management.batchButton') }}</span>
</Button>
<ExternalModelsAccessControl />
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="创建模型"
@click="openCreateModelDialog"
>
<Plus class="w-3.5 h-3.5" />
</Button>
<RefreshButton
:loading="loading"
@click="refreshData"
/>
</div>
</div>
</div>
<Table class="hidden xl:table">
<TableHeader>
<TableRow>
<TableHead class="w-[250px]">
<div class="flex items-center gap-2">
<Checkbox
class="h-3.5 w-3.5 shrink-0"
:checked="isCurrentModelPageFullySelected"
:indeterminate="isCurrentModelPagePartiallySelected"
:disabled="paginatedGlobalModels.length === 0 || loading"
:aria-label="t('models.management.selectCurrentPage')"
data-testid="model-select-page-desktop"
@update:checked="toggleCurrentModelPageSelection($event === true)"
/>
<span>模型名称</span>
</div>
</TableHead>
<TableHead class="w-[160px] text-center">
价格 ($/M)
</TableHead>
<TableHead class="w-[80px] text-center">
提供商
</TableHead>
<TableHead class="w-[80px] text-center">
调用次数
</TableHead>
<TableHead class="w-[70px]">
状态
</TableHead>
<TableHead class="w-[140px] text-center">
操作
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-if="loading">
<TableCell
colspan="6"
class="text-center py-8"
>
<Loader2 class="w-6 h-6 animate-spin mx-auto" />
</TableCell>
</TableRow>
<TableRow v-else-if="filteredGlobalModels.length === 0">
<TableCell
colspan="6"
class="text-center py-8 text-muted-foreground"
>
没有找到匹配的模型
</TableCell>
</TableRow>
<template v-else>
<TableRow
v-for="model in paginatedGlobalModels"
:key="model.id"
class="cursor-pointer hover:bg-muted/50 group"
:class="selectedBatchManageModelIds.has(model.id) ? 'bg-primary/5' : ''"
@mousedown="handleMouseDown"
@click="handleRowClick($event, model)"
>
<TableCell>
<div class="flex min-w-0 items-start gap-2">
<Checkbox
class="mt-0.5 h-3.5 w-3.5 shrink-0"
:checked="selectedBatchManageModelIds.has(model.id)"
:aria-label="t('models.management.selectModel', { name: model.display_name || model.name })"
:data-testid="`model-select-desktop-${model.id}`"
@click.stop
@update:checked="setBatchManageModelSelection(model.id, $event === true)"
/>
<div class="min-w-0 flex-1">
<div class="font-medium truncate">
{{ model.display_name }}
</div>
<div class="text-xs text-muted-foreground flex items-center gap-1">
<span class="truncate">{{ model.name }}</span>
<button
class="p-0.5 rounded hover:bg-muted transition-colors shrink-0"
title="复制模型 ID"
@click.stop="copyToClipboard(model.name)"
>
<Copy class="w-3 h-3" />
</button>
</div>
</div>
</div>
</TableCell>
<TableCell class="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>
<!-- 阶梯计费标记 -->
<span
v-if="hasTieredPricing(model)"
class="ml-1 text-muted-foreground"
title="阶梯计费"
>[阶梯]</span>
</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>
<!-- 视频费用计费 -->
<div v-if="hasVideoPricing(model)">
<span class="text-muted-foreground">视频:</span>
<span
class="font-mono ml-1"
:title="getVideoPricingTooltip(model)"
>{{ getVideoPricingDisplay(model) }}</span>
</div>
<!-- 无计费配置 -->
<div
v-if="!getFirstTierPrice(model, 'input') && !getFirstTierPrice(model, 'output') && !model.default_price_per_request && !hasVideoPricing(model)"
class="text-muted-foreground"
>
-
</div>
</div>
</TableCell>
<TableCell class="text-center">
<Badge variant="secondary">
{{ model.active_provider_count || 0 }}/{{ model.provider_count || 0 }}
</Badge>
</TableCell>
<TableCell class="text-center">
<span class="text-sm font-mono">{{ formatUsageCount(model.usage_count || 0) }}</span>
</TableCell>
<TableCell>
<Badge :variant="model.is_active ? 'default' : 'secondary'">
{{ model.is_active ? '活跃' : '停用' }}
</Badge>
</TableCell>
<TableCell>
<div class="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="查看详情"
@click.stop="selectModel(model)"
>
<Eye class="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="编辑模型"
@click.stop="editModel(model)"
>
<Edit class="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
:title="model.is_active ? '停用模型' : '启用模型'"
@click.stop="toggleModelStatus(model)"
>
<Power class="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="删除模型"
@click.stop="deleteModel(model)"
>
<Trash2 class="w-4 h-4" />
</Button>
</div>
</TableCell>
</TableRow>
</template>
</TableBody>
</Table>
<!-- 移动端卡片列表 -->
<div
v-if="!loading && paginatedGlobalModels.length > 0"
class="xl:hidden divide-y divide-border/40"
>
<div
v-for="model in paginatedGlobalModels"
:key="model.id"
class="p-4 space-y-3 hover:bg-muted/50 cursor-pointer transition-colors"
:class="selectedBatchManageModelIds.has(model.id) ? 'bg-primary/5' : ''"
@click="selectModel(model)"
>
<!-- 第一行名称 + 状态 + 操作 -->
<div class="flex items-start justify-between gap-3">
<div class="flex min-w-0 flex-1 items-start gap-2">
<Checkbox
class="mt-0.5 h-4 w-4 shrink-0"
:checked="selectedBatchManageModelIds.has(model.id)"
:aria-label="t('models.management.selectModel', { name: model.display_name || model.name })"
:data-testid="`model-select-mobile-${model.id}`"
@click.stop
@update:checked="setBatchManageModelSelection(model.id, $event === true)"
/>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="font-medium truncate">{{ model.display_name }}</span>
<Badge
:variant="model.is_active ? 'default' : 'secondary'"
class="text-xs shrink-0"
>
{{ model.is_active ? '活跃' : '停用' }}
</Badge>
</div>
<div class="text-xs text-muted-foreground flex items-center gap-1 mt-0.5">
<span class="font-mono 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>
</div>
<div
class="flex items-center gap-0.5 shrink-0"
@click.stop
>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
@click="editModel(model)"
>
<Edit class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
@click="toggleModelStatus(model)"
>
<Power class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
@click="deleteModel(model)"
>
<Trash2 class="w-3.5 h-3.5" />
</Button>
</div>
</div>
<!-- 第二行统计信息 -->
<div class="flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
<span>提供商 {{ model.active_provider_count || 0 }}/{{ model.provider_count || 0 }}</span>
<span>调用 {{ formatUsageCount(model.usage_count || 0) }}</span>
<span
v-if="getFirstTierPrice(model, 'input') || getFirstTierPrice(model, 'output')"
class="font-mono"
>
${{ getFirstTierPrice(model, 'input')?.toFixed(2) || '-' }}/${{ getFirstTierPrice(model, 'output')?.toFixed(2) || '-' }}
</span>
</div>
</div>
</div>
<!-- 分页 -->
<Pagination
v-if="!loading && totalGlobalModels > 0"
:current="catalogCurrentPage"
:total="totalGlobalModels"
:page-size="catalogPageSize"
cache-key="model-management-page-size"
@update:current="catalogCurrentPage = $event"
@update:page-size="catalogPageSize = $event"
/>
</Card>
</div>
<!-- 创建/编辑模型对话框 -->
<GlobalModelFormDialog
:open="createModelDialogOpen"
:model="editingModel"
@update:open="handleModelDialogUpdate"
@success="handleModelFormSuccess"
@edit-model="editModel"
@pricing-synced="handleModelPricingSynced"
/>
<!-- 模型详情抽屉 -->
<ModelDetailDrawer
ref="modelDetailDrawerRef"
:model="selectedModel"
:open="!!selectedModel"
:has-blocking-dialog-open="hasBlockingDialogOpen"
@update:open="handleDrawerOpenChange"
@edit-model="editModel"
@toggle-model-status="toggleModelStatus"
@add-provider="openAddProviderDialog"
@edit-provider="openEditProviderImplementation"
@delete-provider="confirmDeleteProviderImplementation"
@toggle-provider-status="toggleProviderStatus"
@refresh-model="refreshSelectedModel"
@link-provider="linkProviderToModel"
@link-providers="linkProvidersToModel"
/>
<!-- 批量添加关联提供商对话框 -->
<Dialog
:model-value="batchAddProvidersDialogOpen"
:title="selectedModel ? `批量管理提供商 - ${selectedModel.display_name}` : '批量管理提供商'"
description="选中的提供商将被关联到模型,取消选中将移除关联"
:icon="Server"
size="2xl"
@update:model-value="handleBatchAddProvidersDialogUpdate"
>
<template #default>
<div class="space-y-4">
<!-- 搜索栏 -->
<div class="flex items-center gap-2">
<div class="flex-1 relative">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
v-model="batchProviderSearchQuery"
placeholder="搜索提供商..."
class="pl-8 h-9"
/>
</div>
</div>
<!-- 单列提供商列表 -->
<div class="border rounded-lg overflow-hidden">
<div class="max-h-96 overflow-y-auto">
<div
v-if="loadingProviderOptions"
class="flex items-center justify-center py-12"
>
<Loader2 class="w-6 h-6 animate-spin text-primary" />
</div>
<template v-else>
<!-- 提供商组 -->
<div v-if="filteredBatchProviders.length > 0">
<div
class="flex items-center justify-between px-3 py-2 bg-muted sticky top-0 z-10"
>
<div class="flex items-center gap-2">
<span class="text-xs font-medium">提供商</span>
<span class="text-xs text-muted-foreground">({{ filteredBatchProviders.length }})</span>
</div>
<button
type="button"
class="text-xs text-primary hover:underline shrink-0"
@click="toggleAllBatchProviders"
>
{{ isAllBatchProvidersSelected ? '取消全选' : '全选' }}
</button>
</div>
<div class="space-y-1 p-2">
<div
v-for="provider in filteredBatchProviders"
:key="provider.id"
class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-muted cursor-pointer"
@click="toggleBatchProviderSelection(provider.id)"
>
<div
class="w-4 h-4 border rounded flex items-center justify-center shrink-0"
:class="isBatchProviderSelected(provider.id) ? 'bg-primary border-primary' : ''"
>
<Check
v-if="isBatchProviderSelected(provider.id)"
class="w-3 h-3 text-primary-foreground"
/>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate">
{{ provider.name }}
</p>
</div>
<Badge
:variant="provider.is_active ? 'outline' : 'secondary'"
:class="provider.is_active ? 'text-green-600 border-green-500/60' : ''"
class="text-xs shrink-0"
>
{{ provider.is_active ? '活跃' : '停用' }}
</Badge>
</div>
</div>
</div>
<!-- 空状态 -->
<div
v-if="filteredBatchProviders.length === 0"
class="flex flex-col items-center justify-center py-12 text-muted-foreground"
>
<Building2 class="w-10 h-10 mb-2 opacity-30" />
<p class="text-sm">
{{ batchProviderSearchQuery ? '无匹配结果' : '暂无可用提供商' }}
</p>
</div>
</template>
</div>
</div>
</div>
</template>
<template #footer>
<div class="flex items-center justify-between w-full">
<p class="text-xs text-muted-foreground">
{{ hasBatchProviderChanges ? `${batchProviderPendingChangesCount} 项更改待保存` : '' }}
</p>
<div class="flex items-center gap-2">
<Button
:disabled="!hasBatchProviderChanges || submittingBatchProviders"
@click="saveBatchProviderChanges"
>
<Loader2
v-if="submittingBatchProviders"
class="w-4 h-4 mr-1 animate-spin"
/>
{{ submittingBatchProviders ? '保存中...' : '保存' }}
</Button>
<Button
variant="outline"
@click="closeBatchAddProvidersDialog"
>
关闭
</Button>
</div>
</div>
</template>
</Dialog>
<!-- 编辑提供商模型对话框 -->
<ProviderModelFormDialog
:open="editProviderDialogOpen"
:provider-id="editingProvider?.id || ''"
:provider-name="editingProvider?.name || ''"
:editing-model="editingProviderModel"
@update:open="handleEditProviderDialogUpdate"
@saved="handleEditProviderSaved"
/>
<!-- 批量管理全局模型对话框 -->
<Dialog
:model-value="batchManageDialogOpen"
:title="t('models.management.batch.title')"
:description="t('models.management.batch.description')"
:icon="ListChecks"
size="2xl"
@update:model-value="batchManageDialogOpen = $event"
>
<template #default>
<div class="space-y-4">
<!-- 搜索与在线价格来源 -->
<div class="grid grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_220px]">
<div class="flex-1 relative">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
v-model="batchManageSearchQuery"
placeholder="搜索模型..."
class="pl-8 h-9"
/>
</div>
<Select
v-model="batchPricingProviderId"
:disabled="batchManageOnlineLoading"
>
<SelectTrigger class="h-9 text-xs">
<SelectValue :placeholder="t('models.management.batch.providerPlaceholder')" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="REMEMBERED_PRICING_PROVIDER_ID">
{{ t('models.management.batch.rememberedProvider') }}
</SelectItem>
<SelectItem
v-for="provider in batchPricingProviderOptions"
:key="provider.providerId"
:value="provider.providerId"
>
{{ provider.providerName }}
<span class="ml-1 text-muted-foreground">({{ provider.matchCount }})</span>
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- 快捷选中 -->
<div class="flex flex-wrap items-center gap-1.5">
<span class="text-xs text-muted-foreground mr-0.5">快捷选中:</span>
<button
v-for="shortcut in batchManageShortcuts"
:key="shortcut.label"
type="button"
class="rounded-md border border-border/60 px-2 py-1 text-xs transition-colors hover:bg-muted active:scale-[0.96]"
:class="shortcut.emphasis ? 'border-primary/30 bg-primary/5 text-primary' : ''"
:title="shortcut.description"
@click="applyBatchManageShortcut(shortcut.filter)"
>
{{ shortcut.label }} ({{ shortcut.count }})
</button>
</div>
<!-- 模型列表 -->
<div class="border rounded-lg overflow-hidden">
<div class="max-h-96 overflow-y-auto">
<div
v-if="batchManageLoading || batchManageOnlineLoading"
class="flex items-center justify-center py-12"
>
<Loader2 class="w-6 h-6 animate-spin text-primary" />
</div>
<template v-else-if="filteredBatchManageModels.length > 0">
<div
class="flex items-center justify-between px-3 py-2 bg-muted sticky top-0 z-10"
>
<div class="flex items-center gap-2">
<span class="text-xs font-medium">模型</span>
<span class="text-xs text-muted-foreground">({{ filteredBatchManageModels.length }})</span>
</div>
<button
type="button"
class="text-xs text-primary hover:underline shrink-0"
@click="toggleAllBatchManageModels"
>
{{ isAllBatchManageModelsSelected ? '取消全选' : '全选' }}
</button>
</div>
<div class="space-y-1 p-2">
<div
v-for="model in filteredBatchManageModels"
:key="model.id"
class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-muted cursor-pointer"
@click="toggleBatchManageModelSelection(model.id)"
>
<div
class="w-4 h-4 border rounded flex items-center justify-center shrink-0"
:class="selectedBatchManageModelIds.has(model.id) ? 'bg-primary border-primary' : ''"
>
<Check
v-if="selectedBatchManageModelIds.has(model.id)"
class="w-3 h-3 text-primary-foreground"
/>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate">
{{ model.display_name }}
</p>
<p class="text-xs text-muted-foreground truncate">
{{ model.name }}
</p>
</div>
<div class="flex items-center gap-2 shrink-0">
<span
class="max-w-28 truncate text-[10px] text-muted-foreground"
:title="getBatchPricingSourceLabel(model)"
>{{ getBatchPricingSourceLabel(model) }}</span>
<span
class="inline-flex items-center gap-1.5 text-[11px] font-medium"
:class="getBatchPricingStateClass(model)"
:title="getBatchPricingStateDescription(model)"
>
<span
class="h-1.5 w-1.5 rounded-full"
:class="getBatchPricingStateDotClass(model)"
/>
{{ getBatchPricingStateLabel(model) }}
</span>
<Badge
variant="secondary"
class="text-xs"
>
{{ model.active_provider_count || 0 }}/{{ model.provider_count || 0 }}
</Badge>
<Badge
:variant="model.is_active ? 'outline' : 'secondary'"
:class="model.is_active ? 'text-green-600 border-green-500/60' : ''"
class="text-xs"
>
{{ model.is_active ? '活跃' : '停用' }}
</Badge>
</div>
</div>
</div>
</template>
<!-- 空状态 -->
<div
v-else
class="flex flex-col items-center justify-center py-12 text-muted-foreground"
>
<p class="text-sm">
{{ batchManageSearchQuery ? '无匹配结果' : '暂无模型' }}
</p>
</div>
</div>
</div>
</div>
</template>
<template #footer>
<div class="flex w-full min-w-0 flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<p class="min-w-0 break-keep text-pretty text-xs leading-5 text-muted-foreground lg:flex-1">
{{ batchManageSelectionSummary }}
</p>
<div class="flex w-full min-w-0 flex-col gap-2 sm:flex-row lg:w-auto lg:shrink-0">
<Button
class="w-full whitespace-nowrap sm:flex-1 lg:w-auto lg:flex-none"
:disabled="selectedBatchPriceSyncEntries.length === 0 || submittingBatchManage"
@click="confirmBatchSyncPrices"
>
<Loader2
v-if="batchManageAction === 'sync-prices'"
class="w-4 h-4 mr-1 animate-spin"
/>
<RefreshCw
v-else
class="w-4 h-4 mr-1"
/>
{{ batchManageAction === 'sync-prices'
? t('models.management.batch.syncing')
: t('models.management.batch.syncButton', { count: selectedBatchPriceSyncEntries.length }) }}
</Button>
<Button
class="w-full whitespace-nowrap sm:flex-1 lg:w-auto lg:flex-none"
variant="destructive"
:disabled="selectedBatchManageModelIds.size === 0 || submittingBatchManage"
@click="confirmBatchDeleteModels"
>
<Loader2
v-if="batchManageAction === 'delete'"
class="w-4 h-4 mr-1 animate-spin"
/>
{{ batchManageAction === 'delete' ? '删除中...' : '删除选中' }}
</Button>
<Button
class="w-full whitespace-nowrap sm:flex-1 lg:w-auto lg:flex-none"
variant="outline"
@click="batchManageDialogOpen = false"
>
关闭
</Button>
</div>
</div>
</template>
</Dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onBeforeUnmount, watch } from 'vue'
import {
Plus,
Edit,
Trash2,
Loader2,
Eye,
Building2,
Search,
Power,
Copy,
Server,
Check,
ListChecks,
RefreshCw,
} from 'lucide-vue-next'
import ModelDetailDrawer from '@/features/models/components/ModelDetailDrawer.vue'
import GlobalModelFormDialog from '@/features/models/components/GlobalModelFormDialog.vue'
import ExternalModelsAccessControl from '@/features/models/components/ExternalModelsAccessControl.vue'
import ProviderModelFormDialog from '@/features/providers/components/ProviderModelFormDialog.vue'
import type { Model } from '@/api/endpoints'
import { useToast } from '@/composables/useToast'
import { useConfirm } from '@/composables/useConfirm'
import { useClipboard } from '@/composables/useClipboard'
import { useRowClick } from '@/composables/useRowClick'
import { useI18n } from '@/i18n'
import { parseApiError } from '@/utils/errorParser'
import { sortResolutionEntries } from '@/utils/form'
import {
Button,
Card,
Checkbox,
Input,
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
TableCell,
Badge,
Dialog,
Pagination,
RefreshButton,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui'
import {
listGlobalModels,
getGlobalModel,
updateGlobalModel,
deleteGlobalModel,
batchDeleteGlobalModels,
batchAssignToProviders,
getGlobalModelProviders,
type GlobalModelResponse,
} from '@/api/global-models'
import { log } from '@/utils/logger'
import { formatUsageCount } from '@/utils/format'
import { getProvidersSummary, type ProviderWithEndpointsSummary } from '@/api/endpoints/providers'
import { getModelsDevList, type ModelsDevModelItem } from '@/api/models-dev'
import {
buildGlobalModelPriceSyncPlan,
cloneTieredPricingConfig,
type GlobalModelPriceSyncEntry,
} from '@/features/models/components/global-model-form-helpers'
import {
getModelsDevPricingSourceFromConfig,
modelsDevPricingSourcesEqual,
useModelsDevPricingSources,
withModelsDevPricingSource,
} from '@/features/models/composables/useModelsDevPricingSources'
interface ModelProviderDisplay {
id: string
model_id?: string | null
name: string
provider_type: string
target_model: string
is_active: boolean
input_price_per_1m?: number | null
output_price_per_1m?: number | null
cache_creation_price_per_1m?: number | null
cache_read_price_per_1m?: number | null
cache_1h_creation_price_per_1m?: number | null
price_per_request?: number | null
effective_tiered_pricing?: unknown
tier_count?: number
supports_vision?: boolean | null
supports_function_calling?: boolean | null
supports_streaming?: boolean | null
supports_extended_thinking?: boolean | null
supports_embedding?: boolean | null
}
const { success, error: showError } = useToast()
const { t, locale } = useI18n()
const { copyToClipboard } = useClipboard()
const {
getSource: getModelsDevPricingSource,
getLocalSource: getLocalModelsDevPricingSource,
setSource: setModelsDevPricingSource,
} = useModelsDevPricingSources()
const REMEMBERED_PRICING_PROVIDER_ID = '__remembered__'
// 状态
const loading = ref(false)
const detailTab = ref('basic')
const searchQuery = ref('')
const selectedModel = ref<GlobalModelResponse | null>(null)
const modelDetailDrawerRef = ref<InstanceType<typeof ModelDetailDrawer> | null>(null)
const createModelDialogOpen = ref(false)
const editingModel = ref<GlobalModelResponse | null>(null)
// 数据
const globalModels = ref<GlobalModelResponse[]>([])
const totalGlobalModels = ref(0)
const batchManageModels = ref<GlobalModelResponse[]>([])
const batchManageLoading = ref(false)
const batchManageOnlineModels = ref<ModelsDevModelItem[]>([])
const batchManageOnlineLoading = ref(false)
const GLOBAL_MODELS_BATCH_FETCH_PAGE_SIZE = 1000
let globalModelsRequestId = 0
let modelSelectionRequestId = 0
let modelProvidersRequestId = 0
let batchManageModelsRequestId = 0
let batchManageOnlineModelsRequest: Promise<void> | null = null
const pricingSourceMigrationAttemptedIds = new Set<string>()
let providerOptionsRequest: Promise<void> | null = null
const GLOBAL_MODELS_LIST_CACHE_TTL_MS = 10 * 1000
// 模型目录分页
const catalogCurrentPage = ref(1)
const catalogPageSize = ref(20)
// 选中模型的详细数据
const selectedModelProviders = ref<ModelProviderDisplay[]>([])
const loadingModelProviders = ref(false)
// 批量添加关联提供商
const batchAddProvidersDialogOpen = ref(false)
const submittingBatchProviders = ref(false)
const providerOptions = ref<ProviderWithEndpointsSummary[]>([])
const loadingProviderOptions = ref(false)
// 单列勾选模式所需状态
const batchProviderSearchQuery = ref('')
const selectedBatchProviderIds = ref<Set<string>>(new Set())
const initialBatchProviderIds = ref<Set<string>>(new Set())
// 编辑提供商模型
const editProviderDialogOpen = ref(false)
const editingProvider = ref<ModelProviderDisplay | null>(null)
// 批量管理全局模型
const batchManageDialogOpen = ref(false)
const batchManageSearchQuery = ref('')
const batchPricingProviderId = ref(REMEMBERED_PRICING_PROVIDER_ID)
const selectedBatchManageModelIds = ref<Set<string>>(new Set())
const submittingBatchManage = ref(false)
const batchManageAction = ref<'sync-prices' | 'delete' | null>(null)
// 将 provider 数据转换为 Model 类型供 ProviderModelFormDialog 使用
const editingProviderModel = computed<Model | null>(() => {
if (!editingProvider.value) return null
const p = editingProvider.value
return {
id: p.model_id,
provider_id: p.id,
provider_model_name: p.target_model || '',
// 使用 API 返回的完整阶梯配置
tiered_pricing: null, // 原始配置为空(继承模式)
effective_tiered_pricing: p.effective_tiered_pricing, // 有效配置(含继承)
price_per_request: p.price_per_request,
supports_streaming: p.supports_streaming,
supports_vision: p.supports_vision,
supports_function_calling: p.supports_function_calling,
supports_extended_thinking: p.supports_extended_thinking,
supports_embedding: p.supports_embedding,
is_active: p.is_active,
global_model_display_name: selectedModel.value?.display_name,
} as Model
})
// 使用全局确认对话框
const { confirm, confirmDanger } = useConfirm()
// 从 GlobalModel 的 default_tiered_pricing 获取第一阶梯价格
function getFirstTierPrice(model: GlobalModelResponse, 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: GlobalModelResponse): boolean {
const tiered = model.default_tiered_pricing
return (tiered?.tiers?.length || 0) > 1
}
// 检测是否有视频分辨率计费配置
function hasVideoPricing(model: GlobalModelResponse): boolean {
const priceByResolution = model.config?.billing?.video?.price_per_second_by_resolution
return priceByResolution && typeof priceByResolution === 'object' && Object.keys(priceByResolution).length > 0
}
// 获取视频分辨率计费的数量
function _getVideoPricingCount(model: GlobalModelResponse): number {
const priceByResolution = model.config?.billing?.video?.price_per_second_by_resolution
if (!priceByResolution || typeof priceByResolution !== 'object') return 0
return Object.keys(priceByResolution).length
}
// 获取视频计费的显示文本(如:720p $0.1/s [多分辨率]
function getVideoPricingDisplay(model: GlobalModelResponse): string {
const priceByResolution = 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: GlobalModelResponse): string {
const priceByResolution = 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')
}
// 检测是否有对话框打开(防止误关闭抽屉)
const hasBlockingDialogOpen = computed(() =>
createModelDialogOpen.value ||
batchAddProvidersDialogOpen.value ||
editProviderDialogOpen.value ||
batchManageDialogOpen.value
)
// 能力筛选
const capabilityFilters = ref({
streaming: false,
imageGeneration: false,
vision: false,
toolUse: false,
extendedThinking: false,
})
// 过滤后的提供商列表
const filteredBatchProviders = computed(() => {
const query = batchProviderSearchQuery.value.toLowerCase().trim()
return providerOptions.value.filter(p => {
if (query && !p.name.toLowerCase().includes(query)) {
return false
}
return true
})
})
// 检查提供商是否已选中
function isBatchProviderSelected(providerId: string): boolean {
return selectedBatchProviderIds.value.has(providerId)
}
// 是否全选
const isAllBatchProvidersSelected = computed(() => {
if (filteredBatchProviders.value.length === 0) return false
return filteredBatchProviders.value.every(p => isBatchProviderSelected(p.id))
})
// 计算待添加的提供商
const batchProvidersToAdd = computed(() => {
const toAdd: string[] = []
for (const id of selectedBatchProviderIds.value) {
if (!initialBatchProviderIds.value.has(id)) {
toAdd.push(id)
}
}
return toAdd
})
// 计算待移除的提供商
const batchProvidersToRemove = computed(() => {
const toRemove: string[] = []
for (const id of initialBatchProviderIds.value) {
if (!selectedBatchProviderIds.value.has(id)) {
toRemove.push(id)
}
}
return toRemove
})
// 是否有变更
const hasBatchProviderChanges = computed(() => {
return batchProvidersToAdd.value.length > 0 || batchProvidersToRemove.value.length > 0
})
// 待变更数量
const batchProviderPendingChangesCount = computed(() => {
return batchProvidersToAdd.value.length + batchProvidersToRemove.value.length
})
// 切换提供商选择
function toggleBatchProviderSelection(providerId: string) {
if (selectedBatchProviderIds.value.has(providerId)) {
selectedBatchProviderIds.value.delete(providerId)
} else {
selectedBatchProviderIds.value.add(providerId)
}
selectedBatchProviderIds.value = new Set(selectedBatchProviderIds.value)
}
// 全选/取消全选
function toggleAllBatchProviders() {
const allIds = filteredBatchProviders.value.map(p => p.id)
if (isAllBatchProvidersSelected.value) {
for (const id of allIds) {
selectedBatchProviderIds.value.delete(id)
}
} else {
for (const id of allIds) {
selectedBatchProviderIds.value.add(id)
}
}
selectedBatchProviderIds.value = new Set(selectedBatchProviderIds.value)
}
// 同步初始选择状态
function syncBatchProviderSelection() {
const existingIds = new Set(selectedModelProviders.value.map((p) => p.id))
selectedBatchProviderIds.value = new Set(existingIds)
initialBatchProviderIds.value = new Set(existingIds)
}
// 保存变更
async function saveBatchProviderChanges() {
if (!hasBatchProviderChanges.value || submittingBatchProviders.value || !selectedModel.value) return
submittingBatchProviders.value = true
try {
let totalSuccess = 0
const allErrors: string[] = []
// 并行移除提供商
if (batchProvidersToRemove.value.length > 0) {
const { deleteModel } = await import('@/api/endpoints')
const removePromises = batchProvidersToRemove.value.map(async (providerId) => {
const existingProvider = selectedModelProviders.value.find((p) => p.id === providerId)
if (existingProvider && existingProvider.model_id) {
return deleteModel(providerId, existingProvider.model_id)
}
return null
})
const results = await Promise.allSettled(removePromises)
for (const result of results) {
if (result.status === 'fulfilled' && result.value !== null) {
totalSuccess++
} else if (result.status === 'rejected') {
allErrors.push(parseApiError(result.reason, '移除失败'))
}
}
}
// 添加提供商
if (batchProvidersToAdd.value.length > 0) {
const result = await batchAssignToProviders(selectedModel.value.id, {
provider_ids: batchProvidersToAdd.value,
create_models: true
})
totalSuccess += result.success.length
if (result.errors.length > 0) {
allErrors.push(...result.errors.map(e => e.error))
}
}
if (totalSuccess > 0) {
success(`成功处理 ${totalSuccess} 个提供商`)
}
if (allErrors.length > 0) {
showError(`部分操作失败: ${allErrors.slice(0, 3).join(', ')}${allErrors.length > 3 ? '...' : ''}`, '警告')
}
// 刷新数据并关闭对话框
await loadModelProviders(selectedModel.value.id)
await loadGlobalModels()
// 刷新路由数据
modelDetailDrawerRef.value?.refreshRoutingData?.()
closeBatchAddProvidersDialog()
} catch (err: unknown) {
showError(parseApiError(err, '保存失败'), '错误')
} finally {
submittingBatchProviders.value = false
}
}
// 筛选后的模型列表
const filteredGlobalModels = computed(() => {
let result = globalModels.value
// 能力筛选
if (capabilityFilters.value.streaming) {
result = result.filter(m => m.config?.streaming !== false)
}
if (capabilityFilters.value.imageGeneration) {
result = result.filter(m => m.config?.image_generation === true)
}
if (capabilityFilters.value.vision) {
result = result.filter(m => m.config?.vision === true)
}
if (capabilityFilters.value.toolUse) {
result = result.filter(m => m.config?.function_calling === true)
}
if (capabilityFilters.value.extendedThinking) {
result = result.filter(m => m.config?.extended_thinking === true)
}
return result
})
// 模型目录分页计算
const paginatedGlobalModels = computed(() => filteredGlobalModels.value)
const selectedOnCurrentModelPageCount = computed(() => (
paginatedGlobalModels.value.filter(model => selectedBatchManageModelIds.value.has(model.id)).length
))
const isCurrentModelPageFullySelected = computed(() => (
paginatedGlobalModels.value.length > 0
&& selectedOnCurrentModelPageCount.value === paginatedGlobalModels.value.length
))
const isCurrentModelPagePartiallySelected = computed(() => (
selectedOnCurrentModelPageCount.value > 0 && !isCurrentModelPageFullySelected.value
))
function setBatchManageModelSelection(modelId: string, selected: boolean) {
const nextSelection = new Set(selectedBatchManageModelIds.value)
if (selected) nextSelection.add(modelId)
else nextSelection.delete(modelId)
selectedBatchManageModelIds.value = nextSelection
}
function toggleCurrentModelPageSelection(selected: boolean) {
const nextSelection = new Set(selectedBatchManageModelIds.value)
for (const model of paginatedGlobalModels.value) {
if (selected) nextSelection.add(model.id)
else nextSelection.delete(model.id)
}
selectedBatchManageModelIds.value = nextSelection
}
watch(searchQuery, () => {
catalogCurrentPage.value = 1
})
watch(catalogPageSize, () => {
catalogCurrentPage.value = 1
})
const globalModelsQueryParams = computed(() => ({
skip: Math.max(0, (catalogCurrentPage.value - 1) * catalogPageSize.value),
limit: catalogPageSize.value,
search: searchQuery.value.trim() || undefined,
}))
let modelSearchDebounceTimer: ReturnType<typeof setTimeout> | null = null
async function loadGlobalModels(options: { cacheTtlMs?: number } = {}) {
const requestId = ++globalModelsRequestId
loading.value = true
try {
const response = await listGlobalModels(globalModelsQueryParams.value, {
cacheTtlMs: options.cacheTtlMs ?? 0,
})
if (requestId !== globalModelsRequestId) return
const pageModels = response.models || []
const total = typeof response.total === 'number' ? response.total : pageModels.length
const totalPages = Math.max(1, Math.ceil(total / Math.max(catalogPageSize.value, 1)))
if (total > 0 && catalogCurrentPage.value > totalPages) {
catalogCurrentPage.value = totalPages
return
}
globalModels.value = pageModels
totalGlobalModels.value = total
migrateLegacyModelsDevPricingSources(pageModels)
} catch (err: unknown) {
if (requestId !== globalModelsRequestId) return
log.error('加载模型失败:', err)
showError(parseApiError(err, '加载模型失败'), '加载模型失败')
} finally {
if (requestId === globalModelsRequestId) {
loading.value = false
}
}
}
async function loadBatchManageModels() {
const requestId = ++batchManageModelsRequestId
batchManageLoading.value = true
try {
const allModels: GlobalModelResponse[] = []
let skip = 0
let expectedTotal: number | null = null
while (true) {
const response = await listGlobalModels({
skip,
limit: GLOBAL_MODELS_BATCH_FETCH_PAGE_SIZE,
})
if (expectedTotal === null && typeof response.total === 'number') {
expectedTotal = response.total
}
const pageModels = response.models || []
allModels.push(...pageModels)
if (expectedTotal !== null && allModels.length >= expectedTotal) {
break
}
if (pageModels.length < GLOBAL_MODELS_BATCH_FETCH_PAGE_SIZE) {
break
}
skip += pageModels.length
}
if (requestId !== batchManageModelsRequestId) return
batchManageModels.value = allModels
const validModelIds = new Set(allModels.map(model => model.id))
selectedBatchManageModelIds.value = new Set(
[...selectedBatchManageModelIds.value].filter(modelId => validModelIds.has(modelId)),
)
migrateLegacyModelsDevPricingSources(allModels)
} catch (err: unknown) {
if (requestId !== batchManageModelsRequestId) return
log.error('加载批量管理模型失败:', err)
showError(parseApiError(err, '加载模型失败'), '加载模型失败')
} finally {
if (requestId === batchManageModelsRequestId) {
batchManageLoading.value = false
}
}
}
// 使用复用的行点击逻辑
const { handleMouseDown, shouldTriggerRowClick } = useRowClick()
// 处理行点击,如果用户选择了文字则不触发抽屉
function handleRowClick(event: MouseEvent, model: GlobalModelResponse) {
if (!shouldTriggerRowClick(event)) return
selectModel(model)
}
async function selectModel(model: GlobalModelResponse) {
const requestId = ++modelSelectionRequestId
// 先显示缓存数据,提升响应速度
selectedModel.value = model
detailTab.value = 'basic'
// 并行加载最新模型数据和关联提供商
const [latestModel] = await Promise.all([
getGlobalModel(model.id).catch(err => {
log.error('获取最新模型数据失败:', err)
return null
}),
loadModelProviders(model.id)
])
// 更新为最新数据(如果获取成功)
if (requestId !== modelSelectionRequestId) return
if (latestModel) {
selectedModel.value = latestModel
}
}
// 刷新当前选中的模型数据
async function refreshSelectedModel() {
if (!selectedModel.value) return
try {
const updated = await getGlobalModel(selectedModel.value.id)
selectedModel.value = updated
} catch (err) {
log.error('刷新模型数据失败:', err)
}
}
// 加载指定模型的关联提供商
async function loadModelProviders(_globalModelId: string) {
const requestId = ++modelProvidersRequestId
loadingModelProviders.value = true
try {
// 使用新的 API 获取所有关联提供商(包括非活跃的)
const response = await getGlobalModelProviders(_globalModelId)
if (requestId !== modelProvidersRequestId) return
// 转换为展示格式
selectedModelProviders.value = response.providers.map(p => ({
id: p.provider_id,
model_id: p.model_id,
name: p.provider_name,
provider_type: 'API',
target_model: p.target_model,
is_active: p.is_active,
// 价格信息
input_price_per_1m: p.input_price_per_1m,
output_price_per_1m: p.output_price_per_1m,
cache_creation_price_per_1m: p.cache_creation_price_per_1m,
cache_read_price_per_1m: p.cache_read_price_per_1m,
cache_1h_creation_price_per_1m: p.cache_1h_creation_price_per_1m,
price_per_request: p.price_per_request,
effective_tiered_pricing: p.effective_tiered_pricing,
tier_count: p.tier_count,
// 能力信息
supports_vision: p.supports_vision,
supports_function_calling: p.supports_function_calling,
supports_streaming: p.supports_streaming,
supports_embedding: p.supports_embedding
}))
} catch (err: unknown) {
if (requestId !== modelProvidersRequestId) return
log.error('加载关联提供商失败:', err)
showError(parseApiError(err, '加载关联提供商失败'), '错误')
selectedModelProviders.value = []
} finally {
if (requestId === modelProvidersRequestId) {
loadingModelProviders.value = false
}
}
}
// 确保 Provider 选项已加载
async function ensureProviderOptions() {
if (providerOptions.value.length > 0 || loadingProviderOptions.value) {
return
}
if (providerOptionsRequest) {
await providerOptionsRequest
return
}
providerOptionsRequest = (async () => {
try {
loadingProviderOptions.value = true
providerOptions.value = (await getProvidersSummary({ page_size: 9999 })).items
} catch (err: unknown) {
const message = parseApiError(err, '加载 Provider 列表失败')
showError(message, '错误')
} finally {
loadingProviderOptions.value = false
}
})()
try {
await providerOptionsRequest
} finally {
providerOptionsRequest = null
}
}
// 打开添加关联提供商对话框
function openAddProviderDialog() {
if (!selectedModel.value) return
batchProviderSearchQuery.value = ''
batchAddProvidersDialogOpen.value = true
ensureProviderOptions().then(() => {
// 同步选择状态
syncBatchProviderSelection()
})
}
// 关联指定提供商到当前模型
async function linkProviderToModel(providerId: string) {
await linkProvidersToModel([providerId])
}
// 批量关联提供商到当前模型
async function linkProvidersToModel(providerIds: string[]) {
if (!selectedModel.value || providerIds.length === 0) return
try {
const result = await batchAssignToProviders(selectedModel.value.id, {
provider_ids: providerIds,
create_models: true
})
// 显示关联结果
if (result.errors.length > 0) {
showError(`${result.errors.length} 个提供商关联失败`, '部分失败')
} else {
success(`${providerIds.length} 个提供商已关联`)
}
// 刷新数据
await loadModelProviders(selectedModel.value.id)
await loadGlobalModels()
modelDetailDrawerRef.value?.refreshRoutingData?.()
} catch (err: unknown) {
showError(parseApiError(err, '关联失败'), '错误')
}
}
// 处理批量添加 Provider 对话框关闭事件
function handleBatchAddProvidersDialogUpdate(value: boolean) {
// 只有在不处于提交状态时才允许关闭
if (!value && submittingBatchProviders.value) {
return
}
batchAddProvidersDialogOpen.value = value
}
// 关闭批量添加对话框
function closeBatchAddProvidersDialog() {
batchAddProvidersDialogOpen.value = false
batchProviderSearchQuery.value = ''
selectedBatchProviderIds.value = new Set()
initialBatchProviderIds.value = new Set()
submittingBatchProviders.value = false
}
// 批量管理全局模型 - 过滤
const filteredBatchManageModels = computed(() => {
const query = batchManageSearchQuery.value.toLowerCase().trim()
if (!query) return batchManageModels.value
return batchManageModels.value.filter(m => {
const searchableText = `${m.name} ${m.display_name || ''}`.toLowerCase()
return searchableText.includes(query)
})
})
function applyGlobalModelUpdate(updatedModel: GlobalModelResponse) {
for (const models of [globalModels.value, batchManageModels.value]) {
const current = models.find(model => model.id === updatedModel.id)
if (current) Object.assign(current, updatedModel)
}
if (editingModel.value?.id === updatedModel.id) {
editingModel.value = { ...editingModel.value, ...updatedModel }
}
if (selectedModel.value?.id === updatedModel.id) {
selectedModel.value = { ...selectedModel.value, ...updatedModel }
}
}
function migrateLegacyModelsDevPricingSources(models: GlobalModelResponse[]) {
const tasks = models.flatMap(model => {
const localSource = getLocalModelsDevPricingSource(model.id)
if (
!localSource
|| getModelsDevPricingSourceFromConfig(model.config)
|| pricingSourceMigrationAttemptedIds.has(model.id)
) {
return []
}
pricingSourceMigrationAttemptedIds.add(model.id)
return [async () => {
const nextConfig = withModelsDevPricingSource(model.config, localSource)
try {
const updatedModel = await updateGlobalModel(model.id, { config: nextConfig })
applyGlobalModelUpdate({ ...updatedModel, config: nextConfig })
} catch (err: unknown) {
pricingSourceMigrationAttemptedIds.delete(model.id)
log.warn('迁移本地模型价格来源失败:', err)
}
}]
})
if (tasks.length > 0) void runBatchTasksWithConcurrency(tasks, 4)
}
const batchPricingProviderOptions = computed(() => {
const existingModelNames = new Set(batchManageModels.value.map(model => model.name.trim().toLowerCase()))
const providers = new Map<string, {
providerId: string
providerName: string
matchCount: number
official: boolean
}>()
for (const onlineModel of batchManageOnlineModels.value) {
const provider = providers.get(onlineModel.providerId) ?? {
providerId: onlineModel.providerId,
providerName: onlineModel.providerName,
matchCount: 0,
official: onlineModel.official === true,
}
if (
(onlineModel.tieredPricing || onlineModel.pricingUnsupportedFields?.length)
&& existingModelNames.has(onlineModel.modelId.trim().toLowerCase())
) {
provider.matchCount += 1
}
providers.set(onlineModel.providerId, provider)
}
return [...providers.values()]
.filter(provider => provider.matchCount > 0)
.sort((left, right) => (
Number(right.official) - Number(left.official)
|| right.matchCount - left.matchCount
|| left.providerName.localeCompare(right.providerName)
))
})
const selectedBatchPricingProvider = computed(() => (
batchPricingProviderOptions.value.find(provider => provider.providerId === batchPricingProviderId.value)
))
const batchPricingProviderModels = computed(() => (
batchPricingProviderId.value === REMEMBERED_PRICING_PROVIDER_ID
? batchManageOnlineModels.value
: batchManageOnlineModels.value.filter(model => model.providerId === batchPricingProviderId.value)
))
const rememberedBatchPricingProviderIds = computed(() => {
if (batchPricingProviderId.value !== REMEMBERED_PRICING_PROVIDER_ID) return undefined
const providerIds = new Map<string, string>()
for (const model of batchManageModels.value) {
const source = getModelsDevPricingSource(model.id, model.config)
if (source) providerIds.set(model.id, source.provider_id)
}
return providerIds
})
const batchPriceSyncPlan = computed(() => (
buildGlobalModelPriceSyncPlan(
batchManageModels.value,
batchPricingProviderModels.value,
rememberedBatchPricingProviderIds.value,
)
))
function doesBatchEntryNeedSourcePersistence(entry: GlobalModelPriceSyncEntry): boolean {
return !modelsDevPricingSourcesEqual(
getModelsDevPricingSourceFromConfig(entry.model.config),
{
provider_id: entry.onlineModel.providerId,
provider_name: entry.onlineModel.providerName,
},
)
}
const batchPricingStateByModelId = computed(() => {
const states = new Map<string, 'syncable' | 'source-pending' | 'unchanged' | 'unsupported' | 'unavailable'>()
for (const entry of batchPriceSyncPlan.value.syncable) states.set(entry.model.id, 'syncable')
for (const entry of batchPriceSyncPlan.value.unchanged) {
states.set(
entry.model.id,
doesBatchEntryNeedSourcePersistence(entry) ? 'source-pending' : 'unchanged',
)
}
for (const entry of batchPriceSyncPlan.value.unsupported) states.set(entry.model.id, 'unsupported')
for (const model of batchPriceSyncPlan.value.unavailable) states.set(model.id, 'unavailable')
return states
})
const selectedBatchManageModels = computed(() => (
batchManageModels.value.filter(model => selectedBatchManageModelIds.value.has(model.id))
))
const selectedBatchPriceSyncPlan = computed(() => (
buildGlobalModelPriceSyncPlan(
selectedBatchManageModels.value,
batchPricingProviderModels.value,
rememberedBatchPricingProviderIds.value,
)
))
const selectedBatchPriceSyncEntries = computed(() => [
...selectedBatchPriceSyncPlan.value.syncable,
...selectedBatchPriceSyncPlan.value.unchanged.filter(doesBatchEntryNeedSourcePersistence),
])
const batchManageSelectionSummary = computed(() => {
const selectedCount = selectedBatchManageModelIds.value.size
if (selectedCount === 0) return t('models.management.batch.selectionEmpty')
const plan = selectedBatchPriceSyncPlan.value
const sourcePendingCount = plan.unchanged.filter(doesBatchEntryNeedSourcePersistence).length
const unchangedCount = plan.unchanged.length - sourcePendingCount
return t('models.management.batch.selectionSummary', {
selected: selectedCount,
syncable: plan.syncable.length,
sourcePending: sourcePendingCount,
unchanged: unchangedCount,
unsupported: plan.unsupported.length,
unavailable: plan.unavailable.length,
})
})
function getBatchPricingState(model: GlobalModelResponse) {
return batchPricingStateByModelId.value.get(model.id) ?? 'unavailable'
}
function getBatchPricingStateLabel(model: GlobalModelResponse): string {
const state = getBatchPricingState(model)
if (state === 'syncable') return t('models.management.batch.state.syncable')
if (state === 'source-pending') return t('models.management.batch.state.sourcePending')
if (state === 'unchanged') return t('models.management.batch.state.unchanged')
if (state === 'unsupported') return t('models.management.batch.state.unsupported')
return t('models.management.batch.state.unavailable')
}
function getBatchPricingStateDescription(model: GlobalModelResponse): string {
const providerName = getBatchPricingSourceLabel(model)
const unsupportedEntry = batchPriceSyncPlan.value.unsupported.find(entry => entry.model.id === model.id)
if (unsupportedEntry) {
return `${providerName || unsupportedEntry.onlineModel.providerName} · 不支持独立结算 ${formatBatchUnsupportedPricingFields(unsupportedEntry.onlineModel)}`
}
return `${providerName} · ${getBatchPricingStateLabel(model)}`
}
function getBatchPricingSourceLabel(model: GlobalModelResponse): string {
if (batchPricingProviderId.value !== REMEMBERED_PRICING_PROVIDER_ID) {
return selectedBatchPricingProvider.value?.providerName
?? t('models.management.batch.source.noneSelected')
}
return getModelsDevPricingSource(model.id, model.config)?.provider_name
?? t('models.management.batch.source.noneRecorded')
}
function getBatchPricingStateClass(model: GlobalModelResponse): string {
const state = getBatchPricingState(model)
if (state === 'syncable') return 'text-amber-700 dark:text-amber-300'
if (state === 'source-pending') return 'text-sky-700 dark:text-sky-300'
if (state === 'unchanged') return 'text-emerald-700 dark:text-emerald-300'
if (state === 'unsupported') return 'text-rose-700 dark:text-rose-300'
return 'text-muted-foreground'
}
function getBatchPricingStateDotClass(model: GlobalModelResponse): string {
const state = getBatchPricingState(model)
if (state === 'syncable') return 'bg-amber-500'
if (state === 'source-pending') return 'bg-sky-500'
if (state === 'unchanged') return 'bg-emerald-500'
if (state === 'unsupported') return 'bg-rose-500'
return 'bg-muted-foreground/45'
}
function formatBatchUnsupportedPricingFields(model: ModelsDevModelItem): string {
const labels = {
reasoning: t('models.pricingField.reasoning'),
input_audio: t('models.pricingField.inputAudio'),
output_audio: t('models.pricingField.outputAudio'),
}
return (model.pricingUnsupportedFields ?? [])
.map(field => labels[field])
.join(locale.value === 'zh-CN' ? '、' : ', ')
}
// 批量管理 - 快捷筛选定义
function hasNoPrice(m: GlobalModelResponse): boolean {
return !getFirstTierPrice(m, 'input') && !getFirstTierPrice(m, 'output')
&& !m.default_price_per_request
}
const batchManageShortcuts = computed(() => {
const models = batchManageModels.value
const defs: {
label: string
description: string
filter: (m: GlobalModelResponse) => boolean
emphasis?: boolean
}[] = [
{ label: t('models.management.batch.state.syncable'), description: t('models.management.batch.shortcut.syncable'), filter: m => getBatchPricingState(m) === 'syncable', emphasis: true },
{ label: t('models.management.batch.state.sourcePending'), description: t('models.management.batch.shortcut.sourcePending'), filter: m => getBatchPricingState(m) === 'source-pending', emphasis: true },
{ label: t('models.management.batch.state.unchanged'), description: t('models.management.batch.shortcut.unchanged'), filter: m => getBatchPricingState(m) === 'unchanged' },
{ label: t('models.management.batch.state.unsupported'), description: t('models.management.batch.shortcut.unsupported'), filter: m => getBatchPricingState(m) === 'unsupported' },
{ label: t('models.management.batch.state.unavailable'), description: t('models.management.batch.shortcut.unavailable'), filter: m => getBatchPricingState(m) === 'unavailable' },
{ label: '无提供商', description: '没有关联任何提供商的模型', filter: m => (m.provider_count || 0) === 0 },
{ label: '无活跃提供商', description: '有提供商但没有活跃提供商的模型', filter: m => (m.active_provider_count || 0) === 0 && (m.provider_count || 0) > 0 },
{ label: '禁用', description: '被禁用的模型', filter: m => !m.is_active },
{ label: '未调用', description: '没有调用记录的模型', filter: m => (m.usage_count || 0) === 0 },
{ label: '无价格', description: '没有配置任何价格的模型', filter: m => hasNoPrice(m) },
]
return defs.map(d => ({ ...d, count: models.filter(d.filter).length }))
})
// 批量管理 - 应用快捷选中
function applyBatchManageShortcut(filter: (m: GlobalModelResponse) => boolean) {
const matchedIds = batchManageModels.value.filter(filter).map(m => m.id)
selectedBatchManageModelIds.value = new Set(matchedIds)
}
// 批量管理 - 是否全选
const isAllBatchManageModelsSelected = computed(() => {
if (filteredBatchManageModels.value.length === 0) return false
return filteredBatchManageModels.value.every(m => selectedBatchManageModelIds.value.has(m.id))
})
// 批量管理 - 切换单个选择
function toggleBatchManageModelSelection(modelId: string) {
if (selectedBatchManageModelIds.value.has(modelId)) {
selectedBatchManageModelIds.value.delete(modelId)
} else {
selectedBatchManageModelIds.value.add(modelId)
}
selectedBatchManageModelIds.value = new Set(selectedBatchManageModelIds.value)
}
// 批量管理 - 全选/取消全选
function toggleAllBatchManageModels() {
const allIds = filteredBatchManageModels.value.map(m => m.id)
if (isAllBatchManageModelsSelected.value) {
for (const id of allIds) {
selectedBatchManageModelIds.value.delete(id)
}
} else {
for (const id of allIds) {
selectedBatchManageModelIds.value.add(id)
}
}
selectedBatchManageModelIds.value = new Set(selectedBatchManageModelIds.value)
}
// 打开批量管理对话框
function openBatchManageDialog() {
batchManageSearchQuery.value = ''
batchPricingProviderId.value = REMEMBERED_PRICING_PROVIDER_ID
batchManageDialogOpen.value = true
void Promise.all([loadBatchManageModels(), loadBatchManageOnlineModels()])
}
async function loadBatchManageOnlineModels() {
if (batchManageOnlineModels.value.length > 0) return
if (batchManageOnlineModelsRequest) return batchManageOnlineModelsRequest
batchManageOnlineModelsRequest = (async () => {
batchManageOnlineLoading.value = true
try {
batchManageOnlineModels.value = await getModelsDevList(false)
} catch (err: unknown) {
log.error('加载在线模型价格失败:', err)
showError(parseApiError(err, '加载在线模型价格失败'), '加载失败')
} finally {
batchManageOnlineLoading.value = false
batchManageOnlineModelsRequest = null
}
})()
return batchManageOnlineModelsRequest
}
async function runBatchTasksWithConcurrency(
tasks: Array<() => Promise<void>>,
concurrency: number = 6,
) {
let cursor = 0
const runNext = async (): Promise<void> => {
while (cursor < tasks.length) {
const taskIndex = cursor++
await tasks[taskIndex]()
}
}
await Promise.all(Array.from(
{ length: Math.min(concurrency, tasks.length) },
() => runNext(),
))
}
async function confirmBatchSyncPrices() {
const plan = selectedBatchPriceSyncPlan.value
const entries = selectedBatchPriceSyncEntries.value
if (entries.length === 0) return
const providerName = batchPricingProviderId.value === REMEMBERED_PRICING_PROVIDER_ID
? t('models.management.batch.rememberedProviderLabel')
: selectedBatchPricingProvider.value?.providerName
|| t('models.management.batch.selectedProviderLabel')
const sourceOnlyCount = plan.unchanged.filter(doesBatchEntryNeedSourcePersistence).length
const skippedCount = plan.unchanged.length - sourceOnlyCount
+ plan.unsupported.length
+ plan.unavailable.length
const sourceOnlyMessage = sourceOnlyCount > 0
? ` ${t('models.management.batch.confirmSourceOnly', { count: sourceOnlyCount })}`
: ''
const skippedMessage = skippedCount > 0
? ` ${t('models.management.batch.confirmSkipped', { count: skippedCount })}`
: ''
const confirmed = await confirm({
title: t('models.management.batch.confirmTitle'),
message: `${t('models.management.batch.confirmMain', {
provider: providerName,
count: entries.length,
})}${sourceOnlyMessage}${skippedMessage}\n\n${t('models.management.batch.confirmUnchanged')}`,
confirmText: t('models.management.batch.confirmButton'),
variant: 'info',
})
if (!confirmed) return
submittingBatchManage.value = true
batchManageAction.value = 'sync-prices'
const failedIds = new Set<string>()
const failureMessages: string[] = []
let successCount = 0
try {
const tasks = entries.map(entry => async () => {
try {
const onlinePricing = entry.onlineModel.tieredPricing
if (!onlinePricing) {
throw new Error(t('models.management.batch.catalogMissingPrice'))
}
const pricing = cloneTieredPricingConfig(onlinePricing)
const source = {
provider_id: entry.onlineModel.providerId,
provider_name: entry.onlineModel.providerName,
}
const nextConfig = withModelsDevPricingSource(entry.model.config, source)
const updatedModel = await updateGlobalModel(entry.model.id, {
default_tiered_pricing: pricing,
config: nextConfig,
})
setModelsDevPricingSource(entry.model.id, source)
applyGlobalModelUpdate({
...updatedModel,
default_tiered_pricing: pricing,
config: nextConfig,
})
successCount += 1
} catch (err: unknown) {
failedIds.add(entry.model.id)
failureMessages.push(`${entry.model.display_name}: ${parseApiError(err, '更新失败')}`)
}
})
await runBatchTasksWithConcurrency(tasks)
if (successCount > 0) {
success(t('models.management.batch.success', { count: successCount }))
}
if (failureMessages.length > 0) {
showError(
t('models.management.batch.partialFailure', {
count: failureMessages.length,
details: failureMessages.slice(0, 2).join(locale.value === 'zh-CN' ? '' : '; '),
}),
'部分失败',
)
}
await Promise.all([loadGlobalModels(), loadBatchManageModels()])
selectedBatchManageModelIds.value = failedIds
} finally {
batchManageAction.value = null
submittingBatchManage.value = false
}
}
// 确认批量删除模型
async function confirmBatchDeleteModels() {
const count = selectedBatchManageModelIds.value.size
if (count === 0) return
const confirmed = await confirmDanger(
`确定删除选中的 ${count} 个模型吗?\n\n此操作不可撤销。`,
'批量删除模型'
)
if (!confirmed) return
submittingBatchManage.value = true
batchManageAction.value = 'delete'
try {
const ids = Array.from(selectedBatchManageModelIds.value)
const result = await batchDeleteGlobalModels(ids)
if (result.success_count > 0) {
success(`成功删除 ${result.success_count} 个模型`)
}
if (result.failed.length > 0) {
showError(`${result.failed.length} 个模型删除失败`, '部分失败')
}
// 清除选中的已删除模型
if (selectedModel.value && selectedBatchManageModelIds.value.has(selectedModel.value.id)) {
selectedModel.value = null
}
selectedBatchManageModelIds.value = new Set()
await Promise.all([loadGlobalModels(), loadBatchManageModels()])
} catch (err: unknown) {
showError(parseApiError(err, '批量删除失败'), '错误')
} finally {
batchManageAction.value = null
submittingBatchManage.value = false
}
}
watch([batchPricingProviderOptions, batchManageDialogOpen], ([options, dialogOpen]) => {
if (!dialogOpen) return
if (
batchPricingProviderId.value !== REMEMBERED_PRICING_PROVIDER_ID
&& !options.some(provider => provider.providerId === batchPricingProviderId.value)
) {
batchPricingProviderId.value = REMEMBERED_PRICING_PROVIDER_ID
}
}, { immediate: true })
// 抽屉控制函数
function handleDrawerOpenChange(value: boolean) {
if (!value && !hasBlockingDialogOpen.value) {
modelSelectionRequestId += 1
modelProvidersRequestId += 1
selectedModel.value = null
}
}
// 编辑提供商模型
function openEditProviderImplementation(provider: ModelProviderDisplay) {
editingProvider.value = provider
editProviderDialogOpen.value = true
}
// 处理编辑 Provider 对话框关闭事件
function handleEditProviderDialogUpdate(value: boolean) {
editProviderDialogOpen.value = value
if (!value) {
editingProvider.value = null
}
}
// 编辑提供商模型保存成功后刷新列表
async function handleEditProviderSaved() {
if (selectedModel.value) {
await loadModelProviders(selectedModel.value.id)
}
}
// 切换关联提供商状态
async function toggleProviderStatus(provider: ModelProviderDisplay) {
if (!provider.model_id) {
showError('缺少模型 ID')
return
}
try {
const { updateModel } = await import('@/api/endpoints')
const newStatus = !provider.is_active
await updateModel(provider.id, provider.model_id, { is_active: newStatus })
provider.is_active = newStatus
success(newStatus ? '已启用此关联提供商' : '已停用此关联提供商')
// 刷新路由数据
modelDetailDrawerRef.value?.refreshRoutingData?.()
} catch (err: unknown) {
showError(parseApiError(err, '更新状态失败'))
}
}
// 删除关联提供商
async function confirmDeleteProviderImplementation(provider: ModelProviderDisplay) {
if (!provider.model_id) {
showError('缺少模型 ID')
return
}
const confirmed = await confirmDanger(
`确定要删除 ${provider.name} 的模型关联吗?\n\n此操作不可恢复!`,
'删除关联提供商'
)
if (!confirmed) return
try {
const { deleteModel } = await import('@/api/endpoints')
await deleteModel(provider.id, provider.model_id)
success(`已删除 ${provider.name} 的模型实现`)
// 同步更新 selectedModelProviders 确保状态一致
if (selectedModel.value) {
await loadModelProviders(selectedModel.value.id)
}
// 刷新路由数据
modelDetailDrawerRef.value?.refreshRoutingData?.()
} catch (err: unknown) {
showError(parseApiError(err, '删除模型失败'))
}
}
function openCreateModelDialog() {
editingModel.value = null
createModelDialogOpen.value = true
}
// 处理模型对话框关闭事件
function handleModelDialogUpdate(value: boolean) {
createModelDialogOpen.value = value
if (!value) {
editingModel.value = null
}
}
// 处理模型表单提交成功
async function handleModelFormSuccess() {
// 编辑模式关闭对话框,创建模式保持打开以便连续添加
if (editingModel.value) {
createModelDialogOpen.value = false
editingModel.value = null
}
await loadGlobalModels()
}
async function editModel(model: GlobalModelResponse) {
editingModel.value = model
createModelDialogOpen.value = true
}
function handleModelPricingSynced(model: GlobalModelResponse) {
applyGlobalModelUpdate({
...model,
default_tiered_pricing: cloneTieredPricingConfig(model.default_tiered_pricing),
config: model.config ? { ...model.config } : model.config,
})
}
async function deleteModel(model: GlobalModelResponse) {
const confirmed = await confirmDanger(
`确定删除模型 "${model.name}" 吗?\n\n此操作不可撤销。`,
'删除模型'
)
if (!confirmed) return
try {
await deleteGlobalModel(model.id)
success('模型删除成功')
if (selectedModel.value?.id === model.id) {
selectedModel.value = null
}
setBatchManageModelSelection(model.id, false)
await loadGlobalModels()
} catch (err: unknown) {
showError(parseApiError(err, '删除失败'), '删除失败')
}
}
async function toggleModelStatus(model: GlobalModelResponse) {
try {
await updateGlobalModel(model.id, { is_active: !model.is_active })
model.is_active = !model.is_active
success(model.is_active ? '模型已启用' : '模型已停用')
} catch (err: unknown) {
showError(parseApiError(err, '操作失败'), '操作失败')
}
}
async function refreshData() {
await loadGlobalModels()
}
watch(globalModelsQueryParams, (newParams, oldParams) => {
if (modelSearchDebounceTimer) clearTimeout(modelSearchDebounceTimer)
const isSearchOnly = newParams.search !== oldParams?.search
&& newParams.skip === oldParams?.skip
&& newParams.limit === oldParams?.limit
if (isSearchOnly) {
modelSearchDebounceTimer = setTimeout(() => {
void loadGlobalModels({ cacheTtlMs: GLOBAL_MODELS_LIST_CACHE_TTL_MS })
}, 300)
} else {
void loadGlobalModels({ cacheTtlMs: GLOBAL_MODELS_LIST_CACHE_TTL_MS })
}
}, { immediate: true })
onBeforeUnmount(() => {
if (modelSearchDebounceTimer) clearTimeout(modelSearchDebounceTimer)
globalModelsRequestId += 1
batchManageModelsRequestId += 1
modelSelectionRequestId += 1
modelProvidersRequestId += 1
})
</script>
<style scoped>
/* 抽屉过渡动画 */
.drawer-enter-active,
.drawer-leave-active {
transition: opacity 0.3s ease;
}
.drawer-enter-active .relative,
.drawer-leave-active .relative {
transition: transform 0.3s ease;
}
.drawer-enter-from,
.drawer-leave-to {
opacity: 0;
}
.drawer-enter-from .relative {
transform: translateX(100%);
}
.drawer-leave-to .relative {
transform: translateX(100%);
}
.drawer-enter-to .relative,
.drawer-leave-from .relative {
transform: translateX(0);
}
</style>